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:
parent
d3e7374439
commit
a975043670
48 changed files with 6087 additions and 1406 deletions
|
|
@ -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
21
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
105
prisma/migrate-integrations.ts
Normal file
105
prisma/migrate-integrations.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -77,6 +77,8 @@ model Organization {
|
|||
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
|
||||
|
||||
|
|
@ -86,6 +88,7 @@ model Organization {
|
|||
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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
995
src/app/(dashboard)/onboarding/page.tsx
Normal file
995
src/app/(dashboard)/onboarding/page.tsx
Normal 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'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're all set!</h2>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Your workspace is ready. Here'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
196
src/app/(dashboard)/settings/ai/page.tsx
Normal file
196
src/app/(dashboard)/settings/ai/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
280
src/app/(dashboard)/settings/general/page.tsx
Normal file
280
src/app/(dashboard)/settings/general/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
406
src/app/(dashboard)/settings/integrations/[id]/page.tsx
Normal file
406
src/app/(dashboard)/settings/integrations/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
246
src/app/(dashboard)/settings/integrations/new/page.tsx
Normal file
246
src/app/(dashboard)/settings/integrations/new/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
205
src/app/(dashboard)/settings/integrations/page.tsx
Normal file
205
src/app/(dashboard)/settings/integrations/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
src/app/(dashboard)/settings/layout.tsx
Normal file
66
src/app/(dashboard)/settings/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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="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>
|
||||
<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">
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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="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.
|
||||
</div>
|
||||
<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">
|
||||
<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
358
src/app/(dashboard)/settings/upload-sources/page.tsx
Normal file
358
src/app/(dashboard)/settings/upload-sources/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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="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>
|
||||
<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">
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
40
src/app/api/auth/verify-email/confirm/route.ts
Normal file
40
src/app/api/auth/verify-email/confirm/route.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
37
src/app/api/auth/verify-email/send/route.ts
Normal file
37
src/app/api/auth/verify-email/send/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
42
src/app/api/integrations/[id]/fields/route.ts
Normal file
42
src/app/api/integrations/[id]/fields/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
115
src/app/api/integrations/[id]/route.ts
Normal file
115
src/app/api/integrations/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
90
src/app/api/integrations/[id]/sync/route.ts
Normal file
90
src/app/api/integrations/[id]/sync/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
54
src/app/api/integrations/[id]/test/route.ts
Normal file
54
src/app/api/integrations/[id]/test/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
80
src/app/api/integrations/oauth/[provider]/authorize/route.ts
Normal file
80
src/app/api/integrations/oauth/[provider]/authorize/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
124
src/app/api/integrations/oauth/[provider]/callback/route.ts
Normal file
124
src/app/api/integrations/oauth/[provider]/callback/route.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
76
src/app/api/integrations/route.ts
Normal file
76
src/app/api/integrations/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
353
src/app/api/onboarding/route.ts
Normal file
353
src/app/api/onboarding/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
15
src/auth.ts
15
src/auth.ts
|
|
@ -63,19 +63,26 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||
include: {
|
||||
memberships: {
|
||||
take: 1,
|
||||
include: { organization: { select: { id: true, name: true } } },
|
||||
include: {
|
||||
organization: {
|
||||
select: { id: true, name: true, onboardingComplete: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (dbUser) {
|
||||
token.role = dbUser.role;
|
||||
token.displayName = dbUser.displayName;
|
||||
token.avatarUrl = dbUser.avatarUrl;
|
||||
token.displayName = dbUser.displayName ?? undefined;
|
||||
token.avatarUrl = dbUser.avatarUrl ?? undefined;
|
||||
token.isEmailVerified = !!dbUser.emailVerified;
|
||||
if (dbUser.memberships[0]) {
|
||||
token.orgId = dbUser.memberships[0].organizationId;
|
||||
token.orgName = dbUser.memberships[0].organization.name;
|
||||
token.orgRole = dbUser.memberships[0].role;
|
||||
token.onboardingComplete =
|
||||
dbUser.memberships[0].organization.onboardingComplete ?? false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -90,6 +97,8 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||
session.user.orgName = token.orgName as string | undefined;
|
||||
session.user.displayName = token.displayName as string | undefined;
|
||||
session.user.avatarUrl = token.avatarUrl as string | undefined;
|
||||
session.user.isEmailVerified = token.isEmailVerified ?? false;
|
||||
session.user.onboardingComplete = token.onboardingComplete ?? false;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"flex size-8 items-center justify-center rounded-lg sm:size-9 sm:rounded-xl",
|
||||
card.accentClass
|
||||
)}
|
||||
>
|
||||
<Icon className="size-5" />
|
||||
</div>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
60
src/components/layout/email-verification-banner.tsx
Normal file
60
src/components/layout/email-verification-banner.tsx
Normal 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
77
src/lib/email-sender.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
// --- New Integration model-based dispatch ---
|
||||
if (card.organizationId) {
|
||||
const integrations = await prisma.integration.findMany({
|
||||
where: {
|
||||
organizationId: card.organizationId,
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
// --- 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);
|
||||
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: "monday_error",
|
||||
title: "Monday.com Sync Failed",
|
||||
message: err instanceof Error ? err.message : "Unknown error",
|
||||
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
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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);
|
||||
});
|
||||
}
|
||||
|
||||
// --- 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(() => {});
|
||||
sendWebhook(
|
||||
settings.webhookUrl,
|
||||
settings.webhookSecret,
|
||||
event,
|
||||
cardData
|
||||
).catch(() => {});
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
|
|||
173
src/lib/integrations/providers/airtable.ts
Normal file
173
src/lib/integrations/providers/airtable.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
123
src/lib/integrations/providers/csv-export.ts
Normal file
123
src/lib/integrations/providers/csv-export.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
173
src/lib/integrations/providers/google-sheets.ts
Normal file
173
src/lib/integrations/providers/google-sheets.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
99
src/lib/integrations/providers/monday.ts
Normal file
99
src/lib/integrations/providers/monday.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
316
src/lib/integrations/providers/planning-center.ts
Normal file
316
src/lib/integrations/providers/planning-center.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
86
src/lib/integrations/providers/webhook.ts
Normal file
86
src/lib/integrations/providers/webhook.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
34
src/lib/integrations/registry.ts
Normal file
34
src/lib/integrations/registry.ts
Normal 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);
|
||||
}
|
||||
82
src/lib/integrations/types.ts
Normal file
82
src/lib/integrations/types.ts
Normal 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>;
|
||||
}
|
||||
|
|
@ -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).*)"],
|
||||
|
|
|
|||
4
src/types/next-auth.d.ts
vendored
4
src/types/next-auth.d.ts
vendored
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue