From a9750436703c54898826faac4d8117227e1ffdf4 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Wed, 15 Apr 2026 01:29:13 -0500 Subject: [PATCH] Add onboarding wizard, email verification, integration architecture, and settings restructure - Auto-sign-in after registration instead of redirect to login - Email verification system with token generation, send/confirm API routes, and persistent banner - 7-step onboarding wizard (org, location, services, upload source, AI, integrations, complete) - Middleware redirects owners with incomplete onboarding to /onboarding - Integration provider plugin architecture with registry and 6 providers (Planning Center, Monday.com, Airtable, Google Sheets, Webhook, CSV Export) - Full integration CRUD API with test, sync, fields, and OAuth authorize/callback routes - Refactored fireIntegrationEvent to use Integration model with legacy AppSettings fallback - Migration script for existing Monday.com/webhook config to Integration rows - Settings page restructured from monolithic 1290-line file into focused sub-routes with section navigation - Integration hub UI with provider tiles, connect flow, and individual config pages - Post-onboarding contextual guidance cards on dashboard with dismissible hints - Schema: Integration model, onboardingComplete/onboardingStep on Organization, dismissedHints on OrgMember Made-with: Cursor --- .env.example | 8 + package-lock.json | 21 + package.json | 2 + prisma/migrate-integrations.ts | 105 ++ prisma/schema.prisma | 54 +- src/app/(auth)/signup/page.tsx | 10 +- src/app/(dashboard)/onboarding/page.tsx | 995 +++++++++++++ src/app/(dashboard)/page.tsx | 161 +- src/app/(dashboard)/settings/ai/page.tsx | 196 +++ src/app/(dashboard)/settings/general/page.tsx | 280 ++++ .../settings/integrations/[id]/page.tsx | 406 ++++++ .../settings/integrations/new/page.tsx | 246 ++++ .../settings/integrations/page.tsx | 205 +++ src/app/(dashboard)/settings/layout.tsx | 66 + .../(dashboard)/settings/locations/page.tsx | 201 ++- .../settings/organization/page.tsx | 189 ++- src/app/(dashboard)/settings/page.tsx | 1290 +---------------- .../settings/upload-sources/page.tsx | 358 +++++ src/app/(dashboard)/settings/users/page.tsx | 245 +++- src/app/api/auth/register/route.ts | 8 + .../api/auth/verify-email/confirm/route.ts | 40 + src/app/api/auth/verify-email/send/route.ts | 37 + src/app/api/integrations/[id]/fields/route.ts | 42 + src/app/api/integrations/[id]/route.ts | 115 ++ src/app/api/integrations/[id]/sync/route.ts | 90 ++ src/app/api/integrations/[id]/test/route.ts | 54 + .../oauth/[provider]/authorize/route.ts | 80 + .../oauth/[provider]/callback/route.ts | 124 ++ src/app/api/integrations/route.ts | 76 + src/app/api/onboarding/route.ts | 353 +++++ src/auth.ts | 15 +- src/components/cards/data-table.tsx | 8 +- src/components/cards/filters.tsx | 2 +- src/components/cards/stat-cards.tsx | 24 +- src/components/layout/app-shell.tsx | 8 +- .../layout/email-verification-banner.tsx | 60 + src/lib/email-sender.ts | 77 + src/lib/integrations.ts | 119 +- src/lib/integrations/providers/airtable.ts | 173 +++ src/lib/integrations/providers/csv-export.ts | 123 ++ .../integrations/providers/google-sheets.ts | 173 +++ src/lib/integrations/providers/monday.ts | 99 ++ .../integrations/providers/planning-center.ts | 316 ++++ src/lib/integrations/providers/webhook.ts | 86 ++ src/lib/integrations/registry.ts | 34 + src/lib/integrations/types.ts | 82 ++ src/middleware.ts | 33 +- src/types/next-auth.d.ts | 4 + 48 files changed, 6087 insertions(+), 1406 deletions(-) create mode 100644 prisma/migrate-integrations.ts create mode 100644 src/app/(dashboard)/onboarding/page.tsx create mode 100644 src/app/(dashboard)/settings/ai/page.tsx create mode 100644 src/app/(dashboard)/settings/general/page.tsx create mode 100644 src/app/(dashboard)/settings/integrations/[id]/page.tsx create mode 100644 src/app/(dashboard)/settings/integrations/new/page.tsx create mode 100644 src/app/(dashboard)/settings/integrations/page.tsx create mode 100644 src/app/(dashboard)/settings/layout.tsx create mode 100644 src/app/(dashboard)/settings/upload-sources/page.tsx create mode 100644 src/app/api/auth/verify-email/confirm/route.ts create mode 100644 src/app/api/auth/verify-email/send/route.ts create mode 100644 src/app/api/integrations/[id]/fields/route.ts create mode 100644 src/app/api/integrations/[id]/route.ts create mode 100644 src/app/api/integrations/[id]/sync/route.ts create mode 100644 src/app/api/integrations/[id]/test/route.ts create mode 100644 src/app/api/integrations/oauth/[provider]/authorize/route.ts create mode 100644 src/app/api/integrations/oauth/[provider]/callback/route.ts create mode 100644 src/app/api/integrations/route.ts create mode 100644 src/app/api/onboarding/route.ts create mode 100644 src/components/layout/email-verification-banner.tsx create mode 100644 src/lib/email-sender.ts create mode 100644 src/lib/integrations/providers/airtable.ts create mode 100644 src/lib/integrations/providers/csv-export.ts create mode 100644 src/lib/integrations/providers/google-sheets.ts create mode 100644 src/lib/integrations/providers/monday.ts create mode 100644 src/lib/integrations/providers/planning-center.ts create mode 100644 src/lib/integrations/providers/webhook.ts create mode 100644 src/lib/integrations/registry.ts create mode 100644 src/lib/integrations/types.ts diff --git a/.env.example b/.env.example index 076d3cf..6ce7b3d 100644 --- a/.env.example +++ b/.env.example @@ -30,5 +30,13 @@ AUTHENTIK_CLIENT_SECRET="" AUTHENTIK_URL="https://auth.stillwell.cloud" AUTHENTIK_API_TOKEN="" +# SMTP for outbound email (verification, invitations) +# Falls back to EMAIL_IMAP_* values if not set +SMTP_HOST="" +SMTP_PORT="587" +SMTP_USER="" +SMTP_PASS="" +SMTP_FROM="" + # Environment indicator (set to "staging" for staging deployments) NEXT_PUBLIC_ENV="" diff --git a/package-lock.json b/package-lock.json index 787ae63..3566be7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "next": "16.1.6", "next-auth": "^5.0.0-beta.31", "next-themes": "^0.4.6", + "nodemailer": "^7.0.13", "pdf-lib": "^1.17.1", "pdf2pic": "^3.2.0", "pg": "^8.20.0", @@ -51,6 +52,7 @@ "@types/bcryptjs": "^2.4.6", "@types/mailparser": "^3.4.6", "@types/node": "^25.4.0", + "@types/nodemailer": "^8.0.0", "@types/pg": "^8.18.0", "@types/react": "^19", "@types/react-dom": "^19", @@ -4946,6 +4948,16 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pg": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.18.0.tgz", @@ -10658,6 +10670,15 @@ "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", "license": "MIT" }, + "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/npm-run-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", diff --git a/package.json b/package.json index 378d748..c3195d3 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "next": "16.1.6", "next-auth": "^5.0.0-beta.31", "next-themes": "^0.4.6", + "nodemailer": "^7.0.13", "pdf-lib": "^1.17.1", "pdf2pic": "^3.2.0", "pg": "^8.20.0", @@ -55,6 +56,7 @@ "@types/bcryptjs": "^2.4.6", "@types/mailparser": "^3.4.6", "@types/node": "^25.4.0", + "@types/nodemailer": "^8.0.0", "@types/pg": "^8.18.0", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/prisma/migrate-integrations.ts b/prisma/migrate-integrations.ts new file mode 100644 index 0000000..b651909 --- /dev/null +++ b/prisma/migrate-integrations.ts @@ -0,0 +1,105 @@ +/** + * One-time migration: reads Monday.com and webhook config from AppSettings + * and creates corresponding Integration rows for each organization. + * + * Usage: npx tsx prisma/migrate-integrations.ts + */ + +import { PrismaClient } from "../src/generated/prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; +import pg from "pg"; + +async function main() { + const pool = new pg.Pool({ + connectionString: process.env.DATABASE_URL, + }); + const adapter = new PrismaPg(pool); + const prisma = new PrismaClient({ adapter }); + + const settings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + }); + + if (!settings) { + console.log("No AppSettings found — nothing to migrate."); + await prisma.$disconnect(); + return; + } + + const orgs = await prisma.organization.findMany(); + if (orgs.length === 0) { + console.log("No organizations found — nothing to migrate."); + await prisma.$disconnect(); + return; + } + + const orgId = orgs[0].id; + let created = 0; + + if (settings.mondayEnabled && settings.mondayApiToken && settings.mondayBoardId) { + const existing = await prisma.integration.findFirst({ + where: { organizationId: orgId, provider: "monday" }, + }); + + if (!existing) { + await prisma.integration.create({ + data: { + organizationId: orgId, + provider: "monday", + name: "Monday.com (migrated)", + enabled: settings.mondayEnabled, + config: { + apiToken: settings.mondayApiToken, + boardId: settings.mondayBoardId, + columnMap: settings.mondayColumnMap || {}, + }, + triggerEvents: ["card_reviewed", "card_exported"], + syncDirection: "push", + }, + }); + created++; + console.log("Created Monday.com integration from AppSettings."); + } else { + console.log("Monday.com integration already exists — skipping."); + } + } + + if (settings.webhookEnabled && settings.webhookUrl) { + const existing = await prisma.integration.findFirst({ + where: { organizationId: orgId, provider: "webhook" }, + }); + + if (!existing) { + await prisma.integration.create({ + data: { + organizationId: orgId, + provider: "webhook", + name: "Webhook (migrated)", + enabled: settings.webhookEnabled, + config: { + url: settings.webhookUrl, + secret: settings.webhookSecret, + }, + triggerEvents: (settings.webhookEvents as string[]) || [ + "ocr_complete", + "card_reviewed", + ], + syncDirection: "push", + }, + }); + created++; + console.log("Created Webhook integration from AppSettings."); + } else { + console.log("Webhook integration already exists — skipping."); + } + } + + console.log(`Migration complete: ${created} integration(s) created.`); + await prisma.$disconnect(); + await pool.end(); +} + +main().catch((e) => { + console.error("Migration failed:", e); + process.exit(1); +}); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 14cd900..5fb6c48 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -71,21 +71,24 @@ model VerificationToken { // ─── Organization / Multi-tenancy ──────────────────────────── model Organization { - id String @id @default(cuid()) - name String - slug String @unique - type String @default("church") - timezone String @default("America/Chicago") - settings Json? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + name String + slug String @unique + type String @default("church") + timezone String @default("America/Chicago") + settings Json? + onboardingComplete Boolean @default(false) + onboardingStep Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - locations Location[] - members OrgMember[] - invitations Invitation[] - apiKeys ApiKey[] - cards ResponseCard[] - jobs ProcessingJob[] + locations Location[] + members OrgMember[] + invitations Invitation[] + apiKeys ApiKey[] + cards ResponseCard[] + jobs ProcessingJob[] + integrations Integration[] } model OrgMember { @@ -93,6 +96,7 @@ model OrgMember { userId String organizationId String role String @default("viewer") + dismissedHints Json? user User @relation(fields: [userId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) @@ -131,6 +135,28 @@ model CollectionDay { updatedAt DateTime @updatedAt } +// ─── Integrations ──────────────────────────────────────────── + +model Integration { + id String @id @default(cuid()) + organizationId String + provider String + name String + enabled Boolean @default(false) + config Json + fieldMapping Json? + syncDirection String @default("push") + triggerEvents Json? + lastSyncAt DateTime? + lastSyncStatus String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + + @@unique([organizationId, provider, name]) + @@index([organizationId, provider]) +} + // ─── Invitations ───────────────────────────────────────────── model Invitation { diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx index 9a173eb..d761b1a 100644 --- a/src/app/(auth)/signup/page.tsx +++ b/src/app/(auth)/signup/page.tsx @@ -1,7 +1,8 @@ "use client"; import { Suspense, useState } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useSearchParams } from "next/navigation"; +import { signIn } from "next-auth/react"; import Link from "next/link"; import { ScanLine, Mail, Lock, User, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -17,7 +18,6 @@ export default function SignupPage() { } function SignupForm() { - const router = useRouter(); const searchParams = useSearchParams(); const token = searchParams.get("token") || ""; @@ -69,7 +69,11 @@ function SignupForm() { return; } - router.push("/login?registered=true"); + await signIn("credentials", { + email: formData.email, + password: formData.password, + callbackUrl: "/", + }); } catch { setError("Something went wrong. Please try again."); setLoading(false); diff --git a/src/app/(dashboard)/onboarding/page.tsx b/src/app/(dashboard)/onboarding/page.tsx new file mode 100644 index 0000000..3b51e9e --- /dev/null +++ b/src/app/(dashboard)/onboarding/page.tsx @@ -0,0 +1,995 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { + Building2, + MapPin, + CalendarDays, + Upload, + Cpu, + Plug, + PartyPopper, + Loader2, + ArrowRight, + ArrowLeft, + Check, + Plus, + Trash2, + Mail, + FolderOpen, + Key, + SkipForward, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +const STEPS = [ + { label: "Organization", icon: Building2 }, + { label: "Location", icon: MapPin }, + { label: "Services", icon: CalendarDays }, + { label: "Upload Source", icon: Upload }, + { label: "AI Provider", icon: Cpu }, + { label: "Integrations", icon: Plug }, + { label: "Complete", icon: PartyPopper }, +]; + +const DAYS_OF_WEEK = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +]; + +const ORG_TYPES = [ + { value: "church", label: "Church" }, + { value: "ministry", label: "Ministry" }, + { value: "nonprofit", label: "Nonprofit" }, + { value: "other", label: "Other" }, +]; + +const TIMEZONES = [ + { group: "US", zones: ["America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles", "America/Anchorage", "Pacific/Honolulu"] }, + { group: "Canada", zones: ["America/Toronto", "America/Vancouver", "America/Edmonton", "America/Halifax"] }, + { group: "Europe", zones: ["Europe/London", "Europe/Berlin", "Europe/Paris", "Europe/Madrid"] }, + { group: "Asia/Pacific", zones: ["Asia/Tokyo", "Asia/Shanghai", "Asia/Kolkata", "Australia/Sydney"] }, +]; + +export default function OnboardingPage() { + const router = useRouter(); + const [currentStep, setCurrentStep] = useState(0); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(""); + + const [org, setOrg] = useState({ + name: "", + type: "church", + timezone: "America/Chicago", + }); + + const [locations, setLocations] = useState([{ name: "", address: "" }]); + + const [services, setServices] = useState([ + { name: "Sunday Morning", dayOfWeek: 0, timeStart: "09:00", timeEnd: "10:30" }, + ]); + + const [uploadSource, setUploadSource] = useState("manual"); + const [emailConfig, setEmailConfig] = useState({ + host: "", + port: 993, + user: "", + pass: "", + tls: true, + }); + const [watchDir, setWatchDir] = useState(""); + const [generatedApiKey, setGeneratedApiKey] = useState(""); + + const [aiProvider, setAiProvider] = useState("gateway"); + const [aiModel, setAiModel] = useState(""); + + useEffect(() => { + fetch("/api/onboarding") + .then((r) => r.json()) + .then((data) => { + if (data.onboardingComplete) { + router.replace("/"); + return; + } + setCurrentStep(data.currentStep || 0); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [router]); + + const submitStep = useCallback( + async (step: number, data: Record) => { + setSubmitting(true); + setError(""); + + try { + const res = await fetch("/api/onboarding", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ step, data }), + }); + + const result = await res.json(); + if (!res.ok) { + setError(result.error || "Something went wrong"); + setSubmitting(false); + return null; + } + + if (result.complete) { + router.replace("/"); + return result; + } + + setCurrentStep(result.nextStep); + return result; + } catch { + setError("Something went wrong"); + return null; + } finally { + setSubmitting(false); + } + }, + [router] + ); + + function handleOrgSubmit(e: React.FormEvent) { + e.preventDefault(); + submitStep(1, org); + } + + function handleLocationsSubmit(e: React.FormEvent) { + e.preventDefault(); + const valid = locations.filter((l) => l.name.trim()); + if (valid.length === 0) { + setError("Add at least one location"); + return; + } + submitStep(2, { locations: valid }); + } + + function handleServicesSubmit(e: React.FormEvent) { + e.preventDefault(); + submitStep(3, { services: services.filter((s) => s.name.trim()) }); + } + + async function handleUploadSubmit(e: React.FormEvent) { + e.preventDefault(); + const result = await submitStep(4, { + uploadSource, + emailConfig: uploadSource === "email" ? emailConfig : undefined, + watchDir: uploadSource === "folder" ? watchDir : undefined, + }); + if (result?.apiKey) { + setGeneratedApiKey(result.apiKey); + } + } + + function handleAiSubmit(e: React.FormEvent) { + e.preventDefault(); + submitStep(5, { aiProvider, aiModel }); + } + + function handleIntegrationsSubmit() { + submitStep(6, {}); + } + + function handleComplete() { + submitStep(7, {}); + } + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+

+ Set up your workspace +

+

+ Get Echo OCR ready in just a few steps +

+
+ + {/* Step indicator */} +
+ {STEPS.map((step, i) => { + const done = currentStep > i; + const active = currentStep === i; + const StepIcon = step.icon; + return ( +
+
+ {done ? ( + + ) : ( + + )} +
+ + {step.label} + + {i < STEPS.length - 1 && ( + + )} +
+ ); + })} +
+ + {error && ( +
+ {error} +
+ )} + +
+ {/* Step 1: Organization */} + {currentStep === 0 && ( +
+
+

Your Organization

+

+ Tell us about your church or organization. +

+
+ +
+ + + setOrg((p) => ({ ...p, name: e.target.value })) + } + required + placeholder="Grace Community Church" + /> +
+ +
+ + +
+ +
+ + +
+ + +
+ )} + + {/* Step 2: Locations */} + {currentStep === 1 && ( +
+
+

Locations

+

+ Add your campuses or physical locations. +

+
+ + {locations.map((loc, i) => ( +
+
+ + Location {i + 1} + + {locations.length > 1 && ( + + )} +
+
+ + + setLocations((prev) => + prev.map((l, idx) => + idx === i ? { ...l, name: e.target.value } : l + ) + ) + } + required + placeholder="Main Campus" + /> +
+
+ + + setLocations((prev) => + prev.map((l, idx) => + idx === i ? { ...l, address: e.target.value } : l + ) + ) + } + placeholder="123 Church St, City, ST 12345" + /> +
+
+ ))} + + + +
+ + +
+
+ )} + + {/* Step 3: Service Schedule */} + {currentStep === 2 && ( +
+
+

Service Schedule

+

+ When do you typically collect response cards? +

+
+ + {services.map((svc, i) => ( +
+
+ + Service {i + 1} + + {services.length > 1 && ( + + )} +
+
+ + + setServices((prev) => + prev.map((s, idx) => + idx === i ? { ...s, name: e.target.value } : s + ) + ) + } + placeholder="Sunday Morning" + /> +
+
+ + +
+
+
+ + + setServices((prev) => + prev.map((s, idx) => + idx === i + ? { ...s, timeStart: e.target.value } + : s + ) + ) + } + /> +
+
+ + + setServices((prev) => + prev.map((s, idx) => + idx === i ? { ...s, timeEnd: e.target.value } : s + ) + ) + } + /> +
+
+
+ ))} + + + +
+ + +
+
+ )} + + {/* Step 4: Upload Source */} + {currentStep === 3 && ( +
+
+

Upload Source

+

+ How will response cards get into Echo OCR? +

+
+ +
+ {[ + { + id: "manual", + icon: Upload, + label: "Manual Upload", + desc: "Upload scans directly", + }, + { + id: "email", + icon: Mail, + label: "Email Inbox", + desc: "Auto-import from email", + }, + { + id: "folder", + icon: FolderOpen, + label: "Folder Watch", + desc: "Watch a local folder", + }, + { + id: "api", + icon: Key, + label: "API", + desc: "Push via REST API", + }, + ].map((opt) => ( + + ))} +
+ + {uploadSource === "email" && ( +
+

IMAP Configuration

+
+
+ + + setEmailConfig((p) => ({ + ...p, + host: e.target.value, + })) + } + placeholder="imap.gmail.com" + /> +
+
+ + + setEmailConfig((p) => ({ + ...p, + port: parseInt(e.target.value) || 993, + })) + } + /> +
+
+
+ + + setEmailConfig((p) => ({ ...p, user: e.target.value })) + } + placeholder="echo-ocr@church.org" + /> +
+
+ + + setEmailConfig((p) => ({ ...p, pass: e.target.value })) + } + /> +
+
+ )} + + {uploadSource === "folder" && ( +
+ + setWatchDir(e.target.value)} + placeholder="/mnt/scans" + /> +
+ )} + + {generatedApiKey && ( +
+

+ Your API Key (save this — it won't be shown again): +

+ + {generatedApiKey} + +
+ )} + +
+ + +
+
+ )} + + {/* Step 5: AI Provider */} + {currentStep === 4 && ( +
+
+

AI Provider

+

+ Choose how Echo OCR processes your scanned cards. +

+
+ +
+ {[ + { + id: "gateway", + label: "AI Gateway (Recommended)", + desc: "Routes to OpenAI, Google, or Anthropic via Vercel AI Gateway. Best accuracy.", + }, + { + id: "ollama", + label: "Ollama (Self-hosted)", + desc: "Run models locally. Requires a GPU server on your network.", + }, + ].map((opt) => ( + + ))} +
+ +
+ + setAiModel(e.target.value)} + placeholder={ + aiProvider === "gateway" + ? "gpt-4o-mini (auto-selected if blank)" + : "llava:7b" + } + /> +
+ +
+ + +
+
+ )} + + {/* Step 6: Integrations (skippable) */} + {currentStep === 5 && ( +
+
+

Integrations

+

+ Where should processed cards go? You can configure these later + in Settings. +

+
+ +
+ {[ + { + id: "planning_center", + name: "Planning Center", + desc: "Sync people to PCO", + featured: true, + }, + { + id: "monday", + name: "Monday.com", + desc: "Push to Monday boards", + featured: false, + }, + { + id: "airtable", + name: "Airtable", + desc: "Add rows to Airtable bases", + featured: false, + }, + { + id: "google_sheets", + name: "Google Sheets", + desc: "Append to spreadsheets", + featured: false, + }, + { + id: "webhook", + name: "Webhook", + desc: "Send to any URL", + featured: false, + }, + { + id: "csv_export", + name: "CSV / Excel", + desc: "Auto-generate exports", + featured: false, + }, + ].map((int) => ( +
+ {int.featured && ( + + RECOMMENDED + + )} + + {int.name} + + {int.desc} + + + Configure in Settings + +
+ ))} +
+ +
+ + +
+
+ )} + + {/* Step 7: Complete */} + {currentStep === 6 && ( +
+
+ +
+
+

You're all set!

+

+ Your workspace is ready. Here's what you can do next: +

+
+ +
+ + + +
+ + +
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx index 35fa1f3..9cc2e17 100644 --- a/src/app/(dashboard)/page.tsx +++ b/src/app/(dashboard)/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useCallback } from "react"; import Link from "next/link"; import { CreditCard, @@ -11,28 +11,55 @@ import { AlertCircle, Clock, Users, + Plug, + UserPlus, + X, } from "lucide-react"; import { Header } from "@/components/layout/header"; import { Skeleton } from "@/components/ui/skeleton"; +import { Button } from "@/components/ui/button"; type Stats = { total: number; - byOcrStatus: { ocrStatus: string; _count: { id: number } }[]; - byReviewStatus: { reviewStatus: string; _count: { id: number } }[]; + byOcrStatus: Record; + byReviewStatus: Record; }; -function getStatCount( - groups: { ocrStatus?: string; reviewStatus?: string; _count: { id: number } }[], - key: string, - value: string -): number { - const match = groups.find((g) => (g as Record)[key] === value); - return match?._count.id ?? 0; +const HINTS_STORAGE_KEY = "echo-ocr-dismissed-hints"; + +function loadDismissedHints(): string[] { + if (typeof window === "undefined") return []; + try { + const raw = localStorage.getItem(HINTS_STORAGE_KEY); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +} + +function dismissHint(id: string) { + const current = loadDismissedHints(); + if (!current.includes(id)) { + localStorage.setItem( + HINTS_STORAGE_KEY, + JSON.stringify([...current, id]) + ); + } } export default function DashboardHomePage() { const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); + const [dismissedHints, setDismissedHints] = useState([]); + + useEffect(() => { + setDismissedHints(loadDismissedHints()); + }, []); + + const handleDismiss = useCallback((id: string) => { + dismissHint(id); + setDismissedHints((prev) => [...prev, id]); + }, []); useEffect(() => { fetch("/api/stats") @@ -42,10 +69,10 @@ export default function DashboardHomePage() { .finally(() => setLoading(false)); }, []); - const completed = stats ? getStatCount(stats.byOcrStatus, "ocrStatus", "complete") : 0; - const errors = stats ? getStatCount(stats.byOcrStatus, "ocrStatus", "error") : 0; - const pending = stats ? getStatCount(stats.byOcrStatus, "ocrStatus", "pending") + getStatCount(stats.byOcrStatus, "ocrStatus", "processing") : 0; - const unreviewed = stats ? getStatCount(stats.byReviewStatus, "reviewStatus", "unreviewed") : 0; + const completed = stats?.byOcrStatus?.complete ?? 0; + const errors = stats?.byOcrStatus?.error ?? 0; + const pending = (stats?.byOcrStatus?.pending ?? 0) + (stats?.byOcrStatus?.processing ?? 0); + const unreviewed = stats?.byReviewStatus?.unreviewed ?? 0; const summaryCards = [ { @@ -174,6 +201,112 @@ export default function DashboardHomePage() { })} + + {/* Contextual guidance cards */} + {!loading && ( + + )} + + ); +} + +function GuidanceCards({ + totalCards, + dismissedHints, + onDismiss, +}: { + totalCards: number; + dismissedHints: string[]; + onDismiss: (id: string) => void; +}) { + const hints = [ + { + id: "no_cards", + show: totalCards === 0, + icon: Upload, + title: "Upload your first response card", + description: + "Get started by scanning or importing a response card. You can upload PDFs, images, or use the email inbox.", + href: "/cards", + cta: "Go to Cards", + }, + { + id: "no_integrations", + show: true, + icon: Plug, + title: "Connect an integration", + description: + "Sync processed cards to Planning Center, Monday.com, Airtable, or any webhook endpoint.", + href: "/settings/integrations", + cta: "Set up Integrations", + }, + { + id: "invite_team", + show: true, + icon: UserPlus, + title: "Invite team members", + description: + "Add reviewers, editors, or admins to help process and manage response cards.", + href: "/settings/users", + cta: "Invite Members", + }, + { + id: "collection_days", + show: true, + icon: CalendarDays, + title: "Set up your service schedule", + description: + "Configure your weekly collection days to automatically assign cards to events.", + href: "/settings/locations", + cta: "Manage Schedule", + }, + ]; + + const visible = hints.filter( + (h) => h.show && !dismissedHints.includes(h.id) + ); + if (visible.length === 0) return null; + + return ( +
+

Getting Started

+
+ {visible.map((hint) => ( +
+
+ +
+
+

{hint.title}

+

+ {hint.description} +

+ + {hint.cta} + + +
+ +
+ ))} +
); } diff --git a/src/app/(dashboard)/settings/ai/page.tsx b/src/app/(dashboard)/settings/ai/page.tsx new file mode 100644 index 0000000..62f7a18 --- /dev/null +++ b/src/app/(dashboard)/settings/ai/page.tsx @@ -0,0 +1,196 @@ +"use client"; + +import * as React from "react"; +import { toast } from "sonner"; +import { Loader2, Save, Brain, Check } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +const AI_PROVIDERS = [ + { + value: "gateway", + label: "Vercel AI Gateway", + defaultModel: "openai/gpt-4o-mini", + hint: "openai/gpt-4o-mini, google/gemini-2.5-flash, anthropic/claude-sonnet-4-20250514", + }, + { + value: "ollama", + label: "Ollama (Local)", + defaultModel: "llava:7b", + hint: "llava:7b, moondream, llama3.2-vision", + }, +] as const; + +export default function AiSettingsPage() { + const [aiProvider, setAiProvider] = React.useState("gateway"); + const [aiModel, setAiModel] = React.useState(""); + const [ollamaUrl, setOllamaUrl] = React.useState(""); + const [loading, setLoading] = React.useState(true); + const [saving, setSaving] = React.useState(false); + const [testStatus, setTestStatus] = React.useState< + "idle" | "testing" | "success" | "error" + >("idle"); + + React.useEffect(() => { + fetch("/api/settings") + .then((r) => r.json()) + .then((data) => { + setAiProvider(data.aiProvider || "gateway"); + setAiModel(data.aiModel || ""); + setOllamaUrl(data.ollamaUrl || ""); + setLoading(false); + }) + .catch(() => setLoading(false)); + }, []); + + const handleSave = async () => { + setSaving(true); + try { + const res = await fetch("/api/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ aiProvider, aiModel, ollamaUrl }), + }); + if (!res.ok) throw new Error(); + toast.success("AI settings saved"); + } catch { + toast.error("Failed to save settings"); + } finally { + setSaving(false); + } + }; + + const testConnection = async () => { + setTestStatus("testing"); + try { + const res = await fetch("/api/ai-test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: aiProvider, model: aiModel, ollamaUrl }), + signal: AbortSignal.timeout(15000), + }); + if (res.ok) { + setTestStatus("success"); + toast.success("AI provider connected"); + } else { + setTestStatus("error"); + const data = await res.json().catch(() => ({})); + toast.error(data.error || "Test failed"); + } + } catch { + setTestStatus("error"); + toast.error("Cannot reach AI provider"); + } + }; + + const handleProviderChange = (value: string) => { + setAiProvider(value); + const provider = AI_PROVIDERS.find((p) => p.value === value); + if (provider) setAiModel(provider.defaultModel); + }; + + if (loading) { + return ( +
+ +
+ ); + } + + const currentProvider = AI_PROVIDERS.find((p) => p.value === aiProvider); + + return ( +
+ + + + + AI Provider Configuration + + + Choose the AI service that processes your scanned response cards. + + + +
+ + +
+ +
+ + setAiModel(e.target.value)} + placeholder={currentProvider?.defaultModel} + /> + {currentProvider && ( +

+ Options: {currentProvider.hint} +

+ )} +
+ + {aiProvider === "ollama" && ( +
+ + setOllamaUrl(e.target.value)} + placeholder="http://192.168.68.108:11434" + /> +
+ )} + + +
+
+ +
+ +
+
+ ); +} diff --git a/src/app/(dashboard)/settings/general/page.tsx b/src/app/(dashboard)/settings/general/page.tsx new file mode 100644 index 0000000..99960fa --- /dev/null +++ b/src/app/(dashboard)/settings/general/page.tsx @@ -0,0 +1,280 @@ +"use client"; + +import * as React from "react"; +import { toast } from "sonner"; +import { useTheme } from "next-themes"; +import { + Loader2, + Save, + Sun, + Moon, + Monitor, + Bell, + HardDrive, + Trash2, + RefreshCw, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; + +const NOTIFICATION_STORAGE_KEY = "echo-ocr-notification-prefs"; + +type NotificationPrefs = { + processingComplete: boolean; + processingErrors: boolean; + folderWatchAlerts: boolean; + cleanupReminders: boolean; +}; + +const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = { + processingComplete: true, + processingErrors: true, + folderWatchAlerts: true, + cleanupReminders: false, +}; + +function loadNotificationPrefs(): NotificationPrefs { + if (typeof window === "undefined") return DEFAULT_NOTIFICATION_PREFS; + try { + const raw = localStorage.getItem(NOTIFICATION_STORAGE_KEY); + if (raw) return { ...DEFAULT_NOTIFICATION_PREFS, ...JSON.parse(raw) }; + } catch {} + return DEFAULT_NOTIFICATION_PREFS; +} + +export default function GeneralSettingsPage() { + const { theme, setTheme } = useTheme(); + const [sourceRetention, setSourceRetention] = React.useState(30); + const [imageRetention, setImageRetention] = React.useState(180); + const [loading, setLoading] = React.useState(true); + const [saving, setSaving] = React.useState(false); + const [cleanupStatus, setCleanupStatus] = React.useState<{ + sourcesEligible: number; + imagesEligible: number; + } | null>(null); + const [cleaning, setCleaning] = React.useState(false); + const [notifPrefs, setNotifPrefs] = React.useState( + DEFAULT_NOTIFICATION_PREFS + ); + + React.useEffect(() => { + setNotifPrefs(loadNotificationPrefs()); + }, []); + + React.useEffect(() => { + fetch("/api/settings") + .then((r) => r.json()) + .then((data) => { + setSourceRetention(data.sourceRetentionDays || 30); + setImageRetention(data.imageRetentionDays || 180); + setLoading(false); + }) + .catch(() => setLoading(false)); + + fetch("/api/cleanup") + .then((r) => r.json()) + .then((data) => { + if (data.sourcesEligible !== undefined) setCleanupStatus(data); + }) + .catch(() => {}); + }, []); + + const updateNotifPref = (key: keyof NotificationPrefs, value: boolean) => { + setNotifPrefs((prev) => { + const next = { ...prev, [key]: value }; + try { + localStorage.setItem(NOTIFICATION_STORAGE_KEY, JSON.stringify(next)); + } catch {} + return next; + }); + toast.success("Notification preference updated"); + }; + + const handleSave = async () => { + setSaving(true); + try { + const res = await fetch("/api/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sourceRetentionDays: sourceRetention, + imageRetentionDays: imageRetention, + }), + }); + if (!res.ok) throw new Error(); + toast.success("Settings saved"); + } catch { + toast.error("Failed to save settings"); + } finally { + setSaving(false); + } + }; + + const runCleanup = async () => { + setCleaning(true); + try { + const res = await fetch("/api/cleanup", { method: "POST" }); + if (res.ok) { + const data = await res.json(); + toast.success( + `Cleaned ${data.sourcesDeleted ?? 0} sources and ${data.imagesDeleted ?? 0} images` + ); + setCleanupStatus({ sourcesEligible: 0, imagesEligible: 0 }); + } + } catch { + toast.error("Cleanup failed"); + } finally { + setCleaning(false); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Appearance */} + + + + + Appearance + + + +
+ {[ + { value: "light", icon: Sun, label: "Light" }, + { value: "dark", icon: Moon, label: "Dark" }, + { value: "system", icon: Monitor, label: "System" }, + ].map((opt) => ( + + ))} +
+
+
+ + {/* Notifications */} + + + + + Notifications + + + + {( + [ + ["processingComplete", "Processing Complete"], + ["processingErrors", "Processing Errors"], + ["folderWatchAlerts", "Folder Watch Alerts"], + ["cleanupReminders", "Cleanup Reminders"], + ] as const + ).map(([key, label]) => ( +
+ + updateNotifPref(key, v)} + /> +
+ ))} +
+
+ + {/* Storage & Retention */} + + + + + Storage & Retention + + + Automatically clean up old files to save storage. + + + +
+
+ + + setSourceRetention(parseInt(e.target.value) || 30) + } + /> +
+
+ + + setImageRetention(parseInt(e.target.value) || 180) + } + /> +
+
+ {cleanupStatus && ( +
+ + {cleanupStatus.sourcesEligible} sources,{" "} + {cleanupStatus.imagesEligible} images eligible for cleanup + + +
+ )} +
+
+ +
+ +
+
+ ); +} diff --git a/src/app/(dashboard)/settings/integrations/[id]/page.tsx b/src/app/(dashboard)/settings/integrations/[id]/page.tsx new file mode 100644 index 0000000..76daae5 --- /dev/null +++ b/src/app/(dashboard)/settings/integrations/[id]/page.tsx @@ -0,0 +1,406 @@ +"use client"; + +import * as React from "react"; +import { useRouter, useParams } from "next/navigation"; +import { toast } from "sonner"; +import { + Loader2, + ArrowLeft, + Save, + Plug, + Trash2, + CheckCircle2, + XCircle, + RefreshCw, + Zap, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; + +type Integration = { + id: string; + provider: string; + name: string; + enabled: boolean; + config: Record; + fieldMapping: Record | null; + triggerEvents: string[] | null; + syncDirection: string; + lastSyncAt: string | null; + lastSyncStatus: string | null; +}; + +const TRIGGER_EVENTS = [ + { value: "ocr_complete", label: "OCR Complete" }, + { value: "card_reviewed", label: "Card Reviewed" }, + { value: "card_exported", label: "Card Exported" }, +]; + +export default function IntegrationDetailPage() { + const router = useRouter(); + const params = useParams(); + const id = params.id as string; + + const [integration, setIntegration] = React.useState( + null + ); + const [loading, setLoading] = React.useState(true); + const [saving, setSaving] = React.useState(false); + const [testing, setTesting] = React.useState(false); + const [testResult, setTestResult] = React.useState<{ + success: boolean; + message: string; + } | null>(null); + const [syncing, setSyncing] = React.useState(false); + const [deleting, setDeleting] = React.useState(false); + const [confirmDelete, setConfirmDelete] = React.useState(false); + + React.useEffect(() => { + fetch(`/api/integrations/${id}`) + .then((r) => r.json()) + .then((data) => { + setIntegration(data.integration); + setLoading(false); + }) + .catch(() => setLoading(false)); + }, [id]); + + const handleSave = async () => { + if (!integration) return; + setSaving(true); + try { + const res = await fetch(`/api/integrations/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: integration.name, + enabled: integration.enabled, + config: integration.config, + triggerEvents: integration.triggerEvents, + }), + }); + if (res.ok) { + toast.success("Integration saved"); + } else { + toast.error("Failed to save"); + } + } catch { + toast.error("Failed to save"); + } finally { + setSaving(false); + } + }; + + const handleTest = async () => { + setTesting(true); + setTestResult(null); + try { + const res = await fetch(`/api/integrations/${id}/test`, { + method: "POST", + }); + const data = await res.json(); + setTestResult(data); + if (data.success) { + toast.success(data.message); + } else { + toast.error(data.message); + } + } catch { + setTestResult({ success: false, message: "Test request failed" }); + } finally { + setTesting(false); + } + }; + + const handleSync = async () => { + setSyncing(true); + try { + const res = await fetch(`/api/integrations/${id}/sync`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const data = await res.json(); + toast.success( + `Synced ${data.synced} of ${data.total} cards${data.failed ? ` (${data.failed} failed)` : ""}` + ); + } catch { + toast.error("Sync failed"); + } finally { + setSyncing(false); + } + }; + + const handleDelete = async () => { + if (!confirmDelete) { + setConfirmDelete(true); + return; + } + setDeleting(true); + try { + const res = await fetch(`/api/integrations/${id}`, { + method: "DELETE", + }); + if (res.ok) { + toast.success("Integration deleted"); + router.push("/settings/integrations"); + } else { + toast.error("Failed to delete"); + } + } catch { + toast.error("Failed to delete"); + } finally { + setDeleting(false); + } + }; + + const updateConfig = (key: string, value: string) => { + setIntegration((prev) => { + if (!prev) return prev; + return { + ...prev, + config: { ...prev.config, [key]: value }, + }; + }); + }; + + const toggleTriggerEvent = (event: string) => { + setIntegration((prev) => { + if (!prev) return prev; + const events = prev.triggerEvents || []; + const next = events.includes(event) + ? events.filter((e) => e !== event) + : [...events, event]; + return { ...prev, triggerEvents: next }; + }); + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (!integration) { + return ( +
+ +

+ Integration not found. +

+
+ ); + } + + return ( +
+
+ +
+ + setIntegration((prev) => + prev ? { ...prev, enabled: v } : prev + ) + } + /> + + {integration.enabled ? "Enabled" : "Disabled"} + +
+
+ + {/* Status */} +
+ + {integration.lastSyncStatus === "connected" || + integration.lastSyncStatus === "success" ? ( + + ) : integration.lastSyncStatus === "error" ? ( + + ) : null} + {integration.lastSyncStatus || "Not tested"} + + {integration.lastSyncAt && ( + + Last activity:{" "} + {new Date(integration.lastSyncAt).toLocaleString()} + + )} +
+ + {/* Configuration */} + + + + + Configuration + + + +
+ + + setIntegration((prev) => + prev ? { ...prev, name: e.target.value } : prev + ) + } + /> +
+ + {Object.entries(integration.config).map(([key, value]) => { + if (key.startsWith("_") || key === "columnMap") return null; + const isSecret = + key.toLowerCase().includes("token") || + key.toLowerCase().includes("secret") || + key.toLowerCase().includes("password"); + return ( +
+ + updateConfig(key, e.target.value)} + /> +
+ ); + })} +
+
+ + {/* Trigger Events */} + + + + + Trigger Events + + + When should this integration fire? + + + + {TRIGGER_EVENTS.map((evt) => ( +
+ toggleTriggerEvent(evt.value)} + /> + +
+ ))} +
+
+ + {/* Actions */} +
+ + + +
+ + {testResult && ( +
+ {testResult.message} +
+ )} + + {/* Danger Zone */} + + +
+

+ Delete this integration +

+

+ This cannot be undone. +

+
+ +
+
+
+ ); +} diff --git a/src/app/(dashboard)/settings/integrations/new/page.tsx b/src/app/(dashboard)/settings/integrations/new/page.tsx new file mode 100644 index 0000000..503aa2a --- /dev/null +++ b/src/app/(dashboard)/settings/integrations/new/page.tsx @@ -0,0 +1,246 @@ +"use client"; + +import * as React from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { toast } from "sonner"; +import { Loader2, ArrowLeft, Plug, Plus } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; + +type ProviderInfo = { + id: string; + name: string; + description: string; + configFields: ConfigField[]; + supportsOAuth: boolean; +}; + +type ConfigField = { + key: string; + label: string; + type: string; + placeholder?: string; + required?: boolean; + helpText?: string; + options?: { value: string; label: string }[]; +}; + +export default function NewIntegrationPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const preselectedProvider = searchParams.get("provider") || ""; + + const [providers, setProviders] = React.useState([]); + const [selectedProvider, setSelectedProvider] = React.useState(preselectedProvider); + const [name, setName] = React.useState(""); + const [config, setConfig] = React.useState>({}); + const [loading, setLoading] = React.useState(true); + const [creating, setCreating] = React.useState(false); + + React.useEffect(() => { + fetch("/api/integrations") + .then((r) => r.json()) + .then((data) => { + setProviders(data.providers || []); + setLoading(false); + }) + .catch(() => setLoading(false)); + }, []); + + React.useEffect(() => { + if (selectedProvider) { + const p = providers.find((pr) => pr.id === selectedProvider); + if (p && !name) { + setName(p.name); + } + } + }, [selectedProvider, providers, name]); + + const provider = providers.find((p) => p.id === selectedProvider); + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedProvider) { + toast.error("Select a provider"); + return; + } + + setCreating(true); + try { + const res = await fetch("/api/integrations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: selectedProvider, + name: name || provider?.name || selectedProvider, + config, + }), + }); + + if (res.ok) { + const data = await res.json(); + toast.success("Integration created"); + + if (provider?.supportsOAuth) { + window.location.href = `/api/integrations/oauth/${selectedProvider}/authorize?integrationId=${data.integration.id}`; + } else { + router.push(`/settings/integrations/${data.integration.id}`); + } + } else { + const data = await res.json(); + toast.error(data.error || "Failed to create integration"); + } + } catch { + toast.error("Failed to create integration"); + } finally { + setCreating(false); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ + + {!selectedProvider ? ( +
+

Choose a provider

+
+ {providers.map((p) => ( + + ))} +
+
+ ) : ( + + + + + Configure {provider?.name} + + {provider?.description} + + +
+
+ + setName(e.target.value)} + placeholder={provider?.name || "My Integration"} + /> +
+ + {provider?.configFields.map((field) => ( +
+ + {field.type === "select" && field.options ? ( + + ) : ( + + setConfig((c) => ({ + ...c, + [field.key]: e.target.value, + })) + } + placeholder={field.placeholder} + required={field.required} + /> + )} + {field.helpText && ( +

+ {field.helpText} +

+ )} +
+ ))} + + {provider?.supportsOAuth && ( +

+ After creating, you'll be redirected to authorize with{" "} + {provider.name}. +

+ )} + +
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/settings/integrations/page.tsx b/src/app/(dashboard)/settings/integrations/page.tsx new file mode 100644 index 0000000..7874a05 --- /dev/null +++ b/src/app/(dashboard)/settings/integrations/page.tsx @@ -0,0 +1,205 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { toast } from "sonner"; +import { + Loader2, + Plug, + Plus, + CheckCircle2, + XCircle, + Clock, + ExternalLink, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; + +type Integration = { + id: string; + provider: string; + name: string; + enabled: boolean; + lastSyncAt: string | null; + lastSyncStatus: string | null; +}; + +type ProviderInfo = { + id: string; + name: string; + description: string; + icon: string; + category: string; + supportsOAuth: boolean; +}; + +const PROVIDER_ICONS: Record = { + planning_center: "PCO", + monday: "M", + airtable: "AT", + google_sheets: "GS", + webhook: "WH", + csv_export: "CSV", +}; + +const FEATURED_PROVIDERS = ["planning_center"]; + +export default function IntegrationsPage() { + const [integrations, setIntegrations] = React.useState([]); + const [providers, setProviders] = React.useState([]); + const [loading, setLoading] = React.useState(true); + + React.useEffect(() => { + fetch("/api/integrations") + .then((r) => r.json()) + .then((data) => { + setIntegrations(data.integrations || []); + setProviders(data.providers || []); + setLoading(false); + }) + .catch(() => setLoading(false)); + }, []); + + if (loading) { + return ( +
+ +
+ ); + } + + const connectedProviderIds = new Set(integrations.map((i) => i.provider)); + const availableProviders = providers.filter( + (p) => !connectedProviderIds.has(p.id) + ); + + return ( +
+ {/* Connected Integrations */} + {integrations.length > 0 && ( +
+

+ Connected +

+
+ {integrations.map((int) => { + const provider = providers.find((p) => p.id === int.provider); + return ( + + + +
+ {PROVIDER_ICONS[int.provider] || "?"} +
+
+
+

+ {int.name} +

+ {int.enabled ? ( + + + Active + + ) : ( + + Disabled + + )} +
+

+ {provider?.name || int.provider} + {int.lastSyncAt && ( + <> + {" · Last sync "} + {new Date(int.lastSyncAt).toLocaleDateString()} + + )} +

+
+ +
+
+ + ); + })} +
+
+ )} + + {/* Available Providers */} +
+

+ Available Integrations +

+
+ {(availableProviders.length > 0 + ? availableProviders + : providers + ).map((provider) => { + const isConnected = connectedProviderIds.has(provider.id); + const isFeatured = FEATURED_PROVIDERS.includes(provider.id); + return ( + + {isFeatured && ( + + RECOMMENDED + + )} + +
+ {PROVIDER_ICONS[provider.id] || } +
+
+

{provider.name}

+

+ {provider.description} +

+
+ {isConnected ? ( + + Connected + + ) : ( + + + + )} +
+
+ ); + })} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/settings/layout.tsx b/src/app/(dashboard)/settings/layout.tsx new file mode 100644 index 0000000..2189821 --- /dev/null +++ b/src/app/(dashboard)/settings/layout.tsx @@ -0,0 +1,66 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + Settings, + Upload, + Brain, + Plug, + Users, + MapPin, + Building2, +} from "lucide-react"; +import { Header } from "@/components/layout/header"; +import { cn } from "@/lib/utils"; + +const SECTIONS = [ + { href: "/settings/general", label: "General", icon: Settings }, + { href: "/settings/upload-sources", label: "Upload Sources", icon: Upload }, + { href: "/settings/ai", label: "AI Provider", icon: Brain }, + { href: "/settings/integrations", label: "Integrations", icon: Plug }, + { href: "/settings/users", label: "Team Members", icon: Users }, + { href: "/settings/locations", label: "Locations", icon: MapPin }, + { href: "/settings/organization", label: "Organization", icon: Building2 }, +]; + +export default function SettingsLayout({ + children, +}: { + children: React.ReactNode; +}) { + const pathname = usePathname(); + + return ( +
+
+
+ +
{children}
+
+
+ ); +} diff --git a/src/app/(dashboard)/settings/locations/page.tsx b/src/app/(dashboard)/settings/locations/page.tsx index defaf57..1d5a90e 100644 --- a/src/app/(dashboard)/settings/locations/page.tsx +++ b/src/app/(dashboard)/settings/locations/page.tsx @@ -1,19 +1,200 @@ "use client"; -import { MapPin } from "lucide-react"; -import { Header } from "@/components/layout/header"; +import * as React from "react"; +import { toast } from "sonner"; +import { + Loader2, + Plus, + MapPin, + CalendarDays, + Trash2, + Save, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; + +type Location = { + id: string; + name: string; + address: string | null; + collectionDays: CollectionDay[]; +}; + +type CollectionDay = { + id: string; + name: string; + dayOfWeek: number | null; + timeStart: string | null; + timeEnd: string | null; + isActive: boolean; +}; + +const DAYS = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +]; export default function LocationsSettingsPage() { + const [locations, setLocations] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [newLocName, setNewLocName] = React.useState(""); + const [newLocAddress, setNewLocAddress] = React.useState(""); + const [adding, setAdding] = React.useState(false); + + const fetchData = React.useCallback(async () => { + try { + const res = await fetch("/api/org/locations"); + if (res.ok) { + const data = await res.json(); + setLocations(data.locations || []); + } + } catch {} finally { + setLoading(false); + } + }, []); + + React.useEffect(() => { + fetchData(); + }, [fetchData]); + + const handleAddLocation = async (e: React.FormEvent) => { + e.preventDefault(); + setAdding(true); + try { + const res = await fetch("/api/org/locations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: newLocName, address: newLocAddress }), + }); + if (res.ok) { + toast.success("Location added"); + setNewLocName(""); + setNewLocAddress(""); + fetchData(); + } else { + toast.error("Failed to add location"); + } + } catch { + toast.error("Failed to add location"); + } finally { + setAdding(false); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + return (
-
-
- Location management will be available after the org model is set up. -
+ {/* Add Location */} + + + + + Add Location + + + +
+
+ + setNewLocName(e.target.value)} + placeholder="Main Campus" + required + /> +
+
+ + setNewLocAddress(e.target.value)} + placeholder="123 Church St" + /> +
+ +
+
+
+ + {/* Existing Locations */} + {locations.map((loc) => ( + + + + + {loc.name} + + {loc.address && ( + {loc.address} + )} + + +
+

+ + Collection Days +

+ {loc.collectionDays.length > 0 ? ( +
+ {loc.collectionDays.map((cd) => ( +
+ {cd.name} + + {cd.dayOfWeek != null && DAYS[cd.dayOfWeek]} + {cd.timeStart && ` ${cd.timeStart}`} + {cd.timeEnd && `–${cd.timeEnd}`} + +
+ ))} +
+ ) : ( +

+ No collection days configured. +

+ )} +
+
+
+ ))} + + {locations.length === 0 && ( +
+ No locations yet — add one above. +
+ )}
); } diff --git a/src/app/(dashboard)/settings/organization/page.tsx b/src/app/(dashboard)/settings/organization/page.tsx index 4bf6e4e..bd22232 100644 --- a/src/app/(dashboard)/settings/organization/page.tsx +++ b/src/app/(dashboard)/settings/organization/page.tsx @@ -1,19 +1,190 @@ "use client"; -import { Building2 } from "lucide-react"; -import { Header } from "@/components/layout/header"; +import * as React from "react"; +import { useSession } from "next-auth/react"; +import { toast } from "sonner"; +import { Loader2, Save, Building2, Globe, AlertTriangle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +const ORG_TYPES = [ + { value: "church", label: "Church" }, + { value: "ministry", label: "Ministry" }, + { value: "nonprofit", label: "Nonprofit" }, + { value: "other", label: "Other" }, +]; + +type OrgData = { + id: string; + name: string; + slug: string; + type: string; + timezone: string; +}; export default function OrganizationSettingsPage() { + const { data: session } = useSession(); + const [org, setOrg] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [saving, setSaving] = React.useState(false); + + React.useEffect(() => { + fetch("/api/org") + .then((r) => r.json()) + .then((data) => { + if (data.organization) setOrg(data.organization); + setLoading(false); + }) + .catch(() => setLoading(false)); + }, []); + + const handleSave = async () => { + if (!org) return; + setSaving(true); + try { + const res = await fetch("/api/org", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: org.name, + type: org.type, + timezone: org.timezone, + }), + }); + if (res.ok) { + toast.success("Organization updated"); + } else { + toast.error("Failed to update organization"); + } + } catch { + toast.error("Failed to update organization"); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (!org) { + return ( +
+ No organization found. Complete onboarding to set up your organization. +
+ ); + } + return (
-
-
- Organization settings will be available after the org model is set up. + + + + + Organization Details + + + +
+ + + setOrg((prev) => (prev ? { ...prev, name: e.target.value } : prev)) + } + /> +
+
+ + +

+ The slug is auto-generated and cannot be changed. +

+
+
+ + +
+
+ + + setOrg((prev) => + prev ? { ...prev, timezone: e.target.value } : prev + ) + } + /> +
+
+
+ +
+
+ + {/* Danger Zone */} + + + + + Danger Zone + + + These actions are irreversible and affect all members. + + + + +

+ Organization deletion is not yet available. Contact support. +

+
+
); } diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index cee083f..104136c 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -1,1291 +1,19 @@ "use client"; -import * as React from "react"; +import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import { toast } from "sonner"; -import { useTheme } from "next-themes"; -import { - Loader2, - Save, - Wifi, - WifiOff, - Trash2, - Brain, - FolderSearch, - HardDrive, - Settings, - Sun, - Moon, - Monitor, - Bell, - Mail, - LayoutGrid, - Globe, - Check, - RefreshCw, -} from "lucide-react"; +import { Loader2 } from "lucide-react"; -import { Header } from "@/components/layout/header"; -import { Button } from "@/components/ui/button"; -import { useUserProfile } from "@/lib/user-profile"; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Badge } from "@/components/ui/badge"; -import { Switch } from "@/components/ui/switch"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +export default function SettingsRedirect() { + const router = useRouter(); -const AI_PROVIDERS = [ - { - value: "gateway", - label: "Vercel AI Gateway", - defaultModel: "openai/gpt-4o-mini", - hint: "openai/gpt-4o-mini, google/gemini-2.5-flash, anthropic/claude-sonnet-4-20250514", - }, - { - value: "ollama", - label: "Ollama (Local)", - defaultModel: "llava:7b", - hint: "llava:7b, moondream, llama3.2-vision", - }, -] as const; - -import { Checkbox } from "@/components/ui/checkbox"; - -type MondayColumn = { id: string; title: string; type: string }; - -const MONDAY_MAPPABLE_FIELDS = [ - { field: "name", label: "Name" }, - { field: "gender", label: "Gender" }, - { field: "dateOfBirth", label: "Date of Birth" }, - { field: "maritalStatus", label: "Marital Status" }, - { field: "visitType", label: "Visit Type" }, - { field: "followUp", label: "Follow-Up" }, - { field: "cellPhone", label: "Cell Phone" }, - { field: "homePhone", label: "Home Phone" }, - { field: "email", label: "Email" }, - { field: "address", label: "Address" }, - { field: "aptNumber", label: "Apt #" }, - { field: "city", label: "City" }, - { field: "state", label: "State" }, - { field: "zip", label: "Zip" }, - { field: "prayerRequests", label: "Prayer Requests" }, - { field: "prayerForTeam", label: "For Prayer Team" }, - { field: "prayerConfidential", label: "Confidential" }, - { field: "messageTopics", label: "Message Topics" }, - { field: "messageTopicsOther", label: "Other - Topics" }, - { field: "nextStep", label: "Next Step" }, - { field: "attendanceDuration", label: "Attendance Duration" }, - { field: "campusPreference", label: "Campus Preference" }, - { field: "campusPreferenceOther", label: "Other Location" }, - { field: "howHeard", label: "How Did You Hear" }, - { field: "howHeardOther", label: "Other - How Heard" }, - { field: "serviceAttended", label: "A B C D" }, - { field: "serviceTime", label: "Service Time" }, - { field: "notes", label: "Notes" }, - { field: "planningCenter", label: "Planning Center" }, - { field: "iSaidYesBookSent", label: "I Said Yes Book Sent" }, - { field: "ftGuestLetterSent", label: "FT Guest Letter Sent" }, - { field: "firstTimeGuestDate", label: "First Time Guest Date" }, - { field: "salvationDate", label: "Salvation Date" }, - { field: "reviewStatus", label: "Review Status" }, -] as const; - -type SettingsData = { - ollamaUrl: string; - model: string; - watchDir: string; - watching: boolean; - sourceRetentionDays: number; - imageRetentionDays: number; - aiProvider: string; - aiModel: string; - emailImapHost: string; - emailImapPort: number; - emailImapUser: string; - emailImapPass: string; - emailImapTls: boolean; - emailFolder: string; - emailWatching: boolean; - emailProcessed: string; - emailProcessedFolder: string; - mondayApiToken: string; - mondayBoardId: string; - mondayEnabled: boolean; - mondayColumnMap: Record | null; - mondayWebhookId: string; - mondayWebhookUrl: string; - webhookUrl: string; - webhookSecret: string; - webhookEnabled: boolean; - webhookEvents: string[] | null; -}; - -const NOTIFICATION_STORAGE_KEY = "echo-ocr-notification-prefs"; - -type NotificationPrefs = { - processingComplete: boolean; - processingErrors: boolean; - folderWatchAlerts: boolean; - cleanupReminders: boolean; -}; - -const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = { - processingComplete: true, - processingErrors: true, - folderWatchAlerts: true, - cleanupReminders: false, -}; - -function loadNotificationPrefs(): NotificationPrefs { - if (typeof window === "undefined") return DEFAULT_NOTIFICATION_PREFS; - try { - const raw = localStorage.getItem(NOTIFICATION_STORAGE_KEY); - if (raw) return { ...DEFAULT_NOTIFICATION_PREFS, ...JSON.parse(raw) }; - } catch {} - return DEFAULT_NOTIFICATION_PREFS; -} - -export default function SettingsPage() { - const { theme, setTheme } = useTheme(); - const { role, loading: userLoading } = useUserProfile(); - const settingsRouter = useRouter(); - - React.useEffect(() => { - if (!userLoading && role !== "admin") { - settingsRouter.replace("/"); - toast.error("Settings are restricted to admins"); - } - }, [role, userLoading, settingsRouter]); - - const [settings, setSettings] = React.useState({ - ollamaUrl: "", - model: "", - watchDir: "", - watching: false, - sourceRetentionDays: 30, - imageRetentionDays: 180, - aiProvider: "gateway", - aiModel: "", - emailImapHost: "imap.dreamhost.com", - emailImapPort: 993, - emailImapUser: "echo-ocr@stillwell.cloud", - emailImapPass: "", - emailImapTls: true, - emailFolder: "INBOX", - emailWatching: false, - emailProcessed: "mark_read", - emailProcessedFolder: "Processed", - mondayApiToken: "", - mondayBoardId: "", - mondayEnabled: false, - mondayColumnMap: null, - mondayWebhookId: "", - mondayWebhookUrl: "", - webhookUrl: "", - webhookSecret: "", - webhookEnabled: false, - webhookEvents: null, - }); - const [loading, setLoading] = React.useState(true); - const [saving, setSaving] = React.useState(false); - const [aiTestStatus, setAiTestStatus] = React.useState<"idle" | "testing" | "success" | "error">("idle"); - const [cleanupStatus, setCleanupStatus] = React.useState<{ sourcesEligible: number; imagesEligible: number } | null>(null); - const [cleaning, setCleaning] = React.useState(false); - const [emailTestStatus, setEmailTestStatus] = React.useState<"idle" | "testing" | "success" | "error">("idle"); - const [scanning, setScanning] = React.useState(false); - const [mondayColumns, setMondayColumns] = React.useState([]); - const [fetchingColumns, setFetchingColumns] = React.useState(false); - const [subscribing, setSubscribing] = React.useState(false); - const [pushingAll, setPushingAll] = React.useState(false); - const [webhookTestStatus, setWebhookTestStatus] = React.useState<"idle" | "testing" | "success" | "error">("idle"); - const [recoverCount, setRecoverCount] = React.useState(null); - const [recovering, setRecovering] = React.useState(false); - const [notifPrefs, setNotifPrefs] = React.useState(DEFAULT_NOTIFICATION_PREFS); - - React.useEffect(() => { - setNotifPrefs(loadNotificationPrefs()); - }, []); - - const updateNotifPref = (key: keyof NotificationPrefs, value: boolean) => { - setNotifPrefs((prev) => { - const next = { ...prev, [key]: value }; - try { localStorage.setItem(NOTIFICATION_STORAGE_KEY, JSON.stringify(next)); } catch {} - return next; - }); - toast.success("Notification preference updated"); - }; - - const fetchCleanupStatus = React.useCallback(() => { - fetch("/api/cleanup") - .then((r) => r.json()) - .then((data) => { - if (data.sourcesEligible !== undefined) setCleanupStatus(data); - }) - .catch(() => {}); - }, []); - - React.useEffect(() => { - fetch("/api/settings") - .then((r) => r.json()) - .then((data) => { - setSettings(data); - setLoading(false); - }) - .catch(() => { - setLoading(false); - }); - fetchCleanupStatus(); - fetch("/api/cards/recover-survey") - .then((r) => r.json()) - .then((data) => { if (data.missingCount !== undefined) setRecoverCount(data.missingCount); }) - .catch(() => {}); - }, [fetchCleanupStatus]); - - const handleSave = async () => { - setSaving(true); - try { - const res = await fetch("/api/settings", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(settings), - }); - if (!res.ok) throw new Error(); - toast.success("Settings saved"); - } catch { - toast.error("Failed to save settings"); - } finally { - setSaving(false); - } - }; - - const testAiProvider = async () => { - setAiTestStatus("testing"); - try { - const res = await fetch("/api/ai-test", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - provider: settings.aiProvider, - model: settings.aiModel, - ollamaUrl: settings.ollamaUrl, - }), - signal: AbortSignal.timeout(15000), - }); - if (res.ok) { - setAiTestStatus("success"); - toast.success("AI provider connected successfully"); - } else { - const data = await res.json().catch(() => ({})); - setAiTestStatus("error"); - toast.error(data.error || "AI provider test failed"); - } - } catch { - setAiTestStatus("error"); - toast.error("Cannot reach AI provider. Check your configuration and API keys."); - } - }; - - const handleProviderChange = (value: string | null) => { - if (!value) return; - const provider = AI_PROVIDERS.find((p) => p.value === value); - setSettings((s) => ({ - ...s, - aiProvider: value, - aiModel: provider?.defaultModel || "", - })); - setAiTestStatus("idle"); - }; - - const toggleWatch = async () => { - try { - const action = settings.watching ? "stop" : "start"; - const res = await fetch("/api/watch", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action, watchDir: settings.watchDir }), - }); - if (!res.ok) throw new Error(); - setSettings((s) => ({ ...s, watching: !s.watching })); - toast.success(action === "start" ? "Folder watching started" : "Folder watching stopped"); - } catch { - toast.error("Failed to toggle folder watching"); - } - }; - - const toggleEmailWatch = async () => { - try { - const action = settings.emailWatching ? "stop" : "start"; - const res = await fetch("/api/email-watch", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action }), - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data.error || "Failed"); - } - setSettings((s) => ({ ...s, emailWatching: !s.emailWatching })); - toast.success(action === "start" ? "Email monitoring started" : "Email monitoring stopped"); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to toggle email monitoring"); - } - }; - - const scanInbox = async () => { - setScanning(true); - try { - const res = await fetch("/api/email-watch", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "scan" }), - }); - const data = await res.json(); - if (!res.ok) { - toast.error(data.error || "Scan failed"); - } else if (data.processed === 0 && data.skipped === 0) { - toast.info("No unread emails found in inbox"); - } else { - const parts: string[] = []; - if (data.processed > 0) parts.push(`${data.processed} processed`); - if (data.skipped > 0) parts.push(`${data.skipped} skipped`); - toast.success(`Inbox scan: ${parts.join(", ")}`); - } - } catch { - toast.error("Failed to scan inbox"); - } finally { - setScanning(false); - } - }; - - const testEmailConnection = async () => { - setEmailTestStatus("testing"); - try { - const res = await fetch("/api/email-watch/test", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - host: settings.emailImapHost, - port: settings.emailImapPort, - user: settings.emailImapUser, - pass: settings.emailImapPass, - tls: settings.emailImapTls, - }), - signal: AbortSignal.timeout(15000), - }); - const data = await res.json().catch(() => ({})); - if (res.ok && data.ok) { - setEmailTestStatus("success"); - toast.success(data.message || "Connected successfully"); - } else { - setEmailTestStatus("error"); - toast.error(data.error || "Connection failed"); - } - } catch { - setEmailTestStatus("error"); - toast.error("Cannot reach email server. Check your configuration."); - } - }; - - const fetchMondayColumns = async () => { - setFetchingColumns(true); - try { - const res = await fetch("/api/integrations/monday/columns", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: settings.mondayApiToken, boardId: settings.mondayBoardId }), - }); - const data = await res.json(); - if (res.ok && data.columns) { - setMondayColumns(data.columns); - const typeMap: Record = {}; - for (const col of data.columns as MondayColumn[]) { - typeMap[col.id] = col.type; - } - setSettings((s) => ({ - ...s, - mondayColumnMap: { ...(s.mondayColumnMap || {}), _columnTypes: typeMap }, - })); - toast.success(`Found ${data.columns.length} columns`); - } else { - toast.error(data.error || "Failed to fetch columns"); - } - } catch { - toast.error("Failed to connect to Monday.com"); - } finally { - setFetchingColumns(false); - } - }; - - const toggleMondaySubscription = async () => { - setSubscribing(true); - try { - const isSubscribed = !!settings.mondayWebhookId; - const callbackUrl = settings.mondayWebhookUrl || `${window.location.origin}/api/integrations/monday/webhook`; - const res = await fetch("/api/integrations/monday/subscribe", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: isSubscribed ? "unsubscribe" : "subscribe", - callbackUrl, - }), - }); - const data = await res.json(); - if (res.ok) { - setSettings((s) => ({ - ...s, - mondayWebhookId: data.webhookId || "", - mondayWebhookUrl: isSubscribed ? "" : callbackUrl, - })); - toast.success(isSubscribed ? "Unsubscribed from Monday.com changes" : "Subscribed to Monday.com changes"); - } else { - toast.error(data.error || "Failed"); - } - } catch { - toast.error("Subscription change failed"); - } finally { - setSubscribing(false); - } - }; - - const syncAllToMonday = async () => { - setPushingAll(true); - try { - const res = await fetch("/api/integrations/monday/sync-all", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ mode: "all" }), - }); - const data = await res.json(); - if (res.ok) { - if (data.synced === 0 && data.failed === 0) { - toast.success(data.message || "All cards are already synced"); - } else { - const parts: string[] = []; - if (data.created > 0) parts.push(`${data.created} pushed`); - if (data.updated > 0) parts.push(`${data.updated} updated`); - if (data.failed > 0) parts.push(`${data.failed} failed`); - const msg = parts.join(", "); - if (data.failed > 0 && data.synced === 0) { - toast.error(`Monday.com: ${msg}`, { description: data.errors?.[0] }); - } else if (data.failed > 0) { - toast.warning(`Monday.com: ${msg}`); - } else { - toast.success(`Monday.com: ${msg}`); - } - } - } else { - toast.error(data.error || "Sync failed"); - } - } catch { - toast.error("Failed to sync cards to Monday.com"); - } finally { - setPushingAll(false); - } - }; - - const testWebhook = async () => { - setWebhookTestStatus("testing"); - try { - const res = await fetch("/api/integrations/webhook/test", { method: "POST" }); - const data = await res.json(); - if (data.ok) { - setWebhookTestStatus("success"); - toast.success("Test webhook sent"); - } else { - setWebhookTestStatus("error"); - toast.error(data.error || "Webhook test failed"); - } - } catch { - setWebhookTestStatus("error"); - toast.error("Webhook test failed"); - } - }; - - const setColumnMapping = (cardField: string, colId: string) => { - setSettings((s) => ({ - ...s, - mondayColumnMap: { ...(s.mondayColumnMap || {}), [cardField]: colId }, - })); - }; - - const toggleWebhookEvent = (event: string) => { - setSettings((s) => { - const current = s.webhookEvents ?? []; - const next = current.includes(event) ? current.filter((e) => e !== event) : [...current, event]; - return { ...s, webhookEvents: next }; - }); - }; - - const recoverSurveys = async () => { - setRecovering(true); - try { - const res = await fetch("/api/cards/recover-survey", { method: "POST" }); - const data = await res.json(); - if (res.ok) { - toast.success(data.message || `Recovery started for ${data.queued} card(s)`); - setRecoverCount(0); - } else { - toast.error(data.error || "Recovery failed"); - } - } catch { - toast.error("Failed to start recovery"); - } finally { - setRecovering(false); - } - }; - - const runCleanup = async () => { - setCleaning(true); - try { - const res = await fetch("/api/cleanup", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ dryRun: false }), - }); - if (!res.ok) throw new Error(); - const data = await res.json(); - toast.success( - `Cleanup complete: ${data.sourcesDeleted} source files, ${data.imagesDeleted} images, ${data.jobsPurged} jobs removed` - ); - fetchCleanupStatus(); - } catch { - toast.error("Cleanup failed"); - } finally { - setCleaning(false); - } - }; - - if (loading) { - return ( -
- -
- ); - } - - const themeOptions = [ - { value: "light", label: "Light", icon: Sun, description: "Clean and bright interface" }, - { value: "dark", label: "Dark", icon: Moon, description: "Easy on the eyes in low light" }, - { value: "system", label: "System", icon: Monitor, description: "Follows your OS preference" }, - ] as const; + useEffect(() => { + router.replace("/settings/general"); + }, [router]); return ( -
-
- -
- - - - Application - Preferences - - - -
- - -
-
- - - AI Provider - - Choose the AI model for OCR processing -
- - {aiTestStatus === "success" && } - {aiTestStatus === "error" && } - {aiTestStatus === "testing" && } - {aiTestStatus === "success" - ? "Connected" - : aiTestStatus === "error" - ? "Error" - : aiTestStatus === "testing" - ? "Testing..." - : "Not tested"} - -
-
- -
- - -
-
- - setSettings((s) => ({ ...s, aiModel: e.target.value }))} - placeholder={AI_PROVIDERS.find((p) => p.value === settings.aiProvider)?.defaultModel || ""} - /> -

- {AI_PROVIDERS.find((p) => p.value === settings.aiProvider)?.hint || ""} -

-
- - {settings.aiProvider === "ollama" && ( -
- - setSettings((s) => ({ ...s, ollamaUrl: e.target.value }))} - placeholder="http://192.168.68.108:11434" - /> -
- )} - - {settings.aiProvider === "gateway" && ( -
-

- Uses the AI_GATEWAY_API_KEY env - var. Model format: provider/model -

-
- )} - - -
-
- - - - - - Folder Monitoring - - Automatically process new PDFs dropped into a folder - - -
- - setSettings((s) => ({ ...s, watchDir: e.target.value }))} - placeholder="/data/watch" - /> -

- Absolute path on the server. Mount a host folder into the container. -

-
-
- - {settings.watching && ( - - Active - - )} -
-
-
- - - - - - Storage & Cleanup - - Auto-purge uploaded files and images to save storage - - -
- - - setSettings((s) => ({ - ...s, - sourceRetentionDays: parseInt(e.target.value) || 30, - })) - } - /> -

- Original uploaded PDFs are deleted after this many days. Card data is kept. -

-
-
- - - setSettings((s) => ({ - ...s, - imageRetentionDays: parseInt(e.target.value) || 180, - })) - } - /> -

- Scanned card images are removed after this many days. Card data is kept. -

-
-
-
-
- {cleanupStatus ? ( - <> - {cleanupStatus.sourcesEligible} source files - {" and "} - {cleanupStatus.imagesEligible} card images eligible - - ) : ( - "Checking..." - )} -
- -
-
- - {recoverCount !== null && recoverCount > 0 && ( -
-
-
- {recoverCount}{" "} - card(s) are missing their survey (back) side. - - Re-extracts missing pages from the original source PDFs and runs OCR. - -
- -
-
- )} -
-
- - - -
-
- - - Email Monitoring - - Watch an inbox for scanned card attachments -
- - {emailTestStatus === "success" && } - {emailTestStatus === "error" && } - {emailTestStatus === "testing" && } - {emailTestStatus === "success" - ? "Connected" - : emailTestStatus === "error" - ? "Error" - : emailTestStatus === "testing" - ? "Testing..." - : "Not tested"} - -
-
- -
-
- - setSettings((s) => ({ ...s, emailImapHost: e.target.value }))} - placeholder="imap.dreamhost.com" - /> -
-
- - setSettings((s) => ({ ...s, emailImapPort: parseInt(e.target.value) || 993 }))} - placeholder="993" - /> -
-
-
-
- - setSettings((s) => ({ ...s, emailImapUser: e.target.value }))} - placeholder="echo-ocr@stillwell.cloud" - /> -
-
- - setSettings((s) => ({ ...s, emailImapPass: e.target.value }))} - placeholder="••••••••" - /> -
-
-
-
- - setSettings((s) => ({ ...s, emailFolder: e.target.value }))} - placeholder="INBOX" - /> -
-
- - -
-
- {settings.emailProcessed === "move" && ( -
- - setSettings((s) => ({ ...s, emailProcessedFolder: e.target.value }))} - placeholder="Processed" - /> -
- )} -
- - setSettings((s) => ({ ...s, emailImapTls: val }))} - /> -
-
- - - - {settings.emailWatching && ( - - Active - - )} -
-
-
- - - -
-
- - - Monday.com Integration - - Bidirectional sync with Monday.com boards -
- {settings.mondayEnabled && ( - - Enabled - - )} -
-
- -
-
- - setSettings((s) => ({ ...s, mondayApiToken: e.target.value }))} - placeholder="••••••••" - /> -
-
- - setSettings((s) => ({ ...s, mondayBoardId: e.target.value }))} - placeholder="1234567890" - /> -
-
-
- - setSettings((s) => ({ ...s, mondayWebhookUrl: e.target.value }))} - placeholder="https://your-app.com/api/integrations/monday/webhook" - /> -
- -
- - - {settings.mondayWebhookId && ( - Subscribed - )} -
- - {mondayColumns.length > 0 && ( -
-

Column Mapping

-
- {MONDAY_MAPPABLE_FIELDS.map(({ field, label }) => ( -
- {label} - -
- ))} -
- Files (images) - -
-
-
- )} - -
-
- - setSettings((s) => ({ ...s, mondayEnabled: val }))} - /> -
- -
-
-
- - - -
-
- - - Webhook Integration - - POST card data to any external URL on events -
- {settings.webhookEnabled && ( - - Enabled - - )} -
-
- -
- - setSettings((s) => ({ ...s, webhookUrl: e.target.value }))} - placeholder="https://example.com/webhook" - /> -
-
- - setSettings((s) => ({ ...s, webhookSecret: e.target.value }))} - placeholder="••••••••" - /> -
-
- -
- {[ - { value: "ocr_complete", label: "OCR Complete" }, - { value: "card_reviewed", label: "Card Reviewed" }, - { value: "card_exported", label: "Card Exported" }, - { value: "card_deleted", label: "Card Deleted" }, - ].map((evt) => ( - - ))} -
-
-
- - setSettings((s) => ({ ...s, webhookEnabled: val }))} - /> -
- -
-
-
-
- - -
- - - - - - Appearance - - Choose how Echo OCR looks to you - - -
- {themeOptions.map((opt) => { - const Icon = opt.icon; - const active = theme === opt.value; - return ( - - ); - })} -
-
-
- - - - - - Notifications - - Control which events generate toast notifications - - -
- {([ - { key: "processingComplete" as const, title: "Processing complete", desc: "When a PDF finishes OCR processing" }, - { key: "processingErrors" as const, title: "Processing errors", desc: "When a job fails or encounters an error" }, - { key: "folderWatchAlerts" as const, title: "Folder watch alerts", desc: "When the folder watcher starts, stops, or finds new files" }, - { key: "cleanupReminders" as const, title: "Cleanup reminders", desc: "Periodic reminders when files are eligible for cleanup" }, - ]).map((item) => ( -
-
-

{item.title}

-

{item.desc}

-
- updateNotifPref(item.key, val)} - /> -
- ))} -
-
-
-
-
-
+
+
); } diff --git a/src/app/(dashboard)/settings/upload-sources/page.tsx b/src/app/(dashboard)/settings/upload-sources/page.tsx new file mode 100644 index 0000000..3d6b049 --- /dev/null +++ b/src/app/(dashboard)/settings/upload-sources/page.tsx @@ -0,0 +1,358 @@ +"use client"; + +import * as React from "react"; +import { toast } from "sonner"; +import { + Loader2, + Save, + Mail, + FolderSearch, + Wifi, + WifiOff, + Check, + RefreshCw, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +type EmailSettings = { + emailImapHost: string; + emailImapPort: number; + emailImapUser: string; + emailImapPass: string; + emailImapTls: boolean; + emailFolder: string; + emailWatching: boolean; + emailProcessed: string; + emailProcessedFolder: string; + watchDir: string; + watching: boolean; +}; + +export default function UploadSourcesPage() { + const [settings, setSettings] = React.useState({ + emailImapHost: "", + emailImapPort: 993, + emailImapUser: "", + emailImapPass: "", + emailImapTls: true, + emailFolder: "INBOX", + emailWatching: false, + emailProcessed: "mark_read", + emailProcessedFolder: "Processed", + watchDir: "", + watching: false, + }); + const [loading, setLoading] = React.useState(true); + const [saving, setSaving] = React.useState(false); + const [emailTestStatus, setEmailTestStatus] = React.useState< + "idle" | "testing" | "success" | "error" + >("idle"); + const [scanning, setScanning] = React.useState(false); + + React.useEffect(() => { + fetch("/api/settings") + .then((r) => r.json()) + .then((data) => { + setSettings({ + emailImapHost: data.emailImapHost || "", + emailImapPort: data.emailImapPort || 993, + emailImapUser: data.emailImapUser || "", + emailImapPass: data.emailImapPass || "", + emailImapTls: data.emailImapTls ?? true, + emailFolder: data.emailFolder || "INBOX", + emailWatching: data.emailWatching || false, + emailProcessed: data.emailProcessed || "mark_read", + emailProcessedFolder: data.emailProcessedFolder || "Processed", + watchDir: data.watchDir || "", + watching: data.watching || false, + }); + setLoading(false); + }) + .catch(() => setLoading(false)); + }, []); + + const handleSave = async () => { + setSaving(true); + try { + const res = await fetch("/api/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(settings), + }); + if (!res.ok) throw new Error(); + toast.success("Upload source settings saved"); + } catch { + toast.error("Failed to save settings"); + } finally { + setSaving(false); + } + }; + + const testEmail = async () => { + setEmailTestStatus("testing"); + try { + const res = await fetch("/api/email/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(settings), + }); + setEmailTestStatus(res.ok ? "success" : "error"); + if (res.ok) toast.success("Email connection successful"); + else toast.error("Email connection failed"); + } catch { + setEmailTestStatus("error"); + toast.error("Email test failed"); + } + }; + + const scanNow = async () => { + setScanning(true); + try { + const res = await fetch("/api/email/scan", { method: "POST" }); + if (res.ok) { + const data = await res.json(); + toast.success(`Scanned ${data.processed || 0} emails`); + } + } catch { + toast.error("Scan failed"); + } finally { + setScanning(false); + } + }; + + const toggleFolderWatch = async () => { + const endpoint = settings.watching ? "/api/watch/stop" : "/api/watch/start"; + try { + const res = await fetch(endpoint, { method: "POST" }); + if (res.ok) { + setSettings((s) => ({ ...s, watching: !s.watching })); + toast.success( + settings.watching ? "Folder watch stopped" : "Folder watch started" + ); + } + } catch { + toast.error("Failed to toggle folder watch"); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Email Inbox */} + + + + + Email Inbox + + + Auto-import response cards from an email inbox via IMAP. + + + +
+
+ + + setSettings((s) => ({ ...s, emailImapHost: e.target.value })) + } + placeholder="imap.gmail.com" + /> +
+
+ + + setSettings((s) => ({ + ...s, + emailImapPort: parseInt(e.target.value) || 993, + })) + } + /> +
+
+ + + setSettings((s) => ({ ...s, emailImapUser: e.target.value })) + } + /> +
+
+ + + setSettings((s) => ({ ...s, emailImapPass: e.target.value })) + } + /> +
+
+ +
+
+ + setSettings((s) => ({ ...s, emailImapTls: v })) + } + /> + +
+
+ +
+
+ + + setSettings((s) => ({ ...s, emailFolder: e.target.value })) + } + /> +
+
+ + +
+
+ +
+ + +
+ + setSettings((s) => ({ ...s, emailWatching: v })) + } + /> + +
+
+
+
+ + {/* Folder Watch */} + + + + + Folder Watch + + + Monitor a local directory for new scanned files. + + + +
+ + + setSettings((s) => ({ ...s, watchDir: e.target.value })) + } + placeholder="/mnt/scans" + /> +
+
+ + {settings.watching && ( + + Active + + )} +
+
+
+ +
+ +
+
+ ); +} diff --git a/src/app/(dashboard)/settings/users/page.tsx b/src/app/(dashboard)/settings/users/page.tsx index cbe68dc..764b9db 100644 --- a/src/app/(dashboard)/settings/users/page.tsx +++ b/src/app/(dashboard)/settings/users/page.tsx @@ -1,20 +1,243 @@ "use client"; -import { UserCog } from "lucide-react"; -import { Header } from "@/components/layout/header"; +import * as React from "react"; +import { toast } from "sonner"; +import { useSession } from "next-auth/react"; +import { Loader2, Plus, UserPlus, Shield, Trash2, Mail } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; + +type Member = { + id: string; + role: string; + user: { id: string; email: string; displayName: string | null }; +}; + +type Invitation = { + id: string; + email: string; + role: string; + expiresAt: string; + acceptedAt: string | null; +}; + +const ROLES = [ + { value: "viewer", label: "Viewer" }, + { value: "reviewer", label: "Reviewer" }, + { value: "editor", label: "Editor" }, + { value: "admin", label: "Admin" }, +]; export default function UsersSettingsPage() { + const { data: session } = useSession(); + const [members, setMembers] = React.useState([]); + const [invitations, setInvitations] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [inviteEmail, setInviteEmail] = React.useState(""); + const [inviteRole, setInviteRole] = React.useState("viewer"); + const [sending, setSending] = React.useState(false); + + const fetchData = React.useCallback(async () => { + try { + const [membersRes, invitesRes] = await Promise.all([ + fetch("/api/org/members"), + fetch("/api/org/invitations"), + ]); + if (membersRes.ok) { + const data = await membersRes.json(); + setMembers(data.members || []); + } + if (invitesRes.ok) { + const data = await invitesRes.json(); + setInvitations(data.invitations || []); + } + } catch {} finally { + setLoading(false); + } + }, []); + + React.useEffect(() => { + fetchData(); + }, [fetchData]); + + const handleInvite = async (e: React.FormEvent) => { + e.preventDefault(); + setSending(true); + try { + const res = await fetch("/api/org/invitations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: inviteEmail, role: inviteRole }), + }); + if (res.ok) { + toast.success(`Invitation sent to ${inviteEmail}`); + setInviteEmail(""); + fetchData(); + } else { + const data = await res.json(); + toast.error(data.error || "Failed to send invitation"); + } + } catch { + toast.error("Failed to send invitation"); + } finally { + setSending(false); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + return (
-
-
- User management and invitations will be available after the auth system - is set up. -
+ {/* Invite */} + + + + + Invite Team Member + + + +
+
+ + setInviteEmail(e.target.value)} + placeholder="colleague@church.org" + required + /> +
+
+ + +
+ +
+
+
+ + {/* Current Members */} + + + + + Team Members + + + {members.length} member{members.length !== 1 && "s"} + + + +
+ {members.map((m) => ( +
+
+

+ {m.user.displayName || m.user.email} +

+

+ {m.user.email} +

+
+
+ + {m.role} + + {m.user.id === session?.user?.id && ( + + You + + )} +
+
+ ))} + {members.length === 0 && ( +

+ No team members yet — send an invitation above. +

+ )} +
+
+
+ + {/* Pending Invitations */} + {invitations.length > 0 && ( + + + + + Pending Invitations + + + +
+ {invitations + .filter((i) => !i.acceptedAt) + .map((i) => ( +
+
+

{i.email}

+

+ Expires{" "} + {new Date(i.expiresAt).toLocaleDateString()} +

+
+ + {i.role} + +
+ ))} +
+
+
+ )}
); } diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index bb388bf..0e98cc9 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import bcrypt from "bcryptjs"; import { prisma } from "@/lib/db"; +import { sendVerificationEmail } from "@/lib/email-sender"; export async function POST(req: NextRequest) { try { @@ -94,6 +95,13 @@ export async function POST(req: NextRequest) { } } + try { + const baseUrl = new URL(req.url).origin; + await sendVerificationEmail(email, baseUrl); + } catch (emailErr) { + console.warn("[register] Verification email failed (non-blocking):", emailErr); + } + return NextResponse.json({ success: true, userId: user.id }); } catch (error) { console.error("[register] Error:", error); diff --git a/src/app/api/auth/verify-email/confirm/route.ts b/src/app/api/auth/verify-email/confirm/route.ts new file mode 100644 index 0000000..ea57b6d --- /dev/null +++ b/src/app/api/auth/verify-email/confirm/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; + +export async function GET(req: NextRequest) { + try { + const token = req.nextUrl.searchParams.get("token"); + if (!token) { + return NextResponse.redirect(new URL("/login?error=missing_token", req.url)); + } + + const record = await prisma.verificationToken.findUnique({ + where: { token }, + }); + + if (!record) { + return NextResponse.redirect(new URL("/login?error=invalid_token", req.url)); + } + + if (record.expires < new Date()) { + await prisma.verificationToken.delete({ + where: { token }, + }); + return NextResponse.redirect(new URL("/login?error=expired_token", req.url)); + } + + await prisma.user.update({ + where: { email: record.identifier }, + data: { emailVerified: new Date() }, + }); + + await prisma.verificationToken.delete({ + where: { token }, + }); + + return NextResponse.redirect(new URL("/?verified=true", req.url)); + } catch (error) { + console.error("[verify-email/confirm] Error:", error); + return NextResponse.redirect(new URL("/login?error=verification_failed", req.url)); + } +} diff --git a/src/app/api/auth/verify-email/send/route.ts b/src/app/api/auth/verify-email/send/route.ts new file mode 100644 index 0000000..931d83e --- /dev/null +++ b/src/app/api/auth/verify-email/send/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { sendVerificationEmail } from "@/lib/email-sender"; +import { prisma } from "@/lib/db"; + +export async function POST(req: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { email: true, emailVerified: true }, + }); + + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + if (user.emailVerified) { + return NextResponse.json({ error: "Email already verified" }, { status: 400 }); + } + + const baseUrl = new URL(req.url).origin; + await sendVerificationEmail(user.email, baseUrl); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[verify-email/send] Error:", error); + return NextResponse.json( + { error: "Failed to send verification email" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/integrations/[id]/fields/route.ts b/src/app/api/integrations/[id]/fields/route.ts new file mode 100644 index 0000000..34b95b1 --- /dev/null +++ b/src/app/api/integrations/[id]/fields/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/db"; +import { getProvider } from "@/lib/integrations/registry"; + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.orgId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + const integration = await prisma.integration.findFirst({ + where: { id, organizationId: session.user.orgId }, + }); + + if (!integration) { + return NextResponse.json( + { error: "Integration not found" }, + { status: 404 } + ); + } + + const provider = getProvider(integration.provider); + if (!provider?.getExternalFields) { + return NextResponse.json({ fields: [] }); + } + + const fields = await provider.getExternalFields(integration.config); + return NextResponse.json({ fields }); + } catch (error) { + console.error("[integrations/[id]/fields] error:", error); + return NextResponse.json( + { error: "Failed to fetch fields" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/integrations/[id]/route.ts b/src/app/api/integrations/[id]/route.ts new file mode 100644 index 0000000..2fa64ea --- /dev/null +++ b/src/app/api/integrations/[id]/route.ts @@ -0,0 +1,115 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/db"; + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.orgId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + const integration = await prisma.integration.findFirst({ + where: { id, organizationId: session.user.orgId }, + }); + + if (!integration) { + return NextResponse.json( + { error: "Integration not found" }, + { status: 404 } + ); + } + + return NextResponse.json({ integration }); + } catch (error) { + console.error("[integrations/[id]] GET error:", error); + return NextResponse.json( + { error: "Failed to fetch integration" }, + { status: 500 } + ); + } +} + +export async function PUT( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.orgId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + const existing = await prisma.integration.findFirst({ + where: { id, organizationId: session.user.orgId }, + }); + if (!existing) { + return NextResponse.json( + { error: "Integration not found" }, + { status: 404 } + ); + } + + const body = await req.json(); + const { config, fieldMapping, enabled, triggerEvents, name, syncDirection } = + body; + + const integration = await prisma.integration.update({ + where: { id }, + data: { + ...(config !== undefined && { config }), + ...(fieldMapping !== undefined && { fieldMapping }), + ...(enabled !== undefined && { enabled }), + ...(triggerEvents !== undefined && { triggerEvents }), + ...(name !== undefined && { name }), + ...(syncDirection !== undefined && { syncDirection }), + }, + }); + + return NextResponse.json({ integration }); + } catch (error) { + console.error("[integrations/[id]] PUT error:", error); + return NextResponse.json( + { error: "Failed to update integration" }, + { status: 500 } + ); + } +} + +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.orgId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + const existing = await prisma.integration.findFirst({ + where: { id, organizationId: session.user.orgId }, + }); + if (!existing) { + return NextResponse.json( + { error: "Integration not found" }, + { status: 404 } + ); + } + + await prisma.integration.delete({ where: { id } }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[integrations/[id]] DELETE error:", error); + return NextResponse.json( + { error: "Failed to delete integration" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/integrations/[id]/sync/route.ts b/src/app/api/integrations/[id]/sync/route.ts new file mode 100644 index 0000000..58d796c --- /dev/null +++ b/src/app/api/integrations/[id]/sync/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/db"; +import { getProvider } from "@/lib/integrations/registry"; +import type { CardData } from "@/lib/integrations/types"; + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.orgId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + const integration = await prisma.integration.findFirst({ + where: { id, organizationId: session.user.orgId }, + }); + + if (!integration) { + return NextResponse.json( + { error: "Integration not found" }, + { status: 404 } + ); + } + + const provider = getProvider(integration.provider); + if (!provider) { + return NextResponse.json( + { error: "Unknown provider" }, + { status: 400 } + ); + } + + const body = await req.json().catch(() => ({})); + const cardIds = body.cardIds as string[] | undefined; + + const where: Record = { + organizationId: session.user.orgId, + }; + if (cardIds?.length) { + where.id = { in: cardIds }; + } else { + where.reviewStatus = "reviewed"; + } + + const cards = await prisma.responseCard.findMany({ + where, + take: 100, + }); + + let successCount = 0; + let failCount = 0; + + for (const card of cards) { + const result = await provider.pushCard( + card as unknown as CardData, + integration.config, + integration.fieldMapping + ); + if (result.success) { + successCount++; + } else { + failCount++; + } + } + + await prisma.integration.update({ + where: { id }, + data: { + lastSyncAt: new Date(), + lastSyncStatus: failCount === 0 ? "success" : "partial", + }, + }); + + return NextResponse.json({ + synced: successCount, + failed: failCount, + total: cards.length, + }); + } catch (error) { + console.error("[integrations/[id]/sync] error:", error); + return NextResponse.json( + { error: "Sync failed" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/integrations/[id]/test/route.ts b/src/app/api/integrations/[id]/test/route.ts new file mode 100644 index 0000000..2d0b2a0 --- /dev/null +++ b/src/app/api/integrations/[id]/test/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/db"; +import { getProvider } from "@/lib/integrations/registry"; + +export async function POST( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.orgId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + const integration = await prisma.integration.findFirst({ + where: { id, organizationId: session.user.orgId }, + }); + + if (!integration) { + return NextResponse.json( + { error: "Integration not found" }, + { status: 404 } + ); + } + + const provider = getProvider(integration.provider); + if (!provider) { + return NextResponse.json( + { error: "Unknown provider" }, + { status: 400 } + ); + } + + const result = await provider.testConnection(integration.config); + + await prisma.integration.update({ + where: { id }, + data: { + lastSyncStatus: result.success ? "connected" : "error", + lastSyncAt: new Date(), + }, + }); + + return NextResponse.json(result); + } catch (error) { + console.error("[integrations/[id]/test] error:", error); + return NextResponse.json( + { error: "Test failed" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/integrations/oauth/[provider]/authorize/route.ts b/src/app/api/integrations/oauth/[provider]/authorize/route.ts new file mode 100644 index 0000000..796a820 --- /dev/null +++ b/src/app/api/integrations/oauth/[provider]/authorize/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; + +const OAUTH_CONFIGS: Record< + string, + { authUrl: string; clientIdEnv: string; scopes: string } +> = { + planning_center: { + authUrl: "https://api.planningcenteronline.com/oauth/authorize", + clientIdEnv: "PCO_CLIENT_ID", + scopes: "people", + }, + google_sheets: { + authUrl: "https://accounts.google.com/o/oauth2/v2/auth", + clientIdEnv: "GOOGLE_CLIENT_ID", + scopes: "https://www.googleapis.com/auth/spreadsheets", + }, +}; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ provider: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.orgId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { provider } = await params; + const oauthConfig = OAUTH_CONFIGS[provider]; + if (!oauthConfig) { + return NextResponse.json( + { error: `OAuth not supported for provider: ${provider}` }, + { status: 400 } + ); + } + + const clientId = process.env[oauthConfig.clientIdEnv]; + if (!clientId) { + return NextResponse.json( + { + error: `${provider} OAuth not configured — set ${oauthConfig.clientIdEnv} in environment`, + }, + { status: 400 } + ); + } + + const integrationId = req.nextUrl.searchParams.get("integrationId") || ""; + const baseUrl = new URL(req.url).origin; + const redirectUri = `${baseUrl}/api/integrations/oauth/${provider}/callback`; + + const state = Buffer.from( + JSON.stringify({ + orgId: session.user.orgId, + integrationId, + }) + ).toString("base64url"); + + const authUrl = new URL(oauthConfig.authUrl); + authUrl.searchParams.set("client_id", clientId); + authUrl.searchParams.set("redirect_uri", redirectUri); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("scope", oauthConfig.scopes); + authUrl.searchParams.set("state", state); + + if (provider === "google_sheets") { + authUrl.searchParams.set("access_type", "offline"); + authUrl.searchParams.set("prompt", "consent"); + } + + return NextResponse.redirect(authUrl.toString()); + } catch (error) { + console.error("[oauth/authorize] error:", error); + return NextResponse.json( + { error: "OAuth authorization failed" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/integrations/oauth/[provider]/callback/route.ts b/src/app/api/integrations/oauth/[provider]/callback/route.ts new file mode 100644 index 0000000..7e1cfc7 --- /dev/null +++ b/src/app/api/integrations/oauth/[provider]/callback/route.ts @@ -0,0 +1,124 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; + +const OAUTH_CONFIGS: Record< + string, + { + tokenUrl: string; + clientIdEnv: string; + clientSecretEnv: string; + } +> = { + planning_center: { + tokenUrl: "https://api.planningcenteronline.com/oauth/token", + clientIdEnv: "PCO_CLIENT_ID", + clientSecretEnv: "PCO_CLIENT_SECRET", + }, + google_sheets: { + tokenUrl: "https://oauth2.googleapis.com/token", + clientIdEnv: "GOOGLE_CLIENT_ID", + clientSecretEnv: "GOOGLE_CLIENT_SECRET", + }, +}; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ provider: string }> } +) { + try { + const { provider } = await params; + const oauthConfig = OAUTH_CONFIGS[provider]; + if (!oauthConfig) { + return NextResponse.redirect( + new URL("/settings/integrations?error=unsupported_provider", req.url) + ); + } + + const code = req.nextUrl.searchParams.get("code"); + const stateParam = req.nextUrl.searchParams.get("state"); + + if (!code || !stateParam) { + return NextResponse.redirect( + new URL("/settings/integrations?error=missing_code", req.url) + ); + } + + let state: { orgId: string; integrationId: string }; + try { + state = JSON.parse(Buffer.from(stateParam, "base64url").toString()); + } catch { + return NextResponse.redirect( + new URL("/settings/integrations?error=invalid_state", req.url) + ); + } + + const clientId = process.env[oauthConfig.clientIdEnv] || ""; + const clientSecret = process.env[oauthConfig.clientSecretEnv] || ""; + const baseUrl = new URL(req.url).origin; + const redirectUri = `${baseUrl}/api/integrations/oauth/${provider}/callback`; + + const tokenRes = await fetch(oauthConfig.tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + client_id: clientId, + client_secret: clientSecret, + }), + }); + + if (!tokenRes.ok) { + const text = await tokenRes.text().catch(() => ""); + console.error("[oauth/callback] Token exchange failed:", text); + return NextResponse.redirect( + new URL("/settings/integrations?error=token_exchange_failed", req.url) + ); + } + + const tokens = await tokenRes.json(); + + if (state.integrationId) { + const integration = await prisma.integration.findFirst({ + where: { + id: state.integrationId, + organizationId: state.orgId, + }, + }); + + if (integration) { + const existingConfig = + (integration.config as Record) || {}; + await prisma.integration.update({ + where: { id: integration.id }, + data: { + config: { + ...existingConfig, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || existingConfig.refreshToken, + tokenExpiresAt: tokens.expires_in + ? Date.now() + tokens.expires_in * 1000 + : undefined, + }, + enabled: true, + lastSyncStatus: "connected", + lastSyncAt: new Date(), + }, + }); + } + } + + return NextResponse.redirect( + new URL( + `/settings/integrations${state.integrationId ? `/${state.integrationId}` : ""}?connected=true`, + req.url + ) + ); + } catch (error) { + console.error("[oauth/callback] error:", error); + return NextResponse.redirect( + new URL("/settings/integrations?error=callback_failed", req.url) + ); + } +} diff --git a/src/app/api/integrations/route.ts b/src/app/api/integrations/route.ts new file mode 100644 index 0000000..2a1c339 --- /dev/null +++ b/src/app/api/integrations/route.ts @@ -0,0 +1,76 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/db"; +import { getAllProviders } from "@/lib/integrations/registry"; + +export async function GET() { + try { + const session = await auth(); + if (!session?.user?.orgId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const integrations = await prisma.integration.findMany({ + where: { organizationId: session.user.orgId }, + orderBy: { createdAt: "desc" }, + }); + + const providers = getAllProviders().map((p) => ({ + id: p.id, + name: p.name, + description: p.description, + icon: p.icon, + category: p.category, + supportsOAuth: p.supportsOAuth, + supportsFieldMapping: p.supportsFieldMapping, + configFields: p.configFields, + })); + + return NextResponse.json({ integrations, providers }); + } catch (error) { + console.error("[integrations] GET error:", error); + return NextResponse.json( + { error: "Failed to fetch integrations" }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.orgId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await req.json(); + const { provider, name, config, fieldMapping, triggerEvents } = body; + + if (!provider || !name) { + return NextResponse.json( + { error: "Provider and name are required" }, + { status: 400 } + ); + } + + const integration = await prisma.integration.create({ + data: { + organizationId: session.user.orgId, + provider, + name, + config: config || {}, + fieldMapping: fieldMapping || null, + triggerEvents: triggerEvents || ["card_reviewed"], + enabled: false, + }, + }); + + return NextResponse.json({ integration }); + } catch (error) { + console.error("[integrations] POST error:", error); + return NextResponse.json( + { error: "Failed to create integration" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/onboarding/route.ts b/src/app/api/onboarding/route.ts new file mode 100644 index 0000000..a3afafc --- /dev/null +++ b/src/app/api/onboarding/route.ts @@ -0,0 +1,353 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/db"; + +function slugify(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +export async function GET() { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const membership = await prisma.orgMember.findFirst({ + where: { userId: session.user.id }, + include: { + organization: { + select: { + id: true, + onboardingComplete: true, + onboardingStep: true, + }, + }, + }, + }); + + if (!membership) { + return NextResponse.json({ + onboardingComplete: false, + currentStep: 0, + orgId: null, + }); + } + + return NextResponse.json({ + onboardingComplete: membership.organization.onboardingComplete, + currentStep: membership.organization.onboardingStep, + orgId: membership.organizationId, + }); + } catch (error) { + console.error("[onboarding] GET error:", error); + return NextResponse.json( + { error: "Failed to fetch onboarding status" }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await req.json(); + const { step, data } = body; + + const membership = await prisma.orgMember.findFirst({ + where: { userId: session.user.id }, + include: { + organization: { + select: { id: true, onboardingComplete: true, onboardingStep: true }, + }, + }, + }); + + switch (step) { + case 1: { + const { name, type, timezone } = data; + if (!name) { + return NextResponse.json( + { error: "Organization name is required" }, + { status: 400 } + ); + } + + if (membership) { + await prisma.organization.update({ + where: { id: membership.organizationId }, + data: { + name, + slug: slugify(name), + type: type || "church", + timezone: timezone || "America/Chicago", + onboardingStep: 1, + }, + }); + return NextResponse.json({ + nextStep: 2, + orgId: membership.organizationId, + }); + } + + const org = await prisma.organization.create({ + data: { + name, + slug: slugify(name), + type: type || "church", + timezone: timezone || "America/Chicago", + onboardingStep: 1, + }, + }); + + await prisma.orgMember.create({ + data: { + userId: session.user.id, + organizationId: org.id, + role: "owner", + }, + }); + + return NextResponse.json({ nextStep: 2, orgId: org.id }); + } + + case 2: { + if (!membership) { + return NextResponse.json( + { error: "Complete step 1 first" }, + { status: 400 } + ); + } + + const { locations } = data; + if (!locations || !Array.isArray(locations) || locations.length === 0) { + return NextResponse.json( + { error: "At least one location is required" }, + { status: 400 } + ); + } + + for (const loc of locations) { + if (!loc.name) continue; + const existing = await prisma.location.findFirst({ + where: { + organizationId: membership.organizationId, + name: loc.name, + }, + }); + if (!existing) { + await prisma.location.create({ + data: { + name: loc.name, + address: loc.address || null, + organizationId: membership.organizationId, + }, + }); + } + } + + await prisma.organization.update({ + where: { id: membership.organizationId }, + data: { onboardingStep: 2 }, + }); + + return NextResponse.json({ nextStep: 3 }); + } + + case 3: { + if (!membership) { + return NextResponse.json( + { error: "Complete previous steps first" }, + { status: 400 } + ); + } + + const { services } = data; + if (services && Array.isArray(services)) { + const locations = await prisma.location.findMany({ + where: { organizationId: membership.organizationId }, + take: 1, + }); + const locationId = locations[0]?.id; + + if (locationId) { + for (const svc of services) { + if (!svc.name) continue; + await prisma.collectionDay.create({ + data: { + locationId, + name: svc.name, + dayOfWeek: svc.dayOfWeek ?? 0, + timeStart: svc.timeStart || null, + timeEnd: svc.timeEnd || null, + isRecurring: true, + }, + }); + } + } + } + + await prisma.organization.update({ + where: { id: membership.organizationId }, + data: { onboardingStep: 3 }, + }); + + return NextResponse.json({ nextStep: 4 }); + } + + case 4: { + if (!membership) { + return NextResponse.json( + { error: "Complete previous steps first" }, + { status: 400 } + ); + } + + const { uploadSource } = data; + + if (uploadSource === "email" && data.emailConfig) { + await prisma.appSettings.upsert({ + where: { id: "singleton" }, + update: { + emailImapHost: data.emailConfig.host || "", + emailImapPort: data.emailConfig.port || 993, + emailImapUser: data.emailConfig.user || "", + emailImapPass: data.emailConfig.pass || "", + emailImapTls: data.emailConfig.tls ?? true, + }, + create: { id: "singleton" }, + }); + } + + if (uploadSource === "folder" && data.watchDir) { + await prisma.appSettings.upsert({ + where: { id: "singleton" }, + update: { watchDir: data.watchDir }, + create: { id: "singleton" }, + }); + } + + if (uploadSource === "api") { + const crypto = await import("crypto"); + const rawKey = `ek_${crypto.randomBytes(24).toString("hex")}`; + const hashedKey = crypto + .createHash("sha256") + .update(rawKey) + .digest("hex"); + + await prisma.apiKey.create({ + data: { + name: "Onboarding API Key", + hashedKey, + prefix: rawKey.slice(0, 7), + organizationId: membership.organizationId, + permissions: ["cards:create", "cards:read"], + }, + }); + + await prisma.organization.update({ + where: { id: membership.organizationId }, + data: { onboardingStep: 4 }, + }); + + return NextResponse.json({ nextStep: 5, apiKey: rawKey }); + } + + await prisma.organization.update({ + where: { id: membership.organizationId }, + data: { onboardingStep: 4 }, + }); + + return NextResponse.json({ nextStep: 5 }); + } + + case 5: { + if (!membership) { + return NextResponse.json( + { error: "Complete previous steps first" }, + { status: 400 } + ); + } + + const { aiProvider, aiModel } = data; + await prisma.appSettings.upsert({ + where: { id: "singleton" }, + update: { + aiProvider: aiProvider || "gateway", + aiModel: aiModel || "", + }, + create: { + id: "singleton", + aiProvider: aiProvider || "gateway", + aiModel: aiModel || "", + }, + }); + + await prisma.organization.update({ + where: { id: membership.organizationId }, + data: { onboardingStep: 5 }, + }); + + return NextResponse.json({ nextStep: 6 }); + } + + case 6: { + if (!membership) { + return NextResponse.json( + { error: "Complete previous steps first" }, + { status: 400 } + ); + } + + // Integration selection is optional — just advance step + await prisma.organization.update({ + where: { id: membership.organizationId }, + data: { onboardingStep: 6 }, + }); + + return NextResponse.json({ nextStep: 7 }); + } + + case 7: { + if (!membership) { + return NextResponse.json( + { error: "Complete previous steps first" }, + { status: 400 } + ); + } + + await prisma.organization.update({ + where: { id: membership.organizationId }, + data: { onboardingComplete: true, onboardingStep: 7 }, + }); + + await prisma.systemConfig.upsert({ + where: { id: "singleton" }, + update: { isSetupComplete: true }, + create: { id: "singleton", isSetupComplete: true }, + }); + + return NextResponse.json({ complete: true }); + } + + default: + return NextResponse.json( + { error: "Invalid step" }, + { status: 400 } + ); + } + } catch (error) { + console.error("[onboarding] POST error:", error); + return NextResponse.json( + { error: "Onboarding step failed" }, + { status: 500 } + ); + } +} diff --git a/src/auth.ts b/src/auth.ts index daf82d7..17d462d 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -63,19 +63,26 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ include: { memberships: { take: 1, - include: { organization: { select: { id: true, name: true } } }, + include: { + organization: { + select: { id: true, name: true, onboardingComplete: true }, + }, + }, }, }, }); if (dbUser) { token.role = dbUser.role; - token.displayName = dbUser.displayName; - token.avatarUrl = dbUser.avatarUrl; + token.displayName = dbUser.displayName ?? undefined; + token.avatarUrl = dbUser.avatarUrl ?? undefined; + token.isEmailVerified = !!dbUser.emailVerified; if (dbUser.memberships[0]) { token.orgId = dbUser.memberships[0].organizationId; token.orgName = dbUser.memberships[0].organization.name; token.orgRole = dbUser.memberships[0].role; + token.onboardingComplete = + dbUser.memberships[0].organization.onboardingComplete ?? false; } } } @@ -90,6 +97,8 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ session.user.orgName = token.orgName as string | undefined; session.user.displayName = token.displayName as string | undefined; session.user.avatarUrl = token.avatarUrl as string | undefined; + session.user.isEmailVerified = token.isEmailVerified ?? false; + session.user.onboardingComplete = token.onboardingComplete ?? false; } return session; }, diff --git a/src/components/cards/data-table.tsx b/src/components/cards/data-table.tsx index d306fce..885dd37 100644 --- a/src/components/cards/data-table.tsx +++ b/src/components/cards/data-table.tsx @@ -337,9 +337,9 @@ export function DataTable({
- + {table.getHeaderGroups().map((headerGroup) => ( - + {headerGroup.headers.map((header) => { const colMeta = header.column.columnDef.meta as Record | undefined; const isSticky = header.column.id === "select" || !!colMeta?.sticky; @@ -348,7 +348,7 @@ export function DataTable({ key={header.id} className={cn( "px-4 py-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground", - isSticky && "sticky left-0 z-20 bg-background" + isSticky && "sticky left-0 z-20 bg-muted/40" )} onClick={(e) => handleHeaderClick(e, header.column.id)} > @@ -420,7 +420,7 @@ export function DataTable({ ref={cellRef} className={cn( "px-4 py-3 relative group/cell", - isSticky && "sticky left-0 z-10 bg-background", + isSticky && "sticky left-0 z-10 bg-card", isCopied && "ring-2 ring-primary/40 ring-inset" )} > diff --git a/src/components/cards/filters.tsx b/src/components/cards/filters.tsx index 41e0c38..2ceb78d 100644 --- a/src/components/cards/filters.tsx +++ b/src/components/cards/filters.tsx @@ -129,7 +129,7 @@ export function Filters({ /> -
+