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
This commit is contained in:
Randall Stillwell 2026-04-15 01:29:13 -05:00
parent d3e7374439
commit a975043670
48 changed files with 6087 additions and 1406 deletions

View file

@ -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=""

21
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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);
});

View file

@ -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 {

View file

@ -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);

View file

@ -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<string, unknown>) => {
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 (
<div className="flex min-h-[60vh] items-center justify-center">
<Loader2 className="size-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="mx-auto max-w-2xl py-8">
<div className="mb-8 text-center">
<h1 className="text-3xl font-bold tracking-tight">
Set up your workspace
</h1>
<p className="mt-2 text-muted-foreground">
Get Echo OCR ready in just a few steps
</p>
</div>
{/* Step indicator */}
<div className="mb-8 flex items-center justify-center gap-1 sm:gap-2">
{STEPS.map((step, i) => {
const done = currentStep > i;
const active = currentStep === i;
const StepIcon = step.icon;
return (
<div key={step.label} className="flex items-center gap-1 sm:gap-2">
<div
className={`flex size-8 items-center justify-center rounded-full transition-colors ${
done
? "bg-emerald-500 text-white"
: active
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
{done ? (
<Check className="size-4" />
) : (
<StepIcon className="size-4" />
)}
</div>
<span
className={`hidden text-xs font-medium lg:inline ${
active ? "text-foreground" : "text-muted-foreground"
}`}
>
{step.label}
</span>
{i < STEPS.length - 1 && (
<ArrowRight className="size-3 text-muted-foreground/40" />
)}
</div>
);
})}
</div>
{error && (
<div className="mb-4 rounded-lg bg-destructive/10 p-3 text-center text-sm text-destructive">
{error}
</div>
)}
<div className="glass-card rounded-2xl p-6 sm:p-8">
{/* Step 1: Organization */}
{currentStep === 0 && (
<form onSubmit={handleOrgSubmit} className="space-y-5">
<div>
<h2 className="text-xl font-semibold">Your Organization</h2>
<p className="mt-1 text-sm text-muted-foreground">
Tell us about your church or organization.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="orgName">Organization Name</Label>
<Input
id="orgName"
value={org.name}
onChange={(e) =>
setOrg((p) => ({ ...p, name: e.target.value }))
}
required
placeholder="Grace Community Church"
/>
</div>
<div className="space-y-2">
<Label htmlFor="orgType">Type</Label>
<select
id="orgType"
value={org.type}
onChange={(e) =>
setOrg((p) => ({ ...p, type: e.target.value }))
}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{ORG_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="timezone">Timezone</Label>
<select
id="timezone"
value={org.timezone}
onChange={(e) =>
setOrg((p) => ({ ...p, timezone: e.target.value }))
}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{TIMEZONES.map((group) => (
<optgroup key={group.group} label={group.group}>
{group.zones.map((tz) => (
<option key={tz} value={tz}>
{tz.replace(/_/g, " ")}
</option>
))}
</optgroup>
))}
</select>
</div>
<Button
type="submit"
className="w-full rounded-xl"
disabled={submitting}
>
{submitting && <Loader2 className="mr-2 size-4 animate-spin" />}
Continue
<ArrowRight className="ml-2 size-4" />
</Button>
</form>
)}
{/* Step 2: Locations */}
{currentStep === 1 && (
<form onSubmit={handleLocationsSubmit} className="space-y-5">
<div>
<h2 className="text-xl font-semibold">Locations</h2>
<p className="mt-1 text-sm text-muted-foreground">
Add your campuses or physical locations.
</p>
</div>
{locations.map((loc, i) => (
<div
key={i}
className="space-y-3 rounded-lg border border-border/50 p-4"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">
Location {i + 1}
</span>
{locations.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 text-destructive"
onClick={() =>
setLocations((prev) =>
prev.filter((_, idx) => idx !== i)
)
}
>
<Trash2 className="size-3.5" />
</Button>
)}
</div>
<div className="space-y-2">
<Label>Name</Label>
<Input
value={loc.name}
onChange={(e) =>
setLocations((prev) =>
prev.map((l, idx) =>
idx === i ? { ...l, name: e.target.value } : l
)
)
}
required
placeholder="Main Campus"
/>
</div>
<div className="space-y-2">
<Label>Address (optional)</Label>
<Input
value={loc.address}
onChange={(e) =>
setLocations((prev) =>
prev.map((l, idx) =>
idx === i ? { ...l, address: e.target.value } : l
)
)
}
placeholder="123 Church St, City, ST 12345"
/>
</div>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setLocations((prev) => [...prev, { name: "", address: "" }])
}
>
<Plus className="mr-1.5 size-3.5" />
Add another location
</Button>
<div className="flex gap-3">
<Button
type="button"
variant="outline"
className="rounded-xl"
onClick={() => setCurrentStep(0)}
>
<ArrowLeft className="mr-2 size-4" />
Back
</Button>
<Button
type="submit"
className="flex-1 rounded-xl"
disabled={submitting}
>
{submitting && (
<Loader2 className="mr-2 size-4 animate-spin" />
)}
Continue
<ArrowRight className="ml-2 size-4" />
</Button>
</div>
</form>
)}
{/* Step 3: Service Schedule */}
{currentStep === 2 && (
<form onSubmit={handleServicesSubmit} className="space-y-5">
<div>
<h2 className="text-xl font-semibold">Service Schedule</h2>
<p className="mt-1 text-sm text-muted-foreground">
When do you typically collect response cards?
</p>
</div>
{services.map((svc, i) => (
<div
key={i}
className="space-y-3 rounded-lg border border-border/50 p-4"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">
Service {i + 1}
</span>
{services.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 text-destructive"
onClick={() =>
setServices((prev) =>
prev.filter((_, idx) => idx !== i)
)
}
>
<Trash2 className="size-3.5" />
</Button>
)}
</div>
<div className="space-y-2">
<Label>Service Name</Label>
<Input
value={svc.name}
onChange={(e) =>
setServices((prev) =>
prev.map((s, idx) =>
idx === i ? { ...s, name: e.target.value } : s
)
)
}
placeholder="Sunday Morning"
/>
</div>
<div className="space-y-2">
<Label>Day of Week</Label>
<select
value={svc.dayOfWeek}
onChange={(e) =>
setServices((prev) =>
prev.map((s, idx) =>
idx === i
? { ...s, dayOfWeek: parseInt(e.target.value) }
: s
)
)
}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{DAYS_OF_WEEK.map((day, idx) => (
<option key={day} value={idx}>
{day}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Start Time</Label>
<Input
type="time"
value={svc.timeStart}
onChange={(e) =>
setServices((prev) =>
prev.map((s, idx) =>
idx === i
? { ...s, timeStart: e.target.value }
: s
)
)
}
/>
</div>
<div className="space-y-2">
<Label>End Time</Label>
<Input
type="time"
value={svc.timeEnd}
onChange={(e) =>
setServices((prev) =>
prev.map((s, idx) =>
idx === i ? { ...s, timeEnd: e.target.value } : s
)
)
}
/>
</div>
</div>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setServices((prev) => [
...prev,
{
name: "",
dayOfWeek: 0,
timeStart: "09:00",
timeEnd: "10:30",
},
])
}
>
<Plus className="mr-1.5 size-3.5" />
Add another service
</Button>
<div className="flex gap-3">
<Button
type="button"
variant="outline"
className="rounded-xl"
onClick={() => setCurrentStep(1)}
>
<ArrowLeft className="mr-2 size-4" />
Back
</Button>
<Button
type="submit"
className="flex-1 rounded-xl"
disabled={submitting}
>
{submitting && (
<Loader2 className="mr-2 size-4 animate-spin" />
)}
Continue
<ArrowRight className="ml-2 size-4" />
</Button>
</div>
</form>
)}
{/* Step 4: Upload Source */}
{currentStep === 3 && (
<form onSubmit={handleUploadSubmit} className="space-y-5">
<div>
<h2 className="text-xl font-semibold">Upload Source</h2>
<p className="mt-1 text-sm text-muted-foreground">
How will response cards get into Echo OCR?
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{[
{
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) => (
<button
key={opt.id}
type="button"
onClick={() => setUploadSource(opt.id)}
className={`flex flex-col items-center gap-2 rounded-xl border p-4 text-center transition-colors ${
uploadSource === opt.id
? "border-primary bg-primary/10"
: "border-border/50 hover:border-border"
}`}
>
<opt.icon
className={`size-6 ${uploadSource === opt.id ? "text-primary" : "text-muted-foreground"}`}
/>
<span className="text-sm font-medium">{opt.label}</span>
<span className="text-xs text-muted-foreground">
{opt.desc}
</span>
</button>
))}
</div>
{uploadSource === "email" && (
<div className="space-y-3 rounded-lg border border-border/50 p-4">
<h3 className="text-sm font-medium">IMAP Configuration</h3>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Host</Label>
<Input
value={emailConfig.host}
onChange={(e) =>
setEmailConfig((p) => ({
...p,
host: e.target.value,
}))
}
placeholder="imap.gmail.com"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Port</Label>
<Input
type="number"
value={emailConfig.port}
onChange={(e) =>
setEmailConfig((p) => ({
...p,
port: parseInt(e.target.value) || 993,
}))
}
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Username</Label>
<Input
value={emailConfig.user}
onChange={(e) =>
setEmailConfig((p) => ({ ...p, user: e.target.value }))
}
placeholder="echo-ocr@church.org"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Password</Label>
<Input
type="password"
value={emailConfig.pass}
onChange={(e) =>
setEmailConfig((p) => ({ ...p, pass: e.target.value }))
}
/>
</div>
</div>
)}
{uploadSource === "folder" && (
<div className="space-y-2">
<Label>Watch Directory Path</Label>
<Input
value={watchDir}
onChange={(e) => setWatchDir(e.target.value)}
placeholder="/mnt/scans"
/>
</div>
)}
{generatedApiKey && (
<div className="space-y-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-4">
<p className="text-sm font-medium text-emerald-400">
Your API Key (save this it won&apos;t be shown again):
</p>
<code className="block break-all rounded bg-background p-2 text-xs">
{generatedApiKey}
</code>
</div>
)}
<div className="flex gap-3">
<Button
type="button"
variant="outline"
className="rounded-xl"
onClick={() => setCurrentStep(2)}
>
<ArrowLeft className="mr-2 size-4" />
Back
</Button>
<Button
type="submit"
className="flex-1 rounded-xl"
disabled={submitting}
>
{submitting && (
<Loader2 className="mr-2 size-4 animate-spin" />
)}
Continue
<ArrowRight className="ml-2 size-4" />
</Button>
</div>
</form>
)}
{/* Step 5: AI Provider */}
{currentStep === 4 && (
<form onSubmit={handleAiSubmit} className="space-y-5">
<div>
<h2 className="text-xl font-semibold">AI Provider</h2>
<p className="mt-1 text-sm text-muted-foreground">
Choose how Echo OCR processes your scanned cards.
</p>
</div>
<div className="space-y-3">
{[
{
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) => (
<button
key={opt.id}
type="button"
onClick={() => setAiProvider(opt.id)}
className={`flex w-full items-start gap-3 rounded-xl border p-4 text-left transition-colors ${
aiProvider === opt.id
? "border-primary bg-primary/10"
: "border-border/50 hover:border-border"
}`}
>
<Cpu
className={`mt-0.5 size-5 shrink-0 ${aiProvider === opt.id ? "text-primary" : "text-muted-foreground"}`}
/>
<div>
<span className="text-sm font-medium">{opt.label}</span>
<p className="mt-0.5 text-xs text-muted-foreground">
{opt.desc}
</p>
</div>
</button>
))}
</div>
<div className="space-y-2">
<Label htmlFor="aiModel">
Model {aiProvider === "gateway" ? "(optional)" : ""}
</Label>
<Input
id="aiModel"
value={aiModel}
onChange={(e) => setAiModel(e.target.value)}
placeholder={
aiProvider === "gateway"
? "gpt-4o-mini (auto-selected if blank)"
: "llava:7b"
}
/>
</div>
<div className="flex gap-3">
<Button
type="button"
variant="outline"
className="rounded-xl"
onClick={() => setCurrentStep(3)}
>
<ArrowLeft className="mr-2 size-4" />
Back
</Button>
<Button
type="submit"
className="flex-1 rounded-xl"
disabled={submitting}
>
{submitting && (
<Loader2 className="mr-2 size-4 animate-spin" />
)}
Continue
<ArrowRight className="ml-2 size-4" />
</Button>
</div>
</form>
)}
{/* Step 6: Integrations (skippable) */}
{currentStep === 5 && (
<div className="space-y-5">
<div>
<h2 className="text-xl font-semibold">Integrations</h2>
<p className="mt-1 text-sm text-muted-foreground">
Where should processed cards go? You can configure these later
in Settings.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{[
{
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) => (
<div
key={int.id}
className={`relative flex flex-col items-center gap-2 rounded-xl border p-4 text-center ${
int.featured
? "border-primary/50 bg-primary/5"
: "border-border/50"
}`}
>
{int.featured && (
<span className="absolute -top-2.5 rounded-full bg-primary px-2 py-0.5 text-[10px] font-bold text-primary-foreground">
RECOMMENDED
</span>
)}
<Plug className="size-6 text-muted-foreground" />
<span className="text-sm font-medium">{int.name}</span>
<span className="text-xs text-muted-foreground">
{int.desc}
</span>
<span className="mt-1 text-[11px] text-muted-foreground/60">
Configure in Settings
</span>
</div>
))}
</div>
<div className="flex gap-3">
<Button
type="button"
variant="outline"
className="rounded-xl"
onClick={() => setCurrentStep(4)}
>
<ArrowLeft className="mr-2 size-4" />
Back
</Button>
<Button
onClick={handleIntegrationsSubmit}
className="flex-1 rounded-xl"
disabled={submitting}
>
{submitting && (
<Loader2 className="mr-2 size-4 animate-spin" />
)}
<SkipForward className="mr-2 size-4" />
Continue set up later
</Button>
</div>
</div>
)}
{/* Step 7: Complete */}
{currentStep === 6 && (
<div className="space-y-6 text-center">
<div className="mx-auto flex size-20 items-center justify-center rounded-full bg-emerald-500/20">
<PartyPopper className="size-10 text-emerald-400" />
</div>
<div>
<h2 className="text-2xl font-bold">You&apos;re all set!</h2>
<p className="mt-2 text-muted-foreground">
Your workspace is ready. Here&apos;s what you can do next:
</p>
</div>
<div className="grid gap-3 text-left sm:grid-cols-3">
<button
onClick={() => router.push("/cards")}
className="flex flex-col gap-1.5 rounded-xl border border-border/50 p-4 text-left transition-colors hover:border-primary/50 hover:bg-primary/5"
>
<Upload className="size-5 text-primary" />
<span className="text-sm font-medium">
Upload your first card
</span>
<span className="text-xs text-muted-foreground">
Scan and process a response card
</span>
</button>
<button
onClick={() => router.push("/settings/users")}
className="flex flex-col gap-1.5 rounded-xl border border-border/50 p-4 text-left transition-colors hover:border-primary/50 hover:bg-primary/5"
>
<Building2 className="size-5 text-primary" />
<span className="text-sm font-medium">
Invite team members
</span>
<span className="text-xs text-muted-foreground">
Add reviewers and editors
</span>
</button>
<button
onClick={() => router.push("/")}
className="flex flex-col gap-1.5 rounded-xl border border-border/50 p-4 text-left transition-colors hover:border-primary/50 hover:bg-primary/5"
>
<PartyPopper className="size-5 text-primary" />
<span className="text-sm font-medium">
Explore the dashboard
</span>
<span className="text-xs text-muted-foreground">
See your workspace overview
</span>
</button>
</div>
<Button
onClick={handleComplete}
className="w-full rounded-xl"
disabled={submitting}
>
{submitting && <Loader2 className="mr-2 size-4 animate-spin" />}
Go to Dashboard
</Button>
</div>
)}
</div>
</div>
);
}

View file

@ -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<string, number>;
byReviewStatus: Record<string, number>;
};
function getStatCount(
groups: { ocrStatus?: string; reviewStatus?: string; _count: { id: number } }[],
key: string,
value: string
): number {
const match = groups.find((g) => (g as Record<string, unknown>)[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<Stats | null>(null);
const [loading, setLoading] = useState(true);
const [dismissedHints, setDismissedHints] = useState<string[]>([]);
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() {
})}
</div>
</div>
{/* Contextual guidance cards */}
{!loading && (
<GuidanceCards
totalCards={stats?.total ?? 0}
dismissedHints={dismissedHints}
onDismiss={handleDismiss}
/>
)}
</div>
);
}
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 (
<div className="space-y-3">
<h2 className="text-lg font-semibold">Getting Started</h2>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{visible.map((hint) => (
<div
key={hint.id}
className="glass-card relative flex items-start gap-4 rounded-xl p-4"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<hint.icon className="size-5 text-primary" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">{hint.title}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{hint.description}
</p>
<Link
href={hint.href}
className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
{hint.cta}
<ArrowRight className="size-3" />
</Link>
</div>
<Button
variant="ghost"
size="icon"
className="absolute right-2 top-2 size-6 text-muted-foreground hover:text-foreground"
onClick={() => onDismiss(hint.id)}
>
<X className="size-3" />
</Button>
</div>
))}
</div>
</div>
);
}

View file

@ -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 (
<div className="flex items-center justify-center py-20">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
const currentProvider = AI_PROVIDERS.find((p) => p.value === aiProvider);
return (
<div className="space-y-6">
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Brain className="size-4" />
AI Provider Configuration
</CardTitle>
<CardDescription>
Choose the AI service that processes your scanned response cards.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Provider</Label>
<Select value={aiProvider} onValueChange={(v) => v && handleProviderChange(v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{AI_PROVIDERS.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Model</Label>
<Input
value={aiModel}
onChange={(e) => setAiModel(e.target.value)}
placeholder={currentProvider?.defaultModel}
/>
{currentProvider && (
<p className="text-xs text-muted-foreground">
Options: {currentProvider.hint}
</p>
)}
</div>
{aiProvider === "ollama" && (
<div className="space-y-2">
<Label>Ollama URL</Label>
<Input
value={ollamaUrl}
onChange={(e) => setOllamaUrl(e.target.value)}
placeholder="http://192.168.68.108:11434"
/>
</div>
)}
<Button variant="outline" size="sm" onClick={testConnection}>
{testStatus === "testing" ? (
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
) : testStatus === "success" ? (
<Check className="mr-1.5 size-3.5 text-emerald-500" />
) : (
<Brain className="mr-1.5 size-3.5" />
)}
Test Connection
</Button>
</CardContent>
</Card>
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Save className="mr-2 size-4" />
)}
Save Changes
</Button>
</div>
</div>
);
}

View file

@ -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<NotificationPrefs>(
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 (
<div className="flex items-center justify-center py-20">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6">
{/* Appearance */}
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Sun className="size-4" />
Appearance
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-2">
{[
{ value: "light", icon: Sun, label: "Light" },
{ value: "dark", icon: Moon, label: "Dark" },
{ value: "system", icon: Monitor, label: "System" },
].map((opt) => (
<Button
key={opt.value}
variant={theme === opt.value ? "default" : "outline"}
size="sm"
onClick={() => setTheme(opt.value)}
className="gap-1.5"
>
<opt.icon className="size-3.5" />
{opt.label}
</Button>
))}
</div>
</CardContent>
</Card>
{/* Notifications */}
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Bell className="size-4" />
Notifications
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{(
[
["processingComplete", "Processing Complete"],
["processingErrors", "Processing Errors"],
["folderWatchAlerts", "Folder Watch Alerts"],
["cleanupReminders", "Cleanup Reminders"],
] as const
).map(([key, label]) => (
<div key={key} className="flex items-center justify-between">
<Label className="text-sm">{label}</Label>
<Switch
checked={notifPrefs[key]}
onCheckedChange={(v) => updateNotifPref(key, v)}
/>
</div>
))}
</CardContent>
</Card>
{/* Storage & Retention */}
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<HardDrive className="size-4" />
Storage & Retention
</CardTitle>
<CardDescription>
Automatically clean up old files to save storage.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Source file retention (days)</Label>
<Input
type="number"
min={1}
value={sourceRetention}
onChange={(e) =>
setSourceRetention(parseInt(e.target.value) || 30)
}
/>
</div>
<div className="space-y-2">
<Label>Image retention (days)</Label>
<Input
type="number"
min={1}
value={imageRetention}
onChange={(e) =>
setImageRetention(parseInt(e.target.value) || 180)
}
/>
</div>
</div>
{cleanupStatus && (
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>
{cleanupStatus.sourcesEligible} sources,{" "}
{cleanupStatus.imagesEligible} images eligible for cleanup
</span>
<Button
variant="outline"
size="sm"
onClick={runCleanup}
disabled={cleaning}
>
{cleaning ? (
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
) : (
<Trash2 className="mr-1.5 size-3.5" />
)}
Run Cleanup
</Button>
</div>
)}
</CardContent>
</Card>
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Save className="mr-2 size-4" />
)}
Save Changes
</Button>
</div>
</div>
);
}

View file

@ -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<string, unknown>;
fieldMapping: Record<string, string> | 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<Integration | null>(
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 (
<div className="flex items-center justify-center py-20">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
if (!integration) {
return (
<div className="space-y-4">
<Button
variant="ghost"
size="sm"
onClick={() => router.push("/settings/integrations")}
>
<ArrowLeft className="mr-1.5 size-3.5" />
Back
</Button>
<p className="text-center text-sm text-muted-foreground">
Integration not found.
</p>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
onClick={() => router.push("/settings/integrations")}
>
<ArrowLeft className="mr-1.5 size-3.5" />
Back to Integrations
</Button>
<div className="flex items-center gap-2">
<Switch
checked={integration.enabled}
onCheckedChange={(v) =>
setIntegration((prev) =>
prev ? { ...prev, enabled: v } : prev
)
}
/>
<span className="text-sm">
{integration.enabled ? "Enabled" : "Disabled"}
</span>
</div>
</div>
{/* Status */}
<div className="flex items-center gap-3">
<Badge
variant="outline"
className={
integration.lastSyncStatus === "connected" ||
integration.lastSyncStatus === "success"
? "border-emerald-500/30 text-emerald-500"
: integration.lastSyncStatus === "error"
? "border-destructive/30 text-destructive"
: "text-muted-foreground"
}
>
{integration.lastSyncStatus === "connected" ||
integration.lastSyncStatus === "success" ? (
<CheckCircle2 className="mr-1 size-3" />
) : integration.lastSyncStatus === "error" ? (
<XCircle className="mr-1 size-3" />
) : null}
{integration.lastSyncStatus || "Not tested"}
</Badge>
{integration.lastSyncAt && (
<span className="text-xs text-muted-foreground">
Last activity:{" "}
{new Date(integration.lastSyncAt).toLocaleString()}
</span>
)}
</div>
{/* Configuration */}
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Plug className="size-4" />
Configuration
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Integration Name</Label>
<Input
value={integration.name}
onChange={(e) =>
setIntegration((prev) =>
prev ? { ...prev, name: e.target.value } : prev
)
}
/>
</div>
{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 (
<div key={key} className="space-y-2">
<Label className="capitalize">
{key.replace(/([A-Z])/g, " $1").trim()}
</Label>
<Input
type={isSecret ? "password" : "text"}
value={String(value || "")}
onChange={(e) => updateConfig(key, e.target.value)}
/>
</div>
);
})}
</CardContent>
</Card>
{/* Trigger Events */}
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Zap className="size-4" />
Trigger Events
</CardTitle>
<CardDescription>
When should this integration fire?
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{TRIGGER_EVENTS.map((evt) => (
<div key={evt.value} className="flex items-center gap-3">
<Switch
checked={
integration.triggerEvents?.includes(evt.value) ?? false
}
onCheckedChange={() => toggleTriggerEvent(evt.value)}
/>
<Label className="text-sm">{evt.label}</Label>
</div>
))}
</CardContent>
</Card>
{/* Actions */}
<div className="flex flex-wrap gap-2">
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Save className="mr-2 size-4" />
)}
Save Changes
</Button>
<Button variant="outline" onClick={handleTest} disabled={testing}>
{testing ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<CheckCircle2 className="mr-2 size-4" />
)}
Test Connection
</Button>
<Button variant="outline" onClick={handleSync} disabled={syncing}>
{syncing ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<RefreshCw className="mr-2 size-4" />
)}
Sync Now
</Button>
</div>
{testResult && (
<div
className={`rounded-lg border p-3 text-sm ${
testResult.success
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
: "border-destructive/30 bg-destructive/10 text-destructive"
}`}
>
{testResult.message}
</div>
)}
{/* Danger Zone */}
<Card className="glass-card border-destructive/30">
<CardContent className="flex items-center justify-between p-4">
<div>
<p className="text-sm font-medium text-destructive">
Delete this integration
</p>
<p className="text-xs text-muted-foreground">
This cannot be undone.
</p>
</div>
<Button
variant="destructive"
size="sm"
onClick={handleDelete}
disabled={deleting}
>
{deleting ? (
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
) : (
<Trash2 className="mr-1.5 size-3.5" />
)}
{confirmDelete ? "Confirm Delete" : "Delete"}
</Button>
</CardContent>
</Card>
</div>
);
}

View file

@ -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<ProviderInfo[]>([]);
const [selectedProvider, setSelectedProvider] = React.useState(preselectedProvider);
const [name, setName] = React.useState("");
const [config, setConfig] = React.useState<Record<string, string>>({});
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 (
<div className="flex items-center justify-center py-20">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6">
<Button
variant="ghost"
size="sm"
onClick={() => router.push("/settings/integrations")}
>
<ArrowLeft className="mr-1.5 size-3.5" />
Back to Integrations
</Button>
{!selectedProvider ? (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Choose a provider</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{providers.map((p) => (
<button
key={p.id}
onClick={() => setSelectedProvider(p.id)}
className="flex flex-col items-center gap-2 rounded-xl border border-border/50 p-5 text-center transition-colors hover:border-primary/50 hover:bg-primary/5"
>
<Plug className="size-6 text-muted-foreground" />
<span className="text-sm font-medium">{p.name}</span>
<span className="text-xs text-muted-foreground">
{p.description}
</span>
</button>
))}
</div>
</div>
) : (
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Plug className="size-4" />
Configure {provider?.name}
</CardTitle>
<CardDescription>{provider?.description}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCreate} className="space-y-4">
<div className="space-y-2">
<Label>Integration Name</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={provider?.name || "My Integration"}
/>
</div>
{provider?.configFields.map((field) => (
<div key={field.key} className="space-y-2">
<Label>
{field.label}
{field.required && (
<span className="text-destructive"> *</span>
)}
</Label>
{field.type === "select" && field.options ? (
<select
value={config[field.key] || ""}
onChange={(e) =>
setConfig((c) => ({
...c,
[field.key]: e.target.value,
}))
}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="">Select...</option>
{field.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : (
<Input
type={field.type === "password" ? "password" : "text"}
value={config[field.key] || ""}
onChange={(e) =>
setConfig((c) => ({
...c,
[field.key]: e.target.value,
}))
}
placeholder={field.placeholder}
required={field.required}
/>
)}
{field.helpText && (
<p className="text-xs text-muted-foreground">
{field.helpText}
</p>
)}
</div>
))}
{provider?.supportsOAuth && (
<p className="text-sm text-muted-foreground">
After creating, you&apos;ll be redirected to authorize with{" "}
{provider.name}.
</p>
)}
<div className="flex gap-3">
<Button
type="button"
variant="outline"
onClick={() => setSelectedProvider("")}
>
Change Provider
</Button>
<Button type="submit" disabled={creating} className="flex-1">
{creating ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Plus className="mr-2 size-4" />
)}
{provider?.supportsOAuth
? "Create & Connect"
: "Create Integration"}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
</div>
);
}

View file

@ -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<string, string> = {
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<Integration[]>([]);
const [providers, setProviders] = React.useState<ProviderInfo[]>([]);
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 (
<div className="flex items-center justify-center py-20">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
const connectedProviderIds = new Set(integrations.map((i) => i.provider));
const availableProviders = providers.filter(
(p) => !connectedProviderIds.has(p.id)
);
return (
<div className="space-y-6">
{/* Connected Integrations */}
{integrations.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground">
Connected
</h3>
<div className="grid gap-3 sm:grid-cols-2">
{integrations.map((int) => {
const provider = providers.find((p) => p.id === int.provider);
return (
<Link
key={int.id}
href={`/settings/integrations/${int.id}`}
className="group"
>
<Card className="glass-card transition-colors hover:border-primary/30">
<CardContent className="flex items-start gap-4 p-4">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-xs font-bold text-primary">
{PROVIDER_ICONS[int.provider] || "?"}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium">
{int.name}
</p>
{int.enabled ? (
<Badge
variant="outline"
className="border-emerald-500/30 text-emerald-500"
>
<CheckCircle2 className="mr-1 size-3" />
Active
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
Disabled
</Badge>
)}
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
{provider?.name || int.provider}
{int.lastSyncAt && (
<>
{" · Last sync "}
{new Date(int.lastSyncAt).toLocaleDateString()}
</>
)}
</p>
</div>
<ExternalLink className="size-4 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</CardContent>
</Card>
</Link>
);
})}
</div>
</div>
)}
{/* Available Providers */}
<div className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground">
Available Integrations
</h3>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{(availableProviders.length > 0
? availableProviders
: providers
).map((provider) => {
const isConnected = connectedProviderIds.has(provider.id);
const isFeatured = FEATURED_PROVIDERS.includes(provider.id);
return (
<Card
key={provider.id}
className={`glass-card relative ${isFeatured ? "border-primary/30" : ""}`}
>
{isFeatured && (
<span className="absolute -top-2.5 left-4 rounded-full bg-primary px-2 py-0.5 text-[10px] font-bold text-primary-foreground">
RECOMMENDED
</span>
)}
<CardContent className="flex flex-col items-center gap-3 p-5 text-center">
<div
className={`flex size-12 items-center justify-center rounded-xl text-sm font-bold ${
isFeatured
? "bg-primary/20 text-primary"
: "bg-muted text-muted-foreground"
}`}
>
{PROVIDER_ICONS[provider.id] || <Plug className="size-5" />}
</div>
<div>
<p className="text-sm font-medium">{provider.name}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{provider.description}
</p>
</div>
{isConnected ? (
<Badge
variant="outline"
className="border-emerald-500/30 text-emerald-500"
>
Connected
</Badge>
) : (
<Link href={`/settings/integrations/new?provider=${provider.id}`}>
<Button variant="outline" size="sm">
<Plus className="mr-1.5 size-3.5" />
Connect
</Button>
</Link>
)}
</CardContent>
</Card>
);
})}
</div>
</div>
</div>
);
}

View file

@ -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 (
<div>
<Header title="Settings" />
<div className="mt-6 flex flex-col gap-6 lg:flex-row">
<nav className="flex gap-1 overflow-x-auto lg:w-56 lg:shrink-0 lg:flex-col">
{SECTIONS.map((section) => {
const active =
pathname === section.href ||
(section.href !== "/settings/general" &&
pathname.startsWith(section.href));
const Icon = section.icon;
return (
<Link
key={section.href}
href={section.href}
className={cn(
"flex items-center gap-2.5 whitespace-nowrap rounded-lg px-3 py-2 text-sm font-medium transition-colors",
active
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}
>
<Icon className="size-4 shrink-0" />
{section.label}
</Link>
);
})}
</nav>
<div className="min-w-0 flex-1">{children}</div>
</div>
</div>
);
}

View file

@ -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<Location[]>([]);
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 (
<div className="flex items-center justify-center py-20">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6">
<Header
title="Locations"
description="Manage your organization's locations and campuses"
icon={MapPin}
/>
<div className="glass-card rounded-2xl p-8 text-center text-sm text-muted-foreground">
Location management will be available after the org model is set up.
</div>
{/* Add Location */}
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Plus className="size-4" />
Add Location
</CardTitle>
</CardHeader>
<CardContent>
<form
onSubmit={handleAddLocation}
className="flex flex-wrap items-end gap-3"
>
<div className="min-w-[200px] flex-1 space-y-1.5">
<Label className="text-xs">Name</Label>
<Input
value={newLocName}
onChange={(e) => setNewLocName(e.target.value)}
placeholder="Main Campus"
required
/>
</div>
<div className="min-w-[200px] flex-1 space-y-1.5">
<Label className="text-xs">Address (optional)</Label>
<Input
value={newLocAddress}
onChange={(e) => setNewLocAddress(e.target.value)}
placeholder="123 Church St"
/>
</div>
<Button type="submit" disabled={adding}>
{adding ? (
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
) : (
<Plus className="mr-1.5 size-3.5" />
)}
Add
</Button>
</form>
</CardContent>
</Card>
{/* Existing Locations */}
{locations.map((loc) => (
<Card key={loc.id} className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<MapPin className="size-4" />
{loc.name}
</CardTitle>
{loc.address && (
<CardDescription>{loc.address}</CardDescription>
)}
</CardHeader>
<CardContent>
<div className="space-y-2">
<p className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
<CalendarDays className="size-3.5" />
Collection Days
</p>
{loc.collectionDays.length > 0 ? (
<div className="space-y-1.5">
{loc.collectionDays.map((cd) => (
<div
key={cd.id}
className="flex items-center justify-between rounded-lg border border-border/50 px-3 py-2 text-sm"
>
<span className="font-medium">{cd.name}</span>
<span className="text-muted-foreground">
{cd.dayOfWeek != null && DAYS[cd.dayOfWeek]}
{cd.timeStart && ` ${cd.timeStart}`}
{cd.timeEnd && `${cd.timeEnd}`}
</span>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
No collection days configured.
</p>
)}
</div>
</CardContent>
</Card>
))}
{locations.length === 0 && (
<div className="glass-card rounded-2xl p-8 text-center text-sm text-muted-foreground">
No locations yet add one above.
</div>
)}
</div>
);
}

View file

@ -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<OrgData | null>(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 (
<div className="flex items-center justify-center py-20">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
if (!org) {
return (
<div className="glass-card rounded-2xl p-8 text-center text-sm text-muted-foreground">
No organization found. Complete onboarding to set up your organization.
</div>
);
}
return (
<div className="space-y-6">
<Header
title="Organization"
description="Manage your organization details and preferences"
icon={Building2}
/>
<div className="glass-card rounded-2xl p-8 text-center text-sm text-muted-foreground">
Organization settings will be available after the org model is set up.
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Building2 className="size-4" />
Organization Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Organization Name</Label>
<Input
value={org.name}
onChange={(e) =>
setOrg((prev) => (prev ? { ...prev, name: e.target.value } : prev))
}
/>
</div>
<div className="space-y-2">
<Label>URL Slug</Label>
<Input value={org.slug} disabled className="opacity-60" />
<p className="text-xs text-muted-foreground">
The slug is auto-generated and cannot be changed.
</p>
</div>
<div className="space-y-2">
<Label>Type</Label>
<Select
value={org.type}
onValueChange={(v) =>
v && setOrg((prev) => (prev ? { ...prev, type: v } : prev))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ORG_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Timezone</Label>
<Input
value={org.timezone}
onChange={(e) =>
setOrg((prev) =>
prev ? { ...prev, timezone: e.target.value } : prev
)
}
/>
</div>
</CardContent>
</Card>
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Save className="mr-2 size-4" />
)}
Save Changes
</Button>
</div>
{/* Danger Zone */}
<Card className="glass-card border-destructive/30">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base text-destructive">
<AlertTriangle className="size-4" />
Danger Zone
</CardTitle>
<CardDescription>
These actions are irreversible and affect all members.
</CardDescription>
</CardHeader>
<CardContent>
<Button variant="destructive" size="sm" disabled>
Delete Organization
</Button>
<p className="mt-2 text-xs text-muted-foreground">
Organization deletion is not yet available. Contact support.
</p>
</CardContent>
</Card>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -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<EmailSettings>({
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 (
<div className="flex items-center justify-center py-20">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6">
{/* Email Inbox */}
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Mail className="size-4" />
Email Inbox
</CardTitle>
<CardDescription>
Auto-import response cards from an email inbox via IMAP.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>IMAP Host</Label>
<Input
value={settings.emailImapHost}
onChange={(e) =>
setSettings((s) => ({ ...s, emailImapHost: e.target.value }))
}
placeholder="imap.gmail.com"
/>
</div>
<div className="space-y-2">
<Label>Port</Label>
<Input
type="number"
value={settings.emailImapPort}
onChange={(e) =>
setSettings((s) => ({
...s,
emailImapPort: parseInt(e.target.value) || 993,
}))
}
/>
</div>
<div className="space-y-2">
<Label>Username</Label>
<Input
value={settings.emailImapUser}
onChange={(e) =>
setSettings((s) => ({ ...s, emailImapUser: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label>Password</Label>
<Input
type="password"
value={settings.emailImapPass}
onChange={(e) =>
setSettings((s) => ({ ...s, emailImapPass: e.target.value }))
}
/>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Switch
checked={settings.emailImapTls}
onCheckedChange={(v) =>
setSettings((s) => ({ ...s, emailImapTls: v }))
}
/>
<Label className="text-sm">Use TLS</Label>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Folder</Label>
<Input
value={settings.emailFolder}
onChange={(e) =>
setSettings((s) => ({ ...s, emailFolder: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label>After Processing</Label>
<Select
value={settings.emailProcessed}
onValueChange={(v) =>
v && setSettings((s) => ({ ...s, emailProcessed: v }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="mark_read">Mark as read</SelectItem>
<SelectItem value="move">Move to folder</SelectItem>
<SelectItem value="delete">Delete</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={testEmail}>
{emailTestStatus === "testing" ? (
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
) : emailTestStatus === "success" ? (
<Check className="mr-1.5 size-3.5 text-emerald-500" />
) : (
<Mail className="mr-1.5 size-3.5" />
)}
Test Connection
</Button>
<Button variant="outline" size="sm" onClick={scanNow} disabled={scanning}>
{scanning ? (
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
) : (
<RefreshCw className="mr-1.5 size-3.5" />
)}
Scan Now
</Button>
<div className="flex items-center gap-2">
<Switch
checked={settings.emailWatching}
onCheckedChange={(v) =>
setSettings((s) => ({ ...s, emailWatching: v }))
}
/>
<Label className="text-sm">Auto-watch</Label>
</div>
</div>
</CardContent>
</Card>
{/* Folder Watch */}
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<FolderSearch className="size-4" />
Folder Watch
</CardTitle>
<CardDescription>
Monitor a local directory for new scanned files.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Watch Directory</Label>
<Input
value={settings.watchDir}
onChange={(e) =>
setSettings((s) => ({ ...s, watchDir: e.target.value }))
}
placeholder="/mnt/scans"
/>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
onClick={toggleFolderWatch}
>
{settings.watching ? (
<>
<WifiOff className="mr-1.5 size-3.5" />
Stop Watching
</>
) : (
<>
<Wifi className="mr-1.5 size-3.5" />
Start Watching
</>
)}
</Button>
{settings.watching && (
<Badge variant="outline" className="text-emerald-500">
Active
</Badge>
)}
</div>
</CardContent>
</Card>
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Save className="mr-2 size-4" />
)}
Save Changes
</Button>
</div>
</div>
);
}

View file

@ -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<Member[]>([]);
const [invitations, setInvitations] = React.useState<Invitation[]>([]);
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 (
<div className="flex items-center justify-center py-20">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6">
<Header
title="Users"
description="Manage team members and invitations"
icon={UserCog}
/>
<div className="glass-card rounded-2xl p-8 text-center text-sm text-muted-foreground">
User management and invitations will be available after the auth system
is set up.
</div>
{/* Invite */}
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<UserPlus className="size-4" />
Invite Team Member
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleInvite} className="flex flex-wrap items-end gap-3">
<div className="min-w-[200px] flex-1 space-y-1.5">
<Label className="text-xs">Email</Label>
<Input
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="colleague@church.org"
required
/>
</div>
<div className="w-36 space-y-1.5">
<Label className="text-xs">Role</Label>
<Select value={inviteRole} onValueChange={(v) => v && setInviteRole(v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ROLES.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="submit" disabled={sending}>
{sending ? (
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
) : (
<Plus className="mr-1.5 size-3.5" />
)}
Send Invite
</Button>
</form>
</CardContent>
</Card>
{/* Current Members */}
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Shield className="size-4" />
Team Members
</CardTitle>
<CardDescription>
{members.length} member{members.length !== 1 && "s"}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{members.map((m) => (
<div
key={m.id}
className="flex items-center justify-between rounded-lg border border-border/50 px-4 py-3"
>
<div>
<p className="text-sm font-medium">
{m.user.displayName || m.user.email}
</p>
<p className="text-xs text-muted-foreground">
{m.user.email}
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs capitalize">
{m.role}
</Badge>
{m.user.id === session?.user?.id && (
<Badge variant="secondary" className="text-xs">
You
</Badge>
)}
</div>
</div>
))}
{members.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">
No team members yet send an invitation above.
</p>
)}
</div>
</CardContent>
</Card>
{/* Pending Invitations */}
{invitations.length > 0 && (
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Mail className="size-4" />
Pending Invitations
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{invitations
.filter((i) => !i.acceptedAt)
.map((i) => (
<div
key={i.id}
className="flex items-center justify-between rounded-lg border border-border/50 px-4 py-3"
>
<div>
<p className="text-sm font-medium">{i.email}</p>
<p className="text-xs text-muted-foreground">
Expires{" "}
{new Date(i.expiresAt).toLocaleDateString()}
</p>
</div>
<Badge variant="outline" className="text-xs capitalize">
{i.role}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
}

View file

@ -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);

View file

@ -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));
}
}

View file

@ -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 }
);
}
}

View file

@ -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 }
);
}
}

View file

@ -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 }
);
}
}

View file

@ -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<string, unknown> = {
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 }
);
}
}

View file

@ -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 }
);
}
}

View file

@ -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 }
);
}
}

View file

@ -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<string, unknown>) || {};
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)
);
}
}

View file

@ -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 }
);
}
}

View file

@ -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 }
);
}
}

View file

@ -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;
},

View file

@ -337,9 +337,9 @@ export function DataTable<TData extends ResponseCard>({
<div className="glass-card overflow-hidden rounded-2xl hidden md:block">
<div className="overflow-x-auto">
<Table>
<TableHeader onContextMenu={handleHeaderContextMenu}>
<TableHeader onContextMenu={handleHeaderContextMenu} className="bg-muted/40">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
<TableRow key={headerGroup.id} className="hover:bg-transparent">
{headerGroup.headers.map((header) => {
const colMeta = header.column.columnDef.meta as Record<string, unknown> | undefined;
const isSticky = header.column.id === "select" || !!colMeta?.sticky;
@ -348,7 +348,7 @@ export function DataTable<TData extends ResponseCard>({
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<TData extends ResponseCard>({
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"
)}
>

View file

@ -129,7 +129,7 @@ export function Filters({
/>
</div>
<div className="flex items-center gap-2 overflow-x-auto">
<div className="flex flex-wrap items-center gap-2">
<Select
value={visitType || null}
onValueChange={(v: string | null) =>

View file

@ -106,7 +106,7 @@ export function StatCards({ activeFilter, onFilterChange }: StatCardsProps) {
};
return (
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-5">
<div className="grid grid-cols-3 gap-2 sm:gap-3 md:grid-cols-5">
{cards.map((card) => {
const Icon = card.icon;
const isActive = activeFilter === card.filterKey;
@ -116,26 +116,24 @@ export function StatCards({ activeFilter, onFilterChange }: StatCardsProps) {
type="button"
onClick={() => handleClick(card.filterKey)}
className={cn(
"gradient-stat flex flex-col gap-3 rounded-2xl p-4 text-left transition-all sm:p-5",
"gradient-stat flex flex-col gap-2 rounded-xl p-3 text-left transition-all sm:rounded-2xl sm:p-4",
"hover:scale-[1.02] hover:shadow-lg active:scale-[0.98]",
isActive && `ring-2 ${card.activeRing} shadow-lg`
)}
>
<div className="flex items-center justify-between">
<div
className={cn(
"flex size-10 items-center justify-center rounded-xl",
card.accentClass
)}
>
<Icon className="size-5" />
</div>
<div
className={cn(
"flex size-8 items-center justify-center rounded-lg sm:size-9 sm:rounded-xl",
card.accentClass
)}
>
<Icon className="size-4 sm:size-5" />
</div>
<div>
<p className="text-2xl font-bold tracking-tight sm:text-3xl">
<p className="text-lg font-bold tracking-tight sm:text-2xl">
{stats ? card.value.toLocaleString() : "—"}
</p>
<p className="mt-0.5 text-xs text-muted-foreground sm:text-sm">
<p className="text-[11px] text-muted-foreground sm:text-xs">
{card.label}
</p>
</div>

View file

@ -3,6 +3,7 @@
import { cn } from "@/lib/utils";
import { TopBar } from "@/components/layout/top-bar";
import { Sidebar, SidebarProvider, useSidebar } from "@/components/layout/sidebar";
import { EmailVerificationBanner } from "@/components/layout/email-verification-banner";
function ShellContent({ children }: { children: React.ReactNode }) {
const { collapsed } = useSidebar();
@ -14,11 +15,14 @@ function ShellContent({ children }: { children: React.ReactNode }) {
<Sidebar />
<main
className={cn(
"relative z-10 pt-16 transition-[margin-left] duration-200 ease-in-out",
"relative z-0 pt-16 transition-[margin-left] duration-200 ease-in-out",
collapsed ? "ml-16" : "ml-60"
)}
>
<div className="p-4 sm:p-6 lg:p-8">{children}</div>
<div className="p-4 sm:p-6 lg:p-8">
<EmailVerificationBanner />
{children}
</div>
</main>
</div>
);

View file

@ -0,0 +1,60 @@
"use client";
import { useState } from "react";
import { useSession } from "next-auth/react";
import { Mail, X, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
export function EmailVerificationBanner() {
const { data: session } = useSession();
const [dismissed, setDismissed] = useState(false);
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false);
if (!session?.user || session.user.isEmailVerified || dismissed) {
return null;
}
async function handleResend() {
setSending(true);
try {
const res = await fetch("/api/auth/verify-email/send", { method: "POST" });
if (res.ok) setSent(true);
} catch {
// silent fail — user can try again
} finally {
setSending(false);
}
}
return (
<div className="relative flex items-center gap-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-200">
<Mail className="size-4 shrink-0 text-amber-400" />
<p className="flex-1">
{sent ? (
"Verification email sent — check your inbox."
) : (
<>
Please verify your email address.{" "}
<button
onClick={handleResend}
disabled={sending}
className="inline-flex items-center gap-1 font-medium text-amber-400 underline underline-offset-2 hover:text-amber-300 disabled:opacity-60"
>
{sending && <Loader2 className="size-3 animate-spin" />}
Resend verification email
</button>
</>
)}
</p>
<Button
variant="ghost"
size="icon"
className="size-6 shrink-0 text-amber-400 hover:bg-amber-500/20 hover:text-amber-300"
onClick={() => setDismissed(true)}
>
<X className="size-3.5" />
</Button>
</div>
);
}

77
src/lib/email-sender.ts Normal file
View file

@ -0,0 +1,77 @@
import nodemailer from "nodemailer";
import { prisma } from "@/lib/db";
import crypto from "crypto";
function getTransporter() {
const host = process.env.SMTP_HOST || process.env.EMAIL_IMAP_HOST || "";
const port = parseInt(process.env.SMTP_PORT || "587", 10);
const user = process.env.SMTP_USER || process.env.EMAIL_IMAP_USER || "";
const pass = process.env.SMTP_PASS || process.env.EMAIL_IMAP_PASS || "";
return nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: { user, pass },
});
}
function getFromAddress(): string {
return (
process.env.SMTP_FROM ||
process.env.SMTP_USER ||
process.env.EMAIL_IMAP_USER ||
"noreply@echoocr.app"
);
}
export async function sendEmail(to: string, subject: string, html: string) {
const transporter = getTransporter();
return transporter.sendMail({
from: getFromAddress(),
to,
subject,
html,
});
}
export async function generateVerificationToken(email: string): Promise<string> {
const token = crypto.randomBytes(32).toString("hex");
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h
await prisma.verificationToken.deleteMany({
where: { identifier: email },
});
await prisma.verificationToken.create({
data: {
identifier: email,
token,
expires,
},
});
return token;
}
export async function sendVerificationEmail(email: string, baseUrl: string) {
const token = await generateVerificationToken(email);
const verifyUrl = `${baseUrl}/api/auth/verify-email/confirm?token=${token}`;
const html = `
<div style="max-width:480px;margin:0 auto;font-family:system-ui,sans-serif;color:#1a1a1a">
<h2 style="margin-bottom:16px">Verify your email address</h2>
<p>Click the button below to verify your email for Echo OCR.</p>
<a href="${verifyUrl}"
style="display:inline-block;margin:24px 0;padding:12px 24px;background:#6366f1;color:#fff;text-decoration:none;border-radius:8px;font-weight:600">
Verify Email
</a>
<p style="font-size:13px;color:#666">
If you didn't create an account, you can ignore this email.
This link expires in 24 hours.
</p>
</div>
`;
await sendEmail(email, "Verify your email — Echo OCR", html);
}

View file

@ -9,6 +9,8 @@ import {
mapCardToColumnValues,
} from "./monday";
import { sendWebhook } from "./webhook";
import { getProvider } from "./integrations/registry";
import type { CardData } from "./integrations/types";
export type IntegrationEvent =
| "ocr_complete"
@ -23,52 +25,97 @@ export async function fireIntegrationEvent(
extra?: { oldCard?: Record<string, unknown> }
) {
try {
const [settings, card] = await Promise.all([
prisma.appSettings.findUnique({ where: { id: "singleton" } }),
prisma.responseCard.findUnique({ where: { id: cardId } }),
]);
const card = await prisma.responseCard.findUnique({
where: { id: cardId },
});
if (!card) return;
const cardData = card as unknown as Record<string, unknown>;
// --- Activity Log ---
logActivityForEvent(event, cardId, cardData, extra?.oldCard).catch(() => {});
// --- Notifications ---
createNotificationForEvent(event, cardId, cardData).catch(() => {});
if (!settings) return;
// --- Monday.com ---
if (settings.mondayEnabled && settings.mondayApiToken && settings.mondayBoardId) {
handleMonday(event, settings, card as unknown as Record<string, unknown>, cardId).catch((err) => {
console.error("[integrations] Monday.com error:", err);
createNotification({
type: "monday_error",
title: "Monday.com Sync Failed",
message: err instanceof Error ? err.message : "Unknown error",
cardId,
actionUrl: `/cards/${cardId}`,
}).catch(() => {});
// --- New Integration model-based dispatch ---
if (card.organizationId) {
const integrations = await prisma.integration.findMany({
where: {
organizationId: card.organizationId,
enabled: true,
},
});
for (const integration of integrations) {
const triggers = (integration.triggerEvents as string[]) || [];
if (!triggers.includes(event)) continue;
const provider = getProvider(integration.provider);
if (!provider) continue;
provider
.pushCard(
cardData as unknown as CardData,
integration.config,
integration.fieldMapping
)
.then(async (result) => {
await prisma.integration.update({
where: { id: integration.id },
data: {
lastSyncAt: new Date(),
lastSyncStatus: result.success ? "success" : "error",
},
});
if (!result.success) {
createNotification({
type: "integration_error",
title: `${integration.name} Sync Failed`,
message: result.message || "Unknown error",
cardId,
actionUrl: `/cards/${cardId}`,
}).catch(() => {});
}
})
.catch((err) => {
console.error(
`[integrations] ${integration.provider} error:`,
err
);
});
}
}
// --- Generic Webhook ---
if (settings.webhookEnabled && settings.webhookUrl) {
const events = (settings.webhookEvents as string[] | null) ?? [];
if (events.includes(event)) {
sendWebhook(settings.webhookUrl, settings.webhookSecret, event, cardData).then((result) => {
if (!result.ok) {
console.error("[integrations] Webhook error:", result.error);
createNotification({
type: "webhook_error",
title: "Webhook Delivery Failed",
message: `${result.error} (${settings.webhookUrl})`,
cardId,
actionUrl: `/cards/${cardId}`,
}).catch(() => {});
}
}).catch(() => {});
// --- Legacy AppSettings-based dispatch (deprecated, kept for backward compat) ---
const settings = await prisma.appSettings
.findUnique({ where: { id: "singleton" } })
.catch(() => null);
if (settings) {
if (
settings.mondayEnabled &&
settings.mondayApiToken &&
settings.mondayBoardId
) {
handleMonday(
event,
settings,
card as unknown as Record<string, unknown>,
cardId
).catch((err) => {
console.error("[integrations] Monday.com legacy error:", err);
});
}
if (settings.webhookEnabled && settings.webhookUrl) {
const events = (settings.webhookEvents as string[] | null) ?? [];
if (events.includes(event)) {
sendWebhook(
settings.webhookUrl,
settings.webhookSecret,
event,
cardData
).catch(() => {});
}
}
}
} catch (err) {

View file

@ -0,0 +1,173 @@
import type {
IntegrationProvider,
CardData,
TestResult,
PushResult,
ExternalField,
JsonValue,
} from "../types";
const AIRTABLE_API = "https://api.airtable.com/v0";
interface AirtableConfig {
personalAccessToken: string;
baseId: string;
tableIdOrName: string;
}
function parseConfig(config: JsonValue): AirtableConfig {
const c = config as Record<string, unknown>;
return {
personalAccessToken: (c.personalAccessToken as string) || "",
baseId: (c.baseId as string) || "",
tableIdOrName: (c.tableIdOrName as string) || "",
};
}
async function airtableFetch(
token: string,
path: string,
options: RequestInit = {}
) {
const res = await fetch(`${AIRTABLE_API}${path}`, {
...options,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...(options.headers || {}),
},
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Airtable API ${res.status}: ${text}`);
}
return res.json();
}
export const airtableProvider: IntegrationProvider = {
id: "airtable",
name: "Airtable",
description: "Push response cards as rows in Airtable bases",
icon: "airtable",
category: "spreadsheet",
supportsFieldMapping: true,
supportsOAuth: false,
configFields: [
{
key: "personalAccessToken",
label: "Personal Access Token",
type: "password",
required: true,
helpText: "Create at airtable.com/create/tokens",
},
{
key: "baseId",
label: "Base ID",
type: "text",
required: true,
placeholder: "appXXXXXXXXXXXXXX",
helpText: "Found in the Airtable API docs for your base",
},
{
key: "tableIdOrName",
label: "Table Name or ID",
type: "text",
required: true,
placeholder: "Response Cards",
},
],
async testConnection(config: JsonValue): Promise<TestResult> {
try {
const { personalAccessToken, baseId, tableIdOrName } =
parseConfig(config);
if (!personalAccessToken || !baseId || !tableIdOrName) {
return {
success: false,
message: "Token, base ID, and table name are all required",
};
}
const data = await airtableFetch(
personalAccessToken,
`/${baseId}/${encodeURIComponent(tableIdOrName)}?maxRecords=1`
);
return {
success: true,
message: `Connected to table (${data.records?.length ?? 0} sample records)`,
};
} catch (err) {
return {
success: false,
message: err instanceof Error ? err.message : "Connection failed",
};
}
},
async getExternalFields(config: JsonValue): Promise<ExternalField[]> {
const { personalAccessToken, baseId, tableIdOrName } =
parseConfig(config);
const data = await airtableFetch(
personalAccessToken,
`/${baseId}/${encodeURIComponent(tableIdOrName)}?maxRecords=1`
);
if (data.records?.[0]?.fields) {
return Object.keys(data.records[0].fields).map((key) => ({
id: key,
name: key,
type: typeof data.records[0].fields[key],
}));
}
return [];
},
async pushCard(
card: CardData,
config: JsonValue,
mapping: JsonValue
): Promise<PushResult> {
try {
const { personalAccessToken, baseId, tableIdOrName } =
parseConfig(config);
const fieldMap = (mapping as Record<string, string>) || {};
const fields: Record<string, unknown> = {};
for (const [cardField, airtableField] of Object.entries(fieldMap)) {
if (!airtableField || cardField.startsWith("_")) continue;
const val = card[cardField];
if (val !== null && val !== undefined) {
fields[airtableField] = typeof val === "object" ? JSON.stringify(val) : val;
}
}
if (Object.keys(fields).length === 0 && card.name) {
fields["Name"] = card.name;
if (card.email) fields["Email"] = card.email;
if (card.cellPhone) fields["Phone"] = card.cellPhone;
}
const data = await airtableFetch(
personalAccessToken,
`/${baseId}/${encodeURIComponent(tableIdOrName)}`,
{
method: "POST",
body: JSON.stringify({ records: [{ fields }] }),
}
);
const recordId = data.records?.[0]?.id;
return {
success: true,
externalId: recordId,
message: `Created record ${recordId}`,
};
} catch (err) {
return {
success: false,
message: err instanceof Error ? err.message : "Push failed",
};
}
},
};

View file

@ -0,0 +1,123 @@
import type {
IntegrationProvider,
CardData,
TestResult,
PushResult,
JsonValue,
} from "../types";
const DEFAULT_COLUMNS = [
"name",
"email",
"cellPhone",
"homePhone",
"address",
"city",
"state",
"zip",
"gender",
"dateOfBirth",
"maritalStatus",
"visitType",
"prayerRequests",
"followUp",
"notes",
"serviceAttended",
"firstTimeGuestDate",
"salvationDate",
];
interface CsvConfig {
format: "csv" | "tsv";
columns: string[];
includeHeaders: boolean;
}
function parseConfig(config: JsonValue): CsvConfig {
const c = config as Record<string, unknown>;
return {
format: (c.format as "csv" | "tsv") || "csv",
columns: (c.columns as string[]) || DEFAULT_COLUMNS,
includeHeaders: c.includeHeaders !== false,
};
}
function escapeCell(val: string, delimiter: string): string {
if (
val.includes(delimiter) ||
val.includes('"') ||
val.includes("\n")
) {
return `"${val.replace(/"/g, '""')}"`;
}
return val;
}
export function cardToCsvRow(
card: CardData,
columns: string[],
delimiter: string
): string {
return columns
.map((col) => {
const val = card[col];
if (val === null || val === undefined) return "";
if (val instanceof Date) return val.toISOString().split("T")[0];
if (typeof val === "object") return escapeCell(JSON.stringify(val), delimiter);
return escapeCell(String(val), delimiter);
})
.join(delimiter);
}
export const csvExportProvider: IntegrationProvider = {
id: "csv_export",
name: "CSV / Excel Export",
description: "Generate CSV or TSV files from processed cards",
icon: "csv_export",
category: "export",
supportsFieldMapping: false,
supportsOAuth: false,
configFields: [
{
key: "format",
label: "Format",
type: "select",
options: [
{ value: "csv", label: "CSV (comma-separated)" },
{ value: "tsv", label: "TSV (tab-separated, Excel-friendly)" },
],
},
{
key: "includeHeaders",
label: "Include column headers",
type: "boolean",
},
],
async testConnection(): Promise<TestResult> {
return {
success: true,
message: "CSV export is always available — no external connection needed",
};
},
async pushCard(
card: CardData,
config: JsonValue,
): Promise<PushResult> {
try {
const { format, columns } = parseConfig(config);
const delimiter = format === "tsv" ? "\t" : ",";
const row = cardToCsvRow(card, columns, delimiter);
return {
success: true,
message: `Generated ${format.toUpperCase()} row (${row.length} chars)`,
};
} catch (err) {
return {
success: false,
message: err instanceof Error ? err.message : "Export failed",
};
}
},
};

View file

@ -0,0 +1,173 @@
import type {
IntegrationProvider,
CardData,
TestResult,
PushResult,
ExternalField,
JsonValue,
} from "../types";
const SHEETS_API = "https://sheets.googleapis.com/v4/spreadsheets";
interface GSheetsConfig {
accessToken: string;
refreshToken?: string;
tokenExpiresAt?: number;
spreadsheetId: string;
sheetName: string;
}
function parseConfig(config: JsonValue): GSheetsConfig {
const c = config as Record<string, unknown>;
return {
accessToken: (c.accessToken as string) || "",
refreshToken: (c.refreshToken as string) || undefined,
tokenExpiresAt: (c.tokenExpiresAt as number) || undefined,
spreadsheetId: (c.spreadsheetId as string) || "",
sheetName: (c.sheetName as string) || "Sheet1",
};
}
async function sheetsFetch(
accessToken: string,
path: string,
options: RequestInit = {}
) {
const res = await fetch(`${SHEETS_API}${path}`, {
...options,
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
...(options.headers || {}),
},
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Google Sheets API ${res.status}: ${text}`);
}
return res.json();
}
export const googleSheetsProvider: IntegrationProvider = {
id: "google_sheets",
name: "Google Sheets",
description: "Append response cards as rows in Google Sheets",
icon: "google_sheets",
category: "spreadsheet",
supportsFieldMapping: true,
supportsOAuth: true,
configFields: [
{
key: "spreadsheetId",
label: "Spreadsheet ID",
type: "text",
required: true,
placeholder: "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms",
helpText: "Found in the spreadsheet URL between /d/ and /edit",
},
{
key: "sheetName",
label: "Sheet Name",
type: "text",
placeholder: "Sheet1",
helpText: "The tab name to append rows to",
},
],
async testConnection(config: JsonValue): Promise<TestResult> {
try {
const { accessToken, spreadsheetId } = parseConfig(config);
if (!accessToken) {
return {
success: false,
message: "Not connected — use the Connect button to authorize with Google",
};
}
if (!spreadsheetId) {
return { success: false, message: "Spreadsheet ID is required" };
}
const data = await sheetsFetch(
accessToken,
`/${spreadsheetId}?fields=properties.title,sheets.properties.title`
);
const title = data.properties?.title || "Unknown";
const sheetCount = data.sheets?.length ?? 0;
return {
success: true,
message: `Connected to "${title}" (${sheetCount} sheets)`,
};
} catch (err) {
return {
success: false,
message: err instanceof Error ? err.message : "Connection failed",
};
}
},
async getExternalFields(config: JsonValue): Promise<ExternalField[]> {
try {
const { accessToken, spreadsheetId, sheetName } = parseConfig(config);
const range = `${sheetName || "Sheet1"}!1:1`;
const data = await sheetsFetch(
accessToken,
`/${spreadsheetId}/values/${encodeURIComponent(range)}`
);
const headers = data.values?.[0] || [];
return headers.map((h: string, i: number) => ({
id: String(i),
name: h,
type: "text",
}));
} catch {
return [];
}
},
async pushCard(
card: CardData,
config: JsonValue,
mapping: JsonValue
): Promise<PushResult> {
try {
const { accessToken, spreadsheetId, sheetName } = parseConfig(config);
if (!accessToken || !spreadsheetId) {
return { success: false, message: "Google Sheets not configured" };
}
const fieldMap = (mapping as Record<string, string>) || {};
const headerRange = `${sheetName || "Sheet1"}!1:1`;
const headerData = await sheetsFetch(
accessToken,
`/${spreadsheetId}/values/${encodeURIComponent(headerRange)}`
);
const headers: string[] = headerData.values?.[0] || [];
const row: string[] = headers.map((header) => {
const cardField = Object.entries(fieldMap).find(
([, ext]) => ext === header
)?.[0];
if (!cardField) return "";
const val = card[cardField];
if (val === null || val === undefined) return "";
return typeof val === "object" ? JSON.stringify(val) : String(val);
});
const range = `${sheetName || "Sheet1"}!A:A`;
await sheetsFetch(
accessToken,
`/${spreadsheetId}/values/${encodeURIComponent(range)}:append?valueInputOption=USER_ENTERED`,
{
method: "POST",
body: JSON.stringify({ values: [row] }),
}
);
return { success: true, message: "Row appended to sheet" };
} catch (err) {
return {
success: false,
message: err instanceof Error ? err.message : "Push failed",
};
}
},
};

View file

@ -0,0 +1,99 @@
import type { IntegrationProvider, CardData, TestResult, PushResult, ExternalField, JsonValue } from "../types";
import {
createItem,
updateItem,
fetchBoardColumns,
mapCardToColumnValues,
} from "@/lib/monday";
interface MondayConfig {
apiToken: string;
boardId: string;
columnMap?: Record<string, unknown>;
}
function parseConfig(config: JsonValue): MondayConfig {
const c = config as Record<string, unknown>;
return {
apiToken: (c.apiToken as string) || "",
boardId: (c.boardId as string) || "",
columnMap: (c.columnMap as Record<string, unknown>) || {},
};
}
export const mondayProvider: IntegrationProvider = {
id: "monday",
name: "Monday.com",
description: "Push response cards to Monday.com boards as items",
icon: "monday",
category: "project_mgmt",
supportsFieldMapping: true,
supportsOAuth: false,
configFields: [
{
key: "apiToken",
label: "API Token",
type: "password",
required: true,
helpText: "Found in Monday.com → Admin → API",
},
{
key: "boardId",
label: "Board ID",
type: "text",
required: true,
helpText: "The numeric board ID from the board URL",
},
],
async testConnection(config: JsonValue): Promise<TestResult> {
try {
const { apiToken, boardId } = parseConfig(config);
if (!apiToken || !boardId) {
return { success: false, message: "API token and board ID are required" };
}
const columns = await fetchBoardColumns(apiToken, boardId);
return {
success: true,
message: `Connected — found ${columns.length} columns`,
};
} catch (err) {
return {
success: false,
message: err instanceof Error ? err.message : "Connection failed",
};
}
},
async getExternalFields(config: JsonValue): Promise<ExternalField[]> {
const { apiToken, boardId } = parseConfig(config);
const columns = await fetchBoardColumns(apiToken, boardId);
return columns.map((col) => ({
id: col.id,
name: col.title,
type: col.type,
}));
},
async pushCard(
card: CardData,
config: JsonValue,
mapping: JsonValue
): Promise<PushResult> {
try {
const { apiToken, boardId, columnMap } = parseConfig(config);
const effectiveMap = (mapping as Record<string, unknown>) || columnMap || {};
const cardData = card as unknown as Record<string, unknown>;
const columnValues = mapCardToColumnValues(cardData, effectiveMap);
const itemName = card.name || "Unnamed Card";
const itemId = await createItem(apiToken, boardId, itemName, columnValues);
return { success: true, externalId: itemId };
} catch (err) {
return {
success: false,
message: err instanceof Error ? err.message : "Push failed",
};
}
},
};

View file

@ -0,0 +1,316 @@
import type {
IntegrationProvider,
CardData,
TestResult,
PushResult,
ExternalField,
JsonValue,
} from "../types";
const PCO_API_BASE = "https://api.planningcenteronline.com/people/v2";
interface PcoConfig {
accessToken: string;
refreshToken?: string;
tokenExpiresAt?: number;
defaultListId?: string;
defaultWorkflowId?: string;
matchStrategy?: "email_first" | "name_first" | "manual";
}
function parseConfig(config: JsonValue): PcoConfig {
const c = config as Record<string, unknown>;
return {
accessToken: (c.accessToken as string) || "",
refreshToken: (c.refreshToken as string) || undefined,
tokenExpiresAt: (c.tokenExpiresAt as number) || undefined,
defaultListId: (c.defaultListId as string) || undefined,
defaultWorkflowId: (c.defaultWorkflowId as string) || undefined,
matchStrategy:
(c.matchStrategy as PcoConfig["matchStrategy"]) || "email_first",
};
}
async function pcoFetch(
accessToken: string,
path: string,
options: RequestInit = {}
) {
const res = await fetch(`${PCO_API_BASE}${path}`, {
...options,
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
...(options.headers || {}),
},
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`PCO API ${res.status}: ${text}`);
}
return res.json();
}
async function findPersonByEmail(
accessToken: string,
email: string
): Promise<string | null> {
const data = await pcoFetch(
accessToken,
`/emails?where[address]=${encodeURIComponent(email)}&include=person`
);
const included = data.included;
if (included && included.length > 0) {
return included[0].id;
}
return null;
}
async function findPersonByName(
accessToken: string,
firstName: string,
lastName: string
): Promise<string | null> {
const data = await pcoFetch(
accessToken,
`/people?where[first_name]=${encodeURIComponent(firstName)}&where[last_name]=${encodeURIComponent(lastName)}`
);
if (data.data && data.data.length > 0) {
return data.data[0].id;
}
return null;
}
function splitName(name: string): { firstName: string; lastName: string } {
const parts = name.trim().split(/\s+/);
if (parts.length === 1) {
return { firstName: parts[0], lastName: "" };
}
return {
firstName: parts[0],
lastName: parts.slice(1).join(" "),
};
}
export const planningCenterProvider: IntegrationProvider = {
id: "planning_center",
name: "Planning Center",
description:
"Sync people and response cards to Planning Center Online",
icon: "planning_center",
category: "chms",
supportsFieldMapping: true,
supportsOAuth: true,
configFields: [
{
key: "defaultListId",
label: "Default List ID (optional)",
type: "text",
helpText: "Add matched/created people to this PCO List",
},
{
key: "defaultWorkflowId",
label: "Default Workflow ID (optional)",
type: "text",
helpText: "Trigger this workflow for new people",
},
{
key: "matchStrategy",
label: "Match Strategy",
type: "select",
options: [
{ value: "email_first", label: "Email first, then name" },
{ value: "name_first", label: "Name first, then email" },
{ value: "manual", label: "Manual review only" },
],
},
],
async testConnection(config: JsonValue): Promise<TestResult> {
try {
const { accessToken } = parseConfig(config);
if (!accessToken) {
return {
success: false,
message:
"Not connected — use the Connect button to authorize with Planning Center",
};
}
const data = await pcoFetch(accessToken, "/people?per_page=1");
const total = data.meta?.total_count ?? "?";
return {
success: true,
message: `Connected — ${total} people in PCO`,
};
} catch (err) {
return {
success: false,
message: err instanceof Error ? err.message : "Connection failed",
};
}
},
async getExternalFields(config: JsonValue): Promise<ExternalField[]> {
const { accessToken } = parseConfig(config);
const builtIn: ExternalField[] = [
{ id: "first_name", name: "First Name", type: "text" },
{ id: "last_name", name: "Last Name", type: "text" },
{ id: "gender", name: "Gender", type: "text" },
{ id: "birthdate", name: "Birthdate", type: "date" },
{ id: "membership", name: "Membership", type: "text" },
{ id: "status", name: "Status", type: "text" },
];
try {
const data = await pcoFetch(accessToken, "/field_definitions");
const custom = (data.data || []).map(
(fd: { id: string; attributes: { name: string; data_type: string } }) => ({
id: `custom_${fd.id}`,
name: fd.attributes.name,
type: fd.attributes.data_type,
})
);
return [...builtIn, ...custom];
} catch {
return builtIn;
}
},
async pushCard(
card: CardData,
config: JsonValue,
): Promise<PushResult> {
try {
const { accessToken, matchStrategy, defaultListId } =
parseConfig(config);
if (!accessToken) {
return { success: false, message: "Not connected to Planning Center" };
}
let personId: string | null = null;
const { firstName, lastName } = card.name
? splitName(card.name)
: { firstName: "", lastName: "" };
if (matchStrategy === "email_first" || !matchStrategy) {
if (card.email) {
personId = await findPersonByEmail(accessToken, card.email);
}
if (!personId && firstName) {
personId = await findPersonByName(accessToken, firstName, lastName);
}
} else if (matchStrategy === "name_first") {
if (firstName) {
personId = await findPersonByName(accessToken, firstName, lastName);
}
if (!personId && card.email) {
personId = await findPersonByEmail(accessToken, card.email);
}
}
const personAttrs: Record<string, unknown> = {
first_name: firstName,
last_name: lastName,
};
if (card.gender) personAttrs.gender = card.gender;
if (personId) {
await pcoFetch(accessToken, `/people/${personId}`, {
method: "PATCH",
body: JSON.stringify({
data: {
type: "Person",
id: personId,
attributes: personAttrs,
},
}),
});
} else {
const createRes = await pcoFetch(accessToken, "/people", {
method: "POST",
body: JSON.stringify({
data: {
type: "Person",
attributes: personAttrs,
},
}),
});
personId = createRes.data.id;
}
if (card.email && personId) {
try {
await pcoFetch(accessToken, `/people/${personId}/emails`, {
method: "POST",
body: JSON.stringify({
data: {
type: "Email",
attributes: {
address: card.email,
location: "Home",
primary: true,
},
},
}),
});
} catch {
// email may already exist
}
}
if (card.cellPhone && personId) {
try {
await pcoFetch(
accessToken,
`/people/${personId}/phone_numbers`,
{
method: "POST",
body: JSON.stringify({
data: {
type: "PhoneNumber",
attributes: {
number: card.cellPhone,
location: "Mobile",
primary: true,
},
},
}),
}
);
} catch {
// phone may already exist
}
}
if (defaultListId && personId) {
try {
await pcoFetch(
accessToken,
`/lists/${defaultListId}/people`,
{
method: "POST",
body: JSON.stringify({
data: { type: "Person", id: personId },
}),
}
);
} catch {
// may already be on list
}
}
return {
success: true,
externalId: personId ?? undefined,
message: personId
? `Synced to PCO person ${personId}`
: "Created in PCO",
};
} catch (err) {
return {
success: false,
message: err instanceof Error ? err.message : "Push failed",
};
}
},
};

View file

@ -0,0 +1,86 @@
import type { IntegrationProvider, CardData, TestResult, PushResult, JsonValue } from "../types";
import { sendWebhook } from "@/lib/webhook";
interface WebhookConfig {
url: string;
secret: string;
}
function parseConfig(config: JsonValue): WebhookConfig {
const c = config as Record<string, unknown>;
return {
url: (c.url as string) || "",
secret: (c.secret as string) || "",
};
}
export const webhookProvider: IntegrationProvider = {
id: "webhook",
name: "Webhook",
description: "Send card data to any URL via HTTP POST",
icon: "webhook",
category: "webhook",
supportsFieldMapping: false,
supportsOAuth: false,
configFields: [
{
key: "url",
label: "Webhook URL",
type: "url",
required: true,
placeholder: "https://example.com/webhook",
},
{
key: "secret",
label: "Signing Secret (optional)",
type: "password",
helpText: "Used to sign payloads with HMAC-SHA256",
},
],
async testConnection(config: JsonValue): Promise<TestResult> {
try {
const { url, secret } = parseConfig(config);
if (!url) {
return { success: false, message: "Webhook URL is required" };
}
const result = await sendWebhook(url, secret, "test", {
test: true,
timestamp: new Date().toISOString(),
});
if (result.ok) {
return { success: true, message: `Webhook responded with ${result.status}` };
}
return { success: false, message: result.error || "Webhook failed" };
} catch (err) {
return {
success: false,
message: err instanceof Error ? err.message : "Connection failed",
};
}
},
async pushCard(
card: CardData,
config: JsonValue,
): Promise<PushResult> {
try {
const { url, secret } = parseConfig(config);
const result = await sendWebhook(
url,
secret,
"card_push",
card as unknown as Record<string, unknown>
);
if (result.ok) {
return { success: true, message: `Delivered (HTTP ${result.status})` };
}
return { success: false, message: result.error || "Delivery failed" };
} catch (err) {
return {
success: false,
message: err instanceof Error ? err.message : "Push failed",
};
}
},
};

View file

@ -0,0 +1,34 @@
import type { IntegrationProvider } from "./types";
import { mondayProvider } from "./providers/monday";
import { webhookProvider } from "./providers/webhook";
import { planningCenterProvider } from "./providers/planning-center";
import { airtableProvider } from "./providers/airtable";
import { googleSheetsProvider } from "./providers/google-sheets";
import { csvExportProvider } from "./providers/csv-export";
const providers: Map<string, IntegrationProvider> = new Map();
function register(provider: IntegrationProvider) {
providers.set(provider.id, provider);
}
register(planningCenterProvider);
register(mondayProvider);
register(airtableProvider);
register(googleSheetsProvider);
register(webhookProvider);
register(csvExportProvider);
export function getProvider(id: string): IntegrationProvider | undefined {
return providers.get(id);
}
export function getAllProviders(): IntegrationProvider[] {
return Array.from(providers.values());
}
export function getProvidersByCategory(
category: string
): IntegrationProvider[] {
return getAllProviders().filter((p) => p.category === category);
}

View file

@ -0,0 +1,82 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type JsonValue = any;
export interface ConfigField {
key: string;
label: string;
type: "text" | "password" | "number" | "boolean" | "select" | "url";
placeholder?: string;
required?: boolean;
options?: { value: string; label: string }[];
helpText?: string;
}
export interface ExternalField {
id: string;
name: string;
type: string;
}
export interface TestResult {
success: boolean;
message: string;
}
export interface PushResult {
success: boolean;
externalId?: string;
message?: string;
}
export interface CardData {
id: string;
name?: string | null;
email?: string | null;
cellPhone?: string | null;
homePhone?: string | null;
address?: string | null;
aptNumber?: string | null;
city?: string | null;
state?: string | null;
zip?: string | null;
gender?: string | null;
dateOfBirth?: string | null;
maritalStatus?: string | null;
visitType?: string | null;
prayerRequests?: string | null;
followUp?: string | null;
notes?: string | null;
serviceAttended?: string | null;
firstTimeGuestDate?: Date | null;
salvationDate?: Date | null;
messageTopics?: JsonValue;
nextStep?: JsonValue;
howHeard?: JsonValue;
[key: string]: unknown;
}
export type ProviderCategory =
| "chms"
| "project_mgmt"
| "spreadsheet"
| "export"
| "webhook";
export interface IntegrationProvider {
id: string;
name: string;
description: string;
icon: string;
category: ProviderCategory;
configFields: ConfigField[];
supportsFieldMapping: boolean;
supportsOAuth: boolean;
testConnection(config: JsonValue): Promise<TestResult>;
getExternalFields?(config: JsonValue): Promise<ExternalField[]>;
pushCard(
card: CardData,
config: JsonValue,
mapping: JsonValue
): Promise<PushResult>;
}

View file

@ -1,5 +1,5 @@
import { auth } from "@/auth";
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";
const publicPaths = [
"/login",
@ -8,6 +8,13 @@ const publicPaths = [
"/api/auth",
"/api/health",
"/api/setup",
"/api/onboarding",
];
const onboardingExemptPaths = [
"/onboarding",
"/api/onboarding",
"/api/auth",
];
function isPublic(pathname: string) {
@ -16,7 +23,13 @@ function isPublic(pathname: string) {
);
}
export default auth((req) => {
function isOnboardingExempt(pathname: string) {
return onboardingExemptPaths.some(
(p) => pathname === p || pathname.startsWith(p + "/")
);
}
export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
if (
@ -31,14 +44,24 @@ export default auth((req) => {
return NextResponse.next();
}
if (!req.auth) {
const token = await getToken({ req, secret: process.env.AUTH_SECRET });
if (!token) {
const loginUrl = new URL("/login", req.url);
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}
if (
token.onboardingComplete === false &&
token.orgRole === "owner" &&
!isOnboardingExempt(pathname)
) {
return NextResponse.redirect(new URL("/onboarding", req.url));
}
return NextResponse.next();
});
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],

View file

@ -12,6 +12,8 @@ declare module "next-auth" {
orgName?: string;
displayName?: string;
avatarUrl?: string;
isEmailVerified?: boolean;
onboardingComplete?: boolean;
};
}
@ -29,5 +31,7 @@ declare module "next-auth/jwt" {
orgRole?: string;
displayName?: string;
avatarUrl?: string;
isEmailVerified?: boolean;
onboardingComplete?: boolean;
}
}