Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica

- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind
- Frontend Homenest (versión alternativa)
- Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp)
- WACRM fork (Next.js 16 + Supabase)
- Hermes + Bridge + Skills (Qwen3.6 via Nan Builders)
- Scripts de migración y operación
- Documentación extensiva en docs/
This commit is contained in:
2026-07-20 07:44:23 +00:00
commit a718592291
699 changed files with 324602 additions and 0 deletions

View File

@@ -0,0 +1,131 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { createClient } from "@/lib/supabase/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { MessageSquare, CheckCircle, ArrowLeft } from "lucide-react";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const supabase = createClient();
const handleReset = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/auth/callback?next=/reset-password`,
});
if (error) {
setError(error.message);
setLoading(false);
return;
}
setSuccess(true);
setLoading(false);
};
if (success) {
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<Card className="w-full max-w-md border-border bg-card">
<CardHeader className="items-center text-center">
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
<CheckCircle className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-xl text-foreground">
Check your email
</CardTitle>
<CardDescription className="text-muted-foreground">
We&apos;ve sent a password reset link to{" "}
<span className="text-foreground">{email}</span>. Please check your
inbox.
</CardDescription>
</CardHeader>
<CardContent>
<Link href="/login">
<Button
variant="outline"
className="w-full border-border text-muted-foreground hover:bg-muted hover:text-foreground"
>
Back to sign in
</Button>
</Link>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<Card className="w-full max-w-md border-border bg-card">
<CardHeader className="items-center text-center">
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
<MessageSquare className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-xl text-foreground">Reset password</CardTitle>
<CardDescription className="text-muted-foreground">
Enter your email and we&apos;ll send you a reset link
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleReset} className="flex flex-col gap-4">
{error && (
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">
{error}
</div>
)}
<div className="flex flex-col gap-2">
<Label htmlFor="email" className="text-muted-foreground">
Email
</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="border-border bg-muted text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-primary/20"
/>
</div>
<Button
type="submit"
disabled={loading}
className="mt-2 h-10 w-full bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "Sending..." : "Send reset link"}
</Button>
</form>
<Link
href="/login"
className="mt-6 flex items-center justify-center gap-2 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back to sign in
</Link>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
// Shared metadata for auth pages (login / signup / forgot-password).
// None of these should be indexed — they'd compete with the marketing
// landing in SERPs and offer nothing to a searcher who hasn't already
// signed up. Each page still gets its own <title> via its own
// metadata.title override below the route group layout.
export const metadata: Metadata = {
robots: {
index: false,
follow: false,
nocache: true,
googleBot: {
index: false,
follow: false,
noimageindex: true,
},
},
};
export default function AuthLayout({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -0,0 +1,161 @@
"use client";
import { Suspense, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { createClient } from "@/lib/supabase/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { MessageSquare, UsersRound } from "lucide-react";
// `useSearchParams` opts the component out of static prerendering
// unless it sits under a Suspense boundary. We split the form into
// a child component so the outer page can prerender the chrome
// (background, card frame) while the form hydrates with the query
// string on the client.
export default function LoginPage() {
return (
<Suspense fallback={null}>
<LoginPageInner />
</Suspense>
);
}
function LoginPageInner() {
const searchParams = useSearchParams();
// Forwarded from `/join/<token>` when the visitor already has an
// account. After a successful sign-in we send them to the join
// page to accept rather than to /dashboard.
const inviteToken = searchParams.get("invite");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const router = useRouter();
const supabase = createClient();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
setError(error.message);
setLoading(false);
return;
}
if (inviteToken) {
router.push(`/join/${encodeURIComponent(inviteToken)}`);
} else {
router.push("/dashboard");
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<Card className="w-full max-w-md border-border bg-card">
<CardHeader className="items-center text-center">
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
{inviteToken ? (
<UsersRound className="h-6 w-6 text-primary" />
) : (
<MessageSquare className="h-6 w-6 text-primary" />
)}
</div>
<CardTitle className="text-xl text-foreground">
{inviteToken ? "Sign in to accept" : "Welcome back"}
</CardTitle>
<CardDescription className="text-muted-foreground">
{inviteToken
? "Sign in and we'll take you to the invitation."
: "Sign in to your account"}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="flex flex-col gap-4">
{error && (
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">
{error}
</div>
)}
<div className="flex flex-col gap-2">
<Label htmlFor="email" className="text-muted-foreground">
Email
</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="border-border bg-muted text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-primary/20"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="password" className="text-muted-foreground">
Password
</Label>
<Link
href="/forgot-password"
className="text-sm text-primary hover:text-primary/80"
>
Forgot password?
</Link>
</div>
<Input
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="border-border bg-muted text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-primary/20"
/>
</div>
<Button
type="submit"
disabled={loading}
className="mt-2 h-10 w-full bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "Signing in..." : "Sign in"}
</Button>
</form>
<p className="mt-6 text-center text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
<Link
href={
inviteToken
? `/signup?invite=${encodeURIComponent(inviteToken)}`
: "/signup"
}
className="text-primary hover:text-primary/80"
>
Create account
</Link>
</p>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,244 @@
"use client";
import { Suspense, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { MessageSquare, CheckCircle, UsersRound } from "lucide-react";
// `useSearchParams` opts the component out of static prerendering
// unless wrapped in Suspense — same pattern as /login.
export default function SignupPage() {
return (
<Suspense fallback={null}>
<SignupPageInner />
</Suspense>
);
}
function SignupPageInner() {
const searchParams = useSearchParams();
// When the user lands here from `/join/<token>` we carry the
// invite token in the query so it survives the signup → email
// verification → redirect round-trip. `emailRedirectTo` below
// points back at /join/<token> so the user lands on the redeem
// step after verifying instead of being dropped on /dashboard.
const inviteToken = searchParams.get("invite");
const [fullName, setFullName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const supabase = createClient();
const handleSignup = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
if (password.length < 6) {
setError("Password must be at least 6 characters");
return;
}
setLoading(true);
// If we have an invite token, point Supabase's verification
// email back at the join page so the user can accept after
// verifying. Without a token, Supabase uses its default
// redirect (the app root).
const emailRedirectTo = inviteToken
? `${window.location.origin}/join/${encodeURIComponent(inviteToken)}`
: undefined;
const { error } = await supabase.auth.signUp({
email,
password,
options: {
data: {
full_name: fullName,
},
...(emailRedirectTo ? { emailRedirectTo } : {}),
},
});
if (error) {
setError(error.message);
setLoading(false);
return;
}
setSuccess(true);
setLoading(false);
};
if (success) {
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<Card className="w-full max-w-md border-border bg-card">
<CardHeader className="items-center text-center">
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
<CheckCircle className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-xl text-foreground">
Check your email
</CardTitle>
<CardDescription className="text-muted-foreground">
We&apos;ve sent a confirmation link to{" "}
<span className="text-foreground">{email}</span>. Please check your
inbox and click the link to verify your account.
</CardDescription>
</CardHeader>
<CardContent>
<Link
href={
inviteToken
? `/login?invite=${encodeURIComponent(inviteToken)}`
: "/login"
}
>
<Button
variant="outline"
className="w-full border-border text-muted-foreground hover:bg-muted hover:text-foreground"
>
Back to sign in
</Button>
</Link>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<Card className="w-full max-w-md border-border bg-card">
<CardHeader className="items-center text-center">
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
{inviteToken ? (
<UsersRound className="h-6 w-6 text-primary" />
) : (
<MessageSquare className="h-6 w-6 text-primary" />
)}
</div>
<CardTitle className="text-xl text-foreground">
{inviteToken ? "Create account & join" : "Create account"}
</CardTitle>
<CardDescription className="text-muted-foreground">
{inviteToken
? "Verify your email, then accept the invitation to join your team."
: "Get started with CRM Template for WhatsApp"}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSignup} className="flex flex-col gap-4">
{error && (
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">
{error}
</div>
)}
<div className="flex flex-col gap-2">
<Label htmlFor="fullName" className="text-muted-foreground">
Full name
</Label>
<Input
id="fullName"
type="text"
placeholder="John Doe"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
required
className="border-border bg-muted text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-primary/20"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="email" className="text-muted-foreground">
Email
</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="border-border bg-muted text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-primary/20"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password" className="text-muted-foreground">
Password
</Label>
<Input
id="password"
type="password"
placeholder="At least 6 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="border-border bg-muted text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-primary/20"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="confirmPassword" className="text-muted-foreground">
Confirm password
</Label>
<Input
id="confirmPassword"
type="password"
placeholder="Repeat your password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="border-border bg-muted text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-primary/20"
/>
</div>
<Button
type="submit"
disabled={loading}
className="mt-2 h-10 w-full bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "Creating account..." : "Create account"}
</Button>
</form>
<p className="mt-6 text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link
href={
inviteToken
? `/login?invite=${encodeURIComponent(inviteToken)}`
: "/login"
}
className="text-primary hover:text-primary/80"
>
Sign in
</Link>
</p>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import { useEffect, useState } from 'react';
import { Bot, Sparkles, Settings2 } from 'lucide-react';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { AiPlayground } from '@/components/agents/ai-playground';
import { AiConfig } from '@/components/settings/ai-config';
type Tab = 'playground' | 'setup';
export default function AgentsPage() {
const [tab, setTab] = useState<Tab>('playground');
const [decided, setDecided] = useState(false);
// Land first-time users on Setup, returning users on the Playground.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch('/api/ai/config');
const data = await res.json().catch(() => ({}));
if (!cancelled) setTab(data?.configured ? 'playground' : 'setup');
} catch {
if (!cancelled) setTab('setup');
} finally {
if (!cancelled) setDecided(true);
}
})();
return () => {
cancelled = true;
};
}, []);
return (
<div>
<div className="flex items-center gap-2">
<Bot className="h-6 w-6 text-primary" />
<h1 className="text-2xl font-bold tracking-tight text-foreground">
AI Agents
</h1>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Your bring-your-own-key AI agent set it up, then test it in the
playground before it replies to customers in the inbox.
</p>
{decided && (
<Tabs
value={tab}
onValueChange={(v) => setTab(v as Tab)}
className="mt-6"
>
<TabsList>
<TabsTrigger value="playground">
<Sparkles className="mr-1.5 h-4 w-4" /> Playground
</TabsTrigger>
<TabsTrigger value="setup">
<Settings2 className="mr-1.5 h-4 w-4" /> Setup
</TabsTrigger>
</TabsList>
<TabsContent value="playground" className="mt-4">
<AiPlayground onGoToSetup={() => setTab('setup')} />
</TabsContent>
<TabsContent value="setup" className="mt-4">
<AiConfig />
</TabsContent>
</Tabs>
)}
</div>
);
}

View File

@@ -0,0 +1,74 @@
"use client"
import { use, useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { Loader2 } from "lucide-react"
import {
AutomationBuilder,
fromServerSteps,
type BuilderInitial,
type ServerStepNode,
} from "@/components/automations/automation-builder"
import type { AutomationTriggerType } from "@/types"
export default function EditAutomationPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = use(params)
const router = useRouter()
const [initial, setInitial] = useState<BuilderInitial | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
const res = await fetch(`/api/automations/${id}`)
if (!res.ok) {
if (!cancelled) setError(`Failed to load (${res.status})`)
return
}
const body = await res.json()
if (cancelled) return
setInitial({
id: body.automation.id,
name: body.automation.name ?? "",
description: body.automation.description ?? "",
trigger_type: body.automation.trigger_type as AutomationTriggerType,
trigger_config: body.automation.trigger_config ?? {},
is_active: !!body.automation.is_active,
steps: fromServerSteps((body.steps ?? []) as ServerStepNode[]),
})
}
load()
return () => {
cancelled = true
}
}, [id])
if (error) {
return (
<div className="flex h-screen flex-col items-center justify-center gap-3">
<p className="text-sm text-red-400">{error}</p>
<button
onClick={() => router.push("/automations")}
className="text-sm text-primary hover:text-primary/80"
>
Back to Automations
</button>
</div>
)
}
if (!initial) {
return (
<div className="flex h-screen items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
)
}
return <AutomationBuilder initial={initial} />
}

View File

@@ -0,0 +1,205 @@
"use client"
import { use, useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import {
ArrowLeft,
Check,
Loader2,
X,
ChevronDown,
ChevronRight,
} from "lucide-react"
import { createClient } from "@/lib/supabase/client"
import type {
Automation,
AutomationLog,
AutomationLogStepResult,
} from "@/types"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { formatRelative } from "@/lib/automations/trigger-meta"
export default function AutomationLogsPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = use(params)
const router = useRouter()
const [automation, setAutomation] = useState<Automation | null>(null)
const [logs, setLogs] = useState<AutomationLog[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [openLogId, setOpenLogId] = useState<string | null>(null)
useEffect(() => {
async function load() {
try {
const supabase = createClient()
const [autRes, logRes] = await Promise.all([
supabase
.from("automations")
.select("*")
.eq("id", id)
.maybeSingle(),
supabase
.from("automation_logs")
.select("*, contact:contacts(id, name, phone)")
.eq("automation_id", id)
.order("created_at", { ascending: false })
.limit(100),
])
if (autRes.error) throw autRes.error
if (logRes.error) throw logRes.error
setAutomation(autRes.data as Automation | null)
setLogs((logRes.data ?? []) as AutomationLog[])
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load logs")
}
}
load()
}, [id])
if (error) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-3">
<p className="text-sm text-red-400">{error}</p>
<Button variant="outline" onClick={() => router.push("/automations")}>
Back
</Button>
</div>
)
}
if (!automation || logs === null) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => router.push("/automations")}
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Back"
>
<ArrowLeft className="h-4 w-4" />
</button>
<div>
<h1 className="text-2xl font-bold text-foreground">{automation.name}</h1>
<p className="mt-0.5 text-sm text-muted-foreground">Execution logs</p>
</div>
</div>
{logs.length === 0 ? (
<div className="flex h-48 flex-col items-center justify-center rounded-xl border border-dashed border-border bg-card/40">
<p className="text-sm text-foreground">No executions yet</p>
<p className="mt-1 text-xs text-muted-foreground">
Trigger this automation to see runs here.
</p>
</div>
) : (
<ul className="space-y-2">
{logs.map((log) => {
const isOpen = openLogId === log.id
return (
<li
key={log.id}
className="rounded-xl border border-border bg-card"
>
<button
type="button"
onClick={() => setOpenLogId(isOpen ? null : log.id)}
className="flex w-full items-center gap-3 px-4 py-3 text-left"
>
{isOpen ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<StatusBadge status={log.status} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">
{log.contact?.name ?? log.contact?.phone ?? "Unknown contact"}
</div>
<div className="truncate text-xs text-muted-foreground">
{log.trigger_event} · {log.steps_executed?.length ?? 0} step
{log.steps_executed?.length === 1 ? "" : "s"}
</div>
</div>
<div className="text-xs text-muted-foreground">
{formatRelative(log.created_at)}
</div>
</button>
{isOpen && (
<div className="border-t border-border px-4 py-3">
{log.error_message && (
<p className="mb-3 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300">
{log.error_message}
</p>
)}
<ul className="space-y-1.5">
{(log.steps_executed ?? []).map((r, i) => (
<StepRow key={i} result={r} />
))}
{(log.steps_executed ?? []).length === 0 && (
<li className="text-xs text-muted-foreground">No steps recorded.</li>
)}
</ul>
</div>
)}
</li>
)
})}
</ul>
)}
</div>
)
}
function StatusBadge({ status }: { status: AutomationLog["status"] }) {
const classes =
status === "success"
? "border-primary/30 bg-primary/10 text-primary"
: status === "partial"
? "border-amber-500/30 bg-amber-500/10 text-amber-300"
: "border-red-500/30 bg-red-500/10 text-red-300"
return (
<span
className={cn(
"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium",
classes,
)}
>
{status}
</span>
)
}
function StepRow({ result }: { result: AutomationLogStepResult }) {
const ok = result.status === "success"
return (
<li className="flex items-start gap-2 text-xs">
<span
className={cn(
"mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full",
ok ? "bg-primary/20 text-primary" : "bg-red-500/20 text-red-400",
)}
aria-hidden
>
{ok ? <Check className="h-3 w-3" /> : <X className="h-3 w-3" />}
</span>
<span className="text-muted-foreground">{result.step_type}</span>
{result.detail && (
<span className="truncate text-muted-foreground"> {result.detail}</span>
)}
</li>
)
}

View File

@@ -0,0 +1,90 @@
"use client"
import { useMemo } from "react"
import { useSearchParams } from "next/navigation"
import {
AutomationBuilder,
type BuilderInitial,
type BuilderStep,
} from "@/components/automations/automation-builder"
import { AUTOMATION_TEMPLATES, type TemplateSlug } from "@/lib/automations/templates"
import type { AutomationStepType, AutomationTriggerType } from "@/types"
export default function NewAutomationPage() {
const params = useSearchParams()
const template = params.get("template") as TemplateSlug | null
const initial: BuilderInitial = useMemo(() => {
if (template && AUTOMATION_TEMPLATES[template]) {
const t = AUTOMATION_TEMPLATES[template]
const steps = expandFromSeeds(
t.steps.map((seed, idx) => ({
index: idx,
step_type: seed.step_type,
step_config: seed.step_config as Record<string, unknown>,
branch: seed.branch ?? null,
parent_index: seed.parent_index ?? null,
})),
)
return {
name: t.name,
description: t.description,
trigger_type: t.trigger_type,
trigger_config: t.trigger_config as Record<string, unknown>,
is_active: false,
steps,
}
}
return {
name: "",
description: "",
trigger_type: "new_message_received" as AutomationTriggerType,
trigger_config: {},
is_active: false,
steps: [],
}
}, [template])
return <AutomationBuilder initial={initial} />
}
interface SeedRow {
index: number
step_type: AutomationStepType
step_config: Record<string, unknown>
branch: "yes" | "no" | null
parent_index: number | null
}
function uid(): string {
return (
"c_" +
(typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: Math.random().toString(36).slice(2) + Date.now().toString(36))
)
}
/** Template seeds are flat with parent_index references. Expand into the
* builder's nested tree, preserving order within each scope. */
function expandFromSeeds(rows: SeedRow[]): BuilderStep[] {
const nodes: BuilderStep[] = rows.map((r) => ({
cid: uid(),
step_type: r.step_type,
step_config: r.step_config,
branches:
r.step_type === "condition" ? { yes: [], no: [] } : undefined,
}))
const roots: BuilderStep[] = []
rows.forEach((r, i) => {
if (r.parent_index == null) {
roots.push(nodes[i])
return
}
const parent = nodes[r.parent_index]
if (!parent.branches) parent.branches = { yes: [], no: [] }
parent.branches[r.branch ?? "yes"].push(nodes[i])
})
return roots
}

View File

@@ -0,0 +1,363 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import {
Zap,
Plus,
MoreVertical,
Copy,
Pencil,
Trash2,
FileText,
MessageCircle,
Clock,
Users,
PhoneCall,
Loader2,
} from "lucide-react"
import { createClient } from "@/lib/supabase/client"
import { useCan } from "@/hooks/use-can"
import type { Automation } from "@/types"
import { Button } from "@/components/ui/button"
import { GatedButton } from "@/components/ui/gated-button"
import { Switch } from "@/components/ui/switch"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { AUTOMATION_TEMPLATES, type TemplateSlug } from "@/lib/automations/templates"
import { triggerMeta, formatRelative } from "@/lib/automations/trigger-meta"
import { cn } from "@/lib/utils"
const TEMPLATE_ORDER: TemplateSlug[] = [
"welcome_message",
"out_of_office",
"lead_qualifier",
"follow_up_reminder",
]
const TEMPLATE_ICON: Record<TemplateSlug, typeof Zap> = {
welcome_message: MessageCircle,
out_of_office: Clock,
lead_qualifier: Users,
follow_up_reminder: PhoneCall,
}
export default function AutomationsPage() {
const router = useRouter()
const canCreate = useCan("send-messages")
const [automations, setAutomations] = useState<Automation[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [pendingDelete, setPendingDelete] = useState<Automation | null>(null)
const [deleting, setDeleting] = useState(false)
async function load() {
try {
const supabase = createClient()
const { data, error: fetchErr } = await supabase
.from("automations")
.select("*")
.order("created_at", { ascending: false })
if (fetchErr) throw fetchErr
setAutomations((data ?? []) as Automation[])
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load automations")
}
}
useEffect(() => {
load()
}, [])
async function toggleActive(a: Automation, next: boolean) {
// Optimistic flip so the switch feels instant.
setAutomations((prev) =>
prev?.map((x) => (x.id === a.id ? { ...x, is_active: next } : x)) ?? prev,
)
const res = await fetch(`/api/automations/${a.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ is_active: next }),
})
if (!res.ok) {
// Roll back on error.
setAutomations((prev) =>
prev?.map((x) => (x.id === a.id ? { ...x, is_active: !next } : x)) ?? prev,
)
const body = await res.json().catch(() => ({}))
toast.error(body?.error ?? "Failed to update")
return
}
toast.success(next ? "Automation activated" : "Automation paused")
}
async function duplicate(a: Automation) {
const res = await fetch(`/api/automations/${a.id}/duplicate`, { method: "POST" })
if (!res.ok) {
const body = await res.json().catch(() => ({}))
toast.error(body?.error ?? "Failed to duplicate")
return
}
toast.success("Automation duplicated")
load()
}
async function confirmDelete() {
if (!pendingDelete) return
setDeleting(true)
const res = await fetch(`/api/automations/${pendingDelete.id}`, { method: "DELETE" })
setDeleting(false)
if (!res.ok) {
const body = await res.json().catch(() => ({}))
toast.error(body?.error ?? "Failed to delete")
return
}
toast.success("Automation deleted")
setPendingDelete(null)
load()
}
async function startFromTemplate(slug: TemplateSlug) {
router.push(`/automations/new?template=${slug}`)
}
if (error) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-2">
<p className="text-sm text-red-400">{error}</p>
<Button variant="outline" onClick={() => window.location.reload()}>
Retry
</Button>
</div>
)
}
if (automations === null) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
)
}
const showTemplates = automations.length < 3
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Automations</h1>
<p className="mt-1 text-sm text-muted-foreground">
Build workflows that react to WhatsApp® events automatically.
</p>
</div>
<GatedButton
canAct={canCreate}
gateReason="create automations"
onClick={() => router.push("/automations/new")}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
<Plus className="h-4 w-4" />
Create Automation
</GatedButton>
</div>
{showTemplates && (
<section>
<h2 className="mb-3 text-sm font-semibold text-muted-foreground">Quick-start templates</h2>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
{TEMPLATE_ORDER.map((slug) => {
const t = AUTOMATION_TEMPLATES[slug]
const Icon = TEMPLATE_ICON[slug]
return (
<button
key={slug}
onClick={() => startFromTemplate(slug)}
className="group flex flex-col items-start rounded-xl border border-border bg-card p-4 text-left transition-colors hover:border-primary/50 hover:bg-card/80"
>
<div className="mb-3 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 text-primary group-hover:bg-primary/15">
<Icon className="h-5 w-5" />
</div>
<div className="text-sm font-semibold text-foreground">{t.name}</div>
<p className="mt-1 text-xs text-muted-foreground">{t.description}</p>
</button>
)
})}
</div>
</section>
)}
{automations.length === 0 ? (
<div className="flex h-48 flex-col items-center justify-center rounded-xl border border-dashed border-border bg-card/40">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
<Zap className="h-6 w-6 text-primary" />
</div>
<p className="mt-3 text-sm font-medium text-foreground">No automations yet</p>
<p className="mt-1 text-xs text-muted-foreground">
Pick a template above or create one from scratch.
</p>
</div>
) : (
<ul className="space-y-3">
{automations.map((a) => (
<AutomationCard
key={a.id}
automation={a}
onToggle={(next) => toggleActive(a, next)}
onEdit={() => router.push(`/automations/${a.id}/edit`)}
onDuplicate={() => duplicate(a)}
onLogs={() => router.push(`/automations/${a.id}/logs`)}
onDelete={() => setPendingDelete(a)}
/>
))}
</ul>
)}
<Dialog open={!!pendingDelete} onOpenChange={(v) => !v && setPendingDelete(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete automation</DialogTitle>
<DialogDescription>
This permanently removes{" "}
<span className="text-foreground">{pendingDelete?.name}</span> and its execution
history. This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setPendingDelete(null)}
disabled={deleting}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmDelete}
disabled={deleting}
>
{deleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
function AutomationCard({
automation,
onToggle,
onEdit,
onDuplicate,
onLogs,
onDelete,
}: {
automation: Automation
onToggle: (next: boolean) => void
onEdit: () => void
onDuplicate: () => void
onLogs: () => void
onDelete: () => void
}) {
const meta = triggerMeta(automation.trigger_type)
return (
<li className="rounded-xl border border-border bg-card transition-colors hover:border-border">
<div className="flex items-center gap-4 p-4">
<div
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-primary/10"
aria-hidden
>
<Zap className="h-5 w-5 text-primary" />
</div>
<button
type="button"
onClick={onEdit}
className="min-w-0 flex-1 text-left"
>
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold text-foreground">
{automation.name}
</span>
{automation.is_active && (
<span className="relative flex h-2 w-2" aria-label="active">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary" />
</span>
)}
</div>
{automation.description && (
<p className="mt-0.5 truncate text-xs text-muted-foreground">{automation.description}</p>
)}
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span
className={cn(
"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium",
meta.pillClass,
)}
>
{meta.label}
</span>
<span className="tabular-nums">
{automation.execution_count} run{automation.execution_count === 1 ? "" : "s"}
</span>
<span aria-hidden>·</span>
<span>last {formatRelative(automation.last_executed_at)}</span>
</div>
</button>
<div className="flex items-center gap-3">
<Switch
checked={automation.is_active}
onCheckedChange={(v) => onToggle(!!v)}
aria-label={automation.is_active ? "Deactivate" : "Activate"}
/>
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open menu"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[popup-open]:bg-muted"
>
<MoreVertical className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit}>
<Pencil className="h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={onDuplicate}>
<Copy className="h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem onClick={onLogs}>
<FileText className="h-4 w-4" />
View Logs
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={onDelete}>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</li>
)
}

View File

@@ -0,0 +1,528 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
import { Broadcast, BroadcastRecipient, RecipientStatus } from '@/types';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
ArrowLeft,
Loader2,
Users,
Send,
CheckCheck,
Eye,
AlertCircle,
MessageCircle,
Filter,
Download,
ChevronDown,
Trash2,
} from 'lucide-react';
import { toast } from 'sonner';
import {
getBroadcastStatus,
getRecipientStatus,
} from '@/lib/broadcast-status';
interface StatCardProps {
label: string;
value: number;
total: number;
icon: React.ReactNode;
color: string;
}
function StatCard({ label, value, total, icon, color }: StatCardProps) {
const pct = total > 0 ? Math.round((value / total) * 100) : 0;
return (
<div className="rounded-xl border border-border bg-card p-4">
<div className="flex items-center justify-between">
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${color}`}>
{icon}
</div>
<span className="text-xs text-muted-foreground">{pct}%</span>
</div>
<p className="mt-3 text-2xl font-bold text-foreground">{value.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
);
}
interface FunnelStep {
label: string;
value: number;
color: string;
}
/**
* Pure-CSS funnel chart: decreasing-width rounded bars.
* Width is relative to the largest step (typically Sent) so we
* always render a full bar at the top and proportional tails.
*/
function FunnelChart({ steps }: { steps: FunnelStep[] }) {
const max = Math.max(...steps.map((s) => s.value), 1);
return (
<div className="rounded-xl border border-border bg-card p-4">
<h3 className="mb-4 text-sm font-medium text-foreground">Funnel</h3>
<div className="space-y-2">
{steps.map((step) => {
const pctOfMax = Math.max(5, Math.round((step.value / max) * 100));
const pctOfSent =
steps[0].value > 0
? Math.round((step.value / steps[0].value) * 100)
: 0;
return (
<div key={step.label} className="flex items-center gap-3">
<span className="w-20 shrink-0 text-xs text-muted-foreground">
{step.label}
</span>
<div className="relative h-7 flex-1 rounded-full bg-muted">
<div
className={`h-7 rounded-full ${step.color} transition-[width] duration-500`}
style={{ width: `${pctOfMax}%` }}
/>
<span className="absolute inset-0 flex items-center px-3 text-xs font-medium text-foreground">
{step.value.toLocaleString()}
<span className="ml-2 text-muted-foreground/80">
({pctOfSent}%)
</span>
</span>
</div>
</div>
);
})}
</div>
</div>
);
}
const RECIPIENT_STATUSES: readonly RecipientStatus[] = [
'pending',
'sent',
'delivered',
'read',
'replied',
'failed',
];
/**
* CSV export helper — RFC 4180 quoting. Quote every field so
* commas/newlines/quotes round-trip cleanly.
*/
function toCsv(rows: string[][]): string {
const escape = (v: string) => `"${v.replace(/"/g, '""')}"`;
return rows.map((r) => r.map(escape).join(',')).join('\n');
}
function downloadBlob(filename: string, content: string) {
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export default function BroadcastDetailPage() {
const params = useParams();
const router = useRouter();
const broadcastId = params.id as string;
const [broadcast, setBroadcast] = useState<Broadcast | null>(null);
const [recipients, setRecipients] = useState<BroadcastRecipient[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<RecipientStatus | 'all'>(
'all',
);
const [confirmDelete, setConfirmDelete] = useState(false);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
async function fetchData() {
try {
const supabase = createClient();
const { data: bc, error: bcError } = await supabase
.from('broadcasts')
.select('*')
.eq('id', broadcastId)
.single();
if (bcError) throw bcError;
setBroadcast(bc);
const { data: recs, error: recsError } = await supabase
.from('broadcast_recipients')
.select('*, contact:contacts(*)')
.eq('broadcast_id', broadcastId)
.order('created_at', { ascending: false });
if (recsError) throw recsError;
setRecipients(recs ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load broadcast');
} finally {
setLoading(false);
}
}
fetchData();
}, [broadcastId]);
const filteredRecipients = useMemo(
() =>
statusFilter === 'all'
? recipients
: recipients.filter((r) => r.status === statusFilter),
[recipients, statusFilter],
);
function handleExport() {
if (!broadcast) return;
const header = [
'Contact',
'Phone',
'Status',
'Sent At',
'Delivered At',
'Read At',
'Replied At',
'Error',
];
const rows = recipients.map((r) => [
r.contact?.name ?? '',
r.contact?.phone ?? '',
r.status,
r.sent_at ?? '',
r.delivered_at ?? '',
r.read_at ?? '',
r.replied_at ?? '',
r.error_message ?? '',
]);
const csv = toCsv([header, ...rows]);
const safeName = broadcast.name.replace(/[^a-z0-9-_]+/gi, '-').toLowerCase();
downloadBlob(`broadcast-${safeName}-${broadcastId.slice(0, 8)}.csv`, csv);
}
async function handleDelete() {
setDeleting(true);
const supabase = createClient();
// broadcast_recipients cascades on broadcasts.id (migration 001), so a
// single delete is sufficient — the aggregate trigger in migration 003
// is defined on broadcast_recipients but fires only on its own row
// changes, not on a cascaded drop of the parent row.
const { error: delErr } = await supabase
.from('broadcasts')
.delete()
.eq('id', broadcastId);
setDeleting(false);
if (delErr) {
toast.error(`Failed to delete: ${delErr.message}`);
return;
}
toast.success('Broadcast deleted');
router.push('/broadcasts');
}
if (loading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
);
}
if (error || !broadcast) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-2">
<p className="text-sm text-red-400">{error ?? 'Broadcast not found'}</p>
<Button variant="outline" onClick={() => router.push('/broadcasts')}>
Back to Broadcasts
</Button>
</div>
);
}
const status = getBroadcastStatus(broadcast.status);
const funnelSteps: FunnelStep[] = [
{ label: 'Sent', value: broadcast.sent_count, color: 'bg-primary' },
{ label: 'Delivered', value: broadcast.delivered_count, color: 'bg-teal-500' },
{ label: 'Read', value: broadcast.read_count, color: 'bg-blue-500' },
{ label: 'Replied', value: broadcast.replied_count, color: 'bg-indigo-500' },
];
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center gap-4">
<Button
variant="outline"
size="icon"
onClick={() => router.push('/broadcasts')}
className="border-border"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{broadcast.name}</h1>
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${status.classes}`}
>
{status.label}
</span>
</div>
<div className="mt-1 flex items-center gap-3 text-sm text-muted-foreground">
<span>Template: {broadcast.template_name}</span>
<span>-</span>
<span>
Created {new Date(broadcast.created_at).toLocaleDateString()}
</span>
</div>
</div>
</div>
{/* Delete — inline-confirm pattern matches the pipeline-settings
"Delete Pipeline" flow. Mid-send broadcasts can't be deleted
because orphaning in-flight Meta messages would leave the
funnel inconsistent. */}
{confirmDelete ? (
<div className="flex items-center gap-2 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-sm">
<span className="text-red-300">Delete this broadcast?</span>
<Button
variant="outline"
size="sm"
onClick={() => setConfirmDelete(false)}
disabled={deleting}
className="h-7 border-border bg-transparent text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
<Button
size="sm"
onClick={handleDelete}
disabled={deleting}
className="h-7 bg-red-600 text-white hover:bg-red-700 disabled:opacity-50"
>
{deleting ? 'Deleting…' : 'Confirm'}
</Button>
</div>
) : (
<Button
variant="outline"
size="sm"
disabled={broadcast.status === 'sending'}
onClick={() => setConfirmDelete(true)}
title={
broadcast.status === 'sending'
? 'Cannot delete while a broadcast is actively sending'
: 'Delete this broadcast'
}
className="border-red-500/30 bg-transparent text-red-400 hover:bg-red-500/10 disabled:opacity-40"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
)}
</div>
{/* Stats — 6 cards: Total / Sent / Delivered / Read / Replied / Failed */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<StatCard
label="Total Recipients"
value={broadcast.total_recipients}
total={broadcast.total_recipients}
icon={<Users className="h-4 w-4" />}
color="bg-muted text-muted-foreground"
/>
<StatCard
label="Sent"
value={broadcast.sent_count}
total={broadcast.total_recipients}
icon={<Send className="h-4 w-4" />}
color="bg-primary/10 text-primary"
/>
<StatCard
label="Delivered"
value={broadcast.delivered_count}
total={broadcast.total_recipients}
icon={<CheckCheck className="h-4 w-4" />}
color="bg-teal-500/10 text-teal-400"
/>
<StatCard
label="Read"
value={broadcast.read_count}
total={broadcast.total_recipients}
icon={<Eye className="h-4 w-4" />}
color="bg-blue-500/10 text-blue-400"
/>
<StatCard
label="Replied"
value={broadcast.replied_count}
total={broadcast.total_recipients}
icon={<MessageCircle className="h-4 w-4" />}
color="bg-indigo-500/10 text-indigo-400"
/>
<StatCard
label="Failed"
value={broadcast.failed_count}
total={broadcast.total_recipients}
icon={<AlertCircle className="h-4 w-4" />}
color="bg-red-500/10 text-red-400"
/>
</div>
<FunnelChart steps={funnelSteps} />
{/* Recipients Table */}
<div className="rounded-xl border border-border bg-card">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-3">
<h2 className="text-sm font-medium text-foreground">
Recipients ({filteredRecipients.length}
{statusFilter !== 'all' ? ` of ${recipients.length}` : ''})
</h2>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="outline"
size="sm"
className="border-border text-muted-foreground hover:bg-muted"
/>
}
>
<Filter className="h-3.5 w-3.5" />
{statusFilter === 'all'
? 'All statuses'
: getRecipientStatus(statusFilter).label}
<ChevronDown className="h-3 w-3" />
</DropdownMenuTrigger>
<DropdownMenuContent className="border-border bg-popover">
<DropdownMenuItem
onClick={() => setStatusFilter('all')}
className={
statusFilter === 'all' ? 'text-primary' : 'text-popover-foreground'
}
>
All statuses
</DropdownMenuItem>
{RECIPIENT_STATUSES.map((s) => (
<DropdownMenuItem
key={s}
onClick={() => setStatusFilter(s)}
className={
statusFilter === s
? 'text-primary'
: 'text-popover-foreground'
}
>
{getRecipientStatus(s).label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="sm"
onClick={handleExport}
disabled={recipients.length === 0}
className="border-border text-muted-foreground hover:bg-muted"
>
<Download className="h-3.5 w-3.5" />
Export CSV
</Button>
</div>
</div>
{filteredRecipients.length === 0 ? (
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-muted-foreground">
{recipients.length === 0
? 'No recipients found.'
: 'No recipients match this filter.'}
</p>
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="border-border hover:bg-transparent">
<TableHead className="text-muted-foreground">Contact</TableHead>
<TableHead className="text-muted-foreground">Phone</TableHead>
<TableHead className="text-muted-foreground">Status</TableHead>
<TableHead className="text-muted-foreground">Sent</TableHead>
<TableHead className="text-muted-foreground">Delivered</TableHead>
<TableHead className="text-muted-foreground">Read</TableHead>
<TableHead className="text-muted-foreground">Error</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRecipients.map((recipient) => {
const rStatus = getRecipientStatus(recipient.status);
return (
<TableRow key={recipient.id} className="border-border">
<TableCell className="font-medium text-foreground">
{recipient.contact?.name ?? 'Unknown'}
</TableCell>
<TableCell className="text-muted-foreground">
{recipient.contact?.phone ?? '-'}
</TableCell>
<TableCell>
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${rStatus.classes}`}
>
{rStatus.label}
</span>
</TableCell>
<TableCell className="text-muted-foreground">
{recipient.sent_at
? new Date(recipient.sent_at).toLocaleString()
: '-'}
</TableCell>
<TableCell className="text-muted-foreground">
{recipient.delivered_at
? new Date(recipient.delivered_at).toLocaleString()
: '-'}
</TableCell>
<TableCell className="text-muted-foreground">
{recipient.read_at
? new Date(recipient.read_at).toLocaleString()
: '-'}
</TableCell>
<TableCell className="max-w-xs truncate text-xs text-red-400">
{recipient.error_message ?? '-'}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,233 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth';
import { toast } from 'sonner';
import { MessageTemplate } from '@/types';
import { Step1ChooseTemplate } from '@/components/broadcasts/step1-choose-template';
import { Step2SelectAudience } from '@/components/broadcasts/step2-select-audience';
import { Step3Personalize } from '@/components/broadcasts/step3-personalize';
import { Step4ScheduleSend } from '@/components/broadcasts/step4-schedule-send';
import { useBroadcastSending } from '@/hooks/use-broadcast-sending';
import { Check } from 'lucide-react';
const steps = [
{ label: 'Template', key: 'template' },
{ label: 'Audience', key: 'audience' },
{ label: 'Personalize', key: 'personalize' },
{ label: 'Send', key: 'send' },
] as const;
export default function NewBroadcastPage() {
const router = useRouter();
const { accountId } = useAuth();
const { createAndSendBroadcast, isProcessing, progress } = useBroadcastSending();
const [currentStep, setCurrentStep] = useState(0);
const [template, setTemplate] = useState<MessageTemplate | null>(null);
const [audience, setAudience] = useState<{
type: 'all' | 'tags' | 'custom_field' | 'csv';
tagIds?: string[];
customField?: {
fieldId: string;
operator: 'is' | 'is_not' | 'contains';
value: string;
};
csvContacts?: { phone: string; name?: string }[];
excludeTagIds?: string[];
}>({ type: 'all' });
const [variables, setVariables] = useState<
Record<string, { type: 'static' | 'field' | 'custom_field'; value: string }>
>({});
const [headerMediaUrl, setHeaderMediaUrl] = useState('');
const [name, setName] = useState('');
async function handleSend() {
if (!template) return;
try {
const broadcastId = await createAndSendBroadcast({
name,
template,
audience: {
type: audience.type,
tagIds: audience.tagIds,
customField: audience.customField,
csvContacts: audience.csvContacts,
excludeTagIds: audience.excludeTagIds,
},
variables,
headerMediaUrl,
});
router.push(`/broadcasts/${broadcastId}`);
} catch (err) {
// Previously swallowed with console.error — the wizard would
// just no-op, leaving the user confused. Surface the reason.
const message = err instanceof Error ? err.message : 'Broadcast failed';
console.error('Broadcast failed:', err);
toast.error(message);
}
}
/**
* Writes a draft broadcast row — no recipients, no sending. The user
* can revisit it via the list page to finish the flow later. We
* don't persist the in-progress audience/variable config here
* because the current schema doesn't carry it past `audience_filter`
* and `template_variables`; those are enough for the user to
* recognize the draft but not to exactly round-trip into the wizard.
* A full resume-draft UX is a future polish.
*/
async function handleSaveDraft() {
if (!template || !name.trim()) {
toast.error('Give the broadcast a name before saving a draft.');
return;
}
const supabase = createClient();
const {
data: { session },
} = await supabase.auth.getSession();
const user = session?.user;
if (!user) {
toast.error('Not signed in.');
return;
}
if (!accountId) {
toast.error('Your profile is not linked to an account.');
return;
}
const { error } = await supabase.from('broadcasts').insert({
user_id: user.id,
account_id: accountId,
name: name.trim(),
template_name: template.name,
template_language: template.language ?? 'en_US',
template_variables: variables,
audience_filter: {
type: audience.type,
tagIds: audience.tagIds,
},
status: 'draft',
total_recipients: 0,
sent_count: 0,
delivered_count: 0,
read_count: 0,
replied_count: 0,
failed_count: 0,
});
if (error) {
toast.error(`Failed to save draft: ${error.message}`);
return;
}
toast.success('Draft saved');
router.push('/broadcasts');
}
return (
<div className="mx-auto max-w-3xl space-y-8">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-foreground">New Broadcast</h1>
<p className="mt-1 text-sm text-muted-foreground">
Create and send a broadcast message to your contacts.
</p>
</div>
{/* Step Indicator */}
<div className="flex items-center justify-between">
{steps.map((step, index) => {
const isActive = index === currentStep;
const isCompleted = index < currentStep;
return (
<div key={step.key} className="flex flex-1 items-center">
<div className="flex items-center gap-2">
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-medium transition-all ${
isCompleted
? 'bg-primary text-primary-foreground'
: isActive
? 'border-2 border-primary bg-primary/10 text-primary'
: 'border border-border bg-muted text-muted-foreground'
}`}
>
{isCompleted ? <Check className="h-4 w-4" /> : index + 1}
</div>
<span
className={`hidden text-sm font-medium sm:block ${
isActive ? 'text-foreground' : isCompleted ? 'text-primary' : 'text-muted-foreground'
}`}
>
{step.label}
</span>
</div>
{index < steps.length - 1 && (
<div
className={`mx-3 h-px flex-1 ${
index < currentStep ? 'bg-primary' : 'bg-muted'
}`}
/>
)}
</div>
);
})}
</div>
{/* Step Content */}
<div className="relative min-h-[400px]">
<div
className="transition-all duration-300 ease-in-out"
style={{
opacity: isProcessing ? 0.6 : 1,
pointerEvents: isProcessing ? 'none' : 'auto',
}}
>
{currentStep === 0 && (
<Step1ChooseTemplate
selectedTemplate={template}
onSelect={setTemplate}
onNext={() => setCurrentStep(1)}
onBack={() => router.push('/broadcasts')}
/>
)}
{currentStep === 1 && (
<Step2SelectAudience
audience={audience}
onUpdate={setAudience}
onNext={() => setCurrentStep(2)}
onBack={() => setCurrentStep(0)}
/>
)}
{currentStep === 2 && template && (
<Step3Personalize
template={template}
variables={variables}
onUpdate={setVariables}
headerMediaUrl={headerMediaUrl}
onHeaderMediaUrlChange={setHeaderMediaUrl}
onNext={() => setCurrentStep(3)}
onBack={() => setCurrentStep(1)}
/>
)}
{currentStep === 3 && template && (
<Step4ScheduleSend
name={name}
onNameChange={setName}
template={template}
audience={audience}
onSend={handleSend}
onSaveDraft={handleSaveDraft}
onBack={() => setCurrentStep(2)}
isProcessing={isProcessing}
progress={progress}
/>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,288 @@
'use client';
import { useEffect, useState, useMemo, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
import { Broadcast } from '@/types';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Radio, Plus, Loader2 } from 'lucide-react';
import { useCan } from '@/hooks/use-can';
import { GatedButton } from '@/components/ui/gated-button';
import { getBroadcastStatus } from '@/lib/broadcast-status';
/**
* Poll cadence while any broadcast is sending. Kept modest so we don't
* beat on Supabase — the aggregate trigger in migration 003 keeps
* counts consistent; we just need to surface the freshest snapshot.
*/
const POLL_INTERVAL_MS = 5_000;
function percent(numerator: number, denominator: number): number {
if (!denominator) return 0;
return Math.round((numerator / denominator) * 100);
}
function RateCell({
value,
total,
color,
}: {
value: number;
total: number;
/** Tailwind bg class for the fill, e.g. "bg-primary" */
color: string;
}) {
const pct = percent(value, total);
return (
<div className="flex items-center gap-2">
<span className="w-10 text-right text-xs tabular-nums text-muted-foreground">
{pct}%
</span>
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-muted">
<div
className={`h-1.5 rounded-full ${color}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
export default function BroadcastsPage() {
const router = useRouter();
const canCreate = useCan('send-messages');
const [broadcasts, setBroadcasts] = useState<Broadcast[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Used to kick off polling only while something is actively sending.
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
async function fetchBroadcasts() {
try {
const supabase = createClient();
const { data, error: fetchError } = await supabase
.from('broadcasts')
.select('*')
.order('created_at', { ascending: false });
if (fetchError) throw fetchError;
setBroadcasts(data ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load broadcasts');
} finally {
setLoading(false);
}
}
useEffect(() => {
fetchBroadcasts();
}, []);
const anySending = useMemo(
() => broadcasts.some((b) => b.status === 'sending'),
[broadcasts],
);
useEffect(() => {
function startPolling() {
if (pollTimer.current) return;
pollTimer.current = setInterval(fetchBroadcasts, POLL_INTERVAL_MS);
}
function stopPolling() {
if (!pollTimer.current) return;
clearInterval(pollTimer.current);
pollTimer.current = null;
}
// Pause polling while the tab is hidden — keeps Supabase cold when
// the user is away, and ensures a fresh fetch the moment they
// refocus so they don't see stale data on return.
function handleVisibilityChange() {
if (!anySending) return;
if (document.visibilityState === 'hidden') {
stopPolling();
} else {
fetchBroadcasts();
startPolling();
}
}
if (anySending && document.visibilityState === 'visible') {
startPolling();
} else {
stopPolling();
}
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
stopPolling();
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [anySending]);
if (loading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
);
}
if (error) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-2">
<p className="text-sm text-red-400">{error}</p>
<Button variant="outline" onClick={() => window.location.reload()}>
Retry
</Button>
</div>
);
}
return (
<div className="space-y-6">
{/* Top indeterminate progress bar: only visible while a broadcast
is mid-send. Pure CSS animation so no extra deps. */}
{anySending && (
<div
role="progressbar"
aria-label="Broadcast in progress"
className="broadcast-indeterminate fixed inset-x-0 top-0 z-40 h-0.5 overflow-hidden bg-muted"
>
<div className="broadcast-indeterminate-bar h-0.5 bg-primary" />
<style jsx>{`
.broadcast-indeterminate-bar {
width: 33%;
transform: translateX(-100%);
animation: broadcast-slide 1.6s cubic-bezier(0.4, 0, 0.2, 1)
infinite;
}
@keyframes broadcast-slide {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(400%);
}
}
`}</style>
</div>
)}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Broadcasts</h1>
<p className="mt-1 text-sm text-muted-foreground">
Send bulk messages to your contacts using approved templates.
</p>
</div>
<GatedButton
canAct={canCreate}
gateReason="create broadcasts"
onClick={() => router.push('/broadcasts/new')}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
<Plus className="h-4 w-4" />
New Broadcast
</GatedButton>
</div>
{broadcasts.length === 0 ? (
<div className="flex h-64 flex-col items-center justify-center rounded-xl border border-border bg-card">
<Radio className="mb-3 h-10 w-10 text-muted-foreground" />
<p className="text-sm font-medium text-foreground">No broadcasts yet</p>
<p className="mt-1 text-xs text-muted-foreground">
Create your first broadcast to reach your contacts at scale.
</p>
<GatedButton
canAct={canCreate}
gateReason="create broadcasts"
onClick={() => router.push('/broadcasts/new')}
className="mt-4 bg-primary text-primary-foreground hover:bg-primary/90"
>
<Plus className="h-4 w-4" />
New Broadcast
</GatedButton>
</div>
) : (
<div className="overflow-x-auto rounded-xl border border-border bg-card">
<Table>
<TableHeader>
<TableRow className="border-border hover:bg-transparent">
<TableHead className="text-muted-foreground">Name</TableHead>
<TableHead className="hidden text-muted-foreground md:table-cell">Template</TableHead>
<TableHead className="hidden text-right text-muted-foreground sm:table-cell">
Recipients
</TableHead>
<TableHead className="hidden text-muted-foreground lg:table-cell">Delivery</TableHead>
<TableHead className="hidden text-muted-foreground lg:table-cell">Read</TableHead>
<TableHead className="text-muted-foreground">Status</TableHead>
<TableHead className="hidden text-muted-foreground sm:table-cell">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{broadcasts.map((broadcast) => {
const status = getBroadcastStatus(broadcast.status);
return (
<TableRow
key={broadcast.id}
className="cursor-pointer border-border hover:bg-muted/50"
onClick={() => router.push(`/broadcasts/${broadcast.id}`)}
>
<TableCell className="font-medium text-foreground">
{broadcast.name}
</TableCell>
<TableCell className="hidden text-muted-foreground md:table-cell">
{broadcast.template_name}
</TableCell>
<TableCell className="hidden text-right text-muted-foreground tabular-nums sm:table-cell">
{broadcast.total_recipients}
</TableCell>
<TableCell className="hidden lg:table-cell">
<RateCell
value={broadcast.delivered_count}
total={broadcast.total_recipients}
color="bg-primary"
/>
</TableCell>
<TableCell className="hidden lg:table-cell">
<RateCell
value={broadcast.read_count}
total={broadcast.total_recipients}
color="bg-blue-500"
/>
</TableCell>
<TableCell>
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium ${status.classes}`}
>
{status.pulse && (
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-yellow-400 opacity-75" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-yellow-400" />
</span>
)}
{status.label}
</span>
</TableCell>
<TableCell className="hidden text-muted-foreground sm:table-cell">
{new Date(broadcast.created_at).toLocaleDateString()}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,836 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { createClient } from '@/lib/supabase/client';
import { toast } from 'sonner';
import type { Contact, Tag, ContactTag } from '@/types';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Search,
Plus,
Upload,
MoreHorizontal,
Pencil,
Trash2,
Loader2,
Users,
ChevronLeft,
ChevronRight,
SlidersHorizontal,
Filter,
X,
} from 'lucide-react';
import { ContactForm } from '@/components/contacts/contact-form';
import { ContactDetailView } from '@/components/contacts/contact-detail-view';
import { ImportModal } from '@/components/contacts/import-modal';
import { CustomFieldsManager } from '@/components/contacts/custom-fields-manager';
import { useCan } from '@/hooks/use-can';
import { GatedButton } from '@/components/ui/gated-button';
import { Checkbox } from '@/components/ui/checkbox';
const PAGE_SIZE = 25;
interface ContactWithTags extends Contact {
tags?: Tag[];
}
export default function ContactsPage() {
const supabase = createClient();
const canEdit = useCan('send-messages');
const canEditSettings = useCan('edit-settings');
const [contacts, setContacts] = useState<ContactWithTags[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [page, setPage] = useState(0);
const [totalCount, setTotalCount] = useState(0);
// Tag filter — contacts shown must have ANY of these tags (OR).
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
// Modals
const [formOpen, setFormOpen] = useState(false);
const [editContact, setEditContact] = useState<Contact | null>(null);
const [editContactTags, setEditContactTags] = useState<ContactTag[]>([]);
const [detailOpen, setDetailOpen] = useState(false);
const [detailContactId, setDetailContactId] = useState<string | null>(null);
const [importOpen, setImportOpen] = useState(false);
const [customFieldsOpen, setCustomFieldsOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Contact | null>(null);
const [deleting, setDeleting] = useState(false);
// Bulk selection (page-scoped — only the loaded rows are selectable)
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
// All tags for display
const [tagsMap, setTagsMap] = useState<Record<string, Tag>>({});
// Guards against out-of-order fetch responses: each fetchContacts run
// claims a sequence number and only the latest is allowed to commit its
// results. Without this, rapidly toggling tag filters could let a slower
// earlier request resolve last and render stale rows.
const fetchSeq = useRef(0);
const fetchTags = useCallback(async () => {
const { data } = await supabase.from('tags').select('*');
if (data) {
const map: Record<string, Tag> = {};
data.forEach((t) => (map[t.id] = t));
setTagsMap(map);
// Drop any filter selections whose tag no longer exists (e.g. a tag
// deleted elsewhere) so it can't linger invisibly in the query.
setSelectedTagIds((prev) => {
const pruned = prev.filter((id) => map[id]);
return pruned.length === prev.length ? prev : pruned;
});
}
}, [supabase]);
const fetchContacts = useCallback(async () => {
const seq = ++fetchSeq.current;
setLoading(true);
// The visible rows are about to change — drop any selection that
// referred to the old page/search results so the bulk bar can't
// act on rows the user can no longer see.
setSelected(new Set());
const from = page * PAGE_SIZE;
const to = from + PAGE_SIZE - 1;
const term = search.trim();
let contactRows: Contact[];
let count: number;
if (selectedTagIds.length > 0) {
// Tag filter active — resolve it server-side (join + distinct +
// windowed total count + pagination) so a tag covering many
// contacts can't silently truncate the result or overflow an IN
// clause. See migration 025_filter_contacts_by_tags.
const { data, error } = await supabase.rpc('filter_contacts_by_tags', {
p_tag_ids: selectedTagIds,
p_search: term || null,
p_limit: PAGE_SIZE,
p_offset: from,
});
if (seq !== fetchSeq.current) return; // superseded by a newer fetch
if (error) {
toast.error('Failed to load contacts');
setLoading(false);
return;
}
const rows = (data ?? []) as { contact: Contact; total_count: number }[];
contactRows = rows.map((r) => r.contact);
count = rows.length > 0 ? Number(rows[0].total_count) : 0;
} else {
let query = supabase
.from('contacts')
.select('*', { count: 'exact' })
.order('created_at', { ascending: false })
.range(from, to);
if (term) {
const like = `%${term}%`;
query = query.or(`name.ilike.${like},phone.ilike.${like},email.ilike.${like}`);
}
const { data, count: exactCount, error } = await query;
if (seq !== fetchSeq.current) return; // superseded by a newer fetch
if (error) {
toast.error('Failed to load contacts');
setLoading(false);
return;
}
contactRows = data ?? [];
count = exactCount ?? 0;
}
setTotalCount(count);
if (contactRows.length === 0) {
setContacts([]);
setLoading(false);
return;
}
// Fetch tags for these contacts
const contactIds = contactRows.map((c) => c.id);
const { data: contactTags } = await supabase
.from('contact_tags')
.select('contact_id, tag_id')
.in('contact_id', contactIds);
if (seq !== fetchSeq.current) return; // superseded by a newer fetch
const tagsByContact: Record<string, string[]> = {};
contactTags?.forEach((ct) => {
if (!tagsByContact[ct.contact_id]) tagsByContact[ct.contact_id] = [];
tagsByContact[ct.contact_id].push(ct.tag_id);
});
const enriched: ContactWithTags[] = contactRows.map((c) => ({
...c,
tags: (tagsByContact[c.id] ?? [])
.map((tid) => tagsMap[tid])
.filter(Boolean),
}));
setContacts(enriched);
setLoading(false);
}, [supabase, page, search, selectedTagIds, tagsMap]);
// Load-once-on-mount-ish data fetches. Each setter inside runs
// inside an async promise completion (Supabase await), not
// synchronously in the effect body, so the cascade the lint rule
// warns about doesn't apply here.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchTags();
}, [fetchTags]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchContacts();
}, [fetchContacts]);
function openAddForm() {
setEditContact(null);
setEditContactTags([]);
setFormOpen(true);
}
async function openEditForm(contact: Contact) {
const { data } = await supabase
.from('contact_tags')
.select('*')
.eq('contact_id', contact.id);
setEditContact(contact);
setEditContactTags(data ?? []);
setFormOpen(true);
}
function openDetail(contactId: string) {
setDetailContactId(contactId);
setDetailOpen(true);
}
function confirmDelete(contact: Contact) {
setDeleteTarget(contact);
setDeleteConfirmOpen(true);
}
async function handleDelete() {
if (!deleteTarget) return;
setDeleting(true);
const { error } = await supabase
.from('contacts')
.delete()
.eq('id', deleteTarget.id);
if (error) {
toast.error('Failed to delete contact');
} else {
toast.success('Contact deleted');
fetchContacts();
}
setDeleting(false);
setDeleteConfirmOpen(false);
setDeleteTarget(null);
}
const allOnPageSelected =
contacts.length > 0 && contacts.every((c) => selected.has(c.id));
const someOnPageSelected = contacts.some((c) => selected.has(c.id));
function toggleSelectAll() {
setSelected((prev) => {
const next = new Set(prev);
if (allOnPageSelected) {
contacts.forEach((c) => next.delete(c.id));
} else {
contacts.forEach((c) => next.add(c.id));
}
return next;
});
}
function toggleSelect(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
async function handleBulkDelete() {
const ids = [...selected];
if (ids.length === 0) return;
setDeleting(true);
const { error } = await supabase.from('contacts').delete().in('id', ids);
if (error) {
toast.error('Failed to delete contacts');
} else {
toast.success(`${ids.length} contact${ids.length === 1 ? '' : 's'} deleted`);
setSelected(new Set());
fetchContacts();
}
setDeleting(false);
setBulkDeleteOpen(false);
}
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
const hasNext = page < totalPages - 1;
const hasPrev = page > 0;
// Tag filter helpers. Every change resets to page 0 — the result set
// shrinks/grows so page N may no longer be valid (mirrors the search box).
const allTags = Object.values(tagsMap).sort((a, b) =>
a.name.localeCompare(b.name)
);
const hasActiveFilters = search.trim().length > 0 || selectedTagIds.length > 0;
function toggleTagFilter(tagId: string) {
setSelectedTagIds((prev) =>
prev.includes(tagId)
? prev.filter((id) => id !== tagId)
: [...prev, tagId]
);
setPage(0);
}
function clearTagFilters() {
setSelectedTagIds([]);
setPage(0);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-foreground">Contacts</h1>
<p className="text-sm text-muted-foreground mt-1">
Manage your contact list. {totalCount > 0 && `${totalCount} total contacts.`}
</p>
</div>
<div className="flex items-center gap-2">
{canEditSettings && (
<Button
variant="outline"
onClick={() => setCustomFieldsOpen(true)}
className="border-border text-muted-foreground hover:bg-muted"
>
<SlidersHorizontal className="size-4" />
Custom fields
</Button>
)}
<GatedButton
variant="outline"
canAct={canEdit}
gateReason="add or import contacts"
onClick={() => setImportOpen(true)}
className="border-border text-muted-foreground hover:bg-muted"
>
<Upload className="size-4" />
Import
</GatedButton>
<GatedButton
canAct={canEdit}
gateReason="add or import contacts"
onClick={openAddForm}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
<Plus className="size-4" />
Add Contact
</GatedButton>
</div>
</div>
{/* Search + tag filter */}
<div className="space-y-2">
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative w-full max-w-sm">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => {
setSearch(e.target.value);
// Reset pagination when the query changes — the result
// set shrinks/grows, page N may no longer be valid.
setPage(0);
}}
placeholder="Search by name, phone, or email..."
className="pl-8 bg-card border-border text-foreground placeholder:text-muted-foreground"
/>
</div>
<Popover>
<PopoverTrigger
render={
<Button
variant="outline"
className="border-border text-muted-foreground hover:bg-muted shrink-0"
/>
}
>
<Filter className="size-4" />
Filter by tags
{selectedTagIds.length > 0 && (
<span className="ml-1 inline-flex items-center justify-center rounded-full bg-primary px-1.5 text-[10px] font-semibold text-primary-foreground">
{selectedTagIds.length}
</span>
)}
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-0">
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
<span className="text-sm font-medium text-popover-foreground">
Filter by tags
</span>
{selectedTagIds.length > 0 && (
<button
onClick={clearTagFilters}
className="text-xs text-muted-foreground hover:text-foreground"
>
Clear all
</button>
)}
</div>
{allTags.length === 0 ? (
<p className="px-3 py-4 text-sm text-muted-foreground text-center">
No tags yet.
</p>
) : (
<div className="max-h-64 overflow-y-auto py-1">
{allTags.map((tag) => (
<label
key={tag.id}
className="flex items-center gap-2.5 px-3 py-1.5 cursor-pointer hover:bg-muted/50"
>
<Checkbox
checked={selectedTagIds.includes(tag.id)}
onCheckedChange={() => toggleTagFilter(tag.id)}
aria-label={`Filter by ${tag.name}`}
/>
<span
className="size-2.5 shrink-0 rounded-full"
style={{ backgroundColor: tag.color }}
/>
<span className="text-sm text-popover-foreground truncate">
{tag.name}
</span>
</label>
))}
</div>
)}
</PopoverContent>
</Popover>
</div>
{/* Active tag-filter chips */}
{selectedTagIds.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
{selectedTagIds.map((id) => {
const tag = tagsMap[id];
if (!tag) return null;
return (
<span
key={id}
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium"
style={{
backgroundColor: tag.color + '20',
color: tag.color,
}}
>
{tag.name}
<button
onClick={() => toggleTagFilter(id)}
aria-label={`Remove ${tag.name} filter`}
className="hover:opacity-70"
>
<X className="size-3" />
</button>
</span>
);
})}
<button
onClick={clearTagFilters}
className="text-xs text-muted-foreground hover:text-foreground px-1"
>
Clear all
</button>
</div>
)}
</div>
{/* Bulk action bar */}
{selected.size > 0 && (
<div className="flex items-center justify-between gap-4 rounded-lg border border-border bg-muted/40 px-4 py-2">
<p className="text-sm text-foreground">
<span className="font-medium">{selected.size}</span>{' '}
{selected.size === 1 ? 'contact' : 'contacts'} selected
</p>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setSelected(new Set())}
className="text-muted-foreground hover:text-foreground"
>
Clear
</Button>
<GatedButton
variant="destructive"
size="sm"
canAct={canEdit}
gateReason="delete contacts"
onClick={() => setBulkDeleteOpen(true)}
>
<Trash2 className="size-4" />
Delete selected
</GatedButton>
</div>
</div>
)}
{/* Table */}
<div className="rounded-lg border border-border overflow-hidden">
<Table>
<TableHeader>
<TableRow className="border-border hover:bg-transparent">
<TableHead className="w-10">
<Checkbox
checked={allOnPageSelected}
indeterminate={!allOnPageSelected && someOnPageSelected}
onCheckedChange={toggleSelectAll}
disabled={contacts.length === 0}
aria-label="Select all contacts on this page"
/>
</TableHead>
<TableHead className="text-muted-foreground">Name</TableHead>
<TableHead className="text-muted-foreground">Phone</TableHead>
<TableHead className="text-muted-foreground hidden md:table-cell">Email</TableHead>
<TableHead className="text-muted-foreground hidden lg:table-cell">Company</TableHead>
<TableHead className="text-muted-foreground hidden md:table-cell">Tags</TableHead>
<TableHead className="text-muted-foreground hidden lg:table-cell">Created</TableHead>
<TableHead className="text-muted-foreground w-12" />
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow className="border-border">
<TableCell colSpan={8} className="text-center py-12">
<div className="flex flex-col items-center gap-2">
<Loader2 className="size-6 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Loading contacts...</p>
</div>
</TableCell>
</TableRow>
) : contacts.length === 0 ? (
<TableRow className="border-border">
<TableCell colSpan={8} className="text-center py-12">
<div className="flex flex-col items-center gap-2">
<Users className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
{hasActiveFilters
? 'No contacts match your filters.'
: 'No contacts yet.'}
</p>
{!hasActiveFilters && (
<GatedButton
canAct={canEdit}
gateReason="add or import contacts"
variant="outline"
size="sm"
onClick={openAddForm}
className="mt-2 border-border text-muted-foreground hover:bg-muted"
>
<Plus className="size-3.5" />
Add your first contact
</GatedButton>
)}
</div>
</TableCell>
</TableRow>
) : (
contacts.map((contact) => (
<TableRow
key={contact.id}
className="border-border hover:bg-muted/50 cursor-pointer"
onClick={() => openDetail(contact.id)}
>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selected.has(contact.id)}
onCheckedChange={() => toggleSelect(contact.id)}
aria-label={`Select ${contact.name || contact.phone}`}
/>
</TableCell>
<TableCell className="text-foreground font-medium">
{contact.name || <span className="text-muted-foreground italic">Unnamed</span>}
</TableCell>
<TableCell className="text-muted-foreground font-mono text-xs">
{contact.phone}
</TableCell>
<TableCell className="text-muted-foreground hidden md:table-cell text-sm">
{contact.email || <span className="text-muted-foreground">-</span>}
</TableCell>
<TableCell className="text-muted-foreground hidden lg:table-cell text-sm">
{contact.company || <span className="text-muted-foreground">-</span>}
</TableCell>
<TableCell className="hidden md:table-cell">
<div className="flex flex-wrap gap-1">
{contact.tags && contact.tags.length > 0 ? (
contact.tags.slice(0, 3).map((tag) => (
<span
key={tag.id}
className="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium"
style={{
backgroundColor: tag.color + '20',
color: tag.color,
}}
>
{tag.name}
</span>
))
) : (
<span className="text-muted-foreground text-xs">-</span>
)}
{contact.tags && contact.tags.length > 3 && (
<span className="text-[10px] text-muted-foreground">
+{contact.tags.length - 3}
</span>
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground text-xs hidden lg:table-cell">
{new Date(contact.created_at).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
/>
}
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="bg-popover border-border"
>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
openEditForm(contact);
}}
className="text-popover-foreground focus:bg-muted focus:text-foreground"
>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-border" />
<DropdownMenuItem
variant="destructive"
onClick={(e) => {
e.stopPropagation();
confirmDelete(contact);
}}
>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Showing {page * PAGE_SIZE + 1}-{Math.min((page + 1) * PAGE_SIZE, totalCount)} of{' '}
{totalCount}
</p>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon-sm"
disabled={!hasPrev}
onClick={() => setPage((p) => p - 1)}
className="border-border text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
>
<ChevronLeft className="size-4" />
</Button>
<span className="text-xs text-muted-foreground px-2">
Page {page + 1} of {totalPages}
</span>
<Button
variant="outline"
size="icon-sm"
disabled={!hasNext}
onClick={() => setPage((p) => p + 1)}
className="border-border text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
>
<ChevronRight className="size-4" />
</Button>
</div>
</div>
)}
{/* Contact Form Dialog */}
<ContactForm
open={formOpen}
onOpenChange={setFormOpen}
contact={editContact}
contactTags={editContactTags}
onSaved={() => {
fetchContacts();
fetchTags();
}}
onViewExisting={(id) => {
setFormOpen(false);
openDetail(id);
}}
/>
{/* Contact Detail Sheet */}
<ContactDetailView
open={detailOpen}
onOpenChange={setDetailOpen}
contactId={detailContactId}
onUpdated={fetchContacts}
/>
{/* Import Modal */}
<ImportModal
open={importOpen}
onOpenChange={setImportOpen}
onImported={fetchContacts}
/>
{/* Custom Fields Manager (admin+) */}
{canEditSettings && (
<CustomFieldsManager
open={customFieldsOpen}
onOpenChange={setCustomFieldsOpen}
/>
)}
{/* Delete Confirmation */}
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<DialogContent className="bg-popover border-border text-popover-foreground sm:max-w-sm">
<DialogHeader>
<DialogTitle className="text-popover-foreground">Delete Contact</DialogTitle>
<DialogDescription className="text-muted-foreground">
Are you sure you want to delete{' '}
<span className="text-popover-foreground font-medium">
{deleteTarget?.name || deleteTarget?.phone}
</span>
? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="bg-popover border-border">
<Button
variant="outline"
onClick={() => setDeleteConfirmOpen(false)}
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleting}
>
{deleting && <Loader2 className="size-4 animate-spin" />}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Bulk Delete Confirmation */}
<Dialog open={bulkDeleteOpen} onOpenChange={setBulkDeleteOpen}>
<DialogContent className="bg-popover border-border text-popover-foreground sm:max-w-sm">
<DialogHeader>
<DialogTitle className="text-popover-foreground">
Delete {selected.size} {selected.size === 1 ? 'Contact' : 'Contacts'}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Are you sure you want to delete{' '}
<span className="text-popover-foreground font-medium">
{selected.size} {selected.size === 1 ? 'contact' : 'contacts'}
</span>
? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="bg-popover border-border">
<Button
variant="outline"
onClick={() => setBulkDeleteOpen(false)}
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleBulkDelete}
disabled={deleting}
>
{deleting && <Loader2 className="size-4 animate-spin" />}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,63 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { AuthProvider, useAuth } from "@/hooks/use-auth";
import { Sidebar } from "@/components/layout/sidebar";
import { Header } from "@/components/layout/header";
import { PresenceHeartbeat } from "@/components/presence/presence-heartbeat";
// Auth-gated dashboard shell. Extracted from the layout so the layout
// itself can stay a server component and export metadata (noindex) —
// client components can't export Next's metadata object.
function DashboardShellInner({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const router = useRouter();
// Sidebar drawer state — only used on mobile. On lg+ the sidebar is
// always visible and this stays at `false` (ignored by the component).
const [sidebarOpen, setSidebarOpen] = useState(false);
const closeSidebar = useCallback(() => setSidebarOpen(false), []);
useEffect(() => {
if (!loading && !user) {
router.push("/login");
}
}, [user, loading, router]);
if (loading) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<p className="text-sm text-muted-foreground">Loading...</p>
</div>
</div>
);
}
if (!user) return null;
return (
<div className="flex h-screen overflow-hidden bg-background">
{/* Reports this tab's online/away presence once we know a user is
signed in. Headless — renders nothing. */}
<PresenceHeartbeat />
<Sidebar open={sidebarOpen} onClose={closeSidebar} />
<div className="flex flex-1 flex-col overflow-hidden">
<Header onOpenSidebar={() => setSidebarOpen(true)} />
{/* Thinner horizontal padding on mobile so cards have room to breathe. */}
<main className="flex-1 overflow-y-auto p-4 sm:p-6">{children}</main>
</div>
</div>
);
}
export function DashboardShell({ children }: { children: React.ReactNode }) {
return (
<AuthProvider>
<DashboardShellInner>{children}</DashboardShellInner>
</AuthProvider>
);
}

View File

@@ -0,0 +1,225 @@
"use client"
import { useCallback, useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useAuth } from '@/hooks/use-auth'
import { formatCurrency } from '@/lib/currency'
import {
MessageSquare,
UserPlus,
DollarSign,
Send,
} from 'lucide-react'
import {
loadActivity,
loadConversationsSeries,
loadMetrics,
loadPipelineDonut,
loadResponseTime,
} from '@/lib/dashboard/queries'
import type {
ActivityItem,
ConversationsSeriesPoint,
MetricsBundle,
PipelineDonutData,
ResponseTimeSummary,
} from '@/lib/dashboard/types'
import { MetricCard } from '@/components/dashboard/metric-card'
import { SkeletonCard } from '@/components/dashboard/skeleton'
import { QuickActions } from '@/components/dashboard/quick-actions'
import { ConversationsChart } from '@/components/dashboard/conversations-chart'
import { PipelineDonut } from '@/components/dashboard/pipeline-donut'
import { ResponseTimeChart } from '@/components/dashboard/response-time-chart'
import { ActivityFeed } from '@/components/dashboard/activity-feed'
type RangeDays = 7 | 30 | 90
export default function DashboardPage() {
const { defaultCurrency } = useAuth()
const [metrics, setMetrics] = useState<MetricsBundle | null>(null)
const [metricsLoading, setMetricsLoading] = useState(true)
const [range, setRange] = useState<RangeDays>(30)
// Keep a cache per range so switching tabs doesn't re-fetch what we
// already have. Ranges the user hasn't opened yet stay null and
// trigger a fetch on first view.
const [series, setSeries] = useState<Record<RangeDays, ConversationsSeriesPoint[] | null>>({
7: null,
30: null,
90: null,
})
const [seriesLoading, setSeriesLoading] = useState(true)
const [pipeline, setPipeline] = useState<PipelineDonutData | null>(null)
const [pipelineLoading, setPipelineLoading] = useState(true)
const [responseTime, setResponseTime] = useState<ResponseTimeSummary | null>(null)
const [responseTimeLoading, setResponseTimeLoading] = useState(true)
const [activity, setActivity] = useState<ActivityItem[] | null>(null)
const [activityLoading, setActivityLoading] = useState(true)
const loadAll = useCallback(() => {
const db = createClient()
// Kick everything off in parallel. Each block has its own
// setState + finally so a slow query doesn't hold up faster
// sections — each widget shows its own skeleton independently.
void loadMetrics(db)
.then((m) => setMetrics(m))
.catch((err) => console.error('[dashboard] metrics failed:', err))
.finally(() => setMetricsLoading(false))
void loadConversationsSeries(db, 30)
.then((s) => setSeries((prev) => ({ ...prev, 30: s })))
.catch((err) => console.error('[dashboard] series failed:', err))
.finally(() => setSeriesLoading(false))
void loadPipelineDonut(db)
.then((p) => setPipeline(p))
.catch((err) => console.error('[dashboard] pipeline failed:', err))
.finally(() => setPipelineLoading(false))
void loadResponseTime(db)
.then((r) => setResponseTime(r))
.catch((err) => console.error('[dashboard] response time failed:', err))
.finally(() => setResponseTimeLoading(false))
// Fetch up to 50 so the biggest page-size option in the feed
// (50 rows) is already in memory — switching sizes then becomes
// a pure client-side slice with no extra round trip.
void loadActivity(db, 50)
.then((a) => setActivity(a))
.catch((err) => console.error('[dashboard] activity failed:', err))
.finally(() => setActivityLoading(false))
}, [])
useEffect(() => {
loadAll()
}, [loadAll])
// Range switch handler — kept in an event callback (not an effect)
// so the setState calls stay out of the react-hooks/set-state-in-effect
// rule's way. The cached bucket check means switching back to a
// previously-viewed range is instant and doesn't re-fetch.
const handleRangeChange = useCallback(
(r: RangeDays) => {
setRange(r)
if (series[r] !== null) return
setSeriesLoading(true)
const db = createClient()
loadConversationsSeries(db, r)
.then((s) => setSeries((prev) => ({ ...prev, [r]: s })))
.catch((err) => console.error('[dashboard] series failed:', err))
.finally(() => setSeriesLoading(false))
},
[series],
)
return (
<div className="space-y-5">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-foreground">Dashboard</h1>
<p className="mt-1 text-sm text-muted-foreground">
Live analytics across conversations, contacts, deals, broadcasts, and automations.
</p>
</div>
{/* Metric cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{metricsLoading || !metrics ? (
Array.from({ length: 4 }).map((_, i) => <SkeletonCard key={i} />)
) : (
<>
<MetricCard
title="Active Conversations"
value={metrics.activeConversations.current.toLocaleString()}
icon={MessageSquare}
delta={{
sign: metrics.activeConversations.previous,
label: deltaLabel(metrics.activeConversations.previous, 'new today vs yesterday'),
}}
/>
<MetricCard
title="New Contacts Today"
value={metrics.newContactsToday.current.toLocaleString()}
icon={UserPlus}
delta={{
sign:
metrics.newContactsToday.current - metrics.newContactsToday.previous,
label: deltaLabel(
metrics.newContactsToday.current - metrics.newContactsToday.previous,
'vs yesterday',
),
}}
/>
<MetricCard
title="Open Deals Value"
value={formatCurrency(metrics.openDealsValue, defaultCurrency)}
icon={DollarSign}
subtitle={`${metrics.openDealsCount} open deal${metrics.openDealsCount === 1 ? '' : 's'}`}
/>
<MetricCard
title="Messages Sent Today"
value={metrics.messagesSentToday.current.toLocaleString()}
icon={Send}
delta={{
sign:
metrics.messagesSentToday.current - metrics.messagesSentToday.previous,
label: deltaLabel(
metrics.messagesSentToday.current - metrics.messagesSentToday.previous,
'vs yesterday',
),
}}
/>
</>
)}
</div>
{/* Quick actions */}
<QuickActions />
{/* Charts row */}
{/* items-stretch (the grid default) stretches the two columns to
match the tallest sibling; adding h-full on each wrapper and
on the inner panels makes both cards actually fill that
stretched height so their rounded borders line up. Without
this, the pipeline card rendered at its natural (shorter)
height while the line chart drove the row height. */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
<div className="h-full lg:col-span-3">
<ConversationsChart
series={series}
loading={seriesLoading}
range={range}
onRangeChange={handleRangeChange}
/>
</div>
<div className="h-full lg:col-span-2">
<PipelineDonut
data={pipeline}
loading={pipelineLoading}
currency={defaultCurrency}
/>
</div>
</div>
{/* Response time */}
<ResponseTimeChart data={responseTime} loading={responseTimeLoading} />
{/* Activity feed */}
<ActivityFeed items={activity} loading={activityLoading} />
</div>
)
}
// ------------------------------------------------------------
function deltaLabel(delta: number, suffix: string): string {
if (delta === 0) return `No change ${suffix}`
const sign = delta > 0 ? '+' : ''
return `${sign}${delta.toLocaleString()} ${suffix}`
}

View File

@@ -0,0 +1,88 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { FlowEditorShell } from "@/components/flows/flow-editor-shell";
import type { FlowRow, FlowNodeRow } from "@/lib/flows/types";
/**
* Flow editor shell.
*
* Loads `{flow, nodes}` from `/api/flows/[id]` and hands it to
* `<FlowBuilder>`. Owns the loading/error state so the builder can
* focus purely on editing.
*
* Open to every authenticated user — the beta gate that previously
* 404'd non-beta accounts was removed in PR #134. The API still
* 404s on a flow id the caller doesn't own (RLS), which becomes the
* "Flow not found" state below.
*/
export default function FlowEditorPage() {
const router = useRouter();
const params = useParams<{ id: string }>();
const [flow, setFlow] = useState<FlowRow | null>(null);
const [nodes, setNodes] = useState<FlowNodeRow[]>([]);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
useEffect(() => {
if (!params.id) return;
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/flows/${params.id}`);
if (res.status === 404) {
if (!cancelled) setNotFound(true);
return;
}
if (!res.ok) throw new Error(`Failed: ${res.status}`);
const json = (await res.json()) as {
flow: FlowRow;
nodes: FlowNodeRow[];
};
if (!cancelled) {
setFlow(json.flow);
setNodes(json.nodes ?? []);
}
} catch (err) {
if (!cancelled) {
console.error(err);
toast.error("Couldn't load flow.");
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [params.id]);
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (notFound || !flow) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3">
<p className="text-sm text-muted-foreground">Flow not found.</p>
<button
type="button"
onClick={() => router.push("/flows")}
className="text-sm text-primary hover:opacity-80"
>
Back to flows
</button>
</div>
);
}
return <FlowEditorShell initialFlow={flow} initialNodes={nodes} />;
}

View File

@@ -0,0 +1,340 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import {
ArrowLeft,
Loader2,
CircleCheck,
CircleAlert,
Clock,
UserPlus,
PlayCircle,
PauseCircle,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { toast } from "sonner";
import { format, formatDistanceToNow } from "date-fns";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
/**
* Run history viewer.
*
* Lists the 50 most recent runs for a flow, newest first. Each row
* collapses to a one-liner (contact + status + time); expanding shows
* the full `flow_run_events` timeline for that run — useful for
* debugging "why didn't my flow advance?" by surfacing the engine's
* own log.
*/
interface RunRow {
id: string;
status:
| "active"
| "completed"
| "handed_off"
| "timed_out"
| "paused_by_agent"
| "failed";
current_node_key: string | null;
started_at: string;
last_advanced_at: string;
ended_at: string | null;
end_reason: string | null;
vars: Record<string, unknown>;
reprompt_count: number;
contact: { id: string; name: string | null; phone: string } | null;
}
interface EventRow {
flow_run_id: string;
event_type: string;
node_key: string | null;
payload: Record<string, unknown>;
created_at: string;
}
const STATUS_META: Record<
RunRow["status"],
{ label: string; classes: string; icon: typeof Clock }
> = {
active: {
label: "Active",
classes: "border-emerald-600/40 bg-emerald-500/10 text-emerald-300",
icon: PlayCircle,
},
completed: {
label: "Completed",
classes: "border-border bg-muted text-muted-foreground",
icon: CircleCheck,
},
handed_off: {
label: "Handed off",
classes: "border-amber-600/40 bg-amber-500/10 text-amber-300",
icon: UserPlus,
},
timed_out: {
label: "Timed out",
classes: "border-border bg-muted/60 text-muted-foreground",
icon: Clock,
},
paused_by_agent: {
label: "Paused by agent",
classes: "border-border bg-muted text-muted-foreground",
icon: PauseCircle,
},
failed: {
label: "Failed",
classes: "border-red-600/40 bg-red-500/10 text-red-300",
icon: CircleAlert,
},
};
export default function FlowRunsPage() {
const router = useRouter();
const params = useParams<{ id: string }>();
const [flow, setFlow] = useState<{ id: string; name: string } | null>(null);
const [runs, setRuns] = useState<RunRow[]>([]);
const [events, setEvents] = useState<EventRow[]>([]);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [notFound, setNotFound] = useState(false);
useEffect(() => {
if (!params.id) return;
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/flows/${params.id}/runs`);
if (res.status === 404) {
if (!cancelled) setNotFound(true);
return;
}
if (!res.ok) throw new Error(`Failed: ${res.status}`);
const json = (await res.json()) as {
flow: { id: string; name: string };
runs: RunRow[];
events: EventRow[];
};
if (!cancelled) {
setFlow(json.flow);
setRuns(json.runs ?? []);
setEvents(json.events ?? []);
}
} catch (err) {
if (!cancelled) {
console.error(err);
toast.error("Couldn't load runs.");
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [params.id]);
function toggle(runId: string) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(runId)) next.delete(runId);
else next.add(runId);
return next;
});
}
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (notFound || !flow) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3">
<p className="text-sm text-muted-foreground">Flow not found.</p>
<button
type="button"
onClick={() => router.push("/flows")}
className="text-sm text-primary hover:opacity-80"
>
Back to flows
</button>
</div>
);
}
return (
<div className="mx-auto max-w-4xl p-6">
<button
type="button"
onClick={() => router.push(`/flows/${flow.id}`)}
className="mb-2 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-3 w-3" />
{flow.name}
</button>
<h1 className="text-xl font-semibold text-foreground">Runs</h1>
<p className="mt-1 text-sm text-muted-foreground">
The 50 most recent times this flow ran. Expand a row to see the engine&apos;s
per-step log.
</p>
{runs.length === 0 ? (
<div className="mt-6 rounded-lg border border-dashed border-border bg-card/50 px-6 py-12 text-center text-sm text-muted-foreground">
No runs yet. Trigger the flow from a personal WhatsApp number to see
it appear here.
</div>
) : (
<div className="mt-6 flex flex-col gap-2">
{runs.map((run) => (
<RunCard
key={run.id}
run={run}
events={events.filter((e) => e.flow_run_id === run.id)}
expanded={expanded.has(run.id)}
onToggle={() => toggle(run.id)}
/>
))}
</div>
)}
</div>
);
}
function RunCard({
run,
events,
expanded,
onToggle,
}: {
run: RunRow;
events: EventRow[];
expanded: boolean;
onToggle: () => void;
}) {
const meta = STATUS_META[run.status];
const StatusIcon = meta.icon;
const contactLabel =
run.contact?.name?.trim() || run.contact?.phone || "Unknown contact";
const duration = run.ended_at
? formatDistanceToNow(new Date(run.ended_at), {
addSuffix: false,
})
: null;
return (
<div className="rounded-lg border border-border bg-card">
<button
type="button"
onClick={onToggle}
className="flex w-full items-center gap-3 px-4 py-3 text-left"
>
{expanded ? (
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-foreground">
{contactLabel}
</span>
<Badge variant="outline" className={cn("gap-1", meta.classes)}>
<StatusIcon className="h-3 w-3" />
{meta.label}
</Badge>
{run.status === "active" && run.current_node_key && (
<code className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
at {run.current_node_key}
</code>
)}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
<span>Started {format(new Date(run.started_at), "PP p")}</span>
{run.reprompt_count > 0 && (
<span>· {run.reprompt_count} re-prompts</span>
)}
{duration && <span>· ran for {duration}</span>}
</div>
</div>
</button>
{expanded && (
<div className="border-t border-border px-4 py-3">
{Object.keys(run.vars).length > 0 && (
<details className="mb-3">
<summary className="cursor-pointer text-xs text-muted-foreground">
Captured vars ({Object.keys(run.vars).length})
</summary>
<pre className="mt-2 overflow-x-auto rounded-md bg-background p-2 text-[11px] text-muted-foreground">
{JSON.stringify(run.vars, null, 2)}
</pre>
</details>
)}
<div className="flex flex-col gap-1">
{events.length === 0 ? (
<p className="text-xs text-muted-foreground">
No events recorded for this run.
</p>
) : (
events.map((ev, ix) => <EventLine key={ix} ev={ev} />)
)}
</div>
</div>
)}
</div>
);
}
const EVENT_COLOR: Record<string, string> = {
started: "text-emerald-300",
node_entered: "text-muted-foreground",
message_sent: "text-sky-300",
reply_received: "text-primary",
fallback_fired: "text-amber-300",
handoff: "text-amber-300",
timeout: "text-muted-foreground",
error: "text-red-300",
completed: "text-emerald-300",
};
function EventLine({ ev }: { ev: EventRow }) {
const cls = EVENT_COLOR[ev.event_type] ?? "text-muted-foreground";
return (
<div className="flex items-start gap-2 rounded-md px-2 py-1 text-xs">
<span className="w-32 shrink-0 text-[10px] text-muted-foreground">
{format(new Date(ev.created_at), "HH:mm:ss")}
</span>
<span className={cn("w-32 shrink-0 font-mono text-[10px]", cls)}>
{ev.event_type}
</span>
{ev.node_key && (
<code className="shrink-0 rounded bg-muted px-1 py-0.5 text-[10px] text-muted-foreground">
{ev.node_key}
</code>
)}
{Object.keys(ev.payload).length > 0 && (
<span className="min-w-0 truncate text-[10px] text-muted-foreground">
{summarizePayload(ev.payload)}
</span>
)}
</div>
);
}
function summarizePayload(payload: Record<string, unknown>): string {
// Show the keys that matter most to a human debugger; full JSON is
// available via the "Captured vars" details panel for the run.
const keys = ["reply_id", "captured_key", "reason", "advancing_to"];
for (const k of keys) {
if (k in payload && payload[k] !== null && payload[k] !== undefined) {
return `${k}=${String(payload[k]).slice(0, 80)}`;
}
}
return "";
}

View File

@@ -0,0 +1,437 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import {
Workflow,
Plus,
Trash2,
Pencil,
Loader2,
MessageSquare,
PlayCircle,
PauseCircle,
Archive,
HelpCircle,
UserPlus,
FileText,
} from "lucide-react";
import { useCan } from "@/hooks/use-can";
import { Button } from "@/components/ui/button";
import { GatedButton } from "@/components/ui/gated-button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
/**
* Flows list page.
*
* Open to every authenticated user. Flows is in soft-GA — the "Beta"
* chip in the header is the only remaining signal that the surface
* is new. The previous per-account beta gate was removed in PR #134.
*/
interface FlowRow {
id: string;
name: string;
description: string | null;
status: "draft" | "active" | "archived";
trigger_type: "keyword" | "first_inbound_message" | "manual";
trigger_config: { keywords?: string[] } | Record<string, unknown>;
execution_count: number;
last_executed_at: string | null;
created_at: string;
updated_at: string;
}
const STATUS_LABELS: Record<FlowRow["status"], string> = {
draft: "Draft",
active: "Active",
archived: "Archived",
};
const STATUS_COLORS: Record<FlowRow["status"], string> = {
draft: "border-border bg-muted text-muted-foreground",
active: "border-emerald-600/40 bg-emerald-500/10 text-emerald-300",
archived: "border-border bg-muted/50 text-muted-foreground",
};
interface TemplateSummary {
slug: string;
name: string;
description: string;
icon: "MessageSquare" | "HelpCircle" | "UserPlus";
trigger_type: string;
node_count: number;
}
const TEMPLATE_ICONS = {
MessageSquare,
HelpCircle,
UserPlus,
} as const;
export default function FlowsPage() {
const router = useRouter();
const canCreate = useCan("send-messages");
const [flows, setFlows] = useState<FlowRow[]>([]);
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [newName, setNewName] = useState("");
const [creating, setCreating] = useState(false);
const [templates, setTemplates] = useState<TemplateSummary[]>([]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [flowsRes, tmplRes] = await Promise.all([
fetch("/api/flows"),
fetch("/api/flows/templates"),
]);
if (!flowsRes.ok) {
throw new Error(`Failed to load flows: ${flowsRes.status}`);
}
const flowsJson = (await flowsRes.json()) as { flows: FlowRow[] };
if (!cancelled) setFlows(flowsJson.flows ?? []);
// Templates endpoint is forward-looking — if it 404s on an
// older deployment, gracefully fall through.
if (tmplRes.ok) {
const tmplJson = (await tmplRes.json()) as {
templates: TemplateSummary[];
};
if (!cancelled) setTemplates(tmplJson.templates ?? []);
}
} catch (err) {
if (!cancelled) {
console.error(err);
toast.error("Couldn't load flows.");
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
async function handleCreate() {
if (!newName.trim()) return;
setCreating(true);
try {
const res = await fetch("/api/flows", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: newName.trim(),
trigger_type: "keyword",
trigger_config: { keywords: [] },
}),
});
if (!res.ok) throw new Error(`Create failed: ${res.status}`);
const json = (await res.json()) as { flow: FlowRow };
setCreateOpen(false);
setNewName("");
router.push(`/flows/${json.flow.id}`);
} catch (err) {
console.error(err);
toast.error("Couldn't create flow.");
} finally {
setCreating(false);
}
}
async function handleUseTemplate(slug: string) {
setCreating(true);
try {
const res = await fetch("/api/flows", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ template_slug: slug }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json.error ?? `Clone failed: ${res.status}`);
}
const json = (await res.json()) as { flow: FlowRow };
setCreateOpen(false);
router.push(`/flows/${json.flow.id}`);
} catch (err) {
const msg = err instanceof Error ? err.message : "Clone failed";
toast.error(msg);
} finally {
setCreating(false);
}
}
async function handleDelete(flow: FlowRow) {
const yes = window.confirm(
`Delete "${flow.name}"? Any active runs will end immediately.`,
);
if (!yes) return;
try {
const res = await fetch(`/api/flows/${flow.id}`, { method: "DELETE" });
if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
setFlows((prev) => prev.filter((f) => f.id !== flow.id));
toast.success("Flow deleted.");
} catch (err) {
console.error(err);
toast.error("Couldn't delete flow.");
}
}
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6 p-6">
<header className="flex flex-wrap items-end justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold text-foreground">Flows</h1>
<span className="inline-flex items-center rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-300">
Beta
</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Build branching, button-driven WhatsApp conversations. Useful for
menus, FAQs, and triage before a human steps in.
</p>
</div>
<GatedButton
canAct={canCreate}
gateReason="create flows"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
New flow
</GatedButton>
</header>
{flows.length === 0 ? (
<EmptyState
onCreate={() => setCreateOpen(true)}
canCreate={canCreate}
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{flows.map((flow) => (
<FlowCard
key={flow.id}
flow={flow}
onEdit={() => router.push(`/flows/${flow.id}`)}
onDelete={() => handleDelete(flow)}
/>
))}
</div>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
{/* `sm:max-w-4xl` not `max-w-4xl` — shadcn's DialogContent has
`sm:max-w-sm` baked into its default classes. Without the
sm: prefix our override applies at base only and the
sm-scoped 384px wins at every real desktop breakpoint. */}
<DialogContent className="sm:max-w-4xl bg-popover text-popover-foreground">
<DialogHeader>
<DialogTitle>Create a new flow</DialogTitle>
<DialogDescription className="text-muted-foreground">
Start from a template or build from scratch.
</DialogDescription>
</DialogHeader>
{templates.length > 0 && (
<div className="space-y-3">
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Start from a template
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{templates.map((t) => {
const Icon = TEMPLATE_ICONS[t.icon] ?? FileText;
return (
<button
key={t.slug}
type="button"
onClick={() => handleUseTemplate(t.slug)}
disabled={creating}
className="flex flex-col gap-2.5 rounded-lg border border-border bg-background p-4 text-left transition-colors hover:border-primary/40 hover:bg-muted disabled:opacity-50"
>
<Icon className="h-5 w-5 text-primary" />
<span className="text-sm font-semibold text-popover-foreground">
{t.name}
</span>
<span className="text-xs leading-relaxed text-muted-foreground">
{t.description}
</span>
<span className="mt-auto border-t border-border pt-2 text-[11px] text-muted-foreground">
{t.node_count} {t.node_count === 1 ? "node" : "nodes"}
</span>
</button>
);
})}
</div>
</div>
)}
<div className="space-y-2 border-t border-border pt-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Or start blank
</p>
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="e.g. Welcome menu"
className="bg-muted"
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
}}
/>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setCreateOpen(false)}
disabled={creating}
>
Cancel
</Button>
<Button onClick={handleCreate} disabled={!newName.trim() || creating}>
{creating && <Loader2 className="h-4 w-4 animate-spin" />}
Create blank flow
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function EmptyState({
onCreate,
canCreate,
}: {
onCreate: () => void;
canCreate: boolean;
}) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border bg-card/50 px-6 py-16 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-muted">
<Workflow className="h-6 w-6 text-muted-foreground" />
</div>
<h2 className="mt-4 text-base font-medium text-foreground">
No flows yet
</h2>
<p className="mt-1 max-w-md text-sm text-muted-foreground">
Build your first conversation a welcome menu, an order lookup, an FAQ
bot. Customers tap buttons; the bot routes them to the right answer (or
the right agent).
</p>
<GatedButton
canAct={canCreate}
gateReason="create flows"
onClick={onCreate}
className="mt-5"
>
<Plus className="h-4 w-4" />
Create your first flow
</GatedButton>
</div>
);
}
function FlowCard({
flow,
onEdit,
onDelete,
}: {
flow: FlowRow;
onEdit: () => void;
onDelete: () => void;
}) {
const triggerSummary = describeTrigger(flow);
const StatusIcon =
flow.status === "active"
? PlayCircle
: flow.status === "archived"
? Archive
: PauseCircle;
return (
<div className="flex flex-col rounded-lg border border-border bg-card p-4 transition-colors hover:border-border">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<Workflow className="h-4 w-4 shrink-0 text-primary" />
<h3 className="truncate text-sm font-semibold text-foreground">
{flow.name}
</h3>
</div>
<Badge
variant="outline"
className={cn(
"shrink-0 gap-1 text-[10px]",
STATUS_COLORS[flow.status],
)}
>
<StatusIcon className="h-3 w-3" />
{STATUS_LABELS[flow.status]}
</Badge>
</div>
<p className="mt-2 line-clamp-2 text-xs text-muted-foreground">
{flow.description || triggerSummary}
</p>
<div className="mt-4 flex items-center gap-3 text-[11px] text-muted-foreground">
<span className="inline-flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
{flow.execution_count} {flow.execution_count === 1 ? "run" : "runs"}
</span>
</div>
<div className="mt-4 flex items-center justify-end gap-2 border-t border-border pt-3">
<Button variant="ghost" size="sm" onClick={onEdit}>
<Pencil className="h-3.5 w-3.5" />
Edit
</Button>
<Button
variant="ghost"
size="sm"
onClick={onDelete}
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
</div>
</div>
);
}
function describeTrigger(flow: FlowRow): string {
if (flow.trigger_type === "keyword") {
const keywords = Array.isArray(flow.trigger_config.keywords)
? (flow.trigger_config.keywords as string[])
: [];
if (keywords.length === 0) return "Triggers on keyword (none set)";
return `Triggers on: ${keywords.join(", ")}`;
}
if (flow.trigger_type === "first_inbound_message") {
return "Triggers on a contact's first-ever inbound message";
}
return "Manual trigger";
}

View File

@@ -0,0 +1,628 @@
"use client";
import { useState, useCallback, useEffect, useRef } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
import {
CONVERSATION_SELECT,
normalizeConversation,
} from "@/lib/inbox/conversations";
import type { Conversation, Message, Contact, ConversationStatus } from "@/types";
import { useRealtime } from "@/hooks/use-realtime";
import { ConversationList } from "@/components/inbox/conversation-list";
import { MessageThread } from "@/components/inbox/message-thread";
import { ContactSidebar } from "@/components/inbox/contact-sidebar";
import { toast } from "sonner";
import { WifiOff } from "lucide-react";
import { cn } from "@/lib/utils";
// Remembers the agent's show/hide choice for the desktop contact panel
// across reloads and sessions (device-scoped, like the theme prefs).
const CONTACT_PANEL_STORAGE_KEY = "wacrm:inbox:contact-panel-open";
export default function InboxPage() {
const router = useRouter();
const searchParams = useSearchParams();
/**
* `?c=<id>` deep-link support. Used when landing here from the
* dashboard's recent-conversations list so the right thread opens
* automatically instead of showing the empty center panel.
*/
const deepLinkConvId = searchParams.get("c");
const [conversations, setConversations] = useState<Conversation[]>([]);
const [activeConversation, setActiveConversation] =
useState<Conversation | null>(null);
const [activeContact, setActiveContact] = useState<Contact | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [whatsappConnected, setWhatsappConnected] = useState<boolean | null>(
null
);
/**
* Bumped whenever we want children (ConversationList, MessageThread)
* to refetch from the DB — used as a safety net against missed
* realtime events. Bumped on WS reconnect and on tab visibility →
* visible. The initial mount fetches don't depend on this; they fire
* once on conversationId-change as usual.
*/
const [resyncToken, setResyncToken] = useState(0);
/**
* Whether the desktop contact sidebar (tags / deals / notes) is shown.
* Defaults to `true` (the historical behaviour) and is restored from
* localStorage after mount. We deliberately do NOT read localStorage in
* the initializer: the server renders with `true`, so reading a stored
* `false` synchronously would produce a hydration mismatch. The effect
* below reconciles to the stored value right after mount instead.
*/
const [contactPanelOpen, setContactPanelOpen] = useState(true);
useEffect(() => {
try {
const stored = localStorage.getItem(CONTACT_PANEL_STORAGE_KEY);
if (stored !== null) setContactPanelOpen(stored === "true");
} catch {
// localStorage can throw in private-browsing / sandboxed contexts.
}
}, []);
const handleToggleContactPanel = useCallback(() => {
setContactPanelOpen((prev) => {
const next = !prev;
try {
localStorage.setItem(CONTACT_PANEL_STORAGE_KEY, String(next));
} catch {
// Persistence is best-effort; ignore storage failures.
}
return next;
});
}, []);
// Fire the deep-link auto-select exactly once per URL — subsequent
// list refreshes (realtime, manual refetch) must not snap the user
// back to the deep-linked conversation if they've already clicked
// elsewhere.
const autoSelectedForDeepLinkRef = useRef<string | null>(null);
// Tracks conversations whose hydrate fetch is currently in flight. The
// conv-INSERT and the first-message-INSERT events both call into
// hydrateConversation; the dedupe here keeps it at one refetch per
// new conversation even when both events arrive within milliseconds.
const hydratingConvIdsRef = useRef<Set<string>>(new Set());
/**
* Synchronous mirror of the conversation ids currently in `conversations`
* state. Event handlers need to know "do we already have this conv?"
* without waiting for a setState updater to run — updaters fire during
* reconciliation, *after* the synchronous handler code returns, so a
* `let foundInList = false; setState(p => { foundInList = ...; return ... })`
* flag reads as `false` in the same tick (this exact bug shipped in #105
* and caused #106: every incoming message and every status flip fired a
* redundant DB hydrate, swamping the supabase client and starving the
* realtime channel). The ref is kept in sync via the effect below.
*/
const knownConvIdsRef = useRef<Set<string>>(new Set());
useEffect(() => {
const next = new Set<string>();
for (const c of conversations) next.add(c.id);
knownConvIdsRef.current = next;
}, [conversations]);
// Pull the conversation row with its `contact` joined and merge it
// into state. Needed because Supabase Realtime payloads only carry the
// row's own columns — a brand-new conversation arrives without a
// contact, which surfaced as "Unknown" names, empty avatars, and
// (when the conv-INSERT event was delayed past the message-INSERT)
// conversations stuck on "No messages yet" until the user reloaded.
// Also self-heals if a realtime event was missed: callers can invoke
// this whenever they reference a conversation id they don't recognise.
const hydrateConversation = useCallback(async (convId: string) => {
if (hydratingConvIdsRef.current.has(convId)) return;
hydratingConvIdsRef.current.add(convId);
try {
const supabase = createClient();
const { data, error } = await supabase
.from("conversations")
.select(CONVERSATION_SELECT)
.eq("id", convId)
.maybeSingle();
if (error) {
// Supabase errors have non-enumerable properties — log fields
// explicitly so the console message isn't just `{}`.
console.error("Failed to hydrate conversation:", {
message: error.message,
details: error.details,
hint: error.hint,
code: error.code,
});
return;
}
if (!data) return;
const fetched = normalizeConversation(data);
setConversations((prev) => {
const existing = prev.find((c) => c.id === fetched.id);
if (existing) {
// Already in state — keep its fields (a realtime UPDATE may
// have landed while the fetch was in flight and patched
// last_message_text / unread_count to fresher values than
// the row we just read). Only backfill `contact`, which the
// realtime payloads never carry.
return prev.map((c) =>
c.id === fetched.id
? { ...c, contact: c.contact ?? fetched.contact }
: c,
);
}
return [fetched, ...prev];
});
} finally {
hydratingConvIdsRef.current.delete(convId);
}
}, []);
// Check WhatsApp connection status on mount
useEffect(() => {
const checkConnection = async () => {
const supabase = createClient();
const {
data: { session },
} = await supabase.auth.getSession();
const user = session?.user;
if (!user) return;
// whatsapp_config is one-row-per-account post-multi-user, so
// the previous `.eq('user_id', user.id)` would miss the row
// for any teammate who didn't personally save the config —
// the "WhatsApp not connected" banner would show in the
// shared inbox even though the admin had it configured.
// Resolve account_id via the profile and query by that.
const { data: profile } = await supabase
.from("profiles")
.select("account_id")
.eq("user_id", user.id)
.maybeSingle();
const accountId = profile?.account_id as string | undefined;
if (!accountId) {
setWhatsappConnected(false);
return;
}
const { data } = await supabase
.from("whatsapp_config")
.select("status")
.eq("account_id", accountId)
.maybeSingle();
setWhatsappConnected(data?.status === "connected");
};
checkConnection();
}, []);
// Handle realtime message events
const handleMessageEvent = useCallback(
(event: { eventType: string; new: Message; old: Partial<Message> }) => {
const newMsg = event.new;
if (event.eventType === "INSERT") {
// Add to messages if it belongs to active conversation
if (
activeConversation &&
newMsg.conversation_id === activeConversation.id
) {
setMessages((prev) => {
// Avoid duplicates
if (prev.some((m) => m.id === newMsg.id)) return prev;
// Replace optimistic message if it exists
const withoutOptimistic = prev.filter(
(m) => !m.id.startsWith("temp-")
);
return [...withoutOptimistic, newMsg];
});
}
// Update conversation list preview. We need to know *synchronously*
// whether the conv is already in state to decide between patching
// the preview and triggering a hydrate — see the comment on
// knownConvIdsRef for why a closure flag inside the updater would
// always read false here.
if (knownConvIdsRef.current.has(newMsg.conversation_id)) {
setConversations((prev) =>
prev.map((c) =>
c.id === newMsg.conversation_id
? {
...c,
last_message_text: newMsg.content_text ?? "",
last_message_at: newMsg.created_at,
unread_count:
activeConversation?.id === newMsg.conversation_id
? 0
: c.unread_count + 1,
}
: c,
),
);
} else {
// First time we're seeing this conv: the conv-INSERT event
// hasn't landed yet, or was missed. Hydrate from the DB so
// the row surfaces with its `contact` joined; the conv-UPDATE
// event the webhook emits right after the message INSERT will
// converge state when it arrives.
hydrateConversation(newMsg.conversation_id);
}
}
if (event.eventType === "UPDATE") {
// Update message status
setMessages((prev) =>
prev.map((m) => (m.id === newMsg.id ? { ...m, ...newMsg } : m))
);
}
},
[activeConversation, hydrateConversation]
);
// Handle realtime conversation events
const handleConversationEvent = useCallback(
(event: {
eventType: string;
new: Conversation;
old: Partial<Conversation>;
}) => {
const conv = event.new;
if (event.eventType === "INSERT") {
// Prepend immediately for snappy UX so the new conv shows in the
// list right away, then hydrate to fill in the `contact` join
// (realtime payloads never include joins). Skip both if we
// already have the row — that shouldn't happen normally, but
// out-of-order delivery would have us prepending a duplicate.
if (!knownConvIdsRef.current.has(conv.id)) {
setConversations((prev) => {
if (prev.some((c) => c.id === conv.id)) return prev;
return [conv, ...prev];
});
hydrateConversation(conv.id);
}
}
if (event.eventType === "UPDATE") {
if (knownConvIdsRef.current.has(conv.id)) {
// If this UPDATE is for the conv the user is currently viewing,
// suppress the incoming unread_count — the user is reading it
// RIGHT NOW, so any positive value would just flicker the badge
// back on for the ~100ms it takes for the reset effect's server
// UPDATE to round-trip. Non-active convs take the value as-is.
const isActive = activeConversation?.id === conv.id;
setConversations((prev) =>
prev.map((c) =>
c.id === conv.id
? {
...c,
...conv,
unread_count: isActive ? 0 : conv.unread_count,
}
: c,
),
);
} else {
// UPDATE arrived before the INSERT (or after a missed INSERT)
// — fetch the row so it surfaces with its contact joined. The
// patch contained in `conv` will already be reflected in what
// the hydrate fetch returns.
hydrateConversation(conv.id);
}
// Update active conversation if it changed
if (activeConversation && conv.id === activeConversation.id) {
setActiveConversation((prev) =>
prev ? { ...prev, ...conv } : prev
);
}
}
},
[activeConversation, hydrateConversation]
);
// Subscribe to realtime. The `isConnected` flag below feeds the
// reconnect resync: realtime is best-effort and events sent while the
// WS was disconnected (laptop sleep, network blip, background-tab
// throttle) are simply lost. We need a way to catch up.
const { isConnected } = useRealtime({
channelName: "inbox-realtime",
onMessageEvent: handleMessageEvent,
onConversationEvent: handleConversationEvent,
enabled: true,
});
/**
* Bump `resyncToken` whenever the realtime channel transitions from
* disconnected → connected *after* the initial connect. The initial
* connect is covered by the children's on-mount fetches; only later
* reconnects need a manual refetch to fill the gap.
*
* Tracked via a `was-connected` ref rather than a count so that React
* strict-mode's dev-only effect double-fire doesn't read as a
* reconnect.
*/
const wasConnectedRef = useRef(false);
const initialConnectDoneRef = useRef(false);
useEffect(() => {
if (isConnected && !wasConnectedRef.current) {
// false → true transition
if (initialConnectDoneRef.current) {
setResyncToken((n) => n + 1);
} else {
initialConnectDoneRef.current = true;
}
}
wasConnectedRef.current = isConnected;
}, [isConnected]);
/**
* Refetch when the tab regains focus. Background tabs may have their
* WS throttled by the browser even without a full disconnect, so a
* visibilitychange → visible is a reliable signal that we may have
* missed events. Cheap to fire; the children dedupe on their own.
*/
useEffect(() => {
const onVisibility = () => {
if (document.visibilityState === "visible") {
setResyncToken((n) => n + 1);
}
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
/**
* Manual refresh trigger for the thread-header refresh button.
* Bumps the same resyncToken the reconnect / visibility paths use,
* so it goes through the existing dedupe & refetch plumbing — no
* separate code path to keep in sync.
*/
const handleManualRefresh = useCallback(() => {
setResyncToken((n) => n + 1);
}, []);
const handleConversationsLoaded = useCallback(
(loaded: Conversation[]) => {
setConversations(loaded);
// Resolve a pending deep-link here rather than in an effect — this
// is an event handler, so the setState calls below are allowed by
// react-hooks/set-state-in-effect. Runs once per ?c=<id> URL value
// via the ref, so realtime refreshes of the list can't snap the
// user back to the deep-linked thread after they've navigated.
if (
deepLinkConvId &&
autoSelectedForDeepLinkRef.current !== deepLinkConvId &&
loaded.length > 0
) {
autoSelectedForDeepLinkRef.current = deepLinkConvId;
// If the deep-linked conversation is already the active one
// (e.g. because the user clicked it in the list and we
// router.replace()'d the URL, which made the ConversationList
// refetch and land us back here), do NOT re-apply it. Doing so
// would setMessages([]) on a thread whose messages have
// already been loaded by MessageThread — and because
// conversationId didn't change, MessageThread wouldn't
// refetch. The thread would read "No messages yet" until a
// full page reload rehydrated state from scratch.
if (activeConversation?.id === deepLinkConvId) return;
const match = loaded.find((c) => c.id === deepLinkConvId);
if (match) {
setActiveConversation(match);
setActiveContact(match.contact ?? null);
setMessages([]);
// Mirror the optimistic unread reset that handleSelectConversation
// does — the user just deep-linked into this conv, treat that the
// same as a click. Leaves activeConversation.unread_count alone so
// the MessageThread reset effect still fires the server UPDATE.
if (match.unread_count > 0) {
setConversations((prev) =>
prev.map((c) =>
c.id === match.id ? { ...c, unread_count: 0 } : c,
),
);
}
}
}
},
[deepLinkConvId, activeConversation?.id]
);
const handleSelectConversation = useCallback(
(conv: Conversation) => {
// Re-clicking the already-active conversation would clear the
// messages array, but the fetch effect in MessageThread only re-runs
// when conversationId changes — so messages would stay empty until
// the user navigated away and back. Bail out early instead.
if (activeConversation?.id === conv.id) return;
setActiveConversation(conv);
setActiveContact(conv.contact ?? null);
setMessages([]);
// Optimistically clear the unread badge for this conv. The
// server-side reset is fired by the unread-reset effect inside
// MessageThread (which reads activeConversation.unread_count, not
// the list copy — so we deliberately leave that intact below to
// keep the effect firing), and the realtime UPDATE that comes
// back will sync to 0 again as a no-op. Zeroing the list copy
// here means the user sees the badge disappear the instant they
// click instead of waiting for the round-trip — and it persists
// even if the realtime UPDATE is dropped.
setConversations((prev) =>
prev.map((c) =>
c.id === conv.id && c.unread_count > 0
? { ...c, unread_count: 0 }
: c,
),
);
// Record the selection on the deep-link ref BEFORE we change the
// URL. The router.replace below flips `deepLinkConvId`, which can
// in turn cause ConversationList to refetch and eventually call
// handleConversationsLoaded again. Without this line, the ref
// still points at the previous value, the auto-select block
// sees `ref !== deepLinkConvId`, fires a second time, and
// clobbers the messages MessageThread just fetched.
autoSelectedForDeepLinkRef.current = conv.id;
// Reflect the selection in the URL so a refresh lands the user
// back in the same thread, and so copy-paste links work. Use
// replace() to avoid polluting browser history with every click.
router.replace(`/inbox?c=${conv.id}`, { scroll: false });
},
[activeConversation?.id, router]
);
// Mobile "back" — deselect the conversation so the list pane comes
// back. Also clears the ?c= param so a refresh lands on the list
// instead of re-opening the thread the user just backed out of.
const handleCloseConversation = useCallback(() => {
setActiveConversation(null);
setActiveContact(null);
setMessages([]);
// Clearing the ref lets the deep-link auto-selector fire again if
// the user later visits /inbox?c=<same-id> — desirable UX.
autoSelectedForDeepLinkRef.current = null;
router.replace("/inbox", { scroll: false });
}, [router]);
const handleMessagesLoaded = useCallback((loaded: Message[]) => {
setMessages(loaded);
}, []);
const handleNewMessage = useCallback((msg: Message) => {
setMessages((prev) => {
if (prev.some((m) => m.id === msg.id)) return prev;
return [...prev, msg];
});
}, []);
const handleUpdateMessage = useCallback(
(id: string, updates: Partial<Message>) => {
setMessages((prev) =>
prev.map((m) => (m.id === id ? { ...m, ...updates } : m))
);
},
[]
);
const handleStatusChange = useCallback(
(conversationId: string, status: ConversationStatus) => {
setConversations((prev) =>
prev.map((c) => (c.id === conversationId ? { ...c, status } : c))
);
if (activeConversation?.id === conversationId) {
setActiveConversation((prev) => (prev ? { ...prev, status } : prev));
}
},
[activeConversation]
);
const handleAssignChange = useCallback(
(conversationId: string, assignedAgentId: string | null) => {
setConversations((prev) =>
prev.map((c) =>
c.id === conversationId
? { ...c, assigned_agent_id: assignedAgentId ?? undefined }
: c
)
);
if (activeConversation?.id === conversationId) {
setActiveConversation((prev) =>
prev
? { ...prev, assigned_agent_id: assignedAgentId ?? undefined }
: prev
);
}
},
[activeConversation]
);
// On mobile (<lg) we show a SINGLE pane — either the list or the
// thread — rather than cramming both side-by-side. Selecting a
// conversation slides the thread in; the thread's back button pops
// it back to the list. On lg+ both panes render side-by-side as
// before, unchanged.
const hasActiveConv = !!activeConversation;
return (
<div className="-m-4 flex h-[calc(100vh-3.5rem)] flex-col overflow-hidden sm:-m-6">
{/* WhatsApp connection banner — in the flex column, not absolute,
so it pushes the panels down instead of overlapping them. */}
{whatsappConnected === false && (
<div className="flex shrink-0 items-center justify-center gap-2 border-b border-amber-500/20 bg-amber-500/10 px-4 py-2">
<WifiOff className="h-4 w-4 text-amber-400" />
<p className="text-xs text-amber-400">
WhatsApp® is not connected. Go to Settings to connect your account.
</p>
</div>
)}
<div className="flex flex-1 overflow-hidden">
{/* Left panel: Conversation list.
Hidden on mobile when a conversation is selected so the
thread can occupy the full width. Always visible on lg+. */}
<div
className={cn(
"flex h-full flex-1 lg:flex-none",
hasActiveConv ? "hidden lg:flex" : "flex",
)}
>
<ConversationList
activeConversationId={activeConversation?.id ?? null}
onSelect={handleSelectConversation}
conversations={conversations}
onConversationsLoaded={handleConversationsLoaded}
resyncToken={resyncToken}
/>
</div>
{/* Center panel: Message thread.
Hidden on mobile when no conversation is selected so the
list can occupy the full width. Always visible on lg+
(shows its own empty-state if no thread is picked yet).
`min-w-0` is load-bearing: without it, a single wide piece
of content inside the thread (long quote preview, very
long URL in a message body) forces the flex child past
its share and pushes the contact-sidebar panel off-screen
on the right. Issue #165. */}
<div
className={cn(
"flex h-full min-w-0 flex-1 lg:flex",
hasActiveConv ? "flex" : "hidden lg:flex",
)}
>
<MessageThread
conversation={activeConversation}
contact={activeContact}
messages={messages}
onMessagesLoaded={handleMessagesLoaded}
onNewMessage={handleNewMessage}
onUpdateMessage={handleUpdateMessage}
onStatusChange={handleStatusChange}
onAssignChange={handleAssignChange}
onBack={handleCloseConversation}
resyncToken={resyncToken}
onRefresh={handleManualRefresh}
contactPanelOpen={contactPanelOpen}
onToggleContactPanel={handleToggleContactPanel}
/>
</div>
{/* Right panel: Contact sidebar — desktop only, and only when the
agent hasn't collapsed it via the thread-header toggle (#258).
On mobile it's always hidden (the `lg:block` below), so the
toggle — which is itself desktop-only — never affects it. */}
{contactPanelOpen && (
<div className="hidden lg:block">
<ContactSidebar contact={activeContact} />
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import type { Metadata } from "next";
import { DashboardShell } from "./dashboard-shell";
// Server layout whose only job is to declare "do not index" metadata
// for the authed app. robots.ts already disallows these paths at the
// crawler-level and middleware redirects unauthenticated visitors, so
// this is belt-and-suspenders — but SEO-critical if a URL ever leaks
// via a link shared externally.
export const metadata: Metadata = {
robots: {
index: false,
follow: false,
nocache: true,
googleBot: {
index: false,
follow: false,
noimageindex: true,
},
},
};
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return <DashboardShell>{children}</DashboardShell>;
}

View File

@@ -0,0 +1,268 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
import { useAuth } from "@/hooks/use-auth";
import type { Notification } from "@/types";
import { Bell, CheckCheck, Loader2, UserPlus } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
// Icon per notification type. Only one type exists today
// (conversation_assigned) but this keeps future types a one-line add.
const TYPE_ICON: Record<Notification["type"], typeof Bell> = {
conversation_assigned: UserPlus,
};
export default function NotificationsPage() {
const router = useRouter();
const { accountId } = useAuth();
const [notifications, setNotifications] = useState<Notification[] | null>(
null,
);
const [error, setError] = useState<string | null>(null);
const [markingAll, setMarkingAll] = useState(false);
const load = useCallback(async () => {
if (!accountId) return;
const supabase = createClient();
const { data, error: fetchErr } = await supabase
.from("notifications")
.select("*")
.eq("account_id", accountId)
.order("created_at", { ascending: false })
.limit(100);
if (fetchErr) {
setError(fetchErr.message);
return;
}
setNotifications((data ?? []) as Notification[]);
}, [accountId]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
load();
}, [load]);
// Realtime — new assignments appear without a refresh, and a
// "mark all read" fired from another tab/device stays in sync here.
useEffect(() => {
const supabase = createClient();
const channel = supabase
.channel("notifications-page")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "notifications" },
(payload) => {
if (payload.eventType === "INSERT") {
const row = payload.new as Notification;
setNotifications((prev) => {
if (!prev) return [row];
if (prev.some((n) => n.id === row.id)) return prev;
return [row, ...prev];
});
} else if (payload.eventType === "UPDATE") {
const row = payload.new as Notification;
setNotifications((prev) =>
prev?.map((n) => (n.id === row.id ? { ...n, ...row } : n)) ??
prev,
);
} else if (payload.eventType === "DELETE") {
const oldRow = payload.old as Partial<Notification>;
setNotifications(
(prev) => prev?.filter((n) => n.id !== oldRow.id) ?? prev,
);
}
},
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, []);
const markRead = useCallback(
async (id: string) => {
// Optimistic — the row is already visually "read" by the time the
// request lands, so the UI doesn't wait on the round-trip.
setNotifications(
(prev) =>
prev?.map((n) =>
n.id === id && !n.read_at
? { ...n, read_at: new Date().toISOString() }
: n,
) ?? prev,
);
const supabase = createClient();
const { error: updateErr } = await supabase
.from("notifications")
.update({ read_at: new Date().toISOString() })
.eq("id", id)
.is("read_at", null);
if (updateErr) {
toast.error("Failed to mark notification as read");
load();
}
},
[load],
);
const handleClick = useCallback(
(n: Notification) => {
if (!n.read_at) markRead(n.id);
if (n.conversation_id) {
router.push(`/inbox?c=${n.conversation_id}`);
}
},
[markRead, router],
);
const unreadIds = notifications?.filter((n) => !n.read_at).map((n) => n.id) ?? [];
const markAllRead = useCallback(async () => {
if (unreadIds.length === 0) return;
setMarkingAll(true);
const now = new Date().toISOString();
setNotifications(
(prev) => prev?.map((n) => (n.read_at ? n : { ...n, read_at: now })) ?? prev,
);
const supabase = createClient();
const { error: updateErr } = await supabase
.from("notifications")
.update({ read_at: now })
.is("read_at", null);
setMarkingAll(false);
if (updateErr) {
toast.error("Failed to mark all as read");
load();
}
}, [unreadIds.length, load]);
if (error) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-2">
<p className="text-sm text-destructive">{error}</p>
<Button variant="outline" onClick={() => window.location.reload()}>
Retry
</Button>
</div>
);
}
if (notifications === null) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Notifications</h1>
<p className="mt-1 text-sm text-muted-foreground">
Conversations other teammates assign to you show up here.
</p>
</div>
<Button
variant="outline"
size="sm"
disabled={unreadIds.length === 0 || markingAll}
onClick={markAllRead}
>
{markingAll ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CheckCheck className="h-4 w-4" />
)}
Mark all as read
</Button>
</div>
{notifications.length === 0 ? (
<div className="flex h-48 flex-col items-center justify-center rounded-xl border border-dashed border-border bg-muted/40">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
<Bell className="h-6 w-6 text-primary" />
</div>
<p className="mt-3 text-sm font-medium text-foreground">
No notifications yet
</p>
<p className="mt-1 text-xs text-muted-foreground">
You&apos;ll see an alert here when someone assigns you a
conversation.
</p>
</div>
) : (
<ul className="space-y-2">
{notifications.map((n) => {
const Icon = TYPE_ICON[n.type] ?? Bell;
const isUnread = !n.read_at;
return (
<li key={n.id}>
<button
type="button"
onClick={() => handleClick(n)}
className={cn(
"flex w-full items-start gap-3 rounded-xl border p-4 text-left transition-colors",
isUnread
? "border-primary/30 bg-primary/5 hover:border-primary/50"
: "border-border bg-card hover:border-border/70",
)}
>
<div
className={cn(
"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg",
isUnread ? "bg-primary/15" : "bg-muted",
)}
aria-hidden
>
<Icon
className={cn(
"h-5 w-5",
isUnread ? "text-primary" : "text-muted-foreground",
)}
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className={cn(
"truncate text-sm font-semibold",
isUnread ? "text-foreground" : "text-muted-foreground",
)}
>
{n.title}
</span>
{isUnread && (
<span
aria-label="Unread"
className="h-2 w-2 flex-shrink-0 rounded-full bg-primary"
/>
)}
</div>
{n.body && (
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{n.body}
</p>
)}
<p className="mt-1 text-[11px] text-muted-foreground/70">
{formatDistanceToNow(new Date(n.created_at), {
addSuffix: true,
})}
</p>
</div>
</button>
</li>
);
})}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,492 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { createClient } from "@/lib/supabase/client";
import type { Pipeline, PipelineStage, Deal } from "@/types";
import { PipelineBoard } from "@/components/pipelines/pipeline-board";
import { PipelineSettings } from "@/components/pipelines/pipeline-settings";
import { DealForm } from "@/components/pipelines/deal-form";
import { PipelineAnalytics } from "@/components/pipelines/pipeline-analytics";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { GitBranch, Plus, ChevronDown, Settings } from "lucide-react";
import { toast } from "sonner";
import { useCan } from "@/hooks/use-can";
import { useAuth } from "@/hooks/use-auth";
import { GatedButton } from "@/components/ui/gated-button";
// Pipeline creation is admin-class (settings-tier write under
// the new RLS); deal creation is operational and only requires
// agent+. The two CTAs gate on different `useCan` capabilities,
// not on different copy.
// Spec-defined seed — name and color per the product spec.
const SPEC_DEFAULT_STAGES = [
{ name: "New Lead", color: "#3b82f6", position: 0 }, // blue
{ name: "Qualified", color: "#eab308", position: 1 }, // yellow
{ name: "Proposal Sent", color: "#f97316", position: 2 }, // orange
{ name: "Negotiation", color: "#8b5cf6", position: 3 }, // purple
{ name: "Won", color: "#22c55e", position: 4 }, // green
];
export default function PipelinesPage() {
const supabase = createClient();
const canEditSettings = useCan("edit-settings");
const canCreateDeals = useCan("send-messages");
const { accountId } = useAuth();
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
const [selectedPipelineId, setSelectedPipelineId] = useState<string>("");
const [stages, setStages] = useState<PipelineStage[]>([]);
const [deals, setDeals] = useState<Deal[]>([]);
const [loading, setLoading] = useState(true);
// Dialog / sheet state
const [newPipelineOpen, setNewPipelineOpen] = useState(false);
const [newPipelineName, setNewPipelineName] = useState("");
const [creating, setCreating] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
// Deal form state is lifted here so both the top-bar "Add Deal" and
// the per-column "+" trigger the same Sheet.
const [dealFormOpen, setDealFormOpen] = useState(false);
const [editingDeal, setEditingDeal] = useState<Deal | null>(null);
const [defaultStageId, setDefaultStageId] = useState<string>("");
// Guard against double-seeding (React StrictMode double-effect in dev).
const seedAttempted = useRef(false);
const loadPipelines = useCallback(async () => {
const { data, error } = await supabase
.from("pipelines")
.select("*")
.order("created_at");
if (error) {
console.error("Failed to load pipelines:", error.message);
return [];
}
return data ?? [];
}, [supabase]);
const loadStages = useCallback(
async (pipelineId: string) => {
const { data } = await supabase
.from("pipeline_stages")
.select("*")
.eq("pipeline_id", pipelineId)
.order("position");
return data ?? [];
},
[supabase],
);
const loadDeals = useCallback(
async (pipelineId: string) => {
const { data } = await supabase
.from("deals")
.select("*, contact:contacts(*), assignee:profiles!deals_assigned_to_fkey(*)")
.eq("pipeline_id", pipelineId)
.order("created_at", { ascending: false });
return (data ?? []) as Deal[];
},
[supabase],
);
const seedDefaultPipeline = useCallback(async (): Promise<Pipeline | null> => {
const {
data: { session },
} = await supabase.auth.getSession();
const user = session?.user;
if (!user) return null;
// pipelines.account_id is NOT NULL post-017 with no DB default.
if (!accountId) return null;
const { data: pipeline, error } = await supabase
.from("pipelines")
.insert({ user_id: user.id, account_id: accountId, name: "Sales Pipeline" })
.select()
.single();
if (error || !pipeline) {
console.error("Failed to seed pipeline:", error?.message);
return null;
}
const stagesPayload = SPEC_DEFAULT_STAGES.map((s) => ({
pipeline_id: pipeline.id,
name: s.name,
color: s.color,
position: s.position,
}));
await supabase.from("pipeline_stages").insert(stagesPayload);
return pipeline as Pipeline;
}, [supabase, accountId]);
// Initial load + seed-if-empty
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
let list = await loadPipelines();
if (list.length === 0 && !seedAttempted.current) {
seedAttempted.current = true;
const seeded = await seedDefaultPipeline();
if (seeded) list = await loadPipelines();
}
if (cancelled) return;
setPipelines(list);
if (list.length > 0) {
setSelectedPipelineId((prev) =>
prev && list.some((p) => p.id === prev) ? prev : list[0].id,
);
} else {
setSelectedPipelineId("");
}
setLoading(false);
})();
return () => {
cancelled = true;
};
}, [loadPipelines, seedDefaultPipeline]);
// Load stages + deals whenever selected pipeline changes.
// Clearing on no-selection is a legitimate sync with URL/prop
// state; the load completion uses async setters inside promise
// callbacks (not synchronous in the effect body).
useEffect(() => {
if (!selectedPipelineId) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setStages([]);
// eslint-disable-next-line react-hooks/set-state-in-effect
setDeals([]);
return;
}
let cancelled = false;
(async () => {
const [s, d] = await Promise.all([
loadStages(selectedPipelineId),
loadDeals(selectedPipelineId),
]);
if (cancelled) return;
setStages(s);
setDeals(d);
})();
return () => {
cancelled = true;
};
}, [selectedPipelineId, loadStages, loadDeals]);
const refreshPipelines = useCallback(async () => {
const list = await loadPipelines();
setPipelines(list);
if (list.length === 0) setSelectedPipelineId("");
else if (!list.some((p) => p.id === selectedPipelineId))
setSelectedPipelineId(list[0].id);
}, [loadPipelines, selectedPipelineId]);
const refreshStages = useCallback(async () => {
if (!selectedPipelineId) return;
setStages(await loadStages(selectedPipelineId));
}, [loadStages, selectedPipelineId]);
const refreshDeals = useCallback(async () => {
if (!selectedPipelineId) return;
setDeals(await loadDeals(selectedPipelineId));
}, [loadDeals, selectedPipelineId]);
const handleDealMoved = useCallback(
async (dealId: string, newStageId: string) => {
// Optimistic update — board already animated; just persist.
setDeals((prev) =>
prev.map((d) => (d.id === dealId ? { ...d, stage_id: newStageId } : d)),
);
const { error } = await supabase
.from("deals")
.update({ stage_id: newStageId })
.eq("id", dealId);
if (error) {
toast.error("Failed to move deal");
refreshDeals();
}
},
[supabase, refreshDeals],
);
const handleAddDeal = useCallback(
(stageId?: string) => {
setEditingDeal(null);
setDefaultStageId(stageId ?? stages[0]?.id ?? "");
setDealFormOpen(true);
},
[stages],
);
const handleEditDeal = useCallback((deal: Deal) => {
setEditingDeal(deal);
setDefaultStageId(deal.stage_id);
setDealFormOpen(true);
}, []);
async function handleCreatePipeline() {
const name = newPipelineName.trim();
if (!name) return;
setCreating(true);
const {
data: { session },
} = await supabase.auth.getSession();
const user = session?.user;
if (!user) {
setCreating(false);
return;
}
// pipelines.account_id is NOT NULL post-017 with no DB default.
if (!accountId) {
toast.error("Your profile is not linked to an account.");
setCreating(false);
return;
}
const { data: pipeline, error } = await supabase
.from("pipelines")
.insert({ user_id: user.id, account_id: accountId, name })
.select()
.single();
if (error || !pipeline) {
toast.error("Failed to create pipeline");
setCreating(false);
return;
}
const stagesPayload = SPEC_DEFAULT_STAGES.map((s) => ({
pipeline_id: pipeline.id,
name: s.name,
color: s.color,
position: s.position,
}));
await supabase.from("pipeline_stages").insert(stagesPayload);
setNewPipelineName("");
setNewPipelineOpen(false);
setSelectedPipelineId(pipeline.id);
await refreshPipelines();
setCreating(false);
toast.success("Pipeline created");
}
const selectedPipeline = pipelines.find((p) => p.id === selectedPipelineId);
if (loading) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="h-8 w-48 animate-pulse rounded bg-muted" />
<div className="h-9 w-28 animate-pulse rounded-lg bg-muted" />
</div>
<div className="flex gap-3">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-96 w-72 animate-pulse rounded-xl bg-muted/50" />
))}
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
{/* Pipeline selector dropdown */}
<DropdownMenu>
<DropdownMenuTrigger
className="inline-flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors data-[popup-open]:bg-muted"
>
<GitBranch className="h-4 w-4 text-primary" />
<span className="font-semibold">
{selectedPipeline?.name ?? "Select Pipeline"}
</span>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-64 border-border bg-popover text-popover-foreground"
>
{pipelines.length === 0 && (
<DropdownMenuItem disabled className="text-muted-foreground">
No pipelines yet
</DropdownMenuItem>
)}
{pipelines.map((p) => (
<DropdownMenuItem
key={p.id}
onClick={() => setSelectedPipelineId(p.id)}
className={
p.id === selectedPipelineId
? "text-primary"
: "text-popover-foreground"
}
>
<GitBranch className="mr-2 h-3.5 w-3.5" />
{p.name}
</DropdownMenuItem>
))}
<DropdownMenuSeparator className="bg-border" />
{selectedPipeline && (
<DropdownMenuItem
onClick={() => setSettingsOpen(true)}
className="text-popover-foreground"
>
<Settings className="mr-2 h-3.5 w-3.5" />
Manage Pipelines
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex items-center gap-2">
<GatedButton
variant="outline"
canAct={canEditSettings}
gateReason="create pipelines"
onClick={() => setNewPipelineOpen(true)}
className="border-border bg-card text-foreground hover:bg-muted"
>
<Plus className="mr-1 h-4 w-4" />
Add Pipeline
</GatedButton>
<GatedButton
canAct={canCreateDeals}
gateReason="create deals"
disabled={!selectedPipelineId || stages.length === 0}
onClick={() => handleAddDeal()}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
<Plus className="mr-1 h-4 w-4" />
Add Deal
</GatedButton>
</div>
</div>
{/* Board */}
{pipelines.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-20">
<GitBranch className="h-12 w-12 text-muted-foreground" />
<h3 className="mt-4 text-lg font-medium text-foreground">
No pipelines yet
</h3>
<p className="mt-2 text-sm text-muted-foreground">
Create a pipeline to start tracking deals
</p>
<GatedButton
canAct={canEditSettings}
gateReason="create pipelines"
onClick={() => setNewPipelineOpen(true)}
className="mt-4 bg-primary text-primary-foreground hover:bg-primary/90"
>
<Plus className="mr-1 h-4 w-4" />
Create Pipeline
</GatedButton>
</div>
) : (
<>
<PipelineAnalytics stages={stages} deals={deals} />
<PipelineBoard
stages={stages}
deals={deals}
onDealMoved={handleDealMoved}
onAddDeal={handleAddDeal}
onEditDeal={handleEditDeal}
/>
</>
)}
{/* New Pipeline Dialog */}
<Dialog open={newPipelineOpen} onOpenChange={setNewPipelineOpen}>
<DialogContent className="sm:max-w-sm bg-popover border-border">
<DialogHeader>
<DialogTitle className="text-popover-foreground">New Pipeline</DialogTitle>
</DialogHeader>
<div className="py-2">
<Label className="text-muted-foreground">Pipeline Name</Label>
<Input
value={newPipelineName}
onChange={(e) => setNewPipelineName(e.target.value)}
placeholder="e.g., Enterprise Sales"
className="mt-2 bg-muted border-border text-foreground"
onKeyDown={(e) => {
if (e.key === "Enter") handleCreatePipeline();
}}
/>
<p className="mt-2 text-xs text-muted-foreground">
Default stages (New Lead Won) will be created automatically.
</p>
</div>
<DialogFooter className="bg-popover/50 border-border">
<Button
variant="outline"
onClick={() => setNewPipelineOpen(false)}
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
<Button
onClick={handleCreatePipeline}
disabled={creating || !newPipelineName.trim()}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
{creating ? "Creating..." : "Create Pipeline"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Pipeline Settings */}
{selectedPipeline && (
<PipelineSettings
open={settingsOpen}
onOpenChange={setSettingsOpen}
pipeline={selectedPipeline}
stages={stages}
onPipelinesChanged={refreshPipelines}
onStagesChanged={refreshStages}
onCreateNewPipeline={() => {
setSettingsOpen(false);
setNewPipelineOpen(true);
}}
/>
)}
{/* Deal Form (Sheet) */}
<DealForm
open={dealFormOpen}
onOpenChange={setDealFormOpen}
deal={editingDeal}
pipelineId={selectedPipelineId}
stages={stages}
defaultStageId={defaultStageId}
onSaved={refreshDeals}
/>
</div>
);
}

View File

@@ -0,0 +1,84 @@
'use client';
import { useMemo, type ReactNode } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useAuth } from '@/hooks/use-auth';
import { useTheme } from '@/hooks/use-theme';
import { SettingsRail } from '@/components/settings/settings-rail';
import { SettingsOverview } from '@/components/settings/settings-overview';
import { ProfileForm } from '@/components/settings/profile-form';
import { SecurityPanel } from '@/components/settings/security-panel';
import { AppearancePanel } from '@/components/settings/appearance-panel';
import { WhatsAppConfig } from '@/components/settings/whatsapp-config';
import { TemplateManager } from '@/components/settings/template-manager';
import { FieldsAndTagsPanel } from '@/components/settings/fields-and-tags-panel';
import { DealsSettings } from '@/components/settings/deals-settings';
import { MembersTab } from '@/components/settings/members-tab';
import { ApiKeysSettings } from '@/components/settings/api-keys-settings';
import {
resolveSection,
type SettingsSection,
} from '@/components/settings/settings-sections';
export default function SettingsPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { defaultCurrency } = useAuth();
const { mode } = useTheme();
// The URL (`?tab=`) is the single source of truth for the active
// section — deep-linkable, and it keeps the existing links in the
// app sidebar/header working. Legacy tab values (tags, custom-fields)
// resolve onto their new home; unknown/empty → the Overview landing.
const section = resolveSection(searchParams.get('tab'));
const go = (next: SettingsSection) => {
const params = new URLSearchParams(searchParams.toString());
params.set('tab', next);
router.replace(`/settings?${params.toString()}`, { scroll: false });
};
// Cheap, fetch-free rail hints. The Overview landing carries the
// full live status/counts; the rail just surfaces the two that are
// already in context.
const hints: Partial<Record<SettingsSection, ReactNode>> = useMemo(
() => ({
appearance: mode.charAt(0).toUpperCase() + mode.slice(1),
deals: defaultCurrency,
}),
[mode, defaultCurrency],
);
const panel: Record<SettingsSection, ReactNode> = {
overview: <SettingsOverview onSelect={go} />,
profile: <ProfileForm />,
security: <SecurityPanel />,
appearance: <AppearancePanel />,
whatsapp: <WhatsAppConfig />,
templates: <TemplateManager />,
fields: <FieldsAndTagsPanel />,
deals: <DealsSettings />,
members: <MembersTab />,
api: <ApiKeysSettings />,
};
return (
<div>
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
Settings
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Everything in one place your account and your workspace. Pick a
section to manage it.
</p>
</div>
<div className="mt-6 grid gap-6 lg:grid-cols-[236px_minmax(0,1fr)] lg:items-start">
<SettingsRail active={section} onSelect={go} hints={hints} />
<div className="min-w-0">{panel[section]}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
// ============================================================
// DELETE /api/account/api-keys/[id] — revoke a key.
//
// Soft revoke: sets `revoked_at` rather than deleting the row, so
// the key's name/prefix stay visible in the roster as an audit
// trail ("this key existed and was turned off") and so the auth
// path's liveness check (`findActiveKeyByHash` filters revoked
// rows) starts rejecting it immediately. Admin+, enforced here and
// by the `api_keys_update` RLS policy.
//
// Revocation is effective on the next request: once `revoked_at` is
// set, `findActiveKeyByHash` returns null and the key 401s.
// ============================================================
import { NextResponse } from 'next/server';
import { requireRole, toErrorResponse } from '@/lib/auth/account';
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from '@/lib/rate-limit';
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const ctx = await requireRole('admin');
const limit = checkRateLimit(
`admin:apiKeyRevoke:${ctx.userId}`,
RATE_LIMITS.adminAction
);
if (!limit.success) return rateLimitResponse(limit);
const { id } = await params;
// Scope the update by account_id as well as id so an admin can
// never revoke another account's key by guessing a UUID. (RLS
// already enforces this; the explicit filter is belt-and-braces
// and makes the "0 rows updated → 404" path precise.)
const { data, error } = await ctx.supabase
.from('api_keys')
.update({ revoked_at: new Date().toISOString() })
.eq('id', id)
.eq('account_id', ctx.accountId)
.is('revoked_at', null)
.select('id')
.maybeSingle();
if (error) {
console.error('[DELETE /api/account/api-keys/[id]] error:', error);
return NextResponse.json(
{ error: 'Failed to revoke API key' },
{ status: 500 }
);
}
if (!data) {
// Either no such key in this account, or it was already revoked.
return NextResponse.json(
{ error: 'API key not found or already revoked' },
{ status: 404 }
);
}
return NextResponse.json({ success: true });
} catch (err) {
return toErrorResponse(err);
}
}

View File

@@ -0,0 +1,159 @@
// ============================================================
// /api/account/api-keys
//
// GET — list this account's API keys (safe columns only).
// POST — mint a new key.
//
// These are the *dashboard* endpoints for managing keys, so they
// authenticate the normal way (cookie session) and go through the
// RLS client. Listing is open to any member (viewer+) — the roster
// is not secret; the secret (the key itself) is never in it. Minting
// is admin+ (a key hands out capabilities), enforced by both
// `requireRole('admin')` here and the `api_keys_insert` RLS policy.
//
// IMPORTANT: the plaintext key is returned exactly ONCE, in the POST
// response. We persist only its SHA-256 hash, so neither GET nor any
// future endpoint can resurface it — same one-time-reveal contract
// as invite links. If the admin loses it, they revoke and re-issue.
// ============================================================
import { NextResponse } from 'next/server';
import {
getCurrentAccount,
requireRole,
toErrorResponse,
} from '@/lib/auth/account';
import { generateApiKey } from '@/lib/api-keys/keys';
import { normalizeScopes } from '@/lib/api-keys/scopes';
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from '@/lib/rate-limit';
const MAX_NAME_LEN = 80;
// Hard ceiling on caller-supplied expiry (1 year), mirroring the
// invite-link clamp. NULL/absent = never expires.
const MAX_EXPIRY_DAYS = 365;
// Columns safe to expose. `key_hash` is deliberately excluded — it
// never leaves the server.
const SAFE_COLUMNS =
'id, name, key_prefix, scopes, last_used_at, expires_at, revoked_at, created_at';
export async function GET() {
try {
// Any member can view the roster (RLS allows it); we just need a
// resolved account context.
const ctx = await getCurrentAccount();
const { data, error } = await ctx.supabase
.from('api_keys')
.select(SAFE_COLUMNS)
.eq('account_id', ctx.accountId)
.order('created_at', { ascending: false });
if (error) {
console.error('[GET /api/account/api-keys] fetch error:', error);
return NextResponse.json(
{ error: 'Failed to load API keys' },
{ status: 500 }
);
}
return NextResponse.json({ keys: data ?? [] });
} catch (err) {
return toErrorResponse(err);
}
}
export async function POST(request: Request) {
try {
const ctx = await requireRole('admin');
const limit = checkRateLimit(
`admin:apiKeyCreate:${ctx.userId}`,
RATE_LIMITS.adminAction
);
if (!limit.success) return rateLimitResponse(limit);
const body = (await request.json().catch(() => null)) as {
name?: unknown;
scopes?: unknown;
expiresInDays?: unknown;
} | null;
const rawName = typeof body?.name === 'string' ? body.name.trim() : '';
if (!rawName) {
return NextResponse.json(
{ error: "'name' is required" },
{ status: 400 }
);
}
if (rawName.length > MAX_NAME_LEN) {
return NextResponse.json(
{ error: `Name must be ${MAX_NAME_LEN} characters or fewer` },
{ status: 400 }
);
}
// Scopes default to none if omitted — that yields a key that can
// only call the scope-free endpoints (e.g. GET /api/v1/me).
const scopes = normalizeScopes(body?.scopes ?? []);
if (scopes === null) {
return NextResponse.json(
{ error: "'scopes' must be an array of known scope strings" },
{ status: 400 }
);
}
let expiresAt: string | null = null;
const rawExpiry = body?.expiresInDays;
if (
typeof rawExpiry === 'number' &&
Number.isFinite(rawExpiry) &&
rawExpiry > 0
) {
const days = Math.min(Math.floor(rawExpiry), MAX_EXPIRY_DAYS);
expiresAt = new Date(
Date.now() + days * 24 * 60 * 60 * 1000
).toISOString();
}
const { plaintext, hash, prefix } = generateApiKey();
const { data, error } = await ctx.supabase
.from('api_keys')
.insert({
account_id: ctx.accountId,
created_by: ctx.userId,
name: rawName,
key_prefix: prefix,
key_hash: hash,
scopes,
expires_at: expiresAt,
})
.select(SAFE_COLUMNS)
.single();
if (error || !data) {
console.error('[POST /api/account/api-keys] insert error:', error);
return NextResponse.json(
{ error: 'Failed to create API key' },
{ status: 500 }
);
}
return NextResponse.json(
{
key: data,
// Plaintext — shown to the admin exactly once.
plaintext,
},
{ status: 201 }
);
} catch (err) {
return toErrorResponse(err);
}
}

View File

@@ -0,0 +1,73 @@
// ============================================================
// DELETE /api/account/invitations/[id]
//
// Admin+. Revokes a pending invitation by id. RLS on
// `account_invitations` already restricts the DELETE to admins
// of the inviting account; we lean on it and skip the explicit
// ownership check.
//
// We intentionally delete the row outright rather than soft-
// deleting (a "revoked_at" flag). Once revoked, an invite is
// dead forever — there's no UX where a former invite should be
// listed; the plaintext token is gone too. Hard delete keeps
// the table small.
// ============================================================
import { NextResponse } from "next/server";
import { requireRole, toErrorResponse } from "@/lib/auth/account";
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from "@/lib/rate-limit";
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const ctx = await requireRole("admin");
const limit = checkRateLimit(
`admin:inviteRevoke:${ctx.userId}`,
RATE_LIMITS.adminAction,
);
if (!limit.success) return rateLimitResponse(limit);
const { id } = await params;
// No `eq('account_id', ctx.accountId)` — the RLS policy
// (`is_account_member(account_id, 'admin')`) already scopes
// the DELETE to invites in the caller's account. Adding the
// filter would be redundant; omitting it surfaces a
// cross-account attempt as a silent 0-row delete (which is
// exactly what we want for a revocation endpoint).
const { error, count } = await ctx.supabase
.from("account_invitations")
.delete({ count: "exact" })
.eq("id", id);
if (error) {
console.error("[DELETE /api/account/invitations/[id]] error:", error);
return NextResponse.json(
{ error: "Failed to revoke invitation" },
{ status: 500 },
);
}
if (count === 0) {
// Either the id doesn't exist or RLS hid it (different
// account). 404 either way — surfacing "exists but not
// yours" would leak existence.
return NextResponse.json(
{ error: "Invitation not found" },
{ status: 404 },
);
}
return NextResponse.json({ ok: true });
} catch (err) {
return toErrorResponse(err);
}
}

View File

@@ -0,0 +1,253 @@
// ============================================================
// /api/account/invitations
//
// GET — list outstanding (un-redeemed, non-expired) invites.
// POST — create a new invite link.
//
// Both admin+. The list endpoint is what the Members tab uses to
// populate the "Pending invitations" section; create is what the
// "Invite member" dialog calls.
//
// IMPORTANT: the plaintext token is returned exactly ONCE — in
// the POST response. We store only the SHA-256 hash on the row,
// so neither GET nor a future PATCH can ever resurface the
// link. The admin sees it in the creation modal, copies it, and
// shares it via WhatsApp/Slack/whatever they like. If they
// dismiss the modal without copying, the only recourse is to
// revoke and re-issue.
// ============================================================
import { NextResponse } from "next/server";
import { requireRole, toErrorResponse } from "@/lib/auth/account";
import {
clampExpiryDays,
generateInviteToken,
inviteExpiresAt,
inviteUrl,
} from "@/lib/auth/invitations";
import { isAccountRole } from "@/lib/auth/roles";
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from "@/lib/rate-limit";
// Resolve the base URL we publish invite links under.
//
// Resolution order, first match wins:
//
// 1. `NEXT_PUBLIC_SITE_URL` — admin's explicit config. Trumps
// everything; if you set this, that's where links point.
// 2. `X-Forwarded-Host` (+ `X-Forwarded-Proto`) — set by every
// reverse proxy in front of the app: Hostinger Managed
// Node.js, Vercel, Cloudflare, nginx. This is what makes
// invite links Just Work in production without forcing the
// operator to set an env var.
// 3. `Host` header + the protocol the request arrived on —
// bare deployments without a proxy.
// 4. Last-resort marketing-site fallback. Only hit if the
// request has no Host header at all, which is essentially
// impossible from a real browser. Logs a warning so the
// operator can spot the misconfig.
//
// Defense-in-depth: `ALLOWED_INVITE_HOSTS`
//
// The request-header path (#2 and #3 above) trusts whatever
// hostname the client (or proxy) puts in the header. On a
// typical proxied deploy (Vercel / Hostinger / Cloudflare) the
// proxy overwrites these so they're trustworthy. On a bare
// deployment exposed to the public internet, an attacker could
// POST directly with a crafted `Host: phishing.example` and
// receive an invite URL pointing at their site.
//
// When `ALLOWED_INVITE_HOSTS` is set (comma-separated hostnames),
// we validate the derived host against the list. Anything not
// on the list falls through to the wacrm.tech fallback with a
// loud console.warn. Operators who care about this attack
// surface should set this to their canonical hostnames; everyone
// else gets today's permissive behavior.
//
// Previous implementation hard-defaulted to `https://wacrm.tech`
// (the docs/marketing site, a different repo). Forks that didn't
// set `NEXT_PUBLIC_SITE_URL` got invite links pointing at the
// marketing site, which 404s on `/join/<token>`. This resolution
// chain removes the foot-gun.
function parseAllowedHosts(): readonly string[] | null {
const raw = process.env.ALLOWED_INVITE_HOSTS?.trim();
if (!raw) return null;
const list = raw
.split(",")
.map((h) => h.trim().toLowerCase())
.filter(Boolean);
return list.length > 0 ? list : null;
}
function isHostAllowed(
hostname: string,
allowList: readonly string[] | null,
): boolean {
if (!allowList) return true; // No allow-list → permissive (legacy behavior).
return allowList.includes(hostname.toLowerCase());
}
function getBaseUrl(request: Request): string {
const explicit = process.env.NEXT_PUBLIC_SITE_URL?.trim();
if (explicit) return explicit.replace(/\/+$/, "");
const allowList = parseAllowedHosts();
const forwardedHost = request.headers
.get("x-forwarded-host")
?.split(",")[0]
?.trim();
const forwardedProto = request.headers
.get("x-forwarded-proto")
?.split(",")[0]
?.trim();
if (forwardedHost && isHostAllowed(forwardedHost, allowList)) {
return `${forwardedProto || "https"}://${forwardedHost}`;
}
const host = request.headers.get("host")?.trim();
if (host && isHostAllowed(host, allowList)) {
// The protocol on `request.url` is whatever the framework saw —
// reliable for bare deployments where no proxy is rewriting it.
const reqProto = new URL(request.url).protocol.replace(":", "");
return `${reqProto}://${host}`;
}
// We fall through here when EITHER no Host header was present at
// all (essentially impossible from a real browser) OR an
// ALLOWED_INVITE_HOSTS list was set and neither candidate matched
// it. The warning is the operator's signal that someone is
// probing the API with a spoofed Host header.
if (allowList && (forwardedHost || host)) {
console.warn(
"[POST /api/account/invitations] rejected non-allow-listed host:",
{ forwardedHost, host, allowList },
);
} else {
console.warn(
"[POST /api/account/invitations] could not derive base URL from request; falling back to marketing domain",
);
}
return "https://wacrm.tech";
}
const MAX_LABEL_LEN = 80;
export async function GET() {
try {
const ctx = await requireRole("admin");
const { data, error } = await ctx.supabase
.from("account_invitations")
.select(
"id, role, label, created_by_user_id, created_at, expires_at, accepted_at, accepted_by_user_id",
)
.eq("account_id", ctx.accountId)
.is("accepted_at", null)
.gt("expires_at", new Date().toISOString())
.order("created_at", { ascending: false });
if (error) {
console.error("[GET /api/account/invitations] fetch error:", error);
return NextResponse.json(
{ error: "Failed to load invitations" },
{ status: 500 },
);
}
return NextResponse.json({ invitations: data ?? [] });
} catch (err) {
return toErrorResponse(err);
}
}
export async function POST(request: Request) {
try {
const ctx = await requireRole("admin");
// 30/min per user. The Members tab is a clicks-only UI so any
// legitimate admin is far below this; the cap exists to keep
// a script run in a loop or a compromised admin session from
// flooding `account_invitations` with rows.
const limit = checkRateLimit(
`admin:inviteCreate:${ctx.userId}`,
RATE_LIMITS.adminAction,
);
if (!limit.success) return rateLimitResponse(limit);
const body = (await request.json().catch(() => null)) as
| { role?: unknown; expiresInDays?: unknown; label?: unknown }
| null;
const role = body?.role;
if (!isAccountRole(role) || role === "owner") {
// The DB CHECK already rejects 'owner', but failing fast
// here gives a clearer 400 than the eventual constraint
// violation surfaced as a 500.
return NextResponse.json(
{ error: "'role' must be one of admin, agent, viewer" },
{ status: 400 },
);
}
const expiresInDaysRaw = body?.expiresInDays;
// `clampExpiryDays` tolerates undefined / NaN / negatives by
// collapsing to the safe default, so we just pass the raw
// value through after a type narrow.
const expiresInDays =
typeof expiresInDaysRaw === "number" ? expiresInDaysRaw : undefined;
const expiryDays = clampExpiryDays(expiresInDays);
const expiresAt = inviteExpiresAt(expiryDays);
let label: string | null = null;
if (typeof body?.label === "string") {
const trimmed = body.label.trim();
if (trimmed.length > MAX_LABEL_LEN) {
return NextResponse.json(
{ error: `Label must be ${MAX_LABEL_LEN} characters or fewer` },
{ status: 400 },
);
}
label = trimmed === "" ? null : trimmed;
}
const { token, hash } = generateInviteToken();
const { data, error } = await ctx.supabase
.from("account_invitations")
.insert({
account_id: ctx.accountId,
token_hash: hash,
role,
created_by_user_id: ctx.userId,
label,
expires_at: expiresAt.toISOString(),
})
.select("id, role, label, expires_at, created_at")
.single();
if (error || !data) {
console.error("[POST /api/account/invitations] insert error:", error);
return NextResponse.json(
{ error: "Failed to create invitation" },
{ status: 500 },
);
}
return NextResponse.json(
{
invitation: data,
// Plaintext payload — visible to the admin exactly once.
token,
url: inviteUrl(token, getBaseUrl(request)),
expiresInDays: expiryDays,
},
{ status: 201 },
);
} catch (err) {
return toErrorResponse(err);
}
}

View File

@@ -0,0 +1,122 @@
// ============================================================
// /api/account/members/[userId]
//
// PATCH — change a member's role. Admin+.
// DELETE — remove a member. Admin+.
//
// Both delegate to SECURITY DEFINER RPCs from migration 018:
// - set_member_role(p_user_id, p_new_role)
// - remove_account_member(p_user_id)
//
// The RPCs do the *real* authorisation work — caller must be
// admin+, target must be in caller's account, target can't be the
// owner, can't be self. The TS layer here only forwards the call
// and maps Postgres SQLSTATEs back to HTTP statuses.
// ============================================================
import { NextResponse } from "next/server";
import type { PostgrestError } from "@supabase/supabase-js";
import { requireRole, toErrorResponse } from "@/lib/auth/account";
import { isAccountRole } from "@/lib/auth/roles";
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from "@/lib/rate-limit";
// Map known SQLSTATEs from the RPCs (see migration 018) onto HTTP
// statuses. The `error.code` field is the SQLSTATE; the `message`
// is the human-readable RAISE message we put in the migration.
function rpcErrorToResponse(err: PostgrestError): NextResponse {
if (err.code === "42501") {
return NextResponse.json({ error: err.message }, { status: 403 });
}
if (err.code === "22023") {
return NextResponse.json({ error: err.message }, { status: 400 });
}
console.error("[members route] unexpected RPC error:", err);
return NextResponse.json(
{ error: "Failed to update member" },
{ status: 500 },
);
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ userId: string }> },
) {
try {
const ctx = await requireRole("admin");
const limit = checkRateLimit(
`admin:memberRole:${ctx.userId}`,
RATE_LIMITS.adminAction,
);
if (!limit.success) return rateLimitResponse(limit);
const { userId } = await params;
const body = (await request.json().catch(() => null)) as
| { role?: unknown }
| null;
const role = body?.role;
if (!isAccountRole(role)) {
return NextResponse.json(
{ error: "'role' must be one of owner, admin, agent, viewer" },
{ status: 400 },
);
}
// The RPC blocks promotion to / demotion from owner, but
// surface the friendlier 400 before crossing the wire too.
if (role === "owner") {
return NextResponse.json(
{
error:
"Use POST /api/account/transfer-ownership to promote a member to owner",
},
{ status: 400 },
);
}
const { error } = await ctx.supabase.rpc("set_member_role", {
p_user_id: userId,
p_new_role: role,
});
if (error) return rpcErrorToResponse(error);
return NextResponse.json({ ok: true });
} catch (err) {
return toErrorResponse(err);
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ userId: string }> },
) {
try {
const ctx = await requireRole("admin");
const limit = checkRateLimit(
`admin:memberRemove:${ctx.userId}`,
RATE_LIMITS.adminAction,
);
if (!limit.success) return rateLimitResponse(limit);
const { userId } = await params;
const { data, error } = await ctx.supabase.rpc("remove_account_member", {
p_user_id: userId,
});
if (error) return rpcErrorToResponse(error);
return NextResponse.json({ ok: true, newPersonalAccountId: data });
} catch (err) {
return toErrorResponse(err);
}
}

View File

@@ -0,0 +1,73 @@
// ============================================================
// GET /api/account/members
//
// Lists every member of the caller's account. Any member can call
// it (the Members tab is shown to admins+, but agents/viewers see
// a read-only roster too).
//
// Field visibility
// Sensitive fields (email) are returned only when the caller is
// admin+. Agents and viewers see name + avatar + role + joined
// date only. This mirrors the design decision from the planning
// phase: "agent/viewer sees names only".
// ============================================================
import { NextResponse } from "next/server";
import { getCurrentAccount, toErrorResponse } from "@/lib/auth/account";
import { canManageMembers, isAccountRole } from "@/lib/auth/roles";
import type { AccountMember } from "@/types";
interface ProfileRow {
user_id: string;
full_name: string | null;
email: string | null;
avatar_url: string | null;
account_role: string;
created_at: string;
}
export async function GET() {
try {
const ctx = await getCurrentAccount();
// RLS on profiles allows reading any row whose account matches
// the caller's, so this query is naturally account-scoped.
const { data, error } = await ctx.supabase
.from("profiles")
.select("user_id, full_name, email, avatar_url, account_role, created_at")
.eq("account_id", ctx.accountId)
.order("created_at", { ascending: true });
if (error) {
console.error("[GET /api/account/members] fetch error:", error);
return NextResponse.json(
{ error: "Failed to load members" },
{ status: 500 },
);
}
const canSeeEmails = canManageMembers(ctx.role);
const members: AccountMember[] = (data as ProfileRow[]).flatMap((row) => {
// Defensive: the DB enum should never let an unknown role
// through, but if a migration ever broadens the enum without
// updating TS, skip the row rather than crash the page.
if (!isAccountRole(row.account_role)) return [];
return [
{
user_id: row.user_id,
full_name: row.full_name ?? "",
email: canSeeEmails ? row.email : null,
avatar_url: row.avatar_url,
role: row.account_role,
joined_at: row.created_at,
},
];
});
return NextResponse.json({ members });
} catch (err) {
return toErrorResponse(err);
}
}

View File

@@ -0,0 +1,103 @@
// ============================================================
// /api/account
//
// GET — current caller's account + role. Any member.
// PATCH — rename the account. Admin+.
//
// Why both verbs share a route file
// They speak about the same singular resource (the caller's
// account) and reuse the same `requireRole` plumbing. Splitting
// them across files would duplicate the `account_id` lookup
// without buying anything.
// ============================================================
import { NextResponse } from "next/server";
import {
requireRole,
getCurrentAccount,
toErrorResponse,
} from "@/lib/auth/account";
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from "@/lib/rate-limit";
export async function GET() {
try {
const ctx = await getCurrentAccount();
return NextResponse.json({
account: ctx.account,
role: ctx.role,
});
} catch (err) {
return toErrorResponse(err);
}
}
const MAX_NAME_LEN = 80;
export async function PATCH(request: Request) {
try {
const ctx = await requireRole("admin");
// Per-user limit on admin-class mutations. Bounds accidental
// abuse (script run in a loop) and a compromised admin session
// spamming renames. Each admin endpoint keys its own bucket so
// one route doesn't starve another.
const limit = checkRateLimit(
`admin:rename:${ctx.userId}`,
RATE_LIMITS.adminAction,
);
if (!limit.success) return rateLimitResponse(limit);
const body = (await request.json().catch(() => null)) as
| { name?: unknown }
| null;
const rawName = body?.name;
if (typeof rawName !== "string") {
return NextResponse.json(
{ error: "'name' must be a string" },
{ status: 400 },
);
}
const name = rawName.trim();
if (name.length === 0) {
return NextResponse.json(
{ error: "Account name cannot be empty" },
{ status: 400 },
);
}
if (name.length > MAX_NAME_LEN) {
return NextResponse.json(
{ error: `Account name must be ${MAX_NAME_LEN} characters or fewer` },
{ status: 400 },
);
}
// RLS allows this UPDATE because accounts_update requires
// `is_account_member(id, 'admin')`, and requireRole already
// guaranteed the caller is admin+.
const { data, error } = await ctx.supabase
.from("accounts")
.update({ name })
.eq("id", ctx.accountId)
.select("id, name")
.single();
if (error) {
console.error("[PATCH /api/account] update error:", error);
return NextResponse.json(
{ error: "Failed to update account" },
{ status: 500 },
);
}
return NextResponse.json({ account: data });
} catch (err) {
return toErrorResponse(err);
}
}

View File

@@ -0,0 +1,94 @@
// ============================================================
// POST /api/account/transfer-ownership
//
// Owner only. Atomically:
// - demotes the current owner to 'admin'
// - promotes the target member to 'owner'
// - updates accounts.owner_user_id
//
// The atomic part lives in the `transfer_account_ownership`
// SECURITY DEFINER RPC (migration 018). This route just validates
// shape and forwards.
//
// Why a separate endpoint instead of PATCH /members/[userId]?
// The semantics differ: transfer demotes the current owner as
// a side-effect and changes the owner_user_id pointer on
// `accounts`. Making it explicit prevents the "I clicked the
// role dropdown by mistake" failure mode where an admin would
// silently hand their account away.
// ============================================================
import { NextResponse } from "next/server";
import type { PostgrestError } from "@supabase/supabase-js";
import { requireRole, toErrorResponse } from "@/lib/auth/account";
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from "@/lib/rate-limit";
function rpcErrorToResponse(err: PostgrestError): NextResponse {
if (err.code === "42501") {
return NextResponse.json({ error: err.message }, { status: 403 });
}
if (err.code === "22023") {
return NextResponse.json({ error: err.message }, { status: 400 });
}
console.error("[transfer-ownership] unexpected RPC error:", err);
return NextResponse.json(
{ error: "Failed to transfer ownership" },
{ status: 500 },
);
}
// Crude shape check — full UUID validation happens DB-side when
// the FK / lookup runs. This guards against obviously-wrong input
// (numbers, objects) before we round-trip.
function looksLikeUuid(v: unknown): v is string {
return (
typeof v === "string" &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v)
);
}
export async function POST(request: Request) {
try {
// `requireRole('owner')` is belt-and-braces — the RPC checks
// this too, but failing fast here saves a Supabase round trip
// on the obvious "admin trying to transfer" case.
const ctx = await requireRole("owner");
// Rate-limit owner-only transfers. Legitimate use is one click
// every few months at most; a script run in a loop would
// produce a noisy audit trail. 30/min is well above any human
// pace and bounds the noise.
const limit = checkRateLimit(
`admin:transferOwnership:${ctx.userId}`,
RATE_LIMITS.adminAction,
);
if (!limit.success) return rateLimitResponse(limit);
const body = (await request.json().catch(() => null)) as
| { newOwnerUserId?: unknown }
| null;
const newOwnerUserId = body?.newOwnerUserId;
if (!looksLikeUuid(newOwnerUserId)) {
return NextResponse.json(
{ error: "'newOwnerUserId' must be a valid UUID" },
{ status: 400 },
);
}
const { error } = await ctx.supabase.rpc("transfer_account_ownership", {
p_new_owner_user_id: newOwnerUserId,
});
if (error) return rpcErrorToResponse(error);
return NextResponse.json({ ok: true });
} catch (err) {
return toErrorResponse(err);
}
}

View File

@@ -0,0 +1,253 @@
import { NextResponse } from 'next/server'
import {
getCurrentAccount,
requireRole,
toErrorResponse,
} from '@/lib/auth/account'
import { checkRateLimit, rateLimitResponse, RATE_LIMITS } from '@/lib/rate-limit'
import { encrypt, decrypt } from '@/lib/whatsapp/encryption'
import { validateAiCredentials } from '@/lib/ai/validate'
import { embedTexts } from '@/lib/ai/embeddings'
import { AiError, type AiProvider } from '@/lib/ai/types'
function bad(message: string) {
return NextResponse.json({ error: message }, { status: 400 })
}
/**
* GET /api/ai/config
*
* Any member may read the config so the inbox/settings can reflect
* whether AI is set up. The encrypted key is NEVER returned — only a
* `has_key` flag; the settings form shows a masked placeholder.
*/
export async function GET() {
try {
const { supabase, accountId } = await getCurrentAccount()
const { data, error } = await supabase
.from('ai_configs')
// `api_key` is selected only to derive `has_key` — it is stripped
// out below and never returned to the client.
.select(
'provider, model, system_prompt, is_active, auto_reply_enabled, auto_reply_max_per_conversation, api_key, embeddings_api_key',
)
.eq('account_id', accountId)
.maybeSingle()
if (error) {
console.error('[ai/config GET] fetch error:', error)
return NextResponse.json(
{ error: 'Failed to load AI configuration' },
{ status: 500 },
)
}
if (!data) return NextResponse.json({ configured: false })
// The keys are selected only to derive the has_* flags; neither is
// returned to the client.
const { api_key, embeddings_api_key, ...safe } = data
return NextResponse.json({
configured: true,
has_key: !!api_key,
has_embeddings_key: !!embeddings_api_key,
...safe,
})
} catch (err) {
return toErrorResponse(err)
}
}
/**
* POST /api/ai/config (admin+)
*
* Upsert the account's AI config. Validates the key with the provider
* before persisting (mirrors the WhatsApp config verifying with Meta
* first), then stores the key AES-256-GCM-encrypted. When `api_key` is
* omitted the existing stored key is reused (the form sends it only
* when the user re-enters it).
*/
export async function POST(request: Request) {
try {
const { supabase, accountId, userId } = await requireRole('admin')
const limit = checkRateLimit(`ai-config:${userId}`, RATE_LIMITS.adminAction)
if (!limit.success) return rateLimitResponse(limit)
const body = await request.json().catch(() => null)
if (!body || typeof body !== 'object') return bad('Invalid request body')
const provider = body.provider as AiProvider
if (provider !== 'openai' && provider !== 'anthropic') {
return bad('provider must be "openai" or "anthropic"')
}
const model = typeof body.model === 'string' ? body.model.trim() : ''
if (!model) return bad('model is required')
const systemPrompt =
typeof body.system_prompt === 'string' && body.system_prompt.trim()
? body.system_prompt.trim()
: null
const isActive = body.is_active === true
const autoReplyEnabled = body.auto_reply_enabled === true
let maxPer = Number(body.auto_reply_max_per_conversation)
if (!Number.isFinite(maxPer)) maxPer = 3
maxPer = Math.min(20, Math.max(1, Math.floor(maxPer)))
const rawKey = typeof body.api_key === 'string' ? body.api_key.trim() : ''
// Embeddings key (optional, for semantic KB search): a non-empty
// string sets/replaces it; an explicit null clears it; absent leaves
// it unchanged. The form only sends it when the admin edits it.
const rawEmbeddingsKey =
typeof body.embeddings_api_key === 'string'
? body.embeddings_api_key.trim()
: ''
const clearEmbeddingsKey = body.embeddings_api_key === null
// Reuse the stored key when the form didn't send a fresh one.
const { data: existing } = await supabase
.from('ai_configs')
.select('id, provider, model, api_key')
.eq('account_id', accountId)
.maybeSingle()
let apiKeyPlain: string
if (rawKey) {
apiKeyPlain = rawKey
} else if (existing?.api_key) {
try {
apiKeyPlain = decrypt(existing.api_key)
} catch {
return bad('Stored API key could not be decrypted — re-enter your key.')
}
} else {
return bad('api_key is required')
}
// Only spend a provider round-trip when the credentials that affect
// reachability actually changed. A save that just flips a toggle or
// edits the system prompt on an existing, already-validated config
// skips the call — no wasted token/latency on the account's key.
const credentialsChanged =
!existing ||
rawKey !== '' ||
provider !== existing.provider ||
model !== existing.model
if (credentialsChanged) {
try {
await validateAiCredentials({
provider,
model,
apiKey: apiKeyPlain,
systemPrompt,
isActive,
autoReplyEnabled,
autoReplyMaxPerConversation: maxPer,
embeddingsApiKey: null,
})
} catch (err) {
if (err instanceof AiError) {
return NextResponse.json(
{ error: err.message, code: err.code },
{ status: 400 },
)
}
console.error('[ai/config POST] validation error:', err)
return bad('Could not validate the API key with the provider.')
}
}
// Validate a new embeddings key before storing (a cheap 1-input
// embed), same "verify before save" discipline as the chat key.
if (rawEmbeddingsKey) {
try {
await embedTexts(rawEmbeddingsKey, ['ping'])
} catch (err) {
if (err instanceof AiError) {
return NextResponse.json(
{ error: `Embeddings key: ${err.message}`, code: err.code },
{ status: 400 },
)
}
console.error('[ai/config POST] embeddings validation error:', err)
return bad('Could not validate the embeddings key.')
}
}
const encryptedKey = rawKey ? encrypt(rawKey) : null
const shared: Record<string, unknown> = {
provider,
model,
system_prompt: systemPrompt,
is_active: isActive,
auto_reply_enabled: autoReplyEnabled,
auto_reply_max_per_conversation: maxPer,
}
if (rawEmbeddingsKey) {
shared.embeddings_api_key = encrypt(rawEmbeddingsKey)
} else if (clearEmbeddingsKey) {
shared.embeddings_api_key = null
}
if (existing) {
const { error: upErr } = await supabase
.from('ai_configs')
.update(encryptedKey ? { ...shared, api_key: encryptedKey } : shared)
.eq('account_id', accountId)
if (upErr) {
console.error('[ai/config POST] update error:', upErr)
return NextResponse.json(
{ error: 'Failed to save AI configuration' },
{ status: 500 },
)
}
} else {
const { error: insErr } = await supabase.from('ai_configs').insert({
account_id: accountId,
created_by: userId,
api_key: encryptedKey, // guaranteed non-null: rawKey required when no existing row
...shared,
})
if (insErr) {
console.error('[ai/config POST] insert error:', insErr)
return NextResponse.json(
{ error: 'Failed to save AI configuration' },
{ status: 500 },
)
}
}
return NextResponse.json({ success: true })
} catch (err) {
return toErrorResponse(err)
}
}
/**
* DELETE /api/ai/config (admin+)
*
* Removes the account's AI config (turns everything off and forgets the
* key). Also used to recover from a corrupted encrypted key.
*/
export async function DELETE() {
try {
const { supabase, accountId } = await requireRole('admin')
const { error } = await supabase
.from('ai_configs')
.delete()
.eq('account_id', accountId)
if (error) {
console.error('[ai/config DELETE] error:', error)
return NextResponse.json(
{ error: 'Failed to delete AI configuration' },
{ status: 500 },
)
}
return NextResponse.json({ success: true })
} catch (err) {
return toErrorResponse(err)
}
}

View File

@@ -0,0 +1,116 @@
import { NextResponse } from 'next/server'
import { requireRole, toErrorResponse } from '@/lib/auth/account'
import { checkRateLimit, rateLimitResponse, RATE_LIMITS } from '@/lib/rate-limit'
import { loadAiConfig } from '@/lib/ai/config'
import { buildConversationContext } from '@/lib/ai/context'
import { retrieveKnowledge } from '@/lib/ai/knowledge'
import { generateReply } from '@/lib/ai/generate'
import { buildSystemPrompt } from '@/lib/ai/defaults'
import { latestUserMessage } from '@/lib/ai/query'
import { AiError } from '@/lib/ai/types'
/**
* POST /api/ai/draft (agent+)
*
* Body: { conversation_id }
* Returns: { draft } — a suggested reply for the agent to edit + send.
*
* Uses the account's configured provider/key (BYO). Read-only: it never
* sends or stores anything, just hands text back to the composer.
*/
export async function POST(request: Request) {
try {
const { supabase, accountId, userId } = await requireRole('agent')
const userLimit = checkRateLimit(`ai-draft:${userId}`, RATE_LIMITS.aiDraft)
if (!userLimit.success) return rateLimitResponse(userLimit)
// Also cap the whole team's draws on the shared BYO provider key.
const accountLimit = checkRateLimit(
`ai-draft-acct:${accountId}`,
RATE_LIMITS.aiDraftAccount,
)
if (!accountLimit.success) return rateLimitResponse(accountLimit)
const body = await request.json().catch(() => null)
const conversationId =
body && typeof body.conversation_id === 'string' ? body.conversation_id : ''
if (!conversationId) {
return NextResponse.json(
{ error: 'conversation_id is required' },
{ status: 400 },
)
}
// RLS scopes the SSR client to the caller's account, so a missing
// row means "not yours / not found" either way.
const { data: conversation, error: convErr } = await supabase
.from('conversations')
.select('id')
.eq('id', conversationId)
.maybeSingle()
if (convErr) {
console.error('[ai/draft] conversation lookup error:', convErr)
return NextResponse.json({ error: 'Failed to load conversation' }, { status: 500 })
}
if (!conversation) {
return NextResponse.json({ error: 'Conversation not found' }, { status: 404 })
}
const config = await loadAiConfig(supabase, accountId).catch((err) => {
// Decrypt failure — surface distinctly from "not configured".
console.error('[ai/draft] loadAiConfig error:', err)
throw new AiError('Stored API key could not be decrypted.', {
code: 'key_decrypt_failed',
status: 400,
})
})
if (!config) {
return NextResponse.json(
{
error: 'AI assistant is not set up. Enable it in Settings → AI Assistant.',
code: 'ai_not_configured',
},
{ status: 400 },
)
}
const messages = await buildConversationContext(supabase, conversationId)
// Nothing to draft from — a brand-new thread with no customer text
// would otherwise produce a nonsensical reply-to-nothing.
if (messages.length === 0) {
return NextResponse.json(
{
error: 'No messages to draft from yet.',
code: 'no_messages',
},
{ status: 400 },
)
}
// Ground the draft in the account's knowledge base (best-effort —
// returns [] when there's no KB or retrieval fails).
const knowledge = await retrieveKnowledge(
supabase,
accountId,
config,
latestUserMessage(messages),
)
const systemPrompt = buildSystemPrompt({
userPrompt: config.systemPrompt,
mode: 'draft',
knowledge,
})
const { text } = await generateReply({ config, systemPrompt, messages })
return NextResponse.json({ draft: text })
} catch (err) {
if (err instanceof AiError) {
return NextResponse.json(
{ error: err.message, code: err.code },
{ status: err.status },
)
}
return toErrorResponse(err)
}
}

View File

@@ -0,0 +1,132 @@
import { NextResponse } from 'next/server'
import {
getCurrentAccount,
requireRole,
toErrorResponse,
} from '@/lib/auth/account'
import { checkRateLimit, rateLimitResponse, RATE_LIMITS } from '@/lib/rate-limit'
import { loadEmbeddingsKey } from '@/lib/ai/config'
import { ingestDocument } from '@/lib/ai/knowledge'
import { AiError } from '@/lib/ai/types'
type Params = { params: Promise<{ id: string }> }
/**
* GET /api/ai/knowledge/[id] — full document (any member).
*/
export async function GET(_request: Request, { params }: Params) {
try {
const { supabase, accountId } = await getCurrentAccount()
const { id } = await params
const { data, error } = await supabase
.from('ai_knowledge_documents')
.select('id, title, content, updated_at')
.eq('account_id', accountId)
.eq('id', id)
.maybeSingle()
if (error) {
console.error('[ai/knowledge/[id] GET] error:', error)
return NextResponse.json({ error: 'Failed to load document' }, { status: 500 })
}
if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 })
return NextResponse.json(data)
} catch (err) {
return toErrorResponse(err)
}
}
/**
* PATCH /api/ai/knowledge/[id] (admin+) — update title/content and
* re-index when the content changed.
*/
export async function PATCH(request: Request, { params }: Params) {
try {
const { supabase, accountId, userId } = await requireRole('admin')
const limit = checkRateLimit(`ai-kb:${userId}`, RATE_LIMITS.adminAction)
if (!limit.success) return rateLimitResponse(limit)
const { id } = await params
const body = await request.json().catch(() => null)
const title = typeof body?.title === 'string' ? body.title.trim() : undefined
const content = typeof body?.content === 'string' ? body.content.trim() : undefined
if (title === undefined && content === undefined) {
return NextResponse.json({ error: 'Nothing to update' }, { status: 400 })
}
if (title !== undefined && !title) {
return NextResponse.json({ error: 'title cannot be empty' }, { status: 400 })
}
if (content !== undefined && !content) {
return NextResponse.json({ error: 'content cannot be empty' }, { status: 400 })
}
const update: Record<string, string> = {}
if (title !== undefined) update.title = title
if (content !== undefined) update.content = content
const { data: updated, error } = await supabase
.from('ai_knowledge_documents')
.update(update)
.eq('account_id', accountId)
.eq('id', id)
.select('id')
.maybeSingle()
if (error) {
console.error('[ai/knowledge/[id] PATCH] error:', error)
return NextResponse.json({ error: 'Failed to update document' }, { status: 500 })
}
if (!updated) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (content !== undefined) {
const { key: embeddingsApiKey, corrupt } = await loadEmbeddingsKey(
supabase,
accountId,
)
try {
await ingestDocument(supabase, accountId, { embeddingsApiKey }, id, content)
} catch (err) {
const message = err instanceof AiError ? err.message : 'indexing failed'
console.error('[ai/knowledge/[id] PATCH] ingest error:', err)
return NextResponse.json(
{
success: true,
warning: `Updated, but semantic indexing failed (${message}). Lexical search still works; use Reindex to retry.`,
},
{ status: 200 },
)
}
if (corrupt) {
return NextResponse.json({
success: true,
warning:
'Updated with keyword search only — your embeddings key could not be decrypted (check ENCRYPTION_KEY, then re-enter the key).',
})
}
}
return NextResponse.json({ success: true })
} catch (err) {
return toErrorResponse(err)
}
}
/**
* DELETE /api/ai/knowledge/[id] (admin+) — chunks cascade.
*/
export async function DELETE(_request: Request, { params }: Params) {
try {
const { supabase, accountId } = await requireRole('admin')
const { id } = await params
const { error } = await supabase
.from('ai_knowledge_documents')
.delete()
.eq('account_id', accountId)
.eq('id', id)
if (error) {
console.error('[ai/knowledge/[id] DELETE] error:', error)
return NextResponse.json({ error: 'Failed to delete document' }, { status: 500 })
}
return NextResponse.json({ success: true })
} catch (err) {
return toErrorResponse(err)
}
}

View File

@@ -0,0 +1,79 @@
import { NextResponse } from 'next/server'
import { requireRole, toErrorResponse } from '@/lib/auth/account'
import { checkRateLimit, rateLimitResponse, RATE_LIMITS } from '@/lib/rate-limit'
import { loadEmbeddingsKey } from '@/lib/ai/config'
import { ingestDocument } from '@/lib/ai/knowledge'
import { AiError } from '@/lib/ai/types'
/**
* POST /api/ai/knowledge/reindex (admin+)
*
* Re-chunk and re-embed every document in the account. The main use is
* after adding an embeddings key: existing documents were stored
* lexical-only, and this backfills their vectors so semantic search
* turns on. Also recovers documents whose indexing failed earlier.
*/
export async function POST() {
try {
const { supabase, accountId, userId } = await requireRole('admin')
const limit = checkRateLimit(`ai-kb-reindex:${userId}`, RATE_LIMITS.adminAction)
if (!limit.success) return rateLimitResponse(limit)
const { data: docs, error } = await supabase
.from('ai_knowledge_documents')
.select('id, content')
.eq('account_id', accountId)
if (error) {
console.error('[ai/knowledge/reindex] fetch error:', error)
return NextResponse.json(
{ error: 'Failed to load documents' },
{ status: 500 },
)
}
const { key: embeddingsApiKey, corrupt } = await loadEmbeddingsKey(
supabase,
accountId,
)
// The whole point of Reindex is usually to backfill embeddings — so
// if a key is configured but can't be decrypted, don't quietly do a
// lexical-only pass and report success. Stop and tell the admin.
if (corrupt) {
return NextResponse.json(
{
success: false,
reindexed: 0,
error:
'Your embeddings key could not be decrypted (check ENCRYPTION_KEY, then re-enter the key in Settings → AI Assistant). Nothing was reindexed.',
},
{ status: 200 },
)
}
let reindexed = 0
for (const doc of docs ?? []) {
try {
await ingestDocument(supabase, accountId, { embeddingsApiKey }, doc.id, doc.content)
reindexed += 1
} catch (err) {
// One bad document (e.g. a mid-run embeddings rate-limit) should
// not abort the whole batch.
const message = err instanceof AiError ? err.message : String(err)
console.error(`[ai/knowledge/reindex] doc ${doc.id} failed:`, message)
return NextResponse.json(
{
success: false,
reindexed,
total: (docs ?? []).length,
error: `Reindexed ${reindexed}, then hit an error: ${message}`,
},
{ status: 200 },
)
}
}
return NextResponse.json({ success: true, reindexed })
} catch (err) {
return toErrorResponse(err)
}
}

View File

@@ -0,0 +1,110 @@
import { NextResponse } from 'next/server'
import {
getCurrentAccount,
requireRole,
toErrorResponse,
} from '@/lib/auth/account'
import { checkRateLimit, rateLimitResponse, RATE_LIMITS } from '@/lib/rate-limit'
import { loadEmbeddingsKey } from '@/lib/ai/config'
import { ingestDocument } from '@/lib/ai/knowledge'
import { AiError } from '@/lib/ai/types'
/**
* GET /api/ai/knowledge
*
* List the account's knowledge-base documents (any member).
*/
export async function GET() {
try {
const { supabase, accountId } = await getCurrentAccount()
const { data, error } = await supabase
.from('ai_knowledge_documents')
.select('id, title, updated_at')
.eq('account_id', accountId)
.order('updated_at', { ascending: false })
if (error) {
console.error('[ai/knowledge GET] error:', error)
return NextResponse.json(
{ error: 'Failed to load knowledge base' },
{ status: 500 },
)
}
return NextResponse.json({ documents: data ?? [] })
} catch (err) {
return toErrorResponse(err)
}
}
/**
* POST /api/ai/knowledge (admin+)
*
* Create a document, then chunk + (optionally) embed it. If indexing
* fails the document is still saved so the admin can retry via reindex.
*/
export async function POST(request: Request) {
try {
const { supabase, accountId, userId } = await requireRole('admin')
const limit = checkRateLimit(`ai-kb:${userId}`, RATE_LIMITS.adminAction)
if (!limit.success) return rateLimitResponse(limit)
const body = await request.json().catch(() => null)
const title = typeof body?.title === 'string' ? body.title.trim() : ''
const content = typeof body?.content === 'string' ? body.content.trim() : ''
if (!title || !content) {
return NextResponse.json(
{ error: 'title and content are required' },
{ status: 400 },
)
}
const { data: doc, error } = await supabase
.from('ai_knowledge_documents')
.insert({ account_id: accountId, created_by: userId, title, content })
.select('id')
.single()
if (error || !doc) {
console.error('[ai/knowledge POST] insert error:', error)
return NextResponse.json(
{ error: 'Failed to save document' },
{ status: 500 },
)
}
const { key: embeddingsApiKey, corrupt } = await loadEmbeddingsKey(
supabase,
accountId,
)
try {
await ingestDocument(
supabase,
accountId,
{ embeddingsApiKey },
doc.id,
content,
)
} catch (err) {
const message = err instanceof AiError ? err.message : 'indexing failed'
console.error('[ai/knowledge POST] ingest error:', err)
return NextResponse.json(
{
success: true,
id: doc.id,
warning: `Saved, but semantic indexing failed (${message}). Lexical search still works; use Reindex to retry.`,
},
{ status: 200 },
)
}
if (corrupt) {
return NextResponse.json({
success: true,
id: doc.id,
warning:
'Saved with keyword search only — your embeddings key could not be decrypted (check ENCRYPTION_KEY, then re-enter the key).',
})
}
return NextResponse.json({ success: true, id: doc.id })
} catch (err) {
return toErrorResponse(err)
}
}

View File

@@ -0,0 +1,98 @@
import { NextResponse } from 'next/server'
import { requireRole, toErrorResponse } from '@/lib/auth/account'
import { checkRateLimit, rateLimitResponse, RATE_LIMITS } from '@/lib/rate-limit'
import { loadAiConfig } from '@/lib/ai/config'
import { retrieveKnowledge } from '@/lib/ai/knowledge'
import { generateReply } from '@/lib/ai/generate'
import { buildSystemPrompt } from '@/lib/ai/defaults'
import { latestUserMessage } from '@/lib/ai/query'
import { AiError, type ChatMessage } from '@/lib/ai/types'
// Keep the tested transcript bounded, mirroring the live context window.
const MAX_TURNS = 20
/**
* POST /api/ai/playground (agent+)
*
* Test-chat with the account's agent WITHOUT touching WhatsApp. Runs the
* exact same path the auto-reply bot uses — knowledge-base retrieval +
* `auto_reply` system prompt + the configured provider — so what you see
* here is what a real customer would get. Reads the config even when the
* master switch is off (requireActive:false) so you can try it before
* going live. Stateless: the client sends the running transcript each turn.
*/
export async function POST(request: Request) {
try {
const { supabase, accountId, userId } = await requireRole('agent')
const limit = checkRateLimit(`ai-playground:${userId}`, RATE_LIMITS.aiDraft)
if (!limit.success) return rateLimitResponse(limit)
const body = await request.json().catch(() => null)
const rawMessages = Array.isArray(body?.messages) ? body.messages : null
if (!rawMessages) {
return NextResponse.json({ error: 'messages is required' }, { status: 400 })
}
const messages: ChatMessage[] = rawMessages
.filter(
(m: unknown): m is ChatMessage =>
!!m &&
typeof m === 'object' &&
((m as ChatMessage).role === 'user' ||
(m as ChatMessage).role === 'assistant') &&
typeof (m as ChatMessage).content === 'string' &&
(m as ChatMessage).content.trim().length > 0,
)
.slice(-MAX_TURNS)
if (messages.length === 0) {
return NextResponse.json(
{ error: 'Send a message to test the agent.' },
{ status: 400 },
)
}
const config = await loadAiConfig(supabase, accountId, {
requireActive: false,
}).catch((err) => {
console.error('[ai/playground] loadAiConfig error:', err)
throw new AiError('Stored API key could not be decrypted.', {
code: 'key_decrypt_failed',
status: 400,
})
})
if (!config) {
return NextResponse.json(
{
error: 'No agent configured yet. Add your provider key in Setup.',
code: 'ai_not_configured',
},
{ status: 400 },
)
}
const knowledge = await retrieveKnowledge(
supabase,
accountId,
config,
latestUserMessage(messages),
)
const systemPrompt = buildSystemPrompt({
userPrompt: config.systemPrompt,
mode: 'auto_reply',
knowledge,
})
const { text, handoff } = await generateReply({ config, systemPrompt, messages })
return NextResponse.json({ reply: text, handoff })
} catch (err) {
if (err instanceof AiError) {
return NextResponse.json(
{ error: err.message, code: err.code },
{ status: err.status },
)
}
return toErrorResponse(err)
}
}

View File

@@ -0,0 +1,94 @@
import { NextResponse } from 'next/server'
import { requireRole, toErrorResponse } from '@/lib/auth/account'
import { checkRateLimit, rateLimitResponse, RATE_LIMITS } from '@/lib/rate-limit'
import { decrypt } from '@/lib/whatsapp/encryption'
import { validateAiCredentials } from '@/lib/ai/validate'
import { AiError, type AiProvider } from '@/lib/ai/types'
/**
* POST /api/ai/test (admin+)
*
* "Test key" button: validate a candidate provider/model/key against
* the provider WITHOUT saving. When `api_key` is omitted the stored
* key is used, so an admin can re-test an existing config (e.g. after
* changing the model). Returns `{ ok: true }` on success, 400 with the
* provider's message on failure.
*/
export async function POST(request: Request) {
try {
const { supabase, accountId, userId } = await requireRole('admin')
const limit = checkRateLimit(`ai-test:${userId}`, RATE_LIMITS.adminAction)
if (!limit.success) return rateLimitResponse(limit)
const body = await request.json().catch(() => null)
if (!body || typeof body !== 'object') {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const provider = body.provider as AiProvider
if (provider !== 'openai' && provider !== 'anthropic') {
return NextResponse.json(
{ error: 'provider must be "openai" or "anthropic"' },
{ status: 400 },
)
}
const model = typeof body.model === 'string' ? body.model.trim() : ''
if (!model) {
return NextResponse.json({ error: 'model is required' }, { status: 400 })
}
const rawKey = typeof body.api_key === 'string' ? body.api_key.trim() : ''
let apiKeyPlain = rawKey
if (!apiKeyPlain) {
const { data: existing } = await supabase
.from('ai_configs')
.select('api_key')
.eq('account_id', accountId)
.maybeSingle()
if (!existing?.api_key) {
return NextResponse.json(
{ error: 'Enter an API key to test.' },
{ status: 400 },
)
}
try {
apiKeyPlain = decrypt(existing.api_key)
} catch {
return NextResponse.json(
{ error: 'Stored API key could not be decrypted — re-enter your key.' },
{ status: 400 },
)
}
}
try {
await validateAiCredentials({
provider,
model,
apiKey: apiKeyPlain,
systemPrompt: null,
isActive: true,
autoReplyEnabled: false,
autoReplyMaxPerConversation: 3,
embeddingsApiKey: null,
})
} catch (err) {
if (err instanceof AiError) {
return NextResponse.json(
{ error: err.message, code: err.code },
{ status: 400 },
)
}
console.error('[ai/test] validation error:', err)
return NextResponse.json(
{ error: 'Could not validate the API key.' },
{ status: 400 },
)
}
return NextResponse.json({ ok: true })
} catch (err) {
return toErrorResponse(err)
}
}

View File

@@ -0,0 +1,75 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { supabaseAdmin } from '@/lib/automations/admin-client'
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const admin = supabaseAdmin()
const { data: original, error: origErr } = await admin
.from('automations')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.maybeSingle()
if (origErr) return NextResponse.json({ error: origErr.message }, { status: 500 })
if (!original) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const { data: copy, error: copyErr } = await admin
.from('automations')
.insert({
// Clone into the same account as the original. account_id is NOT
// NULL post-017, so the INSERT fails the constraint without it.
account_id: original.account_id,
user_id: user.id,
name: `${original.name} (Copy)`,
description: original.description,
trigger_type: original.trigger_type,
trigger_config: original.trigger_config,
is_active: false,
})
.select()
.single()
if (copyErr || !copy) {
return NextResponse.json({ error: copyErr?.message ?? 'copy failed' }, { status: 500 })
}
const { data: steps } = await admin
.from('automation_steps')
.select('id, parent_step_id, branch, step_type, step_config, position')
.eq('automation_id', id)
.order('position', { ascending: true })
if (steps && steps.length > 0) {
// Re-map parent_step_id: build old→new id map first so the second
// pass inserts rows with correct parent references.
const idMap = new Map<string, string>()
const uid = () =>
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: Math.random().toString(36).slice(2) + Date.now().toString(36)
for (const row of steps) idMap.set(row.id as string, uid())
const rows = steps.map((row) => ({
id: idMap.get(row.id as string)!,
automation_id: copy.id,
parent_step_id: row.parent_step_id ? idMap.get(row.parent_step_id as string) : null,
branch: row.branch,
step_type: row.step_type,
step_config: row.step_config,
position: row.position,
}))
const { error: insErr } = await admin.from('automation_steps').insert(rows)
if (insErr) return NextResponse.json({ error: insErr.message }, { status: 500 })
}
return NextResponse.json({ automation: copy }, { status: 201 })
}

View File

@@ -0,0 +1,138 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { supabaseAdmin } from '@/lib/automations/admin-client'
import {
loadStepsTree,
replaceSteps,
type BuilderStepInput,
} from '@/lib/automations/steps-tree'
import {
validateStepsForActivation,
validateTriggerForActivation,
} from '@/lib/automations/validate'
async function requireUser() {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
return user
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params
const user = await requireUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const admin = supabaseAdmin()
const { data: automation, error } = await admin
.from('automations')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.maybeSingle()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (!automation) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const steps = await loadStepsTree(id)
return NextResponse.json({ automation, steps })
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params
const user = await requireUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const body = await request.json().catch(() => null)
if (!body) return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
const admin = supabaseAdmin()
// Ownership check before we touch anything. Load the fields we need
// to compute the post-patch "effective" state for validation.
const { data: existing } = await admin
.from('automations')
.select('id, user_id, is_active, trigger_type, trigger_config')
.eq('id', id)
.maybeSingle()
if (!existing || existing.user_id !== user.id) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const update: Record<string, unknown> = {}
for (const k of [
'name',
'description',
'trigger_type',
'trigger_config',
'is_active',
] as const) {
if (k in body) update[k] = body[k]
}
// If this PATCH leaves the automation active (either explicitly
// activating it OR editing an already-active one), validate the
// merged configuration first. Activation is the natural gate — drafts
// are still allowed to be incomplete.
const willBeActive =
typeof update.is_active === 'boolean' ? update.is_active : existing.is_active
if (willBeActive) {
const mergedTriggerType = (update.trigger_type ?? existing.trigger_type) as string
const mergedTriggerConfig = update.trigger_config ?? existing.trigger_config
const mergedSteps = Array.isArray(body.steps)
? (body.steps as { step_type: string; step_config: Record<string, unknown> }[])
: await loadStepsTree(id)
const issues = [
...validateTriggerForActivation(mergedTriggerType, mergedTriggerConfig),
...validateStepsForActivation(mergedSteps),
]
if (issues.length > 0) {
return NextResponse.json(
{
error: 'Cannot keep automation active with invalid configuration',
issues,
},
{ status: 400 },
)
}
}
if (Object.keys(update).length > 0) {
const { error: updErr } = await admin
.from('automations')
.update(update)
.eq('id', id)
if (updErr) return NextResponse.json({ error: updErr.message }, { status: 500 })
}
if (Array.isArray(body.steps)) {
const err = await replaceSteps(id, body.steps as BuilderStepInput[])
if (err) return NextResponse.json({ error: err }, { status: 500 })
}
return NextResponse.json({ ok: true })
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params
const user = await requireUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { error } = await supabaseAdmin()
.from('automations')
.delete()
.eq('id', id)
.eq('user_id', user.id)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ ok: true })
}

View File

@@ -0,0 +1,68 @@
import { NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/automations/admin-client'
import { resumePendingExecution } from '@/lib/automations/engine'
import type { AutomationContext } from '@/lib/automations/engine'
/**
* Drain due `automation_pending_executions` rows. Meant to be hit
* on a schedule (Vercel Cron / external pinger) — requires a shared
* secret via the `x-cron-secret` header to match
* `AUTOMATION_CRON_SECRET`.
*
* The claim step (status = 'running') serves as a simple lock so
* overlapping invocations don't double-process rows. Best-effort
* only; expensive SELECT ... FOR UPDATE is avoided in favor of a
* two-step UPDATE-by-id.
*/
export async function GET(request: Request) {
const expected = process.env.AUTOMATION_CRON_SECRET
if (!expected) {
return NextResponse.json({ error: 'cron not configured' }, { status: 503 })
}
const supplied = request.headers.get('x-cron-secret')
if (supplied !== expected) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const admin = supabaseAdmin()
const { data: due, error } = await admin
.from('automation_pending_executions')
.select('*')
.eq('status', 'pending')
.lte('run_at', new Date().toISOString())
.order('run_at', { ascending: true })
.limit(50)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (!due || due.length === 0) return NextResponse.json({ processed: 0 })
let processed = 0
for (const row of due) {
const { data: claim } = await admin
.from('automation_pending_executions')
.update({ status: 'running' })
.eq('id', row.id)
.eq('status', 'pending')
.select('id')
.maybeSingle()
if (!claim) continue
await resumePendingExecution({
id: row.id as string,
automation_id: row.automation_id as string,
// account_id is NOT NULL on automation_pending_executions
// post-017; the engine uses it for tenant-scoped lookups.
account_id: row.account_id as string,
user_id: row.user_id as string,
contact_id: (row.contact_id as string | null) ?? null,
log_id: (row.log_id as string | null) ?? null,
parent_step_id: (row.parent_step_id as string | null) ?? null,
branch: (row.branch as 'yes' | 'no' | null) ?? null,
next_step_position: row.next_step_position as number,
context: (row.context as AutomationContext) ?? {},
})
processed++
}
return NextResponse.json({ processed })
}

View File

@@ -0,0 +1,33 @@
import { NextResponse } from 'next/server'
import { getCurrentAccount, toErrorResponse } from '@/lib/auth/account'
import { runAutomationsForTrigger } from '@/lib/automations/engine'
import type { AutomationTriggerType } from '@/types'
/**
* Manual trigger for testing or for external integrations that want
* to fire automations. Auth is required — we resolve the caller's
* account_id and dispatch over the account's automations.
*/
export async function POST(request: Request) {
let accountId: string
try {
const ctx = await getCurrentAccount()
accountId = ctx.accountId
} catch (err) {
return toErrorResponse(err)
}
const body = await request.json().catch(() => null)
if (!body?.trigger_type) {
return NextResponse.json({ error: 'trigger_type required' }, { status: 400 })
}
await runAutomationsForTrigger({
accountId,
triggerType: body.trigger_type as AutomationTriggerType,
contactId: body.contact_id ?? null,
context: body.context ?? {},
})
return NextResponse.json({ ok: true })
}

View File

@@ -0,0 +1,125 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { supabaseAdmin } from '@/lib/automations/admin-client'
import { getTemplate } from '@/lib/automations/templates'
import { insertSteps, type BuilderStepInput } from '@/lib/automations/steps-tree'
import {
validateStepsForActivation,
validateTriggerForActivation,
} from '@/lib/automations/validate'
export async function GET() {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data, error } = await supabase
.from('automations')
.select('*')
.order('created_at', { ascending: false })
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ automations: data ?? [] })
}
export async function POST(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
// Resolve the caller's account_id — `automations.account_id` is NOT
// NULL post-017, so an INSERT without it trips the not-null constraint
// even though the admin client bypasses RLS.
const { data: profile } = await supabase
.from('profiles')
.select('account_id')
.eq('user_id', user.id)
.single()
const accountId = profile?.account_id as string | undefined
if (!accountId) {
return NextResponse.json(
{ error: 'Your profile is not linked to an account.' },
{ status: 403 },
)
}
const body = await request.json().catch(() => null)
if (!body) return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
const { name, description, trigger_type, trigger_config, is_active, steps, template } = body
let effectiveSteps: BuilderStepInput[] | undefined = steps
let effectiveName = name
let effectiveDescription = description
let effectiveTriggerType = trigger_type
let effectiveTriggerConfig = trigger_config
if (template && (!steps || steps.length === 0)) {
const t = getTemplate(template)
if (t) {
effectiveName = effectiveName ?? t.name
effectiveDescription = effectiveDescription ?? t.description
effectiveTriggerType = effectiveTriggerType ?? t.trigger_type
effectiveTriggerConfig = effectiveTriggerConfig ?? t.trigger_config
effectiveSteps = t.steps as unknown as BuilderStepInput[]
}
}
if (!effectiveName || !effectiveTriggerType) {
return NextResponse.json(
{ error: 'name and trigger_type are required' },
{ status: 400 },
)
}
// Block activation of a clearly broken automation up-front instead of
// letting every trigger silently produce a failed log row. Drafts
// (is_active=false) are allowed to be incomplete so users can save
// progress mid-build.
if (is_active) {
const issues = [
...validateTriggerForActivation(effectiveTriggerType, effectiveTriggerConfig ?? {}),
...validateStepsForActivation(
(effectiveSteps ?? []) as unknown as { step_type: string; step_config: Record<string, unknown> }[],
),
]
if (issues.length > 0) {
return NextResponse.json(
{ error: 'Cannot activate automation with invalid configuration', issues },
{ status: 400 },
)
}
}
const admin = supabaseAdmin()
const { data: automation, error: insertErr } = await admin
.from('automations')
.insert({
user_id: user.id,
account_id: accountId,
name: effectiveName,
description: effectiveDescription ?? null,
trigger_type: effectiveTriggerType,
trigger_config: effectiveTriggerConfig ?? {},
is_active: !!is_active,
})
.select()
.single()
if (insertErr || !automation) {
return NextResponse.json(
{ error: insertErr?.message ?? 'insert failed' },
{ status: 500 },
)
}
if (effectiveSteps && effectiveSteps.length > 0) {
const err = await insertSteps(automation.id, effectiveSteps)
if (err) return NextResponse.json({ error: err }, { status: 500 })
}
return NextResponse.json({ automation }, { status: 201 })
}

View File

@@ -0,0 +1,108 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { supabaseAdmin } from '@/lib/flows/admin-client'
import { validateFlowForActivation } from '@/lib/flows/validate'
/**
* POST /api/flows/[id]/activate
*
* Body: { status: 'draft' | 'active' | 'archived' }
*
* Activating runs the full validator and refuses on any 'error'
* severity issue. Drafts and archives are unconditional — users
* need to be able to save broken-work-in-progress and pause flows
* without first fixing them.
*
* Returns the updated flow on success; on validation failure returns
* the full issue list so the builder can highlight each problem.
*/
export async function POST(
request: Request,
context: { params: Promise<{ id: string }> },
) {
const { id } = await context.params
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = (await request.json().catch(() => null)) as
| { status?: 'draft' | 'active' | 'archived' }
| null
const status = body?.status
if (!status || !['draft', 'active', 'archived'].includes(status)) {
return NextResponse.json(
{ error: "status must be one of 'draft' | 'active' | 'archived'" },
{ status: 400 },
)
}
// Ownership via RLS — caller's client.
const { data: existing } = await supabase
.from('flows')
.select('id')
.eq('id', id)
.maybeSingle()
if (!existing) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const admin = supabaseAdmin()
if (status === 'active') {
// Re-load with the full payload the validator needs.
const [{ data: flow }, { data: nodes }] = await Promise.all([
admin
.from('flows')
.select('name, trigger_type, trigger_config, entry_node_id')
.eq('id', id)
.maybeSingle(),
admin
.from('flow_nodes')
.select('node_key, node_type, config')
.eq('flow_id', id),
])
if (!flow) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const issues = validateFlowForActivation(
flow as {
name: string
trigger_type: 'keyword' | 'first_inbound_message' | 'manual'
trigger_config: Record<string, unknown>
entry_node_id: string | null
},
(nodes ?? []) as Array<{
node_key: string
node_type: string
config: Record<string, unknown>
}>,
)
const blockers = issues.filter((i) => i.severity === 'error')
if (blockers.length > 0) {
return NextResponse.json(
{
error: 'Cannot activate flow — fix the issues below first.',
issues,
},
{ status: 422 },
)
}
}
const { data: updated, error } = await admin
.from('flows')
.update({ status, updated_at: new Date().toISOString() })
.eq('id', id)
.select()
.maybeSingle()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ flow: updated })
}

View File

@@ -0,0 +1,194 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { supabaseAdmin } from '@/lib/flows/admin-client'
/**
* GET /api/flows/[id] — fetch one flow with its nodes.
* PUT /api/flows/[id] — replace name/trigger/entry/fallback + the
* full node graph (delete-then-insert under
* the hood; not atomic, but the runner is
* resilient to mid-edit reads — node_not_found
* gracefully ends the run).
* DELETE /api/flows/[id] — hard delete (RLS+CASCADE clean up nodes,
* runs, events).
*
* All three require a signed-in caller who owns the flow. Flows is in
* soft-GA — the beta gate that previously 404'd non-beta accounts is
* gone; the "Beta" label in the UI is the only remaining signal.
*/
async function requireOwnership(
flowId: string,
): Promise<
| {
ok: true
userId: string
supabase: Awaited<ReturnType<typeof createClient>>
}
| { ok: false; status: number; body: { error: string } }
> {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return { ok: false, status: 401, body: { error: 'Unauthorized' } }
}
// RLS scopes this to the caller — a flow owned by another user
// returns null (404 below).
const { data: flow } = await supabase
.from('flows')
.select('id')
.eq('id', flowId)
.maybeSingle()
if (!flow) {
return { ok: false, status: 404, body: { error: 'Not found' } }
}
return { ok: true, userId: user.id, supabase }
}
export async function GET(
_request: Request,
context: { params: Promise<{ id: string }> },
) {
const { id } = await context.params
const guard = await requireOwnership(id)
if (!guard.ok) return NextResponse.json(guard.body, { status: guard.status })
const { supabase } = guard
const [{ data: flow }, { data: nodes }] = await Promise.all([
supabase.from('flows').select('*').eq('id', id).maybeSingle(),
supabase
.from('flow_nodes')
.select('*')
.eq('flow_id', id)
.order('created_at', { ascending: true }),
])
if (!flow) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
return NextResponse.json({ flow, nodes: nodes ?? [] })
}
interface PutBody {
name?: string
description?: string | null
trigger_type?: 'keyword' | 'first_inbound_message' | 'manual'
trigger_config?: Record<string, unknown>
entry_node_id?: string | null
fallback_policy?: Record<string, unknown>
nodes?: Array<{
node_key: string
node_type: string
config: Record<string, unknown>
position_x?: number
position_y?: number
}>
}
export async function PUT(
request: Request,
context: { params: Promise<{ id: string }> },
) {
const { id } = await context.params
const guard = await requireOwnership(id)
if (!guard.ok) return NextResponse.json(guard.body, { status: guard.status })
const body = (await request.json().catch(() => null)) as PutBody | null
if (!body) {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
if (body.name !== undefined && !body.name.trim()) {
return NextResponse.json(
{ error: 'name cannot be empty' },
{ status: 400 },
)
}
const admin = supabaseAdmin()
// Update the flow row first — the body may not include `nodes` (a
// header-only save for editing the trigger config without touching
// the graph). Skip node replacement in that case.
const flowPatch: Record<string, unknown> = {
updated_at: new Date().toISOString(),
}
if (body.name !== undefined) flowPatch.name = body.name.trim()
if (body.description !== undefined)
flowPatch.description = body.description
if (body.trigger_type !== undefined) flowPatch.trigger_type = body.trigger_type
if (body.trigger_config !== undefined)
flowPatch.trigger_config = body.trigger_config
if (body.entry_node_id !== undefined)
flowPatch.entry_node_id = body.entry_node_id
if (body.fallback_policy !== undefined)
flowPatch.fallback_policy = body.fallback_policy
const { error: updErr } = await admin
.from('flows')
.update(flowPatch)
.eq('id', id)
if (updErr) {
return NextResponse.json({ error: updErr.message }, { status: 500 })
}
if (body.nodes !== undefined) {
// Delete-then-insert. Not transactional but the runner handles
// mid-edit reads safely (a node_not_found ends the run cleanly).
const { error: delErr } = await admin
.from('flow_nodes')
.delete()
.eq('flow_id', id)
if (delErr) {
return NextResponse.json({ error: delErr.message }, { status: 500 })
}
if (body.nodes.length > 0) {
const { error: insErr } = await admin.from('flow_nodes').insert(
body.nodes.map((n) => ({
flow_id: id,
node_key: n.node_key,
node_type: n.node_type,
config: n.config,
position_x: n.position_x ?? 0,
position_y: n.position_y ?? 0,
})),
)
if (insErr) {
return NextResponse.json({ error: insErr.message }, { status: 500 })
}
}
}
// Re-fetch and return the new state — the editor uses the response
// to reconcile its local form state.
const [{ data: flow }, { data: nodes }] = await Promise.all([
admin.from('flows').select('*').eq('id', id).maybeSingle(),
admin
.from('flow_nodes')
.select('*')
.eq('flow_id', id)
.order('created_at', { ascending: true }),
])
return NextResponse.json({ flow, nodes: nodes ?? [] })
}
export async function DELETE(
_request: Request,
context: { params: Promise<{ id: string }> },
) {
const { id } = await context.params
const guard = await requireOwnership(id)
if (!guard.ok) return NextResponse.json(guard.body, { status: guard.status })
// CASCADE on flow_nodes / flow_runs / flow_run_events handles the
// children. Active runs end abruptly — there's no graceful "drain"
// mechanism in v1, but that's intentional: deleting a flow is a
// deliberate destructive action and the partial unique index will
// free up the contact for new triggers immediately.
const { error } = await supabaseAdmin().from('flows').delete().eq('id', id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ ok: true })
}

View File

@@ -0,0 +1,86 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
/**
* GET /api/flows/[id]/runs
*
* Newest-first list of flow runs for a single flow, with the latest
* event timeline embedded for each. Used by the run-history viewer
* page (`/flows/[id]/runs`) to give the owner end-to-end visibility
* into what the bot did with each customer.
*
* RLS does the ownership check (flow_runs has a `user_id` policy);
* we also gate on the per-account beta flag so the route 404s for
* non-beta accounts matching the rest of /api/flows.
*
* Limited to the 50 most recent runs. Pagination can come later;
* the dashboard surface here is for debugging, not heavy querying.
*/
export async function GET(
_request: Request,
context: { params: Promise<{ id: string }> },
) {
const { id } = await context.params
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Confirm flow exists + caller owns it (RLS does this) before doing
// the run query — gives us a clean 404 instead of empty array.
const { data: flow } = await supabase
.from('flows')
.select('id, name')
.eq('id', id)
.maybeSingle()
if (!flow) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
// Pull runs + each run's contact name + each run's events. Two
// joined selects keep the round-trip count to the runs query + one
// per-run events query.
const { data: runs, error: runsErr } = await supabase
.from('flow_runs')
.select(
'id, status, current_node_key, started_at, last_advanced_at, ended_at, end_reason, vars, reprompt_count, contact:contacts(id, name, phone)',
)
.eq('flow_id', id)
.order('started_at', { ascending: false })
.limit(50)
if (runsErr) {
return NextResponse.json({ error: runsErr.message }, { status: 500 })
}
const runIds = (runs ?? []).map((r) => (r as { id: string }).id)
let events: Array<{
flow_run_id: string
event_type: string
node_key: string | null
payload: Record<string, unknown>
created_at: string
}> = []
if (runIds.length > 0) {
const { data: evs, error: evsErr } = await supabase
.from('flow_run_events')
.select('flow_run_id, event_type, node_key, payload, created_at')
.in('flow_run_id', runIds)
.order('created_at', { ascending: true })
if (evsErr) {
// Non-fatal — the page can still show runs without timelines.
console.error('[flows-runs] events fetch failed:', evsErr.message)
} else if (evs) {
events = evs as typeof events
}
}
return NextResponse.json({
flow,
runs: runs ?? [],
events,
})
}

View File

@@ -0,0 +1,112 @@
import { timingSafeEqual } from 'node:crypto'
import { NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/flows/admin-client'
import { resolveFallbackPolicy } from '@/lib/flows/fallback'
/**
* Sweep abandoned active flow runs.
*
* Reads each active run's parent-flow `fallback_policy.on_timeout_hours`
* to compute the staleness cutoff (default 24h), then marks any run
* past its cutoff as `timed_out`. Writes a matching `flow_run_events`
* row for the audit trail.
*
* Without this sweep, a customer who abandons a flow mid-conversation
* keeps a row in `idx_one_active_run_per_contact` (the partial unique
* index on `flow_runs WHERE status='active'`) forever — blocking any
* new triggers for them. The cron is therefore not optional.
*
* Auth: re-uses `AUTOMATION_CRON_SECRET` so operators only have one
* secret to provision. The two endpoints (`/api/automations/cron`
* and this one) are independent operations; we keep them on separate
* URLs so one failing doesn't block the other.
*
* Hosting: hit on a schedule (Vercel Cron / GitHub Actions / external
* pinger). A 5-minute interval is more than enough for a 24h timeout
* default; once per hour would also be acceptable for low-volume
* tenants.
*/
export async function GET(request: Request) {
const expected = process.env.AUTOMATION_CRON_SECRET
if (!expected) {
return NextResponse.json({ error: 'cron not configured' }, { status: 503 })
}
// Constant-time compare so an attacker who can hit the endpoint
// can't recover the secret byte-by-byte from response-time deltas.
// Length pre-check is required by timingSafeEqual (throws otherwise)
// and leaks only the length itself, which isn't sensitive.
const supplied = request.headers.get('x-cron-secret') ?? ''
const suppliedBuf = Buffer.from(supplied)
const expectedBuf = Buffer.from(expected)
if (
suppliedBuf.length !== expectedBuf.length ||
!timingSafeEqual(suppliedBuf, expectedBuf)
) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const admin = supabaseAdmin()
const now = new Date()
// Pull all currently-active runs along with their parent flow's
// fallback_policy. Joined in one query — the small set of active
// runs per tenant keeps this cheap.
const { data: runs, error } = await admin
.from('flow_runs')
.select(
'id, flow_id, user_id, contact_id, last_advanced_at, flows ( fallback_policy )',
)
.eq('status', 'active')
if (error) {
console.error('[flows-cron] active-run scan failed:', error.message)
return NextResponse.json({ error: error.message }, { status: 500 })
}
if (!runs?.length) return NextResponse.json({ swept: 0 })
type Row = {
id: string
flow_id: string
user_id: string
contact_id: string | null
last_advanced_at: string
flows: { fallback_policy: unknown } | { fallback_policy: unknown }[] | null
}
let swept = 0
for (const r of runs as Row[]) {
const flowsField = Array.isArray(r.flows) ? r.flows[0] : r.flows
const policy = resolveFallbackPolicy(flowsField?.fallback_policy ?? null)
const lastAdvanced = new Date(r.last_advanced_at)
const ageHours = (now.getTime() - lastAdvanced.getTime()) / (1000 * 60 * 60)
if (ageHours < policy.on_timeout_hours) continue
// Mark timed_out — guarded by the precondition `status='active'`
// so concurrent advance from a late inbound doesn't overwrite a
// legitimate update.
const { data: updated } = await admin
.from('flow_runs')
.update({
status: 'timed_out',
ended_at: now.toISOString(),
end_reason: 'stale_sweep',
})
.eq('id', r.id)
.eq('status', 'active')
.select('id')
if (Array.isArray(updated) && updated.length > 0) {
await admin.from('flow_run_events').insert({
flow_run_id: r.id,
event_type: 'timeout',
payload: {
age_hours: Math.round(ageHours * 10) / 10,
policy_hours: policy.on_timeout_hours,
},
})
swept += 1
}
}
return NextResponse.json({ swept })
}

View File

@@ -0,0 +1,169 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { supabaseAdmin } from '@/lib/flows/admin-client'
import { getFlowTemplate } from '@/lib/flows/templates'
/**
* GET /api/flows — list the caller's flows.
* POST /api/flows — create a new (draft) flow.
*
* Available to every authenticated user. The previous per-account
* beta gate was removed when Flows went to soft-GA; the UI still
* shows a "Beta" label so users know the surface is young, but the
* routes themselves are open.
*/
async function requireUser(): Promise<
| { ok: true; userId: string; supabase: Awaited<ReturnType<typeof createClient>> }
| { ok: false; status: number; body: { error: string } }
> {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return { ok: false, status: 401, body: { error: 'Unauthorized' } }
}
return { ok: true, userId: user.id, supabase }
}
export async function GET() {
const guard = await requireUser()
if (!guard.ok) {
return NextResponse.json(guard.body, { status: guard.status })
}
const { supabase } = guard
const { data, error } = await supabase
.from('flows')
.select('*')
.order('created_at', { ascending: false })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ flows: data ?? [] })
}
export async function POST(request: Request) {
const guard = await requireUser()
if (!guard.ok) {
return NextResponse.json(guard.body, { status: guard.status })
}
const { userId, supabase } = guard
// Resolve the caller's account_id — `flows.account_id` is NOT NULL
// post-017, so an INSERT without it trips the not-null constraint
// even though the admin client below bypasses RLS.
const { data: profile } = await supabase
.from('profiles')
.select('account_id')
.eq('user_id', userId)
.single()
const accountId = profile?.account_id as string | undefined
if (!accountId) {
return NextResponse.json(
{ error: 'Your profile is not linked to an account.' },
{ status: 403 },
)
}
const body = (await request.json().catch(() => null)) as
| {
name?: string
description?: string | null
trigger_type?: 'keyword' | 'first_inbound_message' | 'manual'
trigger_config?: Record<string, unknown>
/**
* If set, clone the matching template's name + trigger +
* entry_node_id + nodes[] into a fresh draft for this user.
* `name` from the body overrides the template default if
* provided.
*/
template_slug?: string
}
| null
if (!body) {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const admin = supabaseAdmin()
// -------- Template clone path --------
if (body.template_slug) {
const template = getFlowTemplate(body.template_slug)
if (!template) {
return NextResponse.json(
{ error: `Unknown template_slug "${body.template_slug}"` },
{ status: 400 },
)
}
const { data: flow, error: flowErr } = await admin
.from('flows')
.insert({
user_id: userId,
account_id: accountId,
name: body.name?.trim() || template.name,
description: template.description,
status: 'draft',
trigger_type: template.trigger_type,
trigger_config: template.trigger_config,
entry_node_id: template.entry_node_id,
})
.select()
.single()
if (flowErr || !flow) {
return NextResponse.json(
{ error: flowErr?.message ?? 'flow insert failed' },
{ status: 500 },
)
}
if (template.nodes.length > 0) {
const { error: nodesErr } = await admin.from('flow_nodes').insert(
template.nodes.map((n) => ({
flow_id: flow.id,
node_key: n.node_key,
node_type: n.node_type,
config: n.config,
})),
)
if (nodesErr) {
// Roll back the parent flow so a half-cloned template doesn't
// sit as an empty draft. CASCADE on flow_id removes the
// (probably zero) nodes too.
await admin.from('flows').delete().eq('id', flow.id)
return NextResponse.json(
{ error: nodesErr.message },
{ status: 500 },
)
}
}
return NextResponse.json({ flow }, { status: 201 })
}
// -------- Plain (empty) create path --------
if (!body.name?.trim()) {
return NextResponse.json({ error: 'name is required' }, { status: 400 })
}
const trigger_type = body.trigger_type ?? 'keyword'
const { data, error } = await admin
.from('flows')
.insert({
user_id: userId,
account_id: accountId,
name: body.name.trim(),
description: body.description ?? null,
status: 'draft',
trigger_type,
trigger_config: body.trigger_config ?? {},
})
.select()
.single()
if (error || !data) {
return NextResponse.json(
{ error: error?.message ?? 'insert failed' },
{ status: 500 },
)
}
return NextResponse.json({ flow: data }, { status: 201 })
}

View File

@@ -0,0 +1,34 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { listFlowTemplates } from '@/lib/flows/templates'
/**
* GET /api/flows/templates
*
* Returns the static template gallery (slug + name + description +
* icon hint + node_count) so the New-flow dialog can render cards
* without bundling the full template payloads client-side. Bodies
* are fetched only on actual clone via POST /api/flows.
*
* Available to any signed-in user. Flows is in soft-GA.
*/
export async function GET() {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Shallow shape so the client gallery doesn't have to know about
// the full node tree.
const templates = listFlowTemplates().map((t) => ({
slug: t.slug,
name: t.name,
description: t.description,
icon: t.icon,
trigger_type: t.trigger_type,
node_count: t.nodes.length,
}))
return NextResponse.json({ templates })
}

View File

@@ -0,0 +1,87 @@
// ============================================================
// GET /api/invitations/[token]/peek
//
// Public — no auth required. Lets the /join/<token> page render
// "You're being invited to <Account> as <Role>" before the
// visitor signs up or signs in.
//
// Security model
// - Token is in the URL path, not the query, so it doesn't
// show up in standard access-log "referer" fields the way a
// `?token=` would.
// - The plaintext token never crosses the DB boundary — we
// hash it in TS first and look up by `token_hash`.
// - The peek RPC is SECURITY DEFINER so it bypasses the RLS
// that would otherwise block an anonymous SELECT on
// `account_invitations`. It returns a fixed-shape JSON
// payload that never leaks columns beyond what the join
// page renders.
// - Per-IP rate limit pinches brute-force enumeration of
// tokens. With 256 bits of entropy the enumeration risk is
// theoretical, but rate limiting is cheap insurance.
// ============================================================
import { NextResponse } from "next/server";
import { hashInviteToken } from "@/lib/auth/invitations";
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from "@/lib/rate-limit";
import { createClient } from "@/lib/supabase/server";
/**
* Best-effort client IP. The `x-forwarded-for` header is what
* every reverse proxy (Vercel, Hostinger, Cloudflare) sets when
* forwarding a request; we take the leftmost entry, which is
* the original client.
*
* Falls back to a constant when no proxy is in front (e.g.
* `localhost` during development) so rate-limit keys still
* exist — the limit then effectively applies "globally," which
* is fine for dev.
*/
function getClientIp(request: Request): string {
const xff = request.headers.get("x-forwarded-for");
if (xff) return xff.split(",")[0].trim();
const xri = request.headers.get("x-real-ip");
if (xri) return xri.trim();
return "unknown";
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ token: string }> },
) {
// Rate-limit by IP first. Returns 429 to a serial bruteforcer
// before we ever touch the DB.
const ip = getClientIp(request);
const limit = checkRateLimit(`peek:${ip}`, RATE_LIMITS.invitationPeek);
if (!limit.success) return rateLimitResponse(limit);
const { token } = await params;
if (!token || typeof token !== "string") {
return NextResponse.json(
{ ok: false, reason: "not_found" },
{ status: 404 },
);
}
const supabase = await createClient();
const { data, error } = await supabase.rpc("peek_invitation", {
p_token_hash: hashInviteToken(token),
});
if (error) {
console.error("[peek] rpc error:", error);
return NextResponse.json(
{ ok: false, reason: "server_error" },
{ status: 500 },
);
}
// The RPC always returns a json object — either ok:true with
// metadata or ok:false with a reason. Forward verbatim.
return NextResponse.json(data);
}

View File

@@ -0,0 +1,91 @@
// ============================================================
// POST /api/invitations/[token]/redeem
//
// Authenticated. Caller atomically moves from their personal
// account (created at signup) to the inviter's account with the
// invite's role. Heavy lifting lives in the SECURITY DEFINER
// `redeem_invitation` RPC from migration 019.
//
// Refusal contract (from the RPC)
// - SQLSTATE 42501 → 401 (caller not authenticated)
// - SQLSTATE 22023 → 400 (invitation not_found / used / expired)
// - SQLSTATE 23505 → 409 (caller's account already has data /
// they're already in this or another shared account)
//
// Rate limit (per IP) is the same shape as peek but tighter —
// a successful redeem changes data, and the RPC's data-loss
// guard makes brute-force retries pointless past a few attempts.
// ============================================================
import { NextResponse } from "next/server";
import type { PostgrestError } from "@supabase/supabase-js";
import { hashInviteToken } from "@/lib/auth/invitations";
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from "@/lib/rate-limit";
import { createClient } from "@/lib/supabase/server";
function getClientIp(request: Request): string {
const xff = request.headers.get("x-forwarded-for");
if (xff) return xff.split(",")[0].trim();
const xri = request.headers.get("x-real-ip");
if (xri) return xri.trim();
return "unknown";
}
function rpcErrorToResponse(err: PostgrestError): NextResponse {
if (err.code === "42501") {
return NextResponse.json({ error: err.message }, { status: 401 });
}
if (err.code === "22023") {
return NextResponse.json({ error: err.message }, { status: 400 });
}
if (err.code === "23505") {
return NextResponse.json({ error: err.message }, { status: 409 });
}
console.error("[redeem] unexpected RPC error:", err);
return NextResponse.json(
{ error: "Failed to redeem invitation" },
{ status: 500 },
);
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ token: string }> },
) {
const ip = getClientIp(request);
const limit = checkRateLimit(`redeem:${ip}`, RATE_LIMITS.invitationRedeem);
if (!limit.success) return rateLimitResponse(limit);
const { token } = await params;
if (!token || typeof token !== "string") {
return NextResponse.json(
{ error: "Missing invitation token" },
{ status: 400 },
);
}
const supabase = await createClient();
// The RPC checks `auth.uid()` itself, but failing fast here
// gives a cleaner 401 without a Supabase round trip on the
// common "user clicked the link before logging in" path.
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { data: accountId, error } = await supabase.rpc("redeem_invitation", {
p_token_hash: hashInviteToken(token),
});
if (error) return rpcErrorToResponse(error);
return NextResponse.json({ ok: true, accountId });
}

View File

@@ -0,0 +1,52 @@
// ============================================================
// GET /api/v1/account/members
// Lists every member of the account (API-key scoped).
// ============================================================
import { requireApiKey } from '@/lib/auth/api-context';
import { ok, fail, toApiErrorResponse } from '@/lib/api/v1/respond';
import { isAccountRole } from '@/lib/auth/roles';
interface ProfileRow {
user_id: string;
full_name: string | null;
email: string | null;
avatar_url: string | null;
account_role: string;
created_at: string;
}
export async function GET(request: Request) {
try {
const ctx = await requireApiKey(request, 'conversations:read');
const { data, error } = await ctx.supabase
.from('profiles')
.select('user_id, full_name, email, avatar_url, account_role, created_at')
.eq('account_id', ctx.accountId)
.order('created_at', { ascending: true });
if (error) {
console.error('[api/v1/account/members] fetch error:', error);
return fail('internal', 'Failed to load members', 500);
}
const members = (data as ProfileRow[]).flatMap((row) => {
if (!isAccountRole(row.account_role)) return [];
return [
{
id: row.user_id,
name: row.full_name ?? row.email ?? row.user_id,
email: row.email,
avatar_url: row.avatar_url,
role: row.account_role,
joined_at: row.created_at,
},
];
});
return ok({ members });
} catch (err) {
return toApiErrorResponse(err);
}
}

View File

@@ -0,0 +1,41 @@
// ============================================================
// GET /api/v1/broadcasts/{id} — broadcast status + counts
// (scope: broadcasts:send).
//
// Poll this after POST /api/v1/broadcasts to watch the fan-out
// progress. `status` moves 'sending' → 'sent'; the delivered/read
// counts continue to climb as Meta delivery webhooks arrive.
// Account-scoped: a foreign id → 404.
// ============================================================
import { requireApiKey } from '@/lib/auth/api-context';
import { ok, fail, toApiErrorResponse } from '@/lib/api/v1/respond';
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const ctx = await requireApiKey(request, 'broadcasts:send');
const { id } = await params;
const { data, error } = await ctx.supabase
.from('broadcasts')
.select(
'id, name, template_name, template_language, status, total_recipients, sent_count, delivered_count, read_count, replied_count, failed_count, created_at, updated_at'
)
.eq('id', id)
.eq('account_id', ctx.accountId)
.maybeSingle();
if (error) {
console.error('[api/v1/broadcasts] read error:', error);
return fail('internal', 'Failed to read broadcast', 500);
}
if (!data) return fail('not_found', 'Broadcast not found', 404);
return ok(data);
} catch (err) {
return toApiErrorResponse(err);
}
}

View File

@@ -0,0 +1,105 @@
// ============================================================
// POST /api/v1/broadcasts — launch a template broadcast
// (scope: broadcasts:send).
//
// Body:
// {
// "name": "July promo", // optional label
// "template_name": "promo_july", // required, approved template
// "template_language": "en_US", // optional (default en_US)
// "recipients": [ // required, 1..1000
// { "to": "+14155550123", "params": ["Jane"] },
// { "to": "+14155550124" }
// ]
// }
//
// The broadcast + its recipient rows are persisted synchronously, then
// the Meta fan-out runs in `after()` so the request returns fast. Poll
// `GET /api/v1/broadcasts/{id}` for progress.
//
// Response (202):
// { "data": { "broadcast_id", "status": "sending",
// "total_recipients", "accepted", "rejected" } }
// ============================================================
import { after } from 'next/server';
import { requireApiKey } from '@/lib/auth/api-context';
// The `after()` fan-out below sends to every recipient sequentially and
// runs within this route's max duration (the same constraint the
// webhook route documents). Give it headroom beyond the platform
// default so a modest batch isn't cut off mid-send — which would leave
// recipient rows 'pending' and the broadcast stuck 'sending'. This is a
// bound, not a guarantee: a near-cap (MAX_RECIPIENTS) audience can
// still exceed 60s, so very large sends should be split across
// requests. A durable queue/cron drain is the complete fix (follow-up).
export const maxDuration = 60;
import { ok, fail, toApiErrorResponse } from '@/lib/api/v1/respond';
import { resolveAuditUserId, ContactError } from '@/lib/api/v1/contacts';
import {
createBroadcast,
deliverBroadcast,
BroadcastError,
} from '@/lib/whatsapp/broadcast-core';
export async function POST(request: Request) {
try {
const ctx = await requireApiKey(request, 'broadcasts:send');
const body = (await request.json().catch(() => null)) as Record<
string,
unknown
> | null;
if (!body || typeof body !== 'object') {
return fail('bad_request', 'Request body must be a JSON object', 400);
}
const templateName =
typeof body.template_name === 'string' ? body.template_name : '';
const recipients = Array.isArray(body.recipients) ? body.recipients : [];
const auditUserId = await resolveAuditUserId(ctx.supabase, ctx.accountId);
const plan = await createBroadcast(ctx.supabase, ctx.accountId, auditUserId, {
name: typeof body.name === 'string' ? body.name : null,
templateName,
templateLanguage:
typeof body.template_language === 'string'
? body.template_language
: null,
recipients: recipients.map((r) => ({
to: typeof r?.to === 'string' ? r.to : '',
params: Array.isArray(r?.params) ? r.params : undefined,
})),
});
// Fan out after the response is sent. Uses the same service-role
// client — no request-scoped auth needed for the Meta calls or
// the account-scoped row updates.
after(() => deliverBroadcast(ctx.supabase, plan));
return ok(
{
broadcast_id: plan.broadcastId,
status: 'sending',
total_recipients: plan.planned.length,
accepted: plan.planned.length,
rejected: plan.rejected,
},
202
);
} catch (err) {
if (err instanceof BroadcastError) {
return fail(err.code, err.message, err.status);
}
if (err instanceof ContactError) {
return fail(
err.status === 400 ? 'bad_request' : 'internal',
err.message,
err.status
);
}
return toApiErrorResponse(err);
}
}

View File

@@ -0,0 +1,102 @@
// ============================================================
// GET /api/v1/contacts/{id} — read a contact (scope: contacts:read)
// PATCH /api/v1/contacts/{id} — update a contact (scope: contacts:write)
//
// Both are account-scoped: a contact belonging to another account
// returns 404 (never 403 — don't reveal it exists elsewhere).
// PATCH updates only the fields present in the body; pass `tags` (an
// array of tag names) to replace the contact's tags.
// ============================================================
import { requireApiKey } from '@/lib/auth/api-context';
import { ok, fail, toApiErrorResponse } from '@/lib/api/v1/respond';
import {
getContactById,
setContactTags,
resolveAuditUserId,
ContactError,
} from '@/lib/api/v1/contacts';
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const ctx = await requireApiKey(request, 'contacts:read');
const { id } = await params;
const contact = await getContactById(ctx.supabase, ctx.accountId, id);
if (!contact) return fail('not_found', 'Contact not found', 404);
return ok(contact);
} catch (err) {
return toApiErrorResponse(err);
}
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const ctx = await requireApiKey(request, 'contacts:write');
const { id } = await params;
const body = (await request.json().catch(() => null)) as Record<
string,
unknown
> | null;
if (!body || typeof body !== 'object') {
return fail('bad_request', 'Request body must be a JSON object', 400);
}
// Verify the contact is in this account before mutating anything.
const existing = await getContactById(ctx.supabase, ctx.accountId, id);
if (!existing) return fail('not_found', 'Contact not found', 404);
// Build a partial update from the provided scalar fields. A field
// is updated only when its key is PRESENT (so omitted fields are
// untouched); `null` clears it, a string sets it, and any other
// type is a 400 rather than a silently-ignored no-op.
const updates: Record<string, unknown> = {};
for (const field of ['name', 'email', 'company'] as const) {
if (!(field in body)) continue;
const value = body[field];
if (value === null || typeof value === 'string') {
updates[field] = value;
} else {
return fail('bad_request', `'${field}' must be a string or null`, 400);
}
}
if (Object.keys(updates).length > 0) {
updates.updated_at = new Date().toISOString();
const { error } = await ctx.supabase
.from('contacts')
.update(updates)
.eq('id', id)
.eq('account_id', ctx.accountId);
if (error) {
console.error('[api/v1/contacts] update error:', error);
return fail('internal', 'Failed to update contact', 500);
}
}
if (Array.isArray(body.tags)) {
const auditUserId = await resolveAuditUserId(ctx.supabase, ctx.accountId);
await setContactTags(
ctx.supabase,
ctx.accountId,
auditUserId,
id,
body.tags.filter((t): t is string => typeof t === 'string')
);
}
const contact = await getContactById(ctx.supabase, ctx.accountId, id);
return ok(contact);
} catch (err) {
if (err instanceof ContactError) {
return fail(err.status === 400 ? 'bad_request' : 'internal', err.message, err.status);
}
return toApiErrorResponse(err);
}
}

View File

@@ -0,0 +1,149 @@
// ============================================================
// GET /api/v1/contacts — list contacts (scope: contacts:read)
// POST /api/v1/contacts — create a contact (scope: contacts:write)
//
// List is keyset-paginated (see src/lib/api/v1/pagination.ts) and
// supports `?search=` (name/phone) and `?tag=<tagId>` filters. Create
// is find-or-create by phone: an existing match returns 200 with
// `created: false`; a new row returns 201 with `created: true`.
// ============================================================
import { requireApiKey } from '@/lib/auth/api-context';
import { ok, okList, fail, toApiErrorResponse } from '@/lib/api/v1/respond';
import {
parseListParams,
keysetFilter,
buildPage,
} from '@/lib/api/v1/pagination';
import {
CONTACT_SELECT,
serializeContact,
findOrCreateContact,
setContactTags,
getContactById,
resolveAuditUserId,
ContactError,
} from '@/lib/api/v1/contacts';
// PostgREST filter values are comma/paren-delimited; strip anything
// that could break the `.or()` grammar before interpolating a search
// term. Leaves the characters a phone or name legitimately contains.
function sanitizeSearch(raw: string): string {
return raw.replace(/[^\p{L}\p{N} +@.\-_]/gu, '').trim();
}
export async function GET(request: Request) {
try {
const ctx = await requireApiKey(request, 'contacts:read');
const { limit, cursor } = parseListParams(request);
const url = new URL(request.url);
const search = sanitizeSearch(url.searchParams.get('search') ?? '');
const tag = url.searchParams.get('tag');
// When filtering by tag, add an aliased INNER join on contact_tags
// used purely for the WHERE — the parent is kept only if it has the
// tag. The main `contact_tags(tags(*))` embed still returns the
// contact's FULL tag set for serialization. This filters in one
// bounded query (paged by limit+1) instead of pre-fetching an
// unbounded id list into an `.in(...)`.
const selectClause = tag
? `${CONTACT_SELECT}, tag_filter:contact_tags!inner(tag_id)`
: CONTACT_SELECT;
let query = ctx.supabase
.from('contacts')
.select(selectClause)
.eq('account_id', ctx.accountId);
if (search) {
query = query.or(`name.ilike.*${search}*,phone.ilike.*${search}*`);
}
if (tag) {
query = query.eq('tag_filter.tag_id', tag);
}
query = query
.order('created_at', { ascending: false })
.order('id', { ascending: false })
.limit(limit + 1);
const kf = keysetFilter(cursor);
if (kf) query = query.or(kf);
const { data, error } = await query;
if (error) {
console.error('[api/v1/contacts] list error:', error);
return fail('internal', 'Failed to list contacts', 500);
}
// Cast via unknown: the conditional `selectClause` (with the
// tag_filter alias) is a runtime string, so supabase-js can't infer
// a row type from it.
const { items, nextCursor } = buildPage(
(data ?? []) as unknown as Array<{ created_at: string; id: string }>,
limit
);
return okList(
items.map((r) => serializeContact(r as Record<string, unknown>)),
nextCursor
);
} catch (err) {
return toApiErrorResponse(err);
}
}
export async function POST(request: Request) {
try {
const ctx = await requireApiKey(request, 'contacts:write');
const body = (await request.json().catch(() => null)) as Record<
string,
unknown
> | null;
if (!body || typeof body !== 'object') {
return fail('bad_request', 'Request body must be a JSON object', 400);
}
const phone = typeof body.phone === 'string' ? body.phone.trim() : '';
if (!phone) {
return fail('bad_request', "'phone' is required", 400);
}
const auditUserId = await resolveAuditUserId(ctx.supabase, ctx.accountId);
const { id, created } = await findOrCreateContact(
ctx.supabase,
ctx.accountId,
auditUserId,
{
phone,
name: typeof body.name === 'string' ? body.name : undefined,
email: typeof body.email === 'string' ? body.email : undefined,
company: typeof body.company === 'string' ? body.company : undefined,
}
);
if (Array.isArray(body.tags)) {
await setContactTags(
ctx.supabase,
ctx.accountId,
auditUserId,
id,
body.tags.filter((t): t is string => typeof t === 'string')
);
}
const contact = await getContactById(ctx.supabase, ctx.accountId, id);
return ok(contact, created ? 201 : 200);
} catch (err) {
if (err instanceof ContactError) {
return fail(
err.status === 400 ? 'bad_request' : 'internal',
err.message,
err.status
);
}
return toApiErrorResponse(err);
}
}

View File

@@ -0,0 +1,78 @@
// ============================================================
// PATCH /api/v1/conversations/{id}/assign
// Assigns or unassigns a conversation to an account member.
// Body: { assigned_agent_id: string | null }
// ============================================================
import { NextResponse } from 'next/server';
import { requireApiKey } from '@/lib/auth/api-context';
import { ok, fail, toApiErrorResponse } from '@/lib/api/v1/respond';
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const ctx = await requireApiKey(request, 'conversations:write');
const { id } = await params;
const body = (await request.json().catch(() => null)) as Record<
string,
unknown
> | null;
if (!body || typeof body !== 'object') {
return fail('bad_request', 'Request body must be a JSON object', 400);
}
const assignedAgentId = body.assigned_agent_id ?? null;
// Validate that the target user is a member of the account (if not null).
if (assignedAgentId && typeof assignedAgentId === 'string') {
const { data: memberRows, error: memberErr } = await ctx.supabase
.from('profiles')
.select('id')
.eq('account_id', ctx.accountId)
.eq('user_id', assignedAgentId)
.limit(1);
if (memberErr || !memberRows || memberRows.length === 0) {
return fail(
'bad_request',
'assigned_agent_id is not a member of this account',
400
);
}
} else if (assignedAgentId !== null) {
return fail(
'bad_request',
'assigned_agent_id must be a string or null',
400
);
}
const { data, error } = await ctx.supabase
.from('conversations')
.update({
assigned_agent_id: assignedAgentId,
updated_at: new Date().toISOString(),
})
.eq('id', id)
.eq('account_id', ctx.accountId)
.select('id, assigned_agent_id')
.single();
if (error) {
console.error('[api/v1/conversations/assign] update error:', error);
return fail('internal', 'Failed to assign conversation', 500);
}
if (!data) {
return fail('not_found', 'Conversation not found', 404);
}
return ok({
id: data.id,
assigned_agent_id: data.assigned_agent_id,
});
} catch (err) {
return toApiErrorResponse(err);
}
}

View File

@@ -0,0 +1,65 @@
// ============================================================
// GET /api/v1/conversations/{id}/messages — list a conversation's
// messages (scope: messages:read), newest first, keyset-paginated.
//
// The conversation is verified to belong to the key's account before
// any message is returned — a foreign or unknown id → 404.
// ============================================================
import { requireApiKey } from '@/lib/auth/api-context';
import { okList, fail, toApiErrorResponse } from '@/lib/api/v1/respond';
import {
parseListParams,
keysetFilter,
buildPage,
} from '@/lib/api/v1/pagination';
import { serializeMessage } from '@/lib/api/v1/conversations';
import type { Message } from '@/types';
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const ctx = await requireApiKey(request, 'messages:read');
const { id } = await params;
const { limit, cursor } = parseListParams(request);
// Gate on account ownership of the conversation first.
const { data: conv } = await ctx.supabase
.from('conversations')
.select('id')
.eq('id', id)
.eq('account_id', ctx.accountId)
.maybeSingle();
if (!conv) return fail('not_found', 'Conversation not found', 404);
let query = ctx.supabase
.from('messages')
.select('*')
.eq('conversation_id', id)
.order('created_at', { ascending: false })
.order('id', { ascending: false })
.limit(limit + 1);
const kf = keysetFilter(cursor);
if (kf) query = query.or(kf);
const { data, error } = await query;
if (error) {
console.error('[api/v1/messages] list error:', error);
return fail('internal', 'Failed to list messages', 500);
}
const { items, nextCursor } = buildPage(
(data ?? []) as Array<{ created_at: string; id: string }>,
limit
);
return okList(
items.map((m) => serializeMessage(m as unknown as Message)),
nextCursor
);
} catch (err) {
return toApiErrorResponse(err);
}
}

View File

@@ -0,0 +1,40 @@
// ============================================================
// GET /api/v1/conversations/{id} — read one conversation
// (scope: conversations:read). Account-scoped: a foreign id → 404.
// ============================================================
import { requireApiKey } from '@/lib/auth/api-context';
import { ok, fail, toApiErrorResponse } from '@/lib/api/v1/respond';
import {
CONVERSATION_SELECT,
normalizeConversation,
} from '@/lib/inbox/conversations';
import { serializeConversation } from '@/lib/api/v1/conversations';
import type { Conversation } from '@/types';
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const ctx = await requireApiKey(request, 'conversations:read');
const { id } = await params;
const { data, error } = await ctx.supabase
.from('conversations')
.select(CONVERSATION_SELECT)
.eq('id', id)
.eq('account_id', ctx.accountId)
.maybeSingle();
if (error) {
console.error('[api/v1/conversations] read error:', error);
return fail('internal', 'Failed to read conversation', 500);
}
if (!data) return fail('not_found', 'Conversation not found', 404);
return ok(serializeConversation(normalizeConversation(data as Conversation)));
} catch (err) {
return toApiErrorResponse(err);
}
}

View File

@@ -0,0 +1,66 @@
// ============================================================
// GET /api/v1/conversations — list conversations (scope: conversations:read)
//
// Keyset-paginated (newest first). Filters: `?status=` (open/pending/
// closed) and `?contact_id=`. Each conversation embeds its contact +
// tags via the shared CONVERSATION_SELECT.
// ============================================================
import { requireApiKey } from '@/lib/auth/api-context';
import { okList, fail, toApiErrorResponse } from '@/lib/api/v1/respond';
import {
parseListParams,
keysetFilter,
buildPage,
} from '@/lib/api/v1/pagination';
import {
CONVERSATION_SELECT,
normalizeConversation,
} from '@/lib/inbox/conversations';
import { serializeConversation } from '@/lib/api/v1/conversations';
import type { Conversation } from '@/types';
export async function GET(request: Request) {
try {
const ctx = await requireApiKey(request, 'conversations:read');
const { limit, cursor } = parseListParams(request);
const url = new URL(request.url);
const status = url.searchParams.get('status');
const contactId = url.searchParams.get('contact_id');
let query = ctx.supabase
.from('conversations')
.select(CONVERSATION_SELECT)
.eq('account_id', ctx.accountId);
if (status) query = query.eq('status', status);
if (contactId) query = query.eq('contact_id', contactId);
query = query
.order('created_at', { ascending: false })
.order('id', { ascending: false })
.limit(limit + 1);
const kf = keysetFilter(cursor);
if (kf) query = query.or(kf);
const { data, error } = await query;
if (error) {
console.error('[api/v1/conversations] list error:', error);
return fail('internal', 'Failed to list conversations', 500);
}
const { items, nextCursor } = buildPage(
(data ?? []) as Array<{ created_at: string; id: string }>,
limit
);
return okList(
items.map((r) =>
serializeConversation(normalizeConversation(r as Conversation))
),
nextCursor
);
} catch (err) {
return toApiErrorResponse(err);
}
}

View File

@@ -0,0 +1,31 @@
// ============================================================
// GET /api/v1/me — public API identity probe.
//
// The reference endpoint for the public API: it requires nothing
// but a valid key (no scope), and returns the account the key is
// bound to plus the scopes it carries. Integrators use it to verify
// their key works and to discover what it's allowed to do before
// wiring up real calls.
//
// It also exercises the entire public-API stack end to end — bearer
// parse → hash lookup → liveness → rate limit → envelope — so a
// green response here means the plumbing every future endpoint
// depends on is sound.
// ============================================================
import { requireApiKey } from '@/lib/auth/api-context';
import { getAccountName } from '@/lib/api-keys/store';
import { ok, toApiErrorResponse } from '@/lib/api/v1/respond';
export async function GET(request: Request) {
try {
const ctx = await requireApiKey(request);
const name = await getAccountName(ctx.accountId);
return ok({
account: { id: ctx.accountId, name },
key: { id: ctx.keyId, scopes: ctx.scopes },
});
} catch (err) {
return toApiErrorResponse(err);
}
}

View File

@@ -0,0 +1,136 @@
// ============================================================
// POST /api/v1/messages — send a WhatsApp message via the public API.
//
// The headline public endpoint (issue #245). Unlike the dashboard's
// `/api/whatsapp/send` (which takes an internal `conversation_id`),
// this takes a phone number — what an external automation actually
// has — resolves-or-creates the contact + conversation, then runs the
// same shared send core.
//
// Auth: API key with the `messages:send` scope. Account context (and
// the service-role client) come from `requireApiKey`.
//
// Body:
// {
// "to": "+14155550123", // required, E.164
// "type": "text", // text|template|image|video|document|audio (default: text)
// "text": "Hello!", // text body, or media caption
// "media_url": "https://…/file.pdf", // required for image/video/document/audio
// "filename": "invoice.pdf", // optional, document filename
// "template": { // required when type=template
// "name": "order_update",
// "language": "en_US",
// "params": ["A123"] | { "body": [...] } // array = positional body; object = structured
// },
// "reply_to_message_id": "<uuid>", // optional, must be in the same conversation
// "name": "Jane Doe" // optional, names a newly-created contact
// }
//
// Response (201):
// { "data": { "message_id", "whatsapp_message_id", "conversation_id",
// "contact_id", "contact_created" } }
// ============================================================
import { requireApiKey } from '@/lib/auth/api-context';
import { ok, fail, toApiErrorResponse } from '@/lib/api/v1/respond';
import { resolveConversationByPhone } from '@/lib/whatsapp/resolve-conversation';
import {
sendMessageToConversation,
validateSendMessageParams,
SendMessageError,
} from '@/lib/whatsapp/send-message';
export async function POST(request: Request) {
try {
const ctx = await requireApiKey(request, 'messages:send');
const body = (await request.json().catch(() => null)) as Record<
string,
unknown
> | null;
if (!body || typeof body !== 'object') {
return fail('bad_request', 'Request body must be a JSON object', 400);
}
const to = typeof body.to === 'string' ? body.to.trim() : '';
if (!to) {
return fail('bad_request', "'to' is required", 400);
}
const type = typeof body.type === 'string' ? body.type : 'text';
// Unpack the optional `template` object into the flat params the
// send core expects. `params` as an array → legacy positional body
// params; as an object → structured header/body/button params.
const template =
body.template && typeof body.template === 'object'
? (body.template as Record<string, unknown>)
: null;
const templateParams = Array.isArray(template?.params)
? (template.params as unknown[]).filter(
(p): p is string => typeof p === 'string'
)
: undefined;
const templateMessageParams =
template?.params && !Array.isArray(template.params)
? template.params
: undefined;
// Validate the message shape BEFORE resolveConversationByPhone
// finds-or-creates a contact + conversation, so a bad payload 400s
// without leaving an orphan contact/conversation behind.
validateSendMessageParams({
messageType: type,
contentText: typeof body.text === 'string' ? body.text : null,
mediaUrl: typeof body.media_url === 'string' ? body.media_url : null,
templateName: typeof template?.name === 'string' ? template.name : null,
});
// Find-or-create the conversation for this phone, then send. Both
// steps share `SendMessageError`, so one catch maps the whole
// pipeline to the envelope.
const resolved = await resolveConversationByPhone(
ctx.supabase,
ctx.accountId,
to,
typeof body.name === 'string' ? body.name : null
);
const result = await sendMessageToConversation(
ctx.supabase,
ctx.accountId,
{
conversationId: resolved.conversationId,
messageType: type,
contentText: typeof body.text === 'string' ? body.text : null,
mediaUrl: typeof body.media_url === 'string' ? body.media_url : null,
filename: typeof body.filename === 'string' ? body.filename : null,
templateName: typeof template?.name === 'string' ? template.name : null,
templateLanguage:
typeof template?.language === 'string' ? template.language : null,
templateParams,
templateMessageParams,
replyToMessageId:
typeof body.reply_to_message_id === 'string'
? body.reply_to_message_id
: null,
}
);
return ok(
{
message_id: result.messageId,
whatsapp_message_id: result.whatsappMessageId,
conversation_id: resolved.conversationId,
contact_id: resolved.contactId,
contact_created: resolved.contactCreated,
},
201
);
} catch (err) {
if (err instanceof SendMessageError) {
return fail(err.code, err.message, err.status);
}
return toApiErrorResponse(err);
}
}

View File

@@ -0,0 +1,114 @@
// ============================================================
// POST /api/v1/messages/simulate — inject a fake outbound message.
//
// Same shape as /api/v1/messages, but bypasses Meta and persists the
// message directly as if it had been sent by an agent. Useful for
// demos, local development, or fallback when WhatsApp is not yet
// connected to a real Meta Business account.
//
// Auth: API key with the `messages:send` scope.
// ============================================================
import { requireApiKey } from '@/lib/auth/api-context';
import { ok, fail, toApiErrorResponse } from '@/lib/api/v1/respond';
import { resolveConversationByPhone } from '@/lib/whatsapp/resolve-conversation';
import {
validateSendMessageParams,
SendMessageError,
} from '@/lib/whatsapp/send-message';
export async function POST(request: Request) {
try {
const ctx = await requireApiKey(request, 'messages:send');
const body = (await request.json().catch(() => null)) as Record<
string,
unknown
> | null;
if (!body || typeof body !== 'object') {
return fail('bad_request', 'Request body must be a JSON object', 400);
}
const to = typeof body.to === 'string' ? body.to.trim() : '';
if (!to) {
return fail('bad_request', "'to' is required", 400);
}
const type = typeof body.type === 'string' ? body.type : 'text';
const template =
body.template && typeof body.template === 'object'
? (body.template as Record<string, unknown>)
: null;
const templateName =
template && typeof template.name === 'string' ? template.name : null;
validateSendMessageParams({
messageType: type,
contentText: typeof body.text === 'string' ? body.text : null,
mediaUrl: typeof body.media_url === 'string' ? body.media_url : null,
templateName,
});
const resolved = await resolveConversationByPhone(
ctx.supabase,
ctx.accountId,
to,
typeof body.name === 'string' ? body.name : null
);
const fakeWaMessageId = `simulate_${Date.now()}_${Math.random()
.toString(36)
.slice(2, 10)}`;
const { data: messageRecord, error: msgError } = await ctx.supabase
.from('messages')
.insert({
conversation_id: resolved.conversationId,
sender_type: 'agent',
content_type: type,
content_text: typeof body.text === 'string' ? body.text : null,
media_url: typeof body.media_url === 'string' ? body.media_url : null,
template_name: templateName,
message_id: fakeWaMessageId,
status: 'sent',
})
.select()
.single();
if (msgError) {
console.error('[messages/simulate] insert error:', msgError);
throw new SendMessageError(
'db_error',
`Failed to persist simulated message: ${msgError.message}`,
500
);
}
await ctx.supabase
.from('conversations')
.update({
last_message_text: typeof body.text === 'string' ? body.text : `[${type}]`,
last_message_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.eq('id', resolved.conversationId);
return ok(
{
message_id: messageRecord.id,
whatsapp_message_id: fakeWaMessageId,
conversation_id: resolved.conversationId,
contact_id: resolved.contactId,
contact_created: resolved.contactCreated,
simulated: true,
},
201
);
} catch (err) {
if (err instanceof SendMessageError) {
return fail(err.code, err.message, err.status);
}
return toApiErrorResponse(err);
}
}

View File

@@ -0,0 +1,146 @@
// ============================================================
// GET /api/v1/webhooks/{id} — read an endpoint (webhooks:manage)
// PATCH /api/v1/webhooks/{id} — update url/events/is_active
// DELETE /api/v1/webhooks/{id} — remove an endpoint
//
// All account-scoped: a foreign id → 404 (never 403). The signing
// secret is never returned here — it's shown once at creation only.
// ============================================================
import { requireApiKey } from '@/lib/auth/api-context';
import { ok, fail, toApiErrorResponse } from '@/lib/api/v1/respond';
import { normalizeEvents } from '@/lib/webhooks/events';
import {
WEBHOOK_PUBLIC_COLUMNS,
serializeWebhookEndpoint,
normalizeWebhookUrl,
} from '@/lib/webhooks/endpoints';
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const ctx = await requireApiKey(request, 'webhooks:manage');
const { id } = await params;
const { data, error } = await ctx.supabase
.from('webhook_endpoints')
.select(WEBHOOK_PUBLIC_COLUMNS)
.eq('id', id)
.eq('account_id', ctx.accountId)
.maybeSingle();
if (error) {
console.error('[api/v1/webhooks] read error:', error);
return fail('internal', 'Failed to read webhook', 500);
}
if (!data) return fail('not_found', 'Webhook not found', 404);
return ok(serializeWebhookEndpoint(data as Record<string, unknown>));
} catch (err) {
return toApiErrorResponse(err);
}
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const ctx = await requireApiKey(request, 'webhooks:manage');
const { id } = await params;
const body = (await request.json().catch(() => null)) as Record<
string,
unknown
> | null;
if (!body || typeof body !== 'object') {
return fail('bad_request', 'Request body must be a JSON object', 400);
}
const updates: Record<string, unknown> = {};
if ('url' in body) {
const url = normalizeWebhookUrl(body.url);
if (!url) {
return fail('bad_request', "'url' must be a valid https:// URL", 400);
}
updates.url = url;
}
if ('events' in body) {
const events = normalizeEvents(body.events);
if (!events) {
return fail(
'bad_request',
"'events' must be a non-empty array of known event names",
400
);
}
updates.events = events;
}
if ('is_active' in body) {
if (typeof body.is_active !== 'boolean') {
return fail('bad_request', "'is_active' must be a boolean", 400);
}
updates.is_active = body.is_active;
// Re-enabling a disabled endpoint clears its failure streak so it
// isn't instantly re-disabled by a single stale failure.
if (body.is_active === true) updates.failure_count = 0;
}
if (Object.keys(updates).length === 0) {
return fail('bad_request', 'No updatable fields provided', 400);
}
// Scope the update by account_id so a foreign id touches nothing;
// the returned row (null when unmatched) drives the 404.
const { data, error } = await ctx.supabase
.from('webhook_endpoints')
.update(updates)
.eq('id', id)
.eq('account_id', ctx.accountId)
.select(WEBHOOK_PUBLIC_COLUMNS)
.maybeSingle();
if (error) {
console.error('[api/v1/webhooks] update error:', error);
return fail('internal', 'Failed to update webhook', 500);
}
if (!data) return fail('not_found', 'Webhook not found', 404);
return ok(serializeWebhookEndpoint(data as Record<string, unknown>));
} catch (err) {
return toApiErrorResponse(err);
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const ctx = await requireApiKey(request, 'webhooks:manage');
const { id } = await params;
const { data, error } = await ctx.supabase
.from('webhook_endpoints')
.delete()
.eq('id', id)
.eq('account_id', ctx.accountId)
.select('id')
.maybeSingle();
if (error) {
console.error('[api/v1/webhooks] delete error:', error);
return fail('internal', 'Failed to delete webhook', 500);
}
if (!data) return fail('not_found', 'Webhook not found', 404);
return ok({ id: data.id, deleted: true });
} catch (err) {
return toApiErrorResponse(err);
}
}

View File

@@ -0,0 +1,102 @@
// ============================================================
// GET /api/v1/webhooks — list webhook endpoints (scope: webhooks:manage)
// POST /api/v1/webhooks — register an endpoint (scope: webhooks:manage)
//
// POST returns the signing `secret` in plaintext exactly once — store
// it to verify the `X-Wacrm-Signature` on deliveries. wacrm keeps only
// an encrypted copy and can never show it again.
// ============================================================
import { requireApiKey } from '@/lib/auth/api-context';
import { ok, okList, fail, toApiErrorResponse } from '@/lib/api/v1/respond';
import { encrypt } from '@/lib/whatsapp/encryption';
import { normalizeEvents } from '@/lib/webhooks/events';
import {
WEBHOOK_PUBLIC_COLUMNS,
serializeWebhookEndpoint,
generateWebhookSecret,
normalizeWebhookUrl,
} from '@/lib/webhooks/endpoints';
export async function GET(request: Request) {
try {
const ctx = await requireApiKey(request, 'webhooks:manage');
const { data, error } = await ctx.supabase
.from('webhook_endpoints')
.select(WEBHOOK_PUBLIC_COLUMNS)
.eq('account_id', ctx.accountId)
.order('created_at', { ascending: false });
if (error) {
console.error('[api/v1/webhooks] list error:', error);
return fail('internal', 'Failed to list webhooks', 500);
}
// The roster is small and settings-class — return it whole (the
// list envelope's cursor is always null here).
return okList(
(data ?? []).map((r) =>
serializeWebhookEndpoint(r as Record<string, unknown>)
),
null
);
} catch (err) {
return toApiErrorResponse(err);
}
}
export async function POST(request: Request) {
try {
const ctx = await requireApiKey(request, 'webhooks:manage');
const body = (await request.json().catch(() => null)) as Record<
string,
unknown
> | null;
if (!body || typeof body !== 'object') {
return fail('bad_request', 'Request body must be a JSON object', 400);
}
const url = normalizeWebhookUrl(body.url);
if (!url) {
return fail('bad_request', "'url' must be a valid https:// URL", 400);
}
const events = normalizeEvents(body.events);
if (!events) {
return fail(
'bad_request',
"'events' must be a non-empty array of known event names",
400
);
}
const secret = generateWebhookSecret();
const { data: created, error } = await ctx.supabase
.from('webhook_endpoints')
.insert({
account_id: ctx.accountId,
created_by: ctx.createdBy,
url,
secret: encrypt(secret),
events,
})
.select(WEBHOOK_PUBLIC_COLUMNS)
.single();
if (error || !created) {
console.error('[api/v1/webhooks] create error:', error);
return fail('internal', 'Failed to create webhook', 500);
}
// Secret shown exactly once.
return ok(
{ ...serializeWebhookEndpoint(created as Record<string, unknown>), secret },
201
);
} catch (err) {
return toApiErrorResponse(err);
}
}

View File

@@ -0,0 +1,263 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { sendTemplateMessage } from '@/lib/whatsapp/meta-api'
import { decrypt } from '@/lib/whatsapp/encryption'
import type { SendTimeParams } from '@/lib/whatsapp/template-send-builder'
import { isMessageTemplate } from '@/lib/whatsapp/template-row-guard'
import {
sanitizePhoneForMeta,
isValidE164,
phoneVariants,
isRecipientNotAllowedError,
} from '@/lib/whatsapp/phone-utils'
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from '@/lib/rate-limit'
interface BroadcastResult {
phone: string
status: 'sent' | 'failed'
whatsapp_message_id?: string
error?: string
}
/**
* Two input shapes are accepted:
*
* NEW (preferred — supports per-recipient variable substitution):
* {
* recipients: Array<{ phone: string; params: string[] }>,
* template_name, template_language
* }
*
* LEGACY (all phones receive the same params — kept so existing
* callers don't break):
* {
* phone_numbers: string[],
* template_params: string[],
* template_name, template_language
* }
*
* Previous implementation only supported the legacy shape, and the
* sending hook was forced to ship every batch with `templateParams[0]`
* — meaning every recipient got contact-0's personalization. The new
* shape is what actually fixes that.
*/
interface NewRecipient {
phone: string
/** Body variable values, one per {{N}}. Legacy field. */
params?: string[]
/**
* Structured per-send values (header text variable, media URL
* override, URL/COPY_CODE button values). When set, takes
* precedence over `params` for the body too — see
* sendTemplateMessage for the merge rules.
*/
messageParams?: SendTimeParams
}
export async function POST(request: Request) {
try {
const supabase = await createClient()
const {
data: { user },
error: authError,
} = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Per-user broadcast budget. Note: this limits how often a user
// can *start* a campaign, not how many messages go out inside
// one — the fan-out loop below runs without additional gating.
const limit = checkRateLimit(`broadcast:${user.id}`, RATE_LIMITS.broadcast)
if (!limit.success) {
return rateLimitResponse(limit)
}
// Resolve the caller's account_id. whatsapp_config + templates
// + broadcasts are all account-scoped post-multi-user, so the
// old `.eq('user_id', user.id)` filters miss every row created
// by a teammate.
const { data: profile } = await supabase
.from('profiles')
.select('account_id')
.eq('user_id', user.id)
.maybeSingle()
const accountId = profile?.account_id as string | undefined
if (!accountId) {
return NextResponse.json(
{ error: 'Your profile is not linked to an account.' },
{ status: 403 },
)
}
const body = await request.json()
const {
recipients: newRecipients,
phone_numbers,
template_name,
template_language,
template_params,
} = body
// Normalize to a list of {phone, params} regardless of shape.
let recipients: NewRecipient[]
if (Array.isArray(newRecipients) && newRecipients.length > 0) {
recipients = newRecipients
} else if (Array.isArray(phone_numbers) && phone_numbers.length > 0) {
const shared: string[] = Array.isArray(template_params)
? template_params
: []
recipients = phone_numbers.map((phone: string) => ({
phone,
params: shared,
}))
} else {
return NextResponse.json(
{
error:
'Provide either `recipients` (preferred) or `phone_numbers` — must be a non-empty array',
},
{ status: 400 }
)
}
if (!template_name) {
return NextResponse.json(
{ error: 'template_name is required' },
{ status: 400 }
)
}
const { data: config, error: configError } = await supabase
.from('whatsapp_config')
.select('*')
.eq('account_id', accountId)
.single()
if (configError || !config) {
return NextResponse.json(
{
error:
'WhatsApp not configured. Please set up your WhatsApp integration first.',
},
{ status: 400 }
)
}
const accessToken = decrypt(config.access_token)
// Load the template row once so sendTemplateMessage can build
// header + button components on each iteration. Loading inside
// the loop would N+1 against Supabase for every recipient.
// Guard against a malformed local row crashing every send in
// the loop with the same opaque TypeError — fail loudly once.
const { data: rawTemplateRow } = await supabase
.from('message_templates')
.select('*')
.eq('account_id', accountId)
.eq('name', template_name)
.eq('language', template_language || 'en_US')
.maybeSingle()
if (rawTemplateRow && !isMessageTemplate(rawTemplateRow)) {
return NextResponse.json(
{
error:
'Template row is malformed locally — run "Sync from Meta" in Settings to repair it before broadcasting.',
},
{ status: 500 },
)
}
const templateRow = rawTemplateRow ?? null
const results: BroadcastResult[] = []
let sentCount = 0
let failedCount = 0
for (const recipient of recipients) {
const sanitized = sanitizePhoneForMeta(recipient.phone)
if (!isValidE164(sanitized)) {
results.push({
phone: recipient.phone,
status: 'failed',
error: 'Invalid phone number format',
})
failedCount++
continue
}
// Retry with phone variants on "not in allowed list" so numbers
// that differ only in a trunk-prefix 0 still reach recipients.
const variants = phoneVariants(sanitized)
let sentMessageId: string | null = null
let lastError: string | null = null
for (const variant of variants) {
try {
const result = await sendTemplateMessage({
phoneNumberId: config.phone_number_id,
accessToken,
to: variant,
templateName: template_name,
language: template_language || 'en_US',
template: templateRow ?? undefined,
messageParams: recipient.messageParams,
params: recipient.params ?? [],
})
sentMessageId = result.messageId
lastError = null
break
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error'
if (!isRecipientNotAllowedError(errorMessage)) {
lastError = errorMessage
break
}
lastError = errorMessage
// retry with next variant
}
}
if (sentMessageId) {
results.push({
phone: recipient.phone,
status: 'sent',
whatsapp_message_id: sentMessageId,
})
sentCount++
} else {
console.error(
`Failed to send broadcast to ${recipient.phone}:`,
lastError
)
results.push({
phone: recipient.phone,
status: 'failed',
error: lastError || 'Unknown error',
})
failedCount++
}
}
return NextResponse.json({
success: true,
total: recipients.length,
sent: sentCount,
failed: failedCount,
results,
})
} catch (error) {
console.error('Error in WhatsApp broadcast POST:', error)
return NextResponse.json(
{ error: 'Failed to process broadcast' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,480 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { createClient as createAdminClient } from '@supabase/supabase-js'
import {
registerPhoneNumber,
subscribeWabaToApp,
verifyPhoneNumber,
} from '@/lib/whatsapp/meta-api'
import { encrypt, decrypt } from '@/lib/whatsapp/encryption'
/**
* Resolve the caller's account_id from their profile. Inlined here
* (rather than going through `@/lib/auth/account.getCurrentAccount`)
* because the GET handler wants to return shaped 200s for every
* non-auth failure mode, not throw — keeping the helper minimal lets
* the existing response branches stay as-is.
*
* Returns null if the user has no profile or no account; callers
* should treat that the same as "not connected".
*/
async function resolveAccountId(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
): Promise<string | null> {
const { data, error } = await supabase
.from('profiles')
.select('account_id')
.eq('user_id', userId)
.maybeSingle()
if (error || !data?.account_id) return null
return data.account_id as string
}
// Lazy-initialised service-role client. We need it to detect a
// phone_number_id already claimed by a *different* user — under RLS,
// the user's own session can't see other users' rows, so the conflict
// would be invisible without the service role.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let _adminClient: any = null
function supabaseAdmin() {
if (!_adminClient) {
_adminClient = createAdminClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
}
return _adminClient
}
/**
* GET /api/whatsapp/config
*
* Used by the "Test API Connection" button and by the page to check
* whether the saved config is healthy. Returns 200 in all non-auth cases
* so the UI can render an appropriate message rather than show a 500.
*
* Response shape:
* { connected: true, phone_info: {...} }
* { connected: false, reason: 'no_config', message: '...' }
* { connected: false, reason: 'token_corrupted', message: '...', needs_reset: true }
* { connected: false, reason: 'meta_api_error', message: '...' }
*/
export async function GET() {
try {
const supabase = await createClient()
const {
data: { user },
error: authError,
} = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const accountId = await resolveAccountId(supabase, user.id)
if (!accountId) {
return NextResponse.json(
{
connected: false,
reason: 'no_account',
message: 'Your profile is not linked to an account.',
},
{ status: 200 },
)
}
const { data: config, error: configError } = await supabase
.from('whatsapp_config')
.select('phone_number_id, access_token, status')
.eq('account_id', accountId)
.maybeSingle()
if (configError) {
console.error('Error fetching whatsapp_config:', configError)
return NextResponse.json(
{ connected: false, reason: 'db_error', message: 'Failed to fetch configuration' },
{ status: 200 }
)
}
if (!config) {
return NextResponse.json(
{
connected: false,
reason: 'no_config',
message: 'No WhatsApp configuration saved yet. Fill in the form and click Save Configuration.',
},
{ status: 200 }
)
}
// Try to decrypt the stored token with the current ENCRYPTION_KEY.
// If this fails, the key changed (or was never consistent across envs).
let accessToken: string
try {
accessToken = decrypt(config.access_token)
} catch (err) {
console.error('[whatsapp/config GET] Token decryption failed:', err)
return NextResponse.json(
{
connected: false,
reason: 'token_corrupted',
needs_reset: true,
message:
'The stored access token cannot be decrypted with the current ENCRYPTION_KEY. This usually means the key changed, or it differs between environments (local vs Hostinger vs Vercel). Click "Reset Configuration" below, then re-save.',
},
{ status: 200 }
)
}
// Validate credentials against Meta
try {
const phoneInfo = await verifyPhoneNumber({
phoneNumberId: config.phone_number_id,
accessToken,
})
return NextResponse.json({ connected: true, phone_info: phoneInfo })
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown Meta API error'
console.error('[whatsapp/config GET] Meta API verification failed:', message)
return NextResponse.json(
{
connected: false,
reason: 'meta_api_error',
message: `Meta API rejected the credentials: ${message}`,
},
{ status: 200 }
)
}
} catch (error) {
console.error('Error in WhatsApp config GET:', error)
return NextResponse.json(
{ connected: false, reason: 'unknown', message: 'Internal server error' },
{ status: 500 }
)
}
}
/**
* POST /api/whatsapp/config
*
* Saves or updates the WhatsApp config for the authenticated user.
* Verifies credentials with Meta first, then encrypts and stores.
*/
export async function POST(request: Request) {
try {
const supabase = await createClient()
const {
data: { user },
error: authError,
} = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const accountId = await resolveAccountId(supabase, user.id)
if (!accountId) {
return NextResponse.json(
{ error: 'Your profile is not linked to an account.' },
{ status: 403 },
)
}
const body = await request.json()
const { phone_number_id, waba_id, access_token, verify_token, pin } = body
if (!access_token || !phone_number_id) {
return NextResponse.json(
{ error: 'access_token and phone_number_id are required' },
{ status: 400 }
)
}
if (pin !== undefined && pin !== null && pin !== '') {
if (typeof pin !== 'string' || !/^\d{6}$/.test(pin)) {
return NextResponse.json(
{ error: 'PIN must be exactly 6 digits.' },
{ status: 400 }
)
}
}
// Reject if another account has already claimed this phone_number_id.
// wacrm is single-tenant-per-WhatsApp-number — letting two accounts
// bind the same number causes the webhook's `.single()` lookup to
// throw PGRST116 ("multiple rows"), silently dropping every
// inbound message. See issue #136. Post-multi-user we key on
// account_id (not user_id) since teammates inside the same account
// all share one config; the conflict is between accounts.
const { data: claimed, error: claimedError } = await supabaseAdmin()
.from('whatsapp_config')
.select('account_id')
.eq('phone_number_id', phone_number_id)
.neq('account_id', accountId)
.maybeSingle()
if (claimedError) {
console.error('Error checking phone_number_id ownership:', claimedError)
return NextResponse.json(
{ error: 'Failed to validate configuration' },
{ status: 500 }
)
}
if (claimed) {
return NextResponse.json(
{
error:
'This WhatsApp phone number is already linked to another account on this instance. Each phone number can only be connected to one wacrm user.',
},
{ status: 409 }
)
}
// Verify credentials with Meta BEFORE saving
let phoneInfo
try {
phoneInfo = await verifyPhoneNumber({
phoneNumberId: phone_number_id,
accessToken: access_token,
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown Meta API error'
console.error('Meta API verification failed during save:', message)
return NextResponse.json(
{ error: `Meta API error: ${message}` },
{ status: 400 }
)
}
// Encrypt sensitive tokens before storing
let encryptedAccessToken: string
let encryptedVerifyToken: string | null
try {
encryptedAccessToken = encrypt(access_token)
encryptedVerifyToken = verify_token ? encrypt(verify_token) : null
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown encryption error'
console.error('Encryption failed:', message)
return NextResponse.json(
{
error:
'Failed to encrypt token. Check that ENCRYPTION_KEY is a valid 64-character hex string in your environment variables.',
},
{ status: 500 }
)
}
// Look up any pre-existing row for this account so we know whether
// this number is already registered with Meta — if so we can skip
// /register when the user didn't provide a PIN this time around.
const { data: existing } = await supabase
.from('whatsapp_config')
.select('id, registered_at, phone_number_id')
.eq('account_id', accountId)
.maybeSingle()
const sameNumber =
existing?.phone_number_id === phone_number_id &&
existing?.registered_at != null
// Step 1: register the phone number for inbound webhooks.
//
// Attempted on first save AND whenever the user supplies a fresh
// PIN (e.g. they rotated the 2FA PIN in Meta Manager). Skipped
// when the same number is already registered and no PIN was
// supplied — re-registering an already-active number with a
// stale PIN would actually fail and undo the active subscription.
let registeredAt: string | null = existing?.registered_at ?? null
let registrationError: string | null = null
// True when registration was deliberately skipped because no PIN
// was supplied (see below). Distinct from registrationError — this
// is not a failure, just an incomplete-but-valid save.
let registrationSkipped = false
const needsRegistration = !sameNumber || (typeof pin === 'string' && pin.length > 0)
if (needsRegistration) {
if (!pin) {
// No PIN provided. Meta TEST numbers (Developer Console) are
// pre-registered by Meta and expose no two-step verification
// PIN to set, so requiring one made them impossible to connect
// (issue #242). The /register + PIN step only matters for
// production numbers under a shared WABA (issue #136), so treat
// it as best-effort: skip it, save the (already Meta-verified)
// credentials as connected, and leave registered_at null. The
// UI surfaces a separate "Not registered" banner with a path to
// add a PIN later for users who do need inbound webhook routing.
registrationSkipped = true
} else {
try {
await registerPhoneNumber({
phoneNumberId: phone_number_id,
accessToken: access_token,
pin,
})
registeredAt = new Date().toISOString()
} catch (err) {
registrationError =
err instanceof Error ? err.message : 'Unknown Meta API error'
console.error('Phone number /register failed:', registrationError)
// We deliberately fall through and still save the row so the
// user can retry without re-entering everything. The UI
// surfaces `last_registration_error` so they see WHY it's
// not actually live yet.
}
}
}
// Step 2: subscribe the WABA to this app. Idempotent on Meta's
// side, so we call on every save and persist the timestamp.
// Skipped only when there's no waba_id (legacy rows from before
// we required it).
let subscribedAppsAt: string | null = null
if (waba_id) {
try {
await subscribeWabaToApp({
wabaId: waba_id,
accessToken: access_token,
})
subscribedAppsAt = new Date().toISOString()
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.warn('WABA subscribed_apps failed (non-fatal):', message)
// Subscription failures are rare once the App has the right
// permissions; we don't block save on them — the diagnostic
// endpoint surfaces this state too.
}
}
// Persist everything in one shot. If /register failed we still
// store the credentials and the error so the UI can guide the
// user through a retry.
const baseRow = {
phone_number_id,
waba_id: waba_id || null,
access_token: encryptedAccessToken,
verify_token: encryptedVerifyToken,
status: registrationError ? 'disconnected' : 'connected',
connected_at: registrationError ? null : new Date().toISOString(),
registered_at: registrationError ? null : registeredAt,
subscribed_apps_at: subscribedAppsAt ?? null,
last_registration_error: registrationError,
updated_at: new Date().toISOString(),
}
if (existing) {
const { error: updateError } = await supabase
.from('whatsapp_config')
.update(baseRow)
.eq('account_id', accountId)
if (updateError) {
console.error('Error updating whatsapp_config:', updateError)
return NextResponse.json(
{ error: 'Failed to update configuration' },
{ status: 500 }
)
}
} else {
// Insert with both columns: `account_id` is the tenancy key
// (NOT NULL post-017, UNIQUE so duplicates trip the constraint
// up-front), `user_id` is the audit column identifying which
// member of the account saved the config.
const { error: insertError } = await supabase
.from('whatsapp_config')
.insert({
account_id: accountId,
user_id: user.id,
...baseRow,
})
if (insertError) {
console.error('Error inserting whatsapp_config:', insertError)
return NextResponse.json(
{ error: 'Failed to save configuration' },
{ status: 500 }
)
}
}
if (registrationError) {
// Save succeeded but the number isn't actually live. Return
// 200 with a structured error so the UI can show the specific
// remediation step instead of a generic toast.
return NextResponse.json({
success: false,
saved: true,
registered: false,
registration_error: registrationError,
phone_info: phoneInfo,
})
}
return NextResponse.json({
success: true,
saved: true,
registered: registeredAt != null,
// Credentials are valid and saved, but inbound webhook
// registration was skipped because no PIN was supplied (e.g. a
// Meta test number). The UI shows the "Not registered" banner
// rather than claiming the number is fully live.
registration_skipped: registrationSkipped,
phone_info: phoneInfo,
})
} catch (error) {
console.error('Error in WhatsApp config POST:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* DELETE /api/whatsapp/config
*
* Removes the authenticated user's WhatsApp configuration row.
* Used by the "Reset Configuration" button to recover from a corrupted
* encrypted token (mismatched ENCRYPTION_KEY across environments).
*/
export async function DELETE() {
try {
const supabase = await createClient()
const {
data: { user },
error: authError,
} = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const accountId = await resolveAccountId(supabase, user.id)
if (!accountId) {
return NextResponse.json(
{ error: 'Your profile is not linked to an account.' },
{ status: 403 },
)
}
const { error: deleteError } = await supabase
.from('whatsapp_config')
.delete()
.eq('account_id', accountId)
if (deleteError) {
console.error('Error deleting whatsapp_config:', deleteError)
return NextResponse.json(
{ error: 'Failed to delete configuration' },
{ status: 500 }
)
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error in WhatsApp config DELETE:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,156 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { decrypt } from '@/lib/whatsapp/encryption'
import {
getSubscribedApps,
verifyPhoneNumber,
} from '@/lib/whatsapp/meta-api'
/**
* GET /api/whatsapp/config/verify-registration
*
* Diagnostic endpoint — confirms the user's saved phone number is
* actually reachable on Meta's side. Solves the failure mode that
* surfaced the multi-number bug originally: "UI says Connected but
* Meta isn't delivering events."
*
* Three checks run independently so the UI can show which step
* passes and which fails:
*
* 1. phone_info — GET /{phone_number_id} succeeds
* 2. waba_subscription — our app appears in
* GET /{waba_id}/subscribed_apps
* 3. registered_at — local timestamp set by POST /config when
* /register last succeeded; NULL means the
* number was saved but never actually subscribed
*
* Returns 200 in every case so the UI can render diagnostic detail
* rather than a generic error toast. The combined `live` flag is
* what the UI badges on.
*/
export async function GET() {
const supabase = await createClient()
const {
data: { user },
error: authError,
} = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// whatsapp_config is one-row-per-account post-017. Resolve the
// caller's account_id so a teammate who joined an existing account
// sees the same registration state as the admin who set it up.
const { data: profile } = await supabase
.from('profiles')
.select('account_id')
.eq('user_id', user.id)
.maybeSingle()
const accountId = profile?.account_id as string | undefined
if (!accountId) {
return NextResponse.json({
live: false,
checks: { config_exists: false },
message: 'Your profile is not linked to an account.',
})
}
const { data: config } = await supabase
.from('whatsapp_config')
.select('*')
.eq('account_id', accountId)
.maybeSingle()
if (!config) {
return NextResponse.json({
live: false,
checks: { config_exists: false },
message: 'No WhatsApp configuration saved yet.',
})
}
let accessToken: string
try {
accessToken = decrypt(config.access_token)
} catch {
return NextResponse.json({
live: false,
checks: {
config_exists: true,
token_decryptable: false,
},
message:
'Stored access token can\'t be decrypted — likely ENCRYPTION_KEY changed. Re-enter the token to repair.',
})
}
const checks: {
config_exists: boolean
token_decryptable: boolean
phone_metadata_ok: boolean
waba_subscribed_to_app: boolean | null
locally_marked_registered: boolean
} = {
config_exists: true,
token_decryptable: true,
phone_metadata_ok: false,
waba_subscribed_to_app: null,
locally_marked_registered: config.registered_at != null,
}
const errors: string[] = []
// 1. Phone metadata
try {
await verifyPhoneNumber({
phoneNumberId: config.phone_number_id,
accessToken,
})
checks.phone_metadata_ok = true
} catch (err) {
errors.push(
`Phone metadata check failed: ${err instanceof Error ? err.message : String(err)}`,
)
}
// 2. WABA subscription — only meaningful if we have a waba_id
if (config.waba_id) {
try {
const subs = await getSubscribedApps({
wabaId: config.waba_id,
accessToken,
})
// Meta returns the apps subscribed to this WABA. If the list
// is non-empty, OUR app is in there (the access_token we used
// belongs to our app — Meta wouldn't return data for an app
// the token can't see). Treat any entry as success.
checks.waba_subscribed_to_app = subs.length > 0
if (!checks.waba_subscribed_to_app) {
errors.push(
'WABA has no subscribed apps. Re-save the configuration to subscribe.',
)
}
} catch (err) {
errors.push(
`WABA subscription check failed: ${err instanceof Error ? err.message : String(err)}`,
)
}
} else {
errors.push(
'No WABA ID on file — webhooks can\'t be wired without it. Add it in the form and re-save.',
)
}
const live =
checks.phone_metadata_ok &&
(checks.waba_subscribed_to_app ?? false) &&
checks.locally_marked_registered
return NextResponse.json({
live,
checks,
errors,
last_registration_error: config.last_registration_error ?? null,
registered_at: config.registered_at ?? null,
subscribed_apps_at: config.subscribed_apps_at ?? null,
})
}

View File

@@ -0,0 +1,90 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getMediaUrl, downloadMedia } from '@/lib/whatsapp/meta-api'
import { decrypt } from '@/lib/whatsapp/encryption'
export async function GET(
request: Request,
{ params }: { params: Promise<{ mediaId: string }> }
) {
try {
const { mediaId } = await params
if (!mediaId) {
return NextResponse.json(
{ error: 'Media ID is required' },
{ status: 400 }
)
}
const supabase = await createClient()
const {
data: { user },
error: authError,
} = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
// Resolve the caller's account_id — whatsapp_config is one-per-
// account post-multi-user, so a teammate fetching media for a
// conversation in the shared inbox needs the account's config,
// not their personal (non-existent) row.
const { data: profile } = await supabase
.from('profiles')
.select('account_id')
.eq('user_id', user.id)
.maybeSingle()
const accountId = profile?.account_id as string | undefined
if (!accountId) {
return NextResponse.json(
{ error: 'Your profile is not linked to an account.' },
{ status: 403 },
)
}
// Fetch and decrypt WhatsApp config
const { data: config, error: configError } = await supabase
.from('whatsapp_config')
.select('*')
.eq('account_id', accountId)
.single()
if (configError || !config) {
return NextResponse.json(
{ error: 'WhatsApp not configured' },
{ status: 400 }
)
}
const accessToken = decrypt(config.access_token)
// Get the download URL from Meta
const mediaInfo = await getMediaUrl({ mediaId, accessToken })
// Download the binary data
const { buffer, contentType } = await downloadMedia({
downloadUrl: mediaInfo.url,
accessToken,
})
return new Response(new Uint8Array(buffer), {
status: 200,
headers: {
'Content-Type': contentType || mediaInfo.mimeType || 'application/octet-stream',
'Cache-Control': 'public, max-age=86400',
},
})
} catch (error) {
console.error('Error in WhatsApp media GET:', error)
return NextResponse.json(
{ error: 'Failed to fetch media' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,193 @@
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { sendReactionMessage } from '@/lib/whatsapp/meta-api';
import { decrypt } from '@/lib/whatsapp/encryption';
import { sanitizePhoneForMeta } from '@/lib/whatsapp/phone-utils';
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from '@/lib/rate-limit';
/**
* POST /api/whatsapp/react
*
* Body: { message_id: <internal UUID>, emoji: <single emoji or "" to remove> }
*
* Sends the reaction to Meta and mirrors it into `message_reactions`
* (delete on empty emoji). Customer-side reactions are handled by the
* webhook — this route only writes `actor_type = 'agent'` rows.
*/
export async function POST(request: Request) {
try {
const supabase = await createClient();
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const limit = checkRateLimit(`react:${user.id}`, RATE_LIMITS.react);
if (!limit.success) {
return rateLimitResponse(limit);
}
// Resolve the caller's account_id so conversation + whatsapp_config
// lookups work for teammates who didn't author the rows directly.
const { data: profile } = await supabase
.from('profiles')
.select('account_id')
.eq('user_id', user.id)
.maybeSingle();
const accountId = profile?.account_id as string | undefined;
if (!accountId) {
return NextResponse.json(
{ error: 'Your profile is not linked to an account.' },
{ status: 403 },
);
}
const body = await request.json();
const { message_id, emoji } = body as {
message_id?: string;
emoji?: string;
};
if (!message_id || typeof emoji !== 'string') {
return NextResponse.json(
{ error: 'message_id and emoji are required' },
{ status: 400 },
);
}
// Resolve target message + its conversation; verify ownership.
const { data: targetMessage, error: msgError } = await supabase
.from('messages')
.select('id, message_id, conversation_id')
.eq('id', message_id)
.maybeSingle();
if (msgError || !targetMessage) {
return NextResponse.json({ error: 'Message not found' }, { status: 404 });
}
if (!targetMessage.message_id) {
// No Meta ID yet — usually a sending/failed agent message. We can't
// tell Meta to react to a message it never received.
return NextResponse.json(
{ error: 'Cannot react to a message that has not been sent to WhatsApp' },
{ status: 400 },
);
}
const { data: conversation, error: convError } = await supabase
.from('conversations')
.select('id, account_id, contact:contacts(phone)')
.eq('id', targetMessage.conversation_id)
.eq('account_id', accountId)
.maybeSingle();
if (convError || !conversation) {
return NextResponse.json(
{ error: 'Conversation not found' },
{ status: 404 },
);
}
const contact = Array.isArray(conversation.contact)
? conversation.contact[0]
: conversation.contact;
if (!contact?.phone) {
return NextResponse.json(
{ error: 'Contact phone number not found' },
{ status: 400 },
);
}
// WhatsApp config + access token. Account-scoped post-multi-user.
const { data: config, error: configError } = await supabase
.from('whatsapp_config')
.select('phone_number_id, access_token')
.eq('account_id', accountId)
.single();
if (configError || !config) {
return NextResponse.json(
{ error: 'WhatsApp not configured.' },
{ status: 400 },
);
}
const accessToken = decrypt(config.access_token);
const sanitizedPhone = sanitizePhoneForMeta(contact.phone);
try {
await sendReactionMessage({
phoneNumberId: config.phone_number_id,
accessToken,
to: sanitizedPhone,
targetMessageId: targetMessage.message_id,
emoji,
});
} catch (err) {
const message =
err instanceof Error ? err.message : 'Unknown Meta API error';
console.error('[whatsapp/react] Meta send failed:', message);
return NextResponse.json(
{ error: `Meta API error: ${message}` },
{ status: 502 },
);
}
// Mirror into DB. Empty emoji = removal.
if (emoji === '') {
const { error: delError } = await supabase
.from('message_reactions')
.delete()
.eq('message_id', targetMessage.id)
.eq('actor_type', 'agent')
.eq('actor_id', user.id);
if (delError) {
console.error('[whatsapp/react] DB delete failed:', delError.message);
return NextResponse.json(
{ error: 'Reaction sent to Meta but DB delete failed' },
{ status: 500 },
);
}
} else {
// Upsert. The unique constraint (message_id, actor_type, actor_id)
// lets us swap emoji in a single statement.
const { error: upsertError } = await supabase.from('message_reactions').upsert(
{
message_id: targetMessage.id,
conversation_id: targetMessage.conversation_id,
actor_type: 'agent',
actor_id: user.id,
emoji,
},
{ onConflict: 'message_id,actor_type,actor_id' },
);
if (upsertError) {
console.error('[whatsapp/react] DB upsert failed:', upsertError.message);
return NextResponse.json(
{ error: 'Reaction sent to Meta but DB upsert failed' },
{ status: 500 },
);
}
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error in WhatsApp react POST:', error);
return NextResponse.json(
{ error: 'Failed to react to message' },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,261 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// ---------------------------------------------------------------------------
// Tests for the `contact_id` send path (issue #296): sending an approved
// template to a single contact from the Contact detail view. The route must
// find-or-create the contact's conversation server-side, then run the normal
// send + persistence path — no inbound message required to bootstrap a thread.
// ---------------------------------------------------------------------------
// Records of what the route wrote, so we can assert the right rows landed.
const conversationInserts: Array<Record<string, unknown>> = []
const messageInserts: Array<Record<string, unknown>> = []
// Toggles for the per-test scenario.
let existingConversation: Record<string, unknown> | null = null
let contactRow: Record<string, unknown> | null = null
// A conversation created during the request becomes retrievable by id —
// the shared send core re-loads the conversation (with its contact) from
// just the id, so the mock must model insert-then-select-by-id.
let createdConversation: Record<string, unknown> | null = null
const CONTACT = {
id: 'contact-1',
account_id: 'acct-1',
phone: '+15551234567',
}
// Chainable Supabase mock. A fresh builder per `.from()` call tracks whether
// `.insert()` ran so the terminal resolves to the inserted row for creates
// and the canned select row otherwise.
function makeSupabaseMock() {
function builder(table: string) {
let didInsert = false
const selectResult = () => {
switch (table) {
case 'profiles':
return { data: { account_id: 'acct-1' }, error: null }
case 'contacts':
return { data: contactRow, error: null }
case 'conversations':
// Once created this request, a by-id reload returns it (with
// its contact); otherwise fall back to the canned existing row.
return { data: createdConversation ?? existingConversation, error: null }
case 'whatsapp_config':
return {
data: {
id: 'cfg-1',
account_id: 'acct-1',
phone_number_id: 'PNID-1',
access_token: 'enc-token',
},
error: null,
}
case 'message_templates':
return { data: null, error: null }
default:
return { data: null, error: null }
}
}
const insertResult = () => {
switch (table) {
case 'conversations':
return {
data: {
id: 'conv-new',
account_id: 'acct-1',
contact_id: 'contact-1',
contact: CONTACT,
},
error: null,
}
case 'messages':
return { data: { id: 'msg-1' }, error: null }
default:
return { data: null, error: null }
}
}
const terminal = () =>
Promise.resolve(didInsert ? insertResult() : selectResult())
const b: Record<string, unknown> = {}
const chain = () => b
for (const m of ['select', 'eq', 'in', 'order', 'limit', 'update', 'delete']) {
b[m] = vi.fn(chain)
}
b.insert = vi.fn((payload: Record<string, unknown>) => {
didInsert = true
if (table === 'conversations') {
conversationInserts.push(payload)
createdConversation = {
id: 'conv-new',
account_id: 'acct-1',
contact_id: 'contact-1',
contact: CONTACT,
}
}
if (table === 'messages') messageInserts.push(payload)
return b
})
b.single = vi.fn(terminal)
b.maybeSingle = vi.fn(terminal)
b.then = (resolve: (v: unknown) => unknown) =>
resolve(didInsert ? insertResult() : selectResult())
return b
}
return {
auth: {
getUser: vi.fn(async () => ({
data: { user: { id: 'user-1' } },
error: null,
})),
},
from: vi.fn((table: string) => builder(table)),
}
}
let supabaseMock = makeSupabaseMock()
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => supabaseMock),
}))
vi.mock('@/lib/flows/admin-client', () => ({
supabaseAdmin: () => ({
from: () => {
const b: Record<string, unknown> = {}
const chain = () => b
for (const m of ['update', 'eq', 'select']) b[m] = vi.fn(chain)
b.then = (resolve: (v: unknown) => unknown) =>
resolve({ data: null, error: null })
return b
},
}),
}))
vi.mock('@/lib/whatsapp/encryption', () => ({
decrypt: vi.fn(() => 'plaintext-token'),
encrypt: vi.fn(() => 'enc-token'),
isLegacyFormat: vi.fn(() => false),
}))
const { sendTemplateMessage } = vi.hoisted(() => ({
sendTemplateMessage: vi.fn(async () => ({ messageId: 'wamid-1' })),
}))
vi.mock('@/lib/whatsapp/meta-api', () => ({
sendTemplateMessage,
sendTextMessage: vi.fn(),
sendMediaMessage: vi.fn(),
}))
import { POST } from './route'
function postContactTemplate(overrides: Record<string, unknown> = {}) {
return POST(
new Request('http://localhost/api/whatsapp/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contact_id: 'contact-1',
message_type: 'template',
template_name: 'order_update',
template_language: 'en_US',
template_message_params: { body: ['Acme', '#1234'] },
template_params: ['Acme', '#1234'],
...overrides,
}),
}),
)
}
describe('POST /api/whatsapp/send — contact_id template path', () => {
beforeEach(() => {
conversationInserts.length = 0
messageInserts.length = 0
existingConversation = null
createdConversation = null
contactRow = CONTACT
supabaseMock = makeSupabaseMock()
sendTemplateMessage.mockClear()
})
afterEach(() => {
vi.clearAllMocks()
})
it('creates a conversation for a contact with none, then sends the template', async () => {
const res = await postContactTemplate()
const json = await res.json()
expect(res.status).toBe(200)
expect(json.success).toBe(true)
expect(json.whatsapp_message_id).toBe('wamid-1')
// A conversation was created for this contact.
expect(conversationInserts).toHaveLength(1)
expect(conversationInserts[0]).toMatchObject({
account_id: 'acct-1',
contact_id: 'contact-1',
})
// The template was sent to the contact's number.
expect(sendTemplateMessage).toHaveBeenCalledTimes(1)
const args = (sendTemplateMessage.mock.calls[0] as unknown[])[0] as Record<
string,
unknown
>
// Meta wants the bare E.164 digits — sanitizePhoneForMeta strips the '+'.
expect(args.to).toBe('15551234567')
expect(args.templateName).toBe('order_update')
// The outbound message was persisted under the new conversation.
expect(messageInserts).toHaveLength(1)
expect(messageInserts[0]).toMatchObject({
conversation_id: 'conv-new',
content_type: 'template',
template_name: 'order_update',
sender_type: 'agent',
})
})
it('reuses an existing conversation instead of creating a duplicate', async () => {
existingConversation = {
id: 'conv-existing',
account_id: 'acct-1',
contact_id: 'contact-1',
contact: CONTACT,
}
const res = await postContactTemplate()
expect(res.status).toBe(200)
expect(conversationInserts).toHaveLength(0)
expect(messageInserts[0]).toMatchObject({ conversation_id: 'conv-existing' })
})
it('404s when the contact is not in the caller account', async () => {
contactRow = null
const res = await postContactTemplate()
const json = await res.json()
expect(res.status).toBe(404)
expect(json.error).toMatch(/contact not found/i)
expect(sendTemplateMessage).not.toHaveBeenCalled()
})
it('400s when neither conversation_id nor contact_id is provided', async () => {
const res = await POST(
new Request('http://localhost/api/whatsapp/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message_type: 'template', template_name: 'x' }),
}),
)
expect(res.status).toBe(400)
})
})

View File

@@ -0,0 +1,249 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from '@/lib/rate-limit'
import {
sendMessageToConversation,
validateSendMessageParams,
SendMessageError,
} from '@/lib/whatsapp/send-message'
// The dashboard's outbound-send endpoint. It owns auth, per-user rate
// limiting, and the two ways the UI targets a thread — an existing
// `conversation_id` (inbox) or a `contact_id` (Contact detail →
// find-or-create the conversation). The actual Meta plumbing (validate
// → send → persist → pause flows) lives in the shared
// `sendMessageToConversation` core, which the public `/api/v1/messages`
// endpoint reuses. This route is a thin adapter: resolve the
// conversation, delegate, then map `SendMessageError` back onto the
// dashboard's internal `{ error }` shape.
export async function POST(request: Request) {
try {
const supabase = await createClient()
const {
data: { user },
error: authError,
} = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
// Per-user rate limit. Bucket key is scoped to this route so
// `/broadcast` has an independent budget.
const limit = checkRateLimit(`send:${user.id}`, RATE_LIMITS.send)
if (!limit.success) {
return rateLimitResponse(limit)
}
// Resolve the caller's account_id. Every downstream lookup
// (conversation, whatsapp_config, message_templates) is account-
// scoped post-multi-user, so the previous `user_id` filters
// returned nothing for teammates who didn't author the row.
const { data: profile } = await supabase
.from('profiles')
.select('account_id')
.eq('user_id', user.id)
.maybeSingle()
const accountId = profile?.account_id as string | undefined
if (!accountId) {
return NextResponse.json(
{ error: 'Your profile is not linked to an account.' },
{ status: 403 },
)
}
const body = await request.json()
const {
// `conversation_id` targets an existing thread (inbox). `contact_id`
// lets a caller initiate from a contact that may have no conversation
// yet (Contact detail → Send template) — we find-or-create one below.
conversation_id: conversationIdInput,
contact_id,
message_type,
content_text,
media_url,
filename,
template_name,
template_language,
template_params,
template_message_params,
reply_to_message_id,
} = body
if ((!conversationIdInput && !contact_id) || !message_type) {
return NextResponse.json(
{
error:
'Either conversation_id or contact_id, plus message_type, are required',
},
{ status: 400 }
)
}
// Validate the message shape up front — before the contact_id path
// finds-or-creates a conversation — so an invalid payload 400s
// without leaving an orphan empty conversation behind.
try {
validateSendMessageParams({
messageType: message_type,
contentText: content_text,
mediaUrl: media_url,
templateName: template_name,
})
} catch (err) {
if (err instanceof SendMessageError) {
return NextResponse.json({ error: err.message }, { status: err.status })
}
throw err
}
// Resolve the target conversation. With `conversation_id` we load the
// existing thread; with `contact_id` we find-or-create one for the
// contact so a business-initiated template send (Contact detail view)
// reuses the shared send core below.
let conversationId: string | null = null
if (conversationIdInput) {
const { data, error: convError } = await supabase
.from('conversations')
.select('id')
.eq('id', conversationIdInput)
.eq('account_id', accountId)
.single()
if (convError || !data) {
return NextResponse.json(
{ error: 'Conversation not found' },
{ status: 404 }
)
}
conversationId = data.id
} else {
// contact_id path: verify the contact is in this account first so a
// caller can't open a conversation against someone else's contact.
const { data: contactRow, error: contactErr } = await supabase
.from('contacts')
.select('id')
.eq('id', contact_id)
.eq('account_id', accountId)
.maybeSingle()
if (contactErr || !contactRow) {
return NextResponse.json(
{ error: 'Contact not found' },
{ status: 404 }
)
}
const resolved = await findOrCreateConversation(
supabase,
accountId,
user.id,
contact_id
)
if (!resolved) {
return NextResponse.json(
{ error: 'Failed to open a conversation for this contact' },
{ status: 500 }
)
}
conversationId = resolved
}
if (!conversationId) {
return NextResponse.json(
{ error: 'Conversation not found' },
{ status: 404 }
)
}
// Delegate to the shared send core (validates, sends to Meta with
// phone-variant retry, persists, pauses active flow runs). Its
// `SendMessageError` carries a machine code + HTTP status; the
// dashboard maps it to the internal `{ error }` shape.
try {
const result = await sendMessageToConversation(supabase, accountId, {
conversationId,
messageType: message_type,
contentText: content_text,
mediaUrl: media_url,
filename,
templateName: template_name,
templateLanguage: template_language,
templateParams: template_params,
templateMessageParams: template_message_params,
replyToMessageId: reply_to_message_id,
})
return NextResponse.json({
success: true,
message_id: result.messageId,
whatsapp_message_id: result.whatsappMessageId,
})
} catch (err) {
if (err instanceof SendMessageError) {
return NextResponse.json(
{ error: err.message },
{ status: err.status }
)
}
throw err
}
} catch (error) {
console.error('Error in WhatsApp send POST:', error)
return NextResponse.json(
{ error: 'Failed to send message' },
{ status: 500 }
)
}
}
type SendSupabase = Awaited<ReturnType<typeof createClient>>
/**
* Return the contact's conversation id in this account, creating one if
* it doesn't exist yet. Mirrors the webhook's find-or-create so an
* inbound-then-outbound (or outbound-first) sequence converges on a single
* thread per contact. Runs under the caller's RLS — the conversations_insert
* policy requires account agent membership, which the caller already is.
*/
async function findOrCreateConversation(
supabase: SendSupabase,
accountId: string,
userId: string,
contactId: string,
): Promise<string | null> {
const { data: existing } = await supabase
.from('conversations')
.select('id')
.eq('account_id', accountId)
.eq('contact_id', contactId)
.maybeSingle()
if (existing) return existing.id
const { data: created, error } = await supabase
.from('conversations')
.insert({
account_id: accountId,
user_id: userId,
contact_id: contactId,
})
.select('id')
.single()
if (error) {
console.error('Error creating conversation for contact send:', error.message)
return null
}
return created.id
}

View File

@@ -0,0 +1,330 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { decrypt } from '@/lib/whatsapp/encryption'
import {
deleteMessageTemplate,
editMessageTemplate,
} from '@/lib/whatsapp/meta-api'
import {
validateTemplatePayload,
type TemplatePayload,
} from '@/lib/whatsapp/template-validators'
import { buildMetaTemplatePayload } from '@/lib/whatsapp/template-components'
import { ensureImageHeaderHandle } from '@/lib/whatsapp/template-header-handle'
/**
* Per-template lifecycle endpoint.
*
* PATCH — edit an existing Meta-side template (and re-submit). Used
* by the "Edit" action on APPROVED rows and the "Resubmit"
* action on REJECTED / PAUSED rows. Meta replaces components
* wholesale on edit and bumps status back to PENDING.
*
* DELETE — remove the template on Meta (when meta_template_id is set,
* scoped to a single language variant via hsm_id) AND drop
* the local row. Local-only rows skip the Meta call.
*
* Initial submission (DRAFT → PENDING) lives at the sibling
* /submit endpoint — keep this route narrowly about lifecycle of
* already-submitted templates.
*/
const EDITABLE_STATUSES = new Set(['APPROVED', 'REJECTED', 'PAUSED'])
// uuid v4 plus the looser shape Postgres gen_random_uuid emits.
// We don't need exhaustive RFC parsing — just enough to reject
// "../etc/passwd"-style payloads before they hit Supabase.
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
function isDryRun(): boolean {
return (
process.env.WHATSAPP_TEMPLATES_DRY_RUN === 'true' ||
process.env.WHATSAPP_TEMPLATES_DRY_RUN === '1'
)
}
export async function PATCH(
request: Request,
context: { params: Promise<{ id: string }> },
) {
try {
const { id } = await context.params
if (!UUID_RE.test(id)) {
return NextResponse.json(
{ error: 'Invalid template id.' },
{ status: 400 },
)
}
const supabase = await createClient()
const {
data: { user },
error: authError,
} = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Resolve the caller's account_id so template + whatsapp_config
// lookups work for teammates who didn't author the row.
const { data: profile } = await supabase
.from('profiles')
.select('account_id')
.eq('user_id', user.id)
.maybeSingle()
const accountId = profile?.account_id as string | undefined
if (!accountId) {
return NextResponse.json(
{ error: 'Your profile is not linked to an account.' },
{ status: 403 },
)
}
let payload: TemplatePayload
try {
payload = (await request.json()) as TemplatePayload
} catch {
return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 })
}
// RLS handles ownership, but we need the existing row to read
// meta_template_id and status — fetch explicitly.
const { data: existing, error: lookupErr } = await supabase
.from('message_templates')
.select('id, name, status, meta_template_id, language')
.eq('id', id)
.eq('account_id', accountId)
.maybeSingle()
if (lookupErr || !existing) {
return NextResponse.json({ error: 'Template not found.' }, { status: 404 })
}
if (!existing.meta_template_id) {
return NextResponse.json(
{
error:
'This template was never submitted to Meta — use New Template to submit it instead.',
},
{ status: 400 },
)
}
if (!EDITABLE_STATUSES.has(existing.status)) {
return NextResponse.json(
{
error: `Templates in status ${existing.status} cannot be edited. Allowed: APPROVED, REJECTED, PAUSED.`,
},
{ status: 400 },
)
}
if (payload.category === 'Authentication') {
return NextResponse.json(
{
error:
'AUTHENTICATION templates are not editable here — manage them in Meta WhatsApp Manager.',
},
{ status: 400 },
)
}
try {
validateTemplatePayload(payload)
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'Validation failed.' },
{ status: 400 },
)
}
if (!isDryRun()) {
const { data: config, error: configError } = await supabase
.from('whatsapp_config')
.select('*')
.eq('account_id', accountId)
.single()
if (configError || !config) {
return NextResponse.json(
{ error: 'WhatsApp not configured.' },
{ status: 400 },
)
}
const accessToken = decrypt(config.access_token)
// Image headers need a fresh Resumable-Upload handle on every edit
// (Meta replaces components wholesale). Derive from header_media_url.
try {
await ensureImageHeaderHandle(payload, accessToken)
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'Header image upload failed.' },
{ status: 400 },
)
}
const metaPayload = buildMetaTemplatePayload(payload)
try {
await editMessageTemplate({
metaTemplateId: existing.meta_template_id,
accessToken,
components: metaPayload.components,
})
} catch (e) {
const message = e instanceof Error ? e.message : 'Meta edit failed.'
await supabase
.from('message_templates')
.update({
submission_error: message,
last_submitted_at: new Date().toISOString(),
})
.eq('id', id)
return NextResponse.json({ error: message }, { status: 502 })
}
}
// Meta accepted the edit — status flips back to PENDING for review.
const { data: row, error: updErr } = await supabase
.from('message_templates')
.update({
category: payload.category,
header_type: payload.header_type ?? null,
header_content: payload.header_content ?? null,
header_media_url: payload.header_media_url ?? null,
header_handle: payload.header_handle ?? null,
body_text: payload.body_text,
footer_text: payload.footer_text ?? null,
buttons: payload.buttons ?? null,
sample_values: payload.sample_values ?? null,
status: 'PENDING',
submission_error: null,
rejection_reason: null,
last_submitted_at: new Date().toISOString(),
})
.eq('id', id)
.select()
.single()
if (updErr) {
return NextResponse.json(
{
error: `Edited on Meta but failed to save locally: ${updErr.message}. Run "Sync from Meta" to recover.`,
},
{ status: 500 },
)
}
return NextResponse.json({
success: true,
template: row,
dry_run: isDryRun(),
})
} catch (error) {
console.error('Error editing template:', error)
return NextResponse.json(
{
error:
error instanceof Error ? error.message : 'Failed to edit template.',
},
{ status: 500 },
)
}
}
export async function DELETE(
_request: Request,
context: { params: Promise<{ id: string }> },
) {
try {
const { id } = await context.params
if (!UUID_RE.test(id)) {
return NextResponse.json(
{ error: 'Invalid template id.' },
{ status: 400 },
)
}
const supabase = await createClient()
const {
data: { user },
error: authError,
} = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Same account-scoping rationale as the PATCH handler above —
// teammates need to be able to operate on shared templates +
// the shared whatsapp_config.
const { data: profile } = await supabase
.from('profiles')
.select('account_id')
.eq('user_id', user.id)
.maybeSingle()
const accountId = profile?.account_id as string | undefined
if (!accountId) {
return NextResponse.json(
{ error: 'Your profile is not linked to an account.' },
{ status: 403 },
)
}
const { data: existing, error: lookupErr } = await supabase
.from('message_templates')
.select('id, name, meta_template_id')
.eq('id', id)
.eq('account_id', accountId)
.maybeSingle()
if (lookupErr || !existing) {
return NextResponse.json({ error: 'Template not found.' }, { status: 404 })
}
if (existing.meta_template_id && !isDryRun()) {
const { data: config, error: configError } = await supabase
.from('whatsapp_config')
.select('*')
.eq('account_id', accountId)
.single()
if (configError || !config || !config.waba_id) {
return NextResponse.json(
{ error: 'WhatsApp not configured — cannot delete on Meta.' },
{ status: 400 },
)
}
const accessToken = decrypt(config.access_token)
try {
await deleteMessageTemplate({
wabaId: config.waba_id,
accessToken,
name: existing.name,
metaTemplateId: existing.meta_template_id,
})
} catch (e) {
const message = e instanceof Error ? e.message : 'Meta delete failed.'
return NextResponse.json({ error: message }, { status: 502 })
}
}
const { error: delErr } = await supabase
.from('message_templates')
.delete()
.eq('id', id)
if (delErr) {
return NextResponse.json(
{
error: `Deleted on Meta but failed to delete locally: ${delErr.message}.`,
},
{ status: 500 },
)
}
return NextResponse.json({ success: true, dry_run: isDryRun() })
} catch (error) {
console.error('Error deleting template:', error)
return NextResponse.json(
{
error:
error instanceof Error ? error.message : 'Failed to delete template.',
},
{ status: 500 },
)
}
}

View File

@@ -0,0 +1,261 @@
import { NextResponse } from 'next/server'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createClient } from '@/lib/supabase/server'
import { decrypt } from '@/lib/whatsapp/encryption'
import { submitMessageTemplate } from '@/lib/whatsapp/meta-api'
import {
validateTemplatePayload,
type TemplatePayload,
} from '@/lib/whatsapp/template-validators'
import { buildMetaTemplatePayload } from '@/lib/whatsapp/template-components'
import { ensureImageHeaderHandle } from '@/lib/whatsapp/template-header-handle'
import { normalizeStatus } from '@/lib/whatsapp/template-status-normalize'
/**
* Shared upsert payload builder — both the Meta-failure path and the
* Meta-success path write nearly identical rows; dropping the shared
* fields here means adding a column later only touches one spot.
*/
function buildUpsertRow(
accountId: string,
userId: string,
payload: TemplatePayload,
extras: {
status: 'DRAFT' | string
metaTemplateId: string | null
submissionError: string | null
},
) {
return {
// Account tenancy — required NOT NULL on message_templates as
// of migration 017. Without this an INSERT throws on the
// not-null constraint.
account_id: accountId,
// Original author — kept as audit only. The unique index is
// still on (user_id, name, language) — see the upsert helper
// for the cross-teammate dedup follow-up.
user_id: userId,
name: payload.name,
category: payload.category,
language: payload.language,
header_type: payload.header_type ?? null,
header_content: payload.header_content ?? null,
header_media_url: payload.header_media_url ?? null,
header_handle: payload.header_handle ?? null,
body_text: payload.body_text,
footer_text: payload.footer_text ?? null,
buttons: payload.buttons ?? null,
sample_values: payload.sample_values ?? null,
status: extras.status,
meta_template_id: extras.metaTemplateId,
submission_error: extras.submissionError,
// Clear stale rejection_reason whenever we re-submit; the
// webhook will set it again if Meta still rejects.
rejection_reason: extras.submissionError ? null : null,
last_submitted_at: new Date().toISOString(),
}
}
async function upsertTemplateRow(
supabase: SupabaseClient,
row: ReturnType<typeof buildUpsertRow>,
) {
// TODO(account-sharing): conflict target is still scoped to
// user_id. Once a follow-up migration drops the legacy unique
// index on (user_id, name, language) and adds (account_id,
// name, language), switch `onConflict` here so two teammates
// can't shadow each other's same-named template.
return supabase
.from('message_templates')
.upsert(row, { onConflict: 'user_id,name,language' })
.select()
.single()
}
/**
* Submit a template to Meta for approval AND persist it locally.
*
* Auth → fetch whatsapp_config → validate → (DRY_RUN short-circuit) →
* POST to Meta → upsert local row by (user_id, name, language) with
* status, meta_template_id, sample_values, last_submitted_at.
*
* When WHATSAPP_TEMPLATES_DRY_RUN=true, we skip the network call and
* insert a row with a synthetic `dry-run-<uuid>` meta_template_id so
* CI / local dev can exercise the full UI without a real Meta App.
*
* On the Meta side this is a one-way trip — a row can only be
* submitted; editing or deleting requires hsm_id and lives in PR 4.
*/
export async function POST(request: Request) {
try {
const supabase = await createClient()
const {
data: { user },
error: authError,
} = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Resolve the caller's account_id — whatsapp_config + the
// message_templates row are account-scoped post-multi-user.
const { data: profile } = await supabase
.from('profiles')
.select('account_id')
.eq('user_id', user.id)
.maybeSingle()
const accountId = profile?.account_id as string | undefined
if (!accountId) {
return NextResponse.json(
{ error: 'Your profile is not linked to an account.' },
{ status: 403 },
)
}
let payload: TemplatePayload
try {
payload = (await request.json()) as TemplatePayload
} catch {
return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 })
}
if (payload.category === 'Authentication') {
return NextResponse.json(
{
error:
'AUTHENTICATION templates are not yet supported here — create them in Meta WhatsApp Manager and use "Sync from Meta".',
},
{ status: 400 },
)
}
try {
validateTemplatePayload(payload)
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'Validation failed.' },
{ status: 400 },
)
}
const dryRun =
process.env.WHATSAPP_TEMPLATES_DRY_RUN === 'true' ||
process.env.WHATSAPP_TEMPLATES_DRY_RUN === '1'
let metaTemplateId: string
let metaStatus: string
if (dryRun) {
metaTemplateId = `dry-run-${crypto.randomUUID()}`
metaStatus = 'PENDING'
} else {
const { data: config, error: configError } = await supabase
.from('whatsapp_config')
.select('*')
.eq('account_id', accountId)
.single()
if (configError || !config) {
return NextResponse.json(
{
error:
'WhatsApp not configured. Connect your WhatsApp Business account in Settings first.',
},
{ status: 400 },
)
}
if (!config.waba_id) {
return NextResponse.json(
{
error:
'WABA (WhatsApp Business Account) ID missing. Re-connect your account in Settings.',
},
{ status: 400 },
)
}
const accessToken = decrypt(config.access_token)
// Image headers need a Resumable-Upload handle (Meta rejects a
// plain URL at creation). Derive it from header_media_url before
// building the payload. Surfaces a 400 with an actionable message
// (missing META_APP_ID, unreachable URL, wrong type/size).
try {
await ensureImageHeaderHandle(payload, accessToken)
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'Header image upload failed.' },
{ status: 400 },
)
}
const metaPayload = buildMetaTemplatePayload(payload)
try {
const meta = await submitMessageTemplate({
wabaId: config.waba_id,
accessToken,
payload: metaPayload,
})
metaTemplateId = meta.id
metaStatus = meta.status
} catch (e) {
const message = e instanceof Error ? e.message : 'Meta submit failed.'
// Persist the failure so the user can retry; row stays DRAFT
// until they fix and re-submit.
await upsertTemplateRow(
supabase,
buildUpsertRow(accountId, user.id, payload, {
status: 'DRAFT',
metaTemplateId: null,
submissionError: message,
}),
)
const isRateLimit = /\b429\b/.test(message)
return NextResponse.json(
{
error: isRateLimit
? 'Meta rate limit hit (100 template creates per hour). Try again later.'
: message,
},
{ status: isRateLimit ? 429 : 502 },
)
}
}
const { data: row, error: upsertErr } = await upsertTemplateRow(
supabase,
buildUpsertRow(accountId, user.id, payload, {
status: normalizeStatus(metaStatus),
metaTemplateId,
submissionError: null,
}),
)
if (upsertErr) {
// The submit succeeded on Meta's side but we failed to persist
// locally. That's a data-drift state — surface the meta_template_id
// so the user can recover via "Sync from Meta".
return NextResponse.json(
{
error: `Submitted to Meta but failed to save locally: ${upsertErr.message}. Run "Sync from Meta" to recover.`,
meta_template_id: metaTemplateId,
},
{ status: 500 },
)
}
return NextResponse.json({
success: true,
template: row,
dry_run: dryRun,
})
} catch (error) {
console.error('Error submitting template:', error)
return NextResponse.json(
{
error:
error instanceof Error ? error.message : 'Failed to submit template.',
},
{ status: 500 },
)
}
}

View File

@@ -0,0 +1,322 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { decrypt } from '@/lib/whatsapp/encryption'
import { normalizeStatus } from '@/lib/whatsapp/template-status-normalize'
import type { TemplateButton, TemplateSampleValues } from '@/types'
/**
* Sync message templates from Meta → local message_templates table.
*
* The local catalog stores Meta's status enum verbatim (APPROVED /
* PENDING / REJECTED / PAUSED / DISABLED / IN_APPEAL / PENDING_DELETION)
* so the edit / resubmit / delete flows can distinguish recoverable
* states (PAUSED) from terminal ones (DISABLED) and so webhook events
* land 1:1 without a translation table.
*
* Locally-created templates (no Meta counterpart) are NOT deleted —
* they remain visible so the user can notice drift and clean up.
*/
const META_API_VERSION = 'v21.0'
const META_API_BASE = `https://graph.facebook.com/${META_API_VERSION}`
interface MetaButton {
type: string
text: string
url?: string
phone_number?: string
example?: string[] | string
}
interface MetaTemplateComponent {
type: string
text?: string
format?: string
buttons?: MetaButton[]
example?: {
header_text?: string[]
header_handle?: string[]
body_text?: string[][]
}
}
interface MetaTemplate {
id: string
name: string
language: string
status: string
category: string
components?: MetaTemplateComponent[]
quality_score?: { score?: string } | string
}
function normalizeCategory(
meta: string,
): 'Marketing' | 'Utility' | 'Authentication' {
const upper = meta.toUpperCase()
if (upper === 'UTILITY') return 'Utility'
if (upper === 'AUTHENTICATION') return 'Authentication'
return 'Marketing'
}
function normalizeQualityScore(
raw: MetaTemplate['quality_score'],
): 'GREEN' | 'YELLOW' | 'RED' | null {
const score =
typeof raw === 'string' ? raw : raw?.score ? String(raw.score) : null
if (!score) return null
const upper = score.toUpperCase()
return upper === 'GREEN' || upper === 'YELLOW' || upper === 'RED'
? (upper as 'GREEN' | 'YELLOW' | 'RED')
: null
}
function parseButtons(metaButtons: MetaButton[] | undefined): TemplateButton[] {
if (!metaButtons?.length) return []
const out: TemplateButton[] = []
for (const b of metaButtons) {
switch (b.type?.toUpperCase()) {
case 'QUICK_REPLY':
out.push({ type: 'QUICK_REPLY', text: b.text })
break
case 'URL':
out.push({
type: 'URL',
text: b.text,
url: b.url ?? '',
example: Array.isArray(b.example) ? b.example[0] : b.example,
})
break
case 'PHONE_NUMBER':
out.push({
type: 'PHONE_NUMBER',
text: b.text,
phone_number: b.phone_number ?? '',
})
break
case 'COPY_CODE':
out.push({
type: 'COPY_CODE',
text: b.text,
example: Array.isArray(b.example) ? b.example[0] ?? '' : b.example ?? '',
})
break
// OTP, FLOW, etc — out of scope for v1; drop silently.
}
}
return out
}
function extractSampleValues(
body: MetaTemplateComponent | undefined,
header: MetaTemplateComponent | undefined,
): TemplateSampleValues | null {
// Meta returns body_text as a 2D array — one row per example set.
// We take the first row (most templates have exactly one).
const bodySample = body?.example?.body_text?.[0]
const headerSample = header?.example?.header_text
if (!bodySample?.length && !headerSample?.length) return null
const sv: TemplateSampleValues = {}
if (bodySample?.length) sv.body = bodySample
if (headerSample?.length) sv.header = headerSample
return sv
}
export async function POST() {
try {
const supabase = await createClient()
const {
data: { user },
error: authError,
} = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Resolve the caller's account_id — both whatsapp_config and
// the message_templates we sync into are account-scoped.
const { data: profile } = await supabase
.from('profiles')
.select('account_id')
.eq('user_id', user.id)
.maybeSingle()
const accountId = profile?.account_id as string | undefined
if (!accountId) {
return NextResponse.json(
{ error: 'Your profile is not linked to an account.' },
{ status: 403 },
)
}
const { data: config, error: configError } = await supabase
.from('whatsapp_config')
.select('*')
.eq('account_id', accountId)
.single()
if (configError || !config) {
return NextResponse.json(
{
error:
'WhatsApp not configured. Connect your WhatsApp Business account in Settings first.',
},
{ status: 400 },
)
}
if (!config.waba_id) {
return NextResponse.json(
{
error:
'WABA (WhatsApp Business Account) ID missing. Re-connect your account in Settings.',
},
{ status: 400 },
)
}
const accessToken = decrypt(config.access_token)
const metaTemplates: MetaTemplate[] = []
let nextUrl:
| string
| null = `${META_API_BASE}/${config.waba_id}/message_templates?limit=100&fields=id,name,language,status,category,components,quality_score`
const PAGE_CAP = 20
let pageCount = 0
while (nextUrl && pageCount < PAGE_CAP) {
pageCount++
const metaRes: Response = await fetch(nextUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!metaRes.ok) {
let metaErr = `Meta API error: ${metaRes.status}`
try {
const body = await metaRes.json()
if (body?.error?.message) metaErr = body.error.message
} catch {
// response wasn't JSON — keep the fallback
}
return NextResponse.json({ error: metaErr }, { status: 502 })
}
const metaBody: {
data?: MetaTemplate[]
paging?: { next?: string }
} = await metaRes.json()
if (metaBody.data) metaTemplates.push(...metaBody.data)
nextUrl = metaBody.paging?.next ?? null
}
let inserted = 0
let updated = 0
const errors: { name: string; language: string; message: string }[] = []
for (const t of metaTemplates) {
const body = (t.components ?? []).find((c) => c.type === 'BODY')
const header = (t.components ?? []).find((c) => c.type === 'HEADER')
const footer = (t.components ?? []).find((c) => c.type === 'FOOTER')
const buttons = (t.components ?? []).find((c) => c.type === 'BUTTONS')
const parsedButtons = parseButtons(buttons?.buttons)
const sampleValues = extractSampleValues(body, header)
const headerFormat = header?.format?.toUpperCase()
const headerType =
headerFormat === 'TEXT' ||
headerFormat === 'IMAGE' ||
headerFormat === 'VIDEO' ||
headerFormat === 'DOCUMENT'
? headerFormat.toLowerCase()
: null
const row = {
// Account tenancy + user audit, same split as the submit
// route. account_id is NOT NULL on message_templates
// post-017, so an INSERT without it errors.
account_id: accountId,
user_id: user.id,
name: t.name,
category: normalizeCategory(t.category),
language: t.language,
header_type: headerType,
header_content: header?.text ?? null,
header_handle: header?.example?.header_handle?.[0] ?? null,
body_text: body?.text ?? '',
footer_text: footer?.text ?? null,
buttons: parsedButtons.length ? parsedButtons : null,
sample_values: sampleValues,
status: normalizeStatus(t.status),
meta_template_id: t.id,
quality_score: normalizeQualityScore(t.quality_score),
updated_at: new Date().toISOString(),
}
const { data: existing, error: lookupErr } = await supabase
.from('message_templates')
.select('id')
.eq('account_id', accountId)
.eq('name', t.name)
.eq('language', t.language)
.maybeSingle()
if (lookupErr) {
errors.push({
name: t.name,
language: t.language,
message: lookupErr.message,
})
continue
}
if (existing?.id) {
const { error: updErr } = await supabase
.from('message_templates')
.update(row)
.eq('id', existing.id)
if (updErr) {
errors.push({
name: t.name,
language: t.language,
message: updErr.message,
})
} else {
updated++
}
} else {
const { error: insErr } = await supabase
.from('message_templates')
.insert(row)
if (insErr) {
errors.push({
name: t.name,
language: t.language,
message: insErr.message,
})
} else {
inserted++
}
}
}
return NextResponse.json({
success: errors.length === 0,
total: metaTemplates.length,
inserted,
updated,
errors,
truncated: pageCount >= PAGE_CAP && nextUrl !== null,
})
} catch (error) {
console.error('Error syncing WhatsApp templates:', error)
return NextResponse.json(
{
error:
error instanceof Error ? error.message : 'Failed to sync templates',
},
{ status: 500 },
)
}
}

File diff suppressed because it is too large Load Diff

230
wacrm/src/app/globals.css Normal file
View File

@@ -0,0 +1,230 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
/* Secondary card surface (tile / hover backgrounds) and the accent
* extras the Settings redesign leans on. The raw --primary-* vars
* already exist per-accent below; these mappings expose them (and
* --card-2) as Tailwind utilities: bg-card-2, bg-primary-soft,
* bg-primary-soft-2, hover:bg-primary-hover. */
--color-card-2: var(--card-2);
--color-primary-hover: var(--primary-hover);
--color-primary-soft: var(--primary-soft);
--color-primary-soft-2: var(--primary-soft-2);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
/* ============================================================
* THEMING — two orthogonal dimensions
*
* 1. MODE (light / dark) → neutral surfaces. Selected with
* `document.documentElement.dataset.mode`. Defaults to dark.
* 2. ACCENT (violet / …) → the primary color only. Selected
* with `document.documentElement.dataset.theme`.
*
* The two compose: any accent works in either mode. Neutral
* tokens (background / card / border / muted / sidebar surfaces /
* neutral charts / radius) live in the MODE blocks below; accent
* tokens (--primary*, --ring, --chart-1, --sidebar-primary*,
* --sidebar-ring) live in the ACCENT blocks. They set disjoint
* variables, so cascade order between them doesn't matter.
*
* `:root` carries the dark-mode + violet defaults so a visitor
* renders correctly before JS runs; the boot script in
* layout.tsx replays the saved mode + accent before first paint.
*
* The three `--primary-*` extras (hover / soft / soft-2) feed
* hovered primary buttons and tinted-primary surfaces (sidebar
* active pill, validation badges).
*
* Token shapes come straight from the design handoff at
* project/Color Themes.html — change one, change the other.
* ============================================================ */
/* ---- MODE: neutral surfaces ---- */
:root,
html[data-mode="dark"] {
--background: oklch(0.13 0.01 260);
--foreground: oklch(0.985 0 0);
--card: oklch(0.18 0.01 260);
--card-2: oklch(0.205 0.01 260);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.18 0.01 260);
--popover-foreground: oklch(0.985 0 0);
--secondary: oklch(0.22 0.01 260);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.22 0.01 260);
--muted-foreground: oklch(0.65 0.01 260);
--accent: oklch(0.22 0.01 260);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.28 0.01 260);
--input: oklch(0.28 0.01 260);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.16 0.01 260);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.22 0.01 260);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.28 0.01 260);
}
html[data-mode="light"] {
--background: oklch(0.99 0.002 260);
--foreground: oklch(0.21 0.01 260);
--card: oklch(1 0 0);
--card-2: oklch(0.985 0.002 260);
--card-foreground: oklch(0.21 0.01 260);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.21 0.01 260);
--secondary: oklch(0.967 0.003 260);
--secondary-foreground: oklch(0.25 0.01 260);
--muted: oklch(0.967 0.003 260);
--muted-foreground: oklch(0.52 0.015 260);
--accent: oklch(0.96 0.004 260);
--accent-foreground: oklch(0.25 0.01 260);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0.004 260);
--input: oklch(0.922 0.004 260);
--chart-2: oklch(0.6 0.1 260);
--chart-3: oklch(0.7 0.06 260);
--chart-4: oklch(0.8 0.04 260);
--chart-5: oklch(0.87 0.03 260);
--radius: 0.625rem;
--sidebar: oklch(0.985 0.002 260);
--sidebar-foreground: oklch(0.21 0.01 260);
--sidebar-accent: oklch(0.96 0.004 260);
--sidebar-accent-foreground: oklch(0.25 0.01 260);
--sidebar-border: oklch(0.922 0.004 260);
}
/* ---- ACCENT: primary color ---- */
:root,
html[data-theme="violet"] {
--primary: oklch(0.526 0.247 293);
--primary-foreground: oklch(0.985 0 0);
--primary-hover: oklch(0.6 0.22 293);
--primary-soft: oklch(0.526 0.247 293 / 0.12);
--primary-soft-2: oklch(0.526 0.247 293 / 0.2);
--ring: oklch(0.526 0.247 293);
--chart-1: oklch(0.526 0.247 293);
--sidebar-primary: oklch(0.526 0.247 293);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-ring: oklch(0.526 0.247 293);
}
html[data-theme="emerald"] {
--primary: oklch(0.62 0.16 162);
--primary-foreground: oklch(0.16 0.02 162);
--primary-hover: oklch(0.68 0.15 162);
--primary-soft: oklch(0.62 0.16 162 / 0.12);
--primary-soft-2: oklch(0.62 0.16 162 / 0.22);
--ring: oklch(0.62 0.16 162);
--chart-1: oklch(0.62 0.16 162);
--chart-2: oklch(0.7 0.14 195);
--sidebar-primary: oklch(0.62 0.16 162);
--sidebar-primary-foreground: oklch(0.16 0.02 162);
--sidebar-ring: oklch(0.62 0.16 162);
}
html[data-theme="cobalt"] {
--primary: oklch(0.585 0.2 254);
--primary-foreground: oklch(0.985 0 0);
--primary-hover: oklch(0.66 0.18 254);
--primary-soft: oklch(0.585 0.2 254 / 0.12);
--primary-soft-2: oklch(0.585 0.2 254 / 0.22);
--ring: oklch(0.585 0.2 254);
--chart-1: oklch(0.585 0.2 254);
--chart-2: oklch(0.7 0.15 220);
--sidebar-primary: oklch(0.585 0.2 254);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-ring: oklch(0.585 0.2 254);
}
html[data-theme="amber"] {
--primary: oklch(0.745 0.16 65);
--primary-foreground: oklch(0.18 0.03 65);
--primary-hover: oklch(0.8 0.15 65);
--primary-soft: oklch(0.745 0.16 65 / 0.12);
--primary-soft-2: oklch(0.745 0.16 65 / 0.22);
--ring: oklch(0.745 0.16 65);
--chart-1: oklch(0.745 0.16 65);
--chart-2: oklch(0.7 0.15 35);
--sidebar-primary: oklch(0.745 0.16 65);
--sidebar-primary-foreground: oklch(0.18 0.03 65);
--sidebar-ring: oklch(0.745 0.16 65);
}
html[data-theme="rose"] {
--primary: oklch(0.645 0.22 16);
--primary-foreground: oklch(0.985 0 0);
--primary-hover: oklch(0.71 0.2 16);
--primary-soft: oklch(0.645 0.22 16 / 0.12);
--primary-soft-2: oklch(0.645 0.22 16 / 0.22);
--ring: oklch(0.645 0.22 16);
--chart-1: oklch(0.645 0.22 16);
--chart-2: oklch(0.7 0.18 340);
--sidebar-primary: oklch(0.645 0.22 16);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-ring: oklch(0.645 0.22 16);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}

45
wacrm/src/app/icon.tsx Normal file
View File

@@ -0,0 +1,45 @@
import { ImageResponse } from "next/og";
// Replaces the default Next.js favicon with the brand mark — Hostinger
// violet rounded square + white chat-square glyph — matching the
// sidebar logo in `src/components/layout/sidebar.tsx`. Next.js renders
// this at build time and auto-injects <link rel="icon"> into <head>.
//
// This route takes precedence over src/app/favicon.ico, which is the
// Next.js default and can stay on disk harmlessly (or be removed).
export const runtime = "edge";
export const size = { width: 32, height: 32 };
export const contentType = "image/png";
export default function Icon() {
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#7c3aed", // primary (Hostinger-aligned purple)
borderRadius: 6,
}}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="#ffffff"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</div>
),
{ ...size },
);
}

View File

@@ -0,0 +1,431 @@
'use client';
// ============================================================
// /join/[token] — invitation redemption landing page.
//
// Four UI states driven by:
// - the peek result (server-validated invite payload), and
// - whether the visitor is currently authenticated.
//
// ┌──────────────────────┬───────────────┬─────────────────────────┐
// │ peek │ auth │ render │
// ├──────────────────────┼───────────────┼─────────────────────────┤
// │ loading │ — │ spinner │
// │ ok:false (any reason)│ — │ friendly error + signup │
// │ ok:true │ signed out │ "Sign up" + "Sign in" │
// │ ok:true │ signed in │ "Accept" button → redeem │
// └──────────────────────┴───────────────┴─────────────────────────┘
//
// We deliberately do NOT redeem automatically on page load — the
// invitee should confirm what account/role they're accepting.
// Auto-redeem would also race with the signup flow returning to
// this page after email verification.
// ============================================================
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { toast } from 'sonner';
import {
AlertTriangle,
CheckCircle,
Loader2,
MailX,
ShieldCheck,
UsersRound,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { createClient } from '@/lib/supabase/client';
interface PeekOk {
ok: true;
account_name: string;
role: 'admin' | 'agent' | 'viewer';
expires_at: string;
}
interface PeekFail {
ok: false;
reason: 'not_found' | 'used' | 'expired' | 'server_error';
}
type PeekResult = PeekOk | PeekFail;
const ROLE_LABEL: Record<PeekOk['role'], string> = {
admin: 'Admin',
agent: 'Agent',
viewer: 'Viewer',
};
const FAIL_COPY: Record<PeekFail['reason'], { title: string; body: string }> = {
not_found: {
title: 'Invite not found',
body: 'This link doesnt match a valid invitation. Double-check the URL or ask the person who invited you to send a new one.',
},
used: {
title: 'Invite already used',
body: 'This invitation has already been accepted. If that wasnt you, ask the account admin to send a fresh link.',
},
expired: {
title: 'Invite expired',
body: 'This invitation has expired. Ask the account admin to send a new one — they take a few seconds to generate.',
},
server_error: {
title: 'Something went wrong',
body: 'We couldnt verify this invitation right now. Try refreshing the page in a moment.',
},
};
export default function JoinPage() {
const params = useParams<{ token: string }>();
const token = params?.token;
const [peek, setPeek] = useState<PeekResult | null>(null);
// Local auth probe — the AuthProvider lives inside the (dashboard)
// route group, so it doesn't reach this page. We hit Supabase
// directly the same way `/login` and `/signup` do.
const [authedUserId, setAuthedUserId] = useState<string | null | undefined>(
undefined, // undefined = unknown / still loading; null = signed out
);
const [accepting, setAccepting] = useState(false);
// `redeem_invitation` returns 409 when the caller's current account
// has domain data, or they're already a member of a shared account.
// A transient toast wasn't enough — the user has no actionable next
// step. Surface a blocking modal that walks them through it.
const [conflictMessage, setConflictMessage] = useState<string | null>(null);
const [signingOut, setSigningOut] = useState(false);
// Extracted so the "Try again" button on the server_error card
// can re-run the same logic without remounting the component.
const loadPeekAndAuth = useCallback(async () => {
if (!token) return;
setPeek(null);
setAuthedUserId(undefined);
try {
const [peekRes, authRes] = await Promise.all([
fetch(`/api/invitations/${encodeURIComponent(token)}/peek`, {
cache: 'no-store',
}),
createClient().auth.getUser(),
]);
const peekBody = (await peekRes.json()) as PeekResult;
setPeek(peekBody);
setAuthedUserId(authRes.data.user?.id ?? null);
} catch (err) {
console.error('[join] peek error:', err);
setPeek({ ok: false, reason: 'server_error' });
setAuthedUserId(null);
}
}, [token]);
// Fetch peek + auth state on mount. The peek endpoint is
// rate-limited per-IP (30/min) so double-mounting in React 19
// strict mode dev is harmless. We also use the `cancelled` flag
// to drop setState calls if the component unmounts mid-fetch.
useEffect(() => {
if (!token) return;
let cancelled = false;
(async () => {
try {
const [peekRes, authRes] = await Promise.all([
fetch(`/api/invitations/${encodeURIComponent(token)}/peek`, {
cache: 'no-store',
}),
createClient().auth.getUser(),
]);
const peekBody = (await peekRes.json()) as PeekResult;
if (cancelled) return;
setPeek(peekBody);
setAuthedUserId(authRes.data.user?.id ?? null);
} catch (err) {
console.error('[join] peek error:', err);
if (cancelled) return;
setPeek({ ok: false, reason: 'server_error' });
setAuthedUserId(null);
}
})();
return () => {
cancelled = true;
};
}, [token]);
const handleAccept = useCallback(async () => {
if (!token) return;
setAccepting(true);
try {
const res = await fetch(
`/api/invitations/${encodeURIComponent(token)}/redeem`,
{ method: 'POST' },
);
if (!res.ok) {
const payload = (await res.json().catch(() => ({}))) as {
error?: string;
};
// 409 = caller already has data / is in another shared
// account. The redeem RPC's error message is descriptive
// enough to show directly; we open a modal so the user has
// a clear next-action (sign out → use different email)
// rather than a 3-second toast.
if (res.status === 409) {
setConflictMessage(
payload.error ||
'You are already in another account. Sign in with a different email to join this one.',
);
} else {
toast.error(payload.error || 'Failed to accept invitation');
}
setAccepting(false);
return;
}
toast.success('Welcome to the team');
// Full reload (not router.push) so AuthProvider re-fetches
// the profile with the new account_id and account_role.
window.location.href = '/dashboard';
} catch (err) {
console.error('[join] redeem error:', err);
toast.error('Could not reach the server');
setAccepting(false);
}
}, [token]);
const handleSignOutAndRetry = useCallback(async () => {
setSigningOut(true);
try {
await createClient().auth.signOut();
// Hard reload so the new auth state propagates everywhere
// (middleware, AuthProvider). Preserves the invite token in
// the URL so the rebuilt page renders the signed-out CTA path.
window.location.reload();
} catch (err) {
console.error('[join] sign-out error:', err);
toast.error('Could not sign out. Try refreshing the page.');
setSigningOut(false);
}
}, []);
// ----- Loading state (peek pending OR auth not yet resolved) -----
if (peek === null || authedUserId === undefined) {
return (
<Card className="w-full max-w-md border-border bg-card">
<CardContent className="flex flex-col items-center gap-3 py-12">
<Loader2 className="size-6 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Verifying invitation</p>
</CardContent>
</Card>
);
}
// ----- Peek failed -----
if (!peek.ok) {
const copy = FAIL_COPY[peek.reason];
return (
<Card className="w-full max-w-md border-border bg-card">
<CardHeader className="items-center text-center">
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-red-500/10">
<MailX className="h-6 w-6 text-red-400" />
</div>
<CardTitle className="text-xl text-foreground">{copy.title}</CardTitle>
<CardDescription className="text-muted-foreground">
{copy.body}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{/* For server_error the failure is transient — the network
flapped or the peek endpoint hiccupped. Try-again is
the right primary action; the "create account" /
"sign in" links stay as secondary options. Other
failure reasons (not_found / used / expired) are
terminal for this token, so no retry — just the
signup/sign-in escape hatches. */}
{peek.reason === 'server_error' ? (
<>
<Button
onClick={loadPeekAndAuth}
className="w-full bg-primary text-primary-foreground hover:bg-primary/90"
>
Try again
</Button>
<Link href="/signup">
<Button
variant="outline"
className="w-full border-border text-muted-foreground hover:bg-muted hover:text-foreground"
>
Create a new account instead
</Button>
</Link>
</>
) : (
<>
<Link href="/signup">
<Button className="w-full bg-primary text-primary-foreground hover:bg-primary/90">
Create a new account instead
</Button>
</Link>
<Link href="/login">
<Button
variant="outline"
className="w-full border-border text-muted-foreground hover:bg-muted hover:text-foreground"
>
Sign in
</Button>
</Link>
</>
)}
</CardContent>
</Card>
);
}
// ----- Peek OK -----
const inviteHeader = (
<CardHeader className="items-center text-center">
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
<UsersRound className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-xl text-foreground">
You&apos;re invited to{' '}
<span className="text-primary">{peek.account_name}</span>
</CardTitle>
<CardDescription className="text-muted-foreground">
You&apos;ll join as{' '}
<span className="inline-flex items-center gap-1 text-foreground">
<ShieldCheck className="size-3.5 text-primary" />
{ROLE_LABEL[peek.role]}
</span>
. Link valid until{' '}
{new Date(peek.expires_at).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
.
</CardDescription>
</CardHeader>
);
// ----- Authed: show Accept button -----
if (authedUserId) {
return (
<>
<Card className="w-full max-w-md border-border bg-card">
{inviteHeader}
<CardContent className="flex flex-col gap-3">
<Button
onClick={handleAccept}
disabled={accepting}
className="w-full bg-primary text-primary-foreground hover:bg-primary/90"
>
{accepting ? (
<>
<Loader2 className="size-4 animate-spin" />
Accepting
</>
) : (
<>
<CheckCircle className="size-4" />
Accept invitation
</>
)}
</Button>
<p className="text-center text-xs text-muted-foreground">
Accepting moves your login into{' '}
<span className="text-muted-foreground">{peek.account_name}</span>. Your
empty personal account from signup will be cleaned up.
</p>
</CardContent>
</Card>
{/* Conflict modal — opens when the redeem endpoint returns 409
(caller already in a shared account or has domain data).
Blocks the flow until the user picks a recovery action so
they aren't stuck retrying an inevitable failure. */}
<Dialog
open={conflictMessage !== null}
onOpenChange={(open) => {
if (!open) setConflictMessage(null);
}}
>
<DialogContent className="bg-popover border-border sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-popover-foreground">
<AlertTriangle className="size-4 text-amber-400" />
Can&apos;t join {peek.account_name} with this account
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{conflictMessage}
</DialogDescription>
</DialogHeader>
<div className="space-y-2 py-2 text-xs text-muted-foreground">
<p>
To join{' '}
<span className="text-popover-foreground">{peek.account_name}</span>,
sign out and sign up again with a different email address.
The invite link stays valid as long as it hasn&apos;t
expired.
</p>
</div>
<DialogFooter className="bg-popover border-border">
<Button
variant="outline"
onClick={() => setConflictMessage(null)}
className="border-border text-popover-foreground hover:bg-muted"
>
Stay signed in
</Button>
<Button
onClick={handleSignOutAndRetry}
disabled={signingOut}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
{signingOut ? (
<>
<Loader2 className="size-4 animate-spin" />
Signing out
</>
) : (
'Sign out & use a different email'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
// ----- Not authed: prompt to sign up or sign in -----
return (
<Card className="w-full max-w-md border-border bg-card">
{inviteHeader}
<CardContent className="flex flex-col gap-2">
<Link href={`/signup?invite=${encodeURIComponent(token!)}`}>
<Button className="w-full bg-primary text-primary-foreground hover:bg-primary/90">
Create account &amp; join
</Button>
</Link>
<Link href={`/login?invite=${encodeURIComponent(token!)}`}>
<Button
variant="outline"
className="w-full border-border text-muted-foreground hover:bg-muted hover:text-foreground"
>
I already have an account
</Button>
</Link>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,44 @@
// ============================================================
// /join/[token] layout — minimal full-bleed dark shell.
//
// The route group sits outside both `(auth)` and `(dashboard)`
// because it's hybrid: the page must render for anonymous
// visitors (to show "Sign up to join Acme") *and* for signed-in
// users (to show "Accept invite"). Reusing `(auth)`'s layout
// would funnel signed-in users through the middleware's auth-
// page redirect; reusing `(dashboard)` would funnel anonymous
// visitors through its login redirect. A dedicated layout
// avoids both.
//
// Styling matches the login / signup pages — centered card on a
// slate-950 background — so the join experience feels like a
// natural step in the auth funnel rather than a foreign page.
//
// Referrer-Policy: no-referrer
// The plaintext invite token lives in the URL path. Without
// this header, any externally-loaded resource (third-party
// font, CDN script, image) would receive the full join URL in
// its `Referer` header. The /join page doesn't currently load
// anything external, but `Referrer-Policy: no-referrer` is a
// cheap belt-and-braces guard against future regressions
// accidentally leaking tokens. Per Next.js 16's `metadata`
// export, this surfaces as `<meta name="referrer" content="no-referrer">`.
// ============================================================
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
export const metadata: Metadata = {
referrer: 'no-referrer',
// Belt-and-braces against an invite URL ending up in search
// results if a join page is ever crawled.
robots: { index: false, follow: false },
};
export default function JoinLayout({ children }: { children: ReactNode }) {
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
{children}
</div>
);
}

113
wacrm/src/app/layout.tsx Normal file
View File

@@ -0,0 +1,113 @@
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import Script from "next/script";
import "./globals.css";
import { ThemeProvider } from "@/hooks/use-theme";
import { ThemedToaster } from "@/components/themed-toaster";
import {
DEFAULT_MODE,
DEFAULT_THEME,
MODE_STORAGE_KEY,
MODES,
STORAGE_KEY,
THEME_IDS,
} from "@/lib/themes";
const inter = Inter({
variable: "--font-sans",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: {
default: "wacrm",
template: "%s — wacrm",
},
description: "Self-hostable CRM template for WhatsApp.",
robots: {
index: false,
follow: false,
},
icons: {
icon: [{ url: "/icon" }],
},
formatDetection: {
email: false,
address: false,
telephone: false,
},
};
export const viewport: Viewport = {
themeColor: "#020617",
colorScheme: "dark light",
};
// Inline boot script — runs before React hydrates so the user's
// chosen accent (data-theme) AND mode (data-mode) are on the <html>
// element before first paint. Without this every page load flashes
// the server-rendered defaults for a frame before the React tree
// mounts and applies the picked values.
//
// Kept dependency-free (no imports, no JSX) — must be a string the
// browser can run as a single <script>. Knowledge of valid ids is
// sourced from the THEME_IDS / MODES constants so adding one doesn't
// silently break the boot path.
const THEME_BOOT_SCRIPT = `
(function(){
var d = document.documentElement;
try {
var THEME_KEY = ${JSON.stringify(STORAGE_KEY)};
var THEME_DEFAULT = ${JSON.stringify(DEFAULT_THEME)};
var THEMES = ${JSON.stringify(THEME_IDS)};
var savedTheme = localStorage.getItem(THEME_KEY);
d.dataset.theme = THEMES.indexOf(savedTheme) !== -1 ? savedTheme : THEME_DEFAULT;
var MODE_KEY = ${JSON.stringify(MODE_STORAGE_KEY)};
var MODE_DEFAULT = ${JSON.stringify(DEFAULT_MODE)};
var MODES = ${JSON.stringify(MODES)};
var savedMode = localStorage.getItem(MODE_KEY);
d.dataset.mode = MODES.indexOf(savedMode) !== -1 ? savedMode : MODE_DEFAULT;
} catch (_e) {
d.dataset.theme = ${JSON.stringify(DEFAULT_THEME)};
d.dataset.mode = ${JSON.stringify(DEFAULT_MODE)};
}
})();
`;
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
data-theme={DEFAULT_THEME}
data-mode={DEFAULT_MODE}
className={`${inter.variable} h-full antialiased`}
// The `theme-boot` script below rewrites `data-theme` and
// `data-mode` on <html> from localStorage before React hydrates,
// so for any non-default choice the client DOM intentionally
// differs from the server-rendered defaults. suppressHydration-
// Warning silences the expected mismatch — it only applies to
// this element's own attributes, so genuine mismatches in
// children still surface.
suppressHydrationWarning
>
<head>
<Script
id="theme-boot"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{ __html: THEME_BOOT_SCRIPT }}
/>
</head>
<body className="min-h-full bg-background text-foreground font-sans">
<ThemeProvider>
{children}
<ThemedToaster />
</ThemeProvider>
</body>
</html>
);
}

5
wacrm/src/app/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function RootPage() {
redirect('/dashboard')
}

View File

@@ -0,0 +1,198 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import { Bot, RotateCcw, Send, Loader2, UserCircle2, ArrowRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
interface Turn {
role: 'user' | 'assistant';
content: string;
/** assistant-only: the agent signalled a human handoff on this turn. */
handoff?: boolean;
}
export function AiPlayground({ onGoToSetup }: { onGoToSetup?: () => void }) {
const [turns, setTurns] = useState<Turn[]>([]);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
}, [turns, sending]);
const send = async () => {
const text = input.trim();
if (!text || sending) return;
const next: Turn[] = [...turns, { role: 'user', content: text }];
setTurns(next);
setInput('');
setSending(true);
try {
const res = await fetch('/api/ai/playground', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// Send only role+content — the server ignores anything else.
body: JSON.stringify({
messages: next.map((t) => ({ role: t.role, content: t.content })),
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (data.code === 'ai_not_configured') {
toast.error('No agent configured yet — finish Setup first.');
} else {
toast.error(data.error ?? "Couldn't get a reply.");
}
// Roll the unsent user turn back so the transcript stays clean.
setTurns(turns);
setInput(text);
return;
}
setTurns([
...next,
{
role: 'assistant',
content:
typeof data.reply === 'string' && data.reply.trim()
? data.reply
: '',
handoff: Boolean(data.handoff),
},
]);
} catch {
toast.error("Couldn't reach the agent.");
setTurns(turns);
setInput(text);
} finally {
setSending(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void send();
}
};
return (
<div className="flex h-[60vh] min-h-[420px] flex-col rounded-xl border border-border bg-card">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<div className="flex items-center gap-2">
<Bot className="h-4 w-4 text-primary" />
<span className="text-sm font-medium text-foreground">Playground</span>
<span className="text-xs text-muted-foreground">
test replies as if you were a customer
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setTurns([])}
disabled={turns.length === 0 || sending}
className="text-muted-foreground"
>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" /> Reset
</Button>
</div>
{/* Transcript */}
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto p-4">
{turns.length === 0 && (
<div className="flex h-full flex-col items-center justify-center text-center text-sm text-muted-foreground">
<Bot className="mb-2 h-8 w-8 text-muted-foreground/60" />
<p>Send a message to see how your agent would reply.</p>
<p className="mt-1 text-xs">
It uses your knowledge base and behaves exactly like the
auto-reply bot including handoff.
</p>
{onGoToSetup && (
<Button
variant="link"
size="sm"
onClick={onGoToSetup}
className="mt-1 h-auto p-0 text-xs"
>
Not set up yet? Go to Setup <ArrowRight className="ml-1 h-3 w-3" />
</Button>
)}
</div>
)}
{turns.map((t, i) => (
<div
key={i}
className={cn(
'flex gap-2',
t.role === 'user' ? 'justify-end' : 'justify-start',
)}
>
{t.role === 'assistant' && (
<Bot className="mt-1 h-5 w-5 shrink-0 text-primary" />
)}
<div
className={cn(
'max-w-[80%] rounded-2xl px-3.5 py-2 text-sm',
t.role === 'user'
? 'rounded-br-sm bg-primary text-primary-foreground'
: 'rounded-bl-sm bg-muted text-foreground',
)}
>
{t.content && <p className="whitespace-pre-wrap">{t.content}</p>}
{t.role === 'assistant' && t.handoff && (
<p
className={cn(
'flex items-center gap-1 text-xs text-amber-500',
t.content && 'mt-1.5 border-t border-border/50 pt-1.5',
)}
>
<UserCircle2 className="h-3.5 w-3.5" />
Would hand off to a human here
</p>
)}
</div>
{t.role === 'user' && (
<UserCircle2 className="mt-1 h-5 w-5 shrink-0 text-muted-foreground" />
)}
</div>
))}
{sending && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Bot className="h-5 w-5 text-primary" />
<Loader2 className="h-4 w-4 animate-spin" /> Thinking
</div>
)}
</div>
{/* Composer */}
<div className="flex items-end gap-2 border-t border-border p-3">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a customer message…"
rows={1}
className="flex-1 resize-none rounded-xl border border-border bg-muted px-4 py-2.5 text-sm text-foreground placeholder-muted-foreground outline-none focus:border-primary/50"
/>
<Button
size="sm"
onClick={send}
disabled={!input.trim() || sending}
className="h-9 w-9 shrink-0 p-0"
>
{sending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import type { ReactNode } from "react";
import { useAuth } from "@/hooks/use-auth";
import { hasMinRole, type AccountRole } from "@/lib/auth/roles";
interface RequireRoleProps {
/** Minimum role to render `children`. Uses the standard hierarchy
* owner > admin > agent > viewer. */
min: AccountRole;
/** What to render while the role is below `min` OR while we don't
* yet know the role (`profileLoading` is true). Defaults to
* `null` — most call sites just want the gated element to be
* absent until we're sure. Pass a placeholder if a layout slot
* would collapse and re-flow when the role resolves. */
fallback?: ReactNode;
children: ReactNode;
}
/**
* `<RequireRole min="admin">…</RequireRole>` — conditional render
* helper for UI gated by account role.
*
* Three states:
* 1. profileLoading → render `fallback` (we don't know the role
* yet; fail closed so we never flash the gated content to an
* under-privileged user).
* 2. role ≥ min → render `children`.
* 3. role < min → render `fallback`.
*
* Mirrors the server-side `requireRole(min)` from `@/lib/auth/account`
* so client and server gates stay aligned by construction.
*/
export function RequireRole({
min,
fallback = null,
children,
}: RequireRoleProps) {
const { profileLoading, accountRole } = useAuth();
if (profileLoading) return <>{fallback}</>;
if (!accountRole) return <>{fallback}</>;
if (!hasMinRole(accountRole, min)) return <>{fallback}</>;
return <>{children}</>;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,135 @@
'use client';
import { useEffect, useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import { MessageTemplate } from '@/types';
import { Button } from '@/components/ui/button';
import { Loader2, FileText, ArrowRight } from 'lucide-react';
const categoryColors: Record<string, string> = {
Marketing: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
Utility: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
Authentication: 'bg-orange-500/10 text-orange-400 border-orange-500/20',
};
interface Step1Props {
selectedTemplate: MessageTemplate | null;
onSelect: (template: MessageTemplate) => void;
onNext: () => void;
onBack: () => void;
}
export function Step1ChooseTemplate({ selectedTemplate, onSelect, onNext, onBack }: Step1Props) {
const [templates, setTemplates] = useState<MessageTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchTemplates() {
try {
const supabase = createClient();
// Only APPROVED templates can be sent via Meta — anything else
// would 400 at broadcast time. Hide them rather than letting
// the user pick a template that will fail.
const { data, error: fetchError } = await supabase
.from('message_templates')
.select('*')
.eq('status', 'APPROVED')
.order('created_at', { ascending: false });
if (fetchError) throw fetchError;
setTemplates(data ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load templates');
} finally {
setLoading(false);
}
}
fetchTemplates();
}, []);
if (loading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
);
}
if (error) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-2">
<p className="text-sm text-red-400">{error}</p>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-foreground">Choose a Template</h2>
<p className="mt-1 text-sm text-muted-foreground">
Select an approved message template for your broadcast.
</p>
</div>
{templates.length === 0 ? (
<div className="flex h-48 flex-col items-center justify-center rounded-xl border border-border bg-card/50">
<FileText className="mb-2 h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No templates available.</p>
<p className="mt-1 text-xs text-muted-foreground">Create a template in Settings first.</p>
</div>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{templates.map((template) => {
const isSelected = selectedTemplate?.id === template.id;
const catColor = categoryColors[template.category] ?? categoryColors.Utility;
return (
<button
key={template.id}
onClick={() => onSelect(template)}
className={`flex flex-col gap-3 rounded-xl border p-4 text-left transition-all ${
isSelected
? 'border-primary bg-primary/5 ring-1 ring-primary/30'
: 'border-border bg-card/50 hover:border-border hover:bg-card'
}`}
>
<div className="flex items-start justify-between">
<h3 className="text-sm font-medium text-foreground">{template.name}</h3>
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium ${catColor}`}
>
{template.category}
</span>
</div>
<p className="line-clamp-3 text-xs text-muted-foreground">{template.body_text}</p>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<span>{template.language ?? 'en_US'}</span>
{/* Status is omitted on purpose — every template
shown here is already filtered to APPROVED,
so the chip carried no information. */}
</div>
</button>
);
})}
</div>
)}
<div className="flex items-center justify-between border-t border-border pt-4">
<Button variant="outline" onClick={onBack} className="border-border text-muted-foreground">
Back
</Button>
<Button
onClick={onNext}
disabled={!selectedTemplate}
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Next
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,472 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { createClient } from '@/lib/supabase/client';
import { CustomField, Tag } from '@/types';
import { Button } from '@/components/ui/button';
import {
Users,
Tags,
Filter,
Upload,
Loader2,
ArrowRight,
ArrowLeft,
X,
} from 'lucide-react';
type AudienceType = 'all' | 'tags' | 'custom_field' | 'csv';
type CustomFieldOperator = 'is' | 'is_not' | 'contains';
interface CustomFieldFilter {
fieldId: string;
operator: CustomFieldOperator;
value: string;
}
interface AudienceConfig {
type: AudienceType;
tagIds?: string[];
customField?: CustomFieldFilter;
csvContacts?: { phone: string; name?: string }[];
excludeTagIds?: string[];
}
interface Step2Props {
audience: AudienceConfig;
onUpdate: (audience: AudienceConfig) => void;
onNext: () => void;
onBack: () => void;
}
const audienceOptions: {
type: AudienceType;
label: string;
description: string;
icon: typeof Users;
}[] = [
{
type: 'all',
label: 'All Contacts',
description: 'Send to every contact in your database',
icon: Users,
},
{
type: 'tags',
label: 'Filter by Tags',
description: 'Target contacts with specific tags',
icon: Tags,
},
{
type: 'custom_field',
label: 'Custom Field',
description: 'Filter by a custom field value',
icon: Filter,
},
{
type: 'csv',
label: 'Upload CSV',
description: 'Upload a list of phone numbers',
icon: Upload,
},
];
const OPERATOR_OPTIONS: { value: CustomFieldOperator; label: string }[] = [
{ value: 'is', label: 'is' },
{ value: 'is_not', label: 'is not' },
{ value: 'contains', label: 'contains' },
];
export function Step2SelectAudience({
audience,
onUpdate,
onNext,
onBack,
}: Step2Props) {
const [tags, setTags] = useState<Tag[]>([]);
const [customFields, setCustomFields] = useState<CustomField[]>([]);
const [loadingTags, setLoadingTags] = useState(false);
const [loadingFields, setLoadingFields] = useState(false);
const [estimatedCount, setEstimatedCount] = useState<number | null>(null);
const [loadingCount, setLoadingCount] = useState(false);
// Tags are used both by the primary "Filter by Tags" audience type
// AND by the exclude-list below — so always load once on mount.
useEffect(() => {
async function fetchTags() {
setLoadingTags(true);
try {
const supabase = createClient();
const { data } = await supabase.from('tags').select('*').order('name');
setTags(data ?? []);
} finally {
setLoadingTags(false);
}
}
fetchTags();
}, []);
// Lazy-load custom fields only when that audience type is active.
useEffect(() => {
if (audience.type !== 'custom_field') return;
async function fetchFields() {
setLoadingFields(true);
try {
const supabase = createClient();
const { data } = await supabase
.from('custom_fields')
.select('*')
.order('field_name');
setCustomFields(data ?? []);
} finally {
setLoadingFields(false);
}
}
fetchFields();
}, [audience.type]);
const fetchEstimatedCount = useCallback(async () => {
setLoadingCount(true);
try {
const supabase = createClient();
// Base query — produces the superset before exclude is applied.
let baseIds: Set<string> | null = null; // null means "all contacts"
if (audience.type === 'all') {
// Handled below — full-table count adjusted by excludes.
} else if (
audience.type === 'tags' &&
audience.tagIds &&
audience.tagIds.length > 0
) {
const { data } = await supabase
.from('contact_tags')
.select('contact_id')
.in('tag_id', audience.tagIds);
baseIds = new Set((data ?? []).map((r) => r.contact_id));
} else if (
audience.type === 'custom_field' &&
audience.customField?.fieldId &&
audience.customField.value
) {
const { fieldId, operator, value } = audience.customField;
let q = supabase
.from('contact_custom_values')
.select('contact_id')
.eq('custom_field_id', fieldId);
if (operator === 'is') q = q.eq('value', value);
else if (operator === 'is_not') q = q.neq('value', value);
else q = q.ilike('value', `%${value}%`);
const { data } = await q;
baseIds = new Set((data ?? []).map((r) => r.contact_id));
} else if (
audience.type === 'csv' &&
audience.csvContacts &&
audience.csvContacts.length > 0
) {
setEstimatedCount(audience.csvContacts.length);
return;
} else {
// Partially-configured audience — wait for the user to finish.
setEstimatedCount(null);
return;
}
// Apply exclude tags
let excludeSet: Set<string> | null = null;
if (audience.excludeTagIds && audience.excludeTagIds.length > 0) {
const { data: excludeRows } = await supabase
.from('contact_tags')
.select('contact_id')
.in('tag_id', audience.excludeTagIds);
excludeSet = new Set((excludeRows ?? []).map((r) => r.contact_id));
}
if (baseIds) {
const effective = [...baseIds].filter(
(id) => !excludeSet?.has(id),
);
setEstimatedCount(effective.length);
} else {
// "All" — fetch the total, then subtract exclude set if any.
const { count } = await supabase
.from('contacts')
.select('*', { count: 'exact', head: true });
const total = count ?? 0;
setEstimatedCount(excludeSet ? Math.max(0, total - excludeSet.size) : total);
}
} finally {
setLoadingCount(false);
}
}, [
audience.type,
audience.tagIds,
audience.customField,
audience.csvContacts,
audience.excludeTagIds,
]);
useEffect(() => {
fetchEstimatedCount();
}, [fetchEstimatedCount]);
function toggleTag(tagId: string) {
const current = audience.tagIds ?? [];
const updated = current.includes(tagId)
? current.filter((id) => id !== tagId)
: [...current, tagId];
onUpdate({ ...audience, tagIds: updated });
}
function toggleExcludeTag(tagId: string) {
const current = audience.excludeTagIds ?? [];
const updated = current.includes(tagId)
? current.filter((id) => id !== tagId)
: [...current, tagId];
onUpdate({ ...audience, excludeTagIds: updated });
}
function updateCustomField(patch: Partial<CustomFieldFilter>) {
const prev = audience.customField ?? {
fieldId: '',
operator: 'is' as CustomFieldOperator,
value: '',
};
onUpdate({ ...audience, customField: { ...prev, ...patch } });
}
const isValid =
audience.type === 'all' ||
(audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) ||
(audience.type === 'custom_field' &&
!!audience.customField?.fieldId &&
audience.customField.value.length > 0) ||
(audience.type === 'csv' &&
audience.csvContacts &&
audience.csvContacts.length > 0);
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-foreground">Select Audience</h2>
<p className="mt-1 text-sm text-muted-foreground">
Choose who will receive this broadcast.
</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{audienceOptions.map((option) => {
const isSelected = audience.type === option.type;
const Icon = option.icon;
return (
<button
key={option.type}
onClick={() =>
onUpdate({
...audience,
type: option.type,
// Wipe shape fields from other types to avoid stale
// config leaking across selections.
tagIds: option.type === 'tags' ? audience.tagIds : undefined,
customField:
option.type === 'custom_field'
? audience.customField
: undefined,
csvContacts:
option.type === 'csv' ? audience.csvContacts : undefined,
})
}
className={`flex items-start gap-3 rounded-xl border p-4 text-left transition-all ${
isSelected
? 'border-primary bg-primary/5 ring-1 ring-primary/30'
: 'border-border bg-card/50 hover:border-border'
}`}
>
<div
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${
isSelected
? 'bg-primary/10 text-primary'
: 'bg-muted text-muted-foreground'
}`}
>
<Icon className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium text-foreground">{option.label}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{option.description}
</p>
</div>
</button>
);
})}
</div>
{audience.type === 'tags' && (
<div className="rounded-xl border border-border bg-card/50 p-4">
<p className="mb-3 text-sm font-medium text-foreground">Select Tags</p>
{loadingTags ? (
<Loader2 className="h-5 w-5 animate-spin text-primary" />
) : tags.length === 0 ? (
<p className="text-xs text-muted-foreground">
No tags found. Create tags in Settings.
</p>
) : (
<div className="flex flex-wrap gap-2">
{tags.map((tag) => {
const isSelected = audience.tagIds?.includes(tag.id);
return (
<button
key={tag.id}
onClick={() => toggleTag(tag.id)}
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-all ${
isSelected
? 'border-primary/30 bg-primary/10 text-primary'
: 'border-border bg-muted text-muted-foreground hover:border-border'
}`}
>
<span
className="mr-1.5 h-2 w-2 rounded-full"
style={{ backgroundColor: tag.color }}
/>
{tag.name}
</button>
);
})}
</div>
)}
</div>
)}
{audience.type === 'custom_field' && (
<div className="space-y-3 rounded-xl border border-border bg-card/50 p-4">
<p className="text-sm font-medium text-foreground">Custom Field Filter</p>
{loadingFields ? (
<Loader2 className="h-5 w-5 animate-spin text-primary" />
) : customFields.length === 0 ? (
<p className="text-xs text-muted-foreground">
No custom fields defined. Create one in Settings Custom Fields.
</p>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_140px_minmax(0,1fr)]">
<select
value={audience.customField?.fieldId ?? ''}
onChange={(e) => updateCustomField({ fieldId: e.target.value })}
className="h-9 rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary"
>
<option value="">Select field</option>
{customFields.map((f) => (
<option key={f.id} value={f.id}>
{f.field_name}
</option>
))}
</select>
<select
value={audience.customField?.operator ?? 'is'}
onChange={(e) =>
updateCustomField({
operator: e.target.value as CustomFieldOperator,
})
}
className="h-9 rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary"
>
{OPERATOR_OPTIONS.map((op) => (
<option key={op.value} value={op.value}>
{op.label}
</option>
))}
</select>
<input
type="text"
value={audience.customField?.value ?? ''}
onChange={(e) => updateCustomField({ value: e.target.value })}
placeholder="Value"
className="h-9 rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
)}
</div>
)}
{/* Exclude list — applies regardless of audience type */}
<div className="rounded-xl border border-border bg-card/50 p-4">
<div className="mb-3 flex items-center gap-2">
<X className="h-4 w-4 text-red-400" />
<p className="text-sm font-medium text-foreground">
Exclude contacts with these tags
</p>
<span className="text-xs text-muted-foreground">(optional)</span>
</div>
{tags.length === 0 ? (
<p className="text-xs text-muted-foreground">No tags available.</p>
) : (
<div className="flex flex-wrap gap-2">
{tags.map((tag) => {
const isExcluded = audience.excludeTagIds?.includes(tag.id);
return (
<button
key={tag.id}
onClick={() => toggleExcludeTag(tag.id)}
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-all ${
isExcluded
? 'border-red-500/30 bg-red-500/10 text-red-300'
: 'border-border bg-muted text-muted-foreground hover:border-border'
}`}
>
<span
className="mr-1.5 h-2 w-2 rounded-full"
style={{ backgroundColor: tag.color }}
/>
{tag.name}
</button>
);
})}
</div>
)}
</div>
{/* Audience Summary */}
<div className="rounded-xl border border-border bg-card/50 p-4">
<p className="mb-2 text-sm font-medium text-foreground">Audience Summary</p>
{loadingCount ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="text-xs text-muted-foreground">Calculating</span>
</div>
) : estimatedCount !== null ? (
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-primary" />
<span className="text-sm text-foreground">
{estimatedCount.toLocaleString()}
</span>
<span className="text-xs text-muted-foreground">estimated recipients</span>
</div>
) : (
<p className="text-xs text-muted-foreground">
Select an audience type to see the estimate.
</p>
)}
</div>
<div className="flex items-center justify-between border-t border-border pt-4">
<Button
variant="outline"
onClick={onBack}
className="border-border text-muted-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<Button
onClick={onNext}
disabled={!isValid}
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Next
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,460 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import { Contact, CustomField, MessageTemplate } from '@/types';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ArrowLeft, ArrowRight, Eye, ImageIcon, Loader2 } from 'lucide-react';
type VariableType = 'static' | 'field' | 'custom_field';
interface VariableMapping {
type: VariableType;
value: string;
}
interface Step3Props {
template: MessageTemplate;
variables: Record<string, VariableMapping>;
onUpdate: (variables: Record<string, VariableMapping>) => void;
/** Media URL for an IMAGE/VIDEO/DOCUMENT header, when the template has one. */
headerMediaUrl: string;
onHeaderMediaUrlChange: (url: string) => void;
onNext: () => void;
onBack: () => void;
}
const MEDIA_HEADER_TYPES = ['image', 'video', 'document'] as const;
type MediaHeaderType = (typeof MEDIA_HEADER_TYPES)[number];
function isMediaHeaderType(value: unknown): value is MediaHeaderType {
return MEDIA_HEADER_TYPES.includes(value as MediaHeaderType);
}
function isValidHttpUrl(value: string): boolean {
try {
const u = new URL(value);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}
const contactFields = [
{ value: 'name', label: 'Contact Name' },
{ value: 'phone', label: 'Phone Number' },
{ value: 'email', label: 'Email Address' },
{ value: 'company', label: 'Company' },
];
const SAMPLE_CONTACT: Contact = {
id: 'sample',
user_id: '',
account_id: '',
name: 'John Doe',
phone: '+1234567890',
email: 'john@example.com',
company: 'Acme Corp',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
export function Step3Personalize({
template,
variables,
onUpdate,
headerMediaUrl,
onHeaderMediaUrlChange,
onNext,
onBack,
}: Step3Props) {
const [customFields, setCustomFields] = useState<CustomField[]>([]);
const [loadingFields, setLoadingFields] = useState(true);
const [firstContact, setFirstContact] = useState<Contact | null>(null);
const [firstContactCustomValues, setFirstContactCustomValues] = useState<
Map<string, string>
>(new Map());
const [loadingPreview, setLoadingPreview] = useState(true);
// Load user's custom fields + a representative contact for the
// live preview. Fall back to sample data if no contacts exist yet.
useEffect(() => {
let cancelled = false;
(async () => {
const supabase = createClient();
const [fieldsRes, contactRes] = await Promise.all([
supabase.from('custom_fields').select('*').order('field_name'),
supabase
.from('contacts')
.select('*')
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle(),
]);
if (cancelled) return;
setCustomFields(fieldsRes.data ?? []);
setLoadingFields(false);
const contact = contactRes.data ?? null;
setFirstContact(contact);
if (contact) {
const { data: customVals } = await supabase
.from('contact_custom_values')
.select('custom_field_id, value')
.eq('contact_id', contact.id);
if (!cancelled) {
const map = new Map<string, string>();
for (const row of customVals ?? []) {
map.set(row.custom_field_id, row.value ?? '');
}
setFirstContactCustomValues(map);
}
}
setLoadingPreview(false);
})();
return () => {
cancelled = true;
};
}, []);
const placeholders = useMemo(() => {
const matches = template.body_text.match(/\{\{(\d+)\}\}/g);
if (!matches) return [];
return [...new Set(matches)].sort();
}, [template.body_text]);
// Templates with an IMAGE/VIDEO/DOCUMENT header need a media URL at
// send time — Meta requires the media component on every delivery and
// rejects the broadcast without it. The field is hidden for text-only
// headers.
const mediaHeaderType = isMediaHeaderType(template.header_type)
? template.header_type
: null;
// Seed the field with the template's stored sample URL the first time
// we land on a media-header template, so the common "reuse the
// approved media" case needs no typing. Only seeds when empty to avoid
// clobbering a URL the user already edited.
useEffect(() => {
if (mediaHeaderType && !headerMediaUrl && template.header_media_url) {
onHeaderMediaUrlChange(template.header_media_url);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mediaHeaderType, template.header_media_url]);
const headerMediaError = useMemo<'missing' | 'invalid' | null>(() => {
if (!mediaHeaderType) return null;
const value = headerMediaUrl.trim();
if (!value) return 'missing';
if (!isValidHttpUrl(value)) return 'invalid';
return null;
}, [mediaHeaderType, headerMediaUrl]);
/**
* A placeholder is "unmapped" if the user hasn't picked either a
* static value or a field/custom-field source. Blocks Next until
* every placeholder has something — otherwise the broadcast would
* ship with empty strings and confuse recipients.
*/
const unmappedKeys = useMemo(() => {
const missing: string[] = [];
for (const placeholder of placeholders) {
const key = placeholder.replace(/^\{\{|\}\}$/g, '');
const mapping = variables[key];
if (!mapping || !mapping.value?.trim()) {
missing.push(placeholder);
}
}
return missing;
}, [placeholders, variables]);
function updateVariable(key: string, patch: Partial<VariableMapping>) {
const current = variables[key] ?? { type: 'static' as VariableType, value: '' };
onUpdate({
...variables,
[key]: { ...current, ...patch },
});
}
/**
* Substitute placeholders using the first real contact where
* possible. Placeholders keyed by "{{N}}" map to variable key "N".
*/
const previewText = useMemo(() => {
const contact = firstContact ?? SAMPLE_CONTACT;
const customValues = firstContact
? firstContactCustomValues
: new Map<string, string>();
let text = template.body_text;
for (const placeholder of placeholders) {
const key = placeholder.replace(/^\{\{|\}\}$/g, '');
const mapping = variables[key];
let replacement = placeholder;
if (mapping) {
if (mapping.type === 'static' && mapping.value) {
replacement = mapping.value;
} else if (mapping.type === 'field' && mapping.value) {
const fieldMap: Record<string, string | undefined> = {
name: contact.name,
phone: contact.phone,
email: contact.email,
company: contact.company,
};
replacement = fieldMap[mapping.value] ?? placeholder;
} else if (mapping.type === 'custom_field' && mapping.value) {
replacement = customValues.get(mapping.value) || placeholder;
}
}
text = text.replaceAll(placeholder, replacement);
}
return text;
}, [
template.body_text,
variables,
placeholders,
firstContact,
firstContactCustomValues,
]);
const previewLabel = firstContact
? firstContact.name || firstContact.phone
: 'sample data';
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-foreground">Personalize Message</h2>
<p className="mt-1 text-sm text-muted-foreground">
Map template variables to contact fields, custom fields, or static
values.
</p>
</div>
{mediaHeaderType && (
<div className="rounded-xl border border-border bg-card/50 p-4">
<div className="mb-3 flex items-center gap-2">
<ImageIcon className="h-4 w-4 text-primary" />
<p className="text-sm font-medium text-foreground">Header media</p>
<span className="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium uppercase text-primary">
{mediaHeaderType}
</span>
</div>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">
Media URL
</label>
<Input
type="url"
value={headerMediaUrl}
onChange={(e) => onHeaderMediaUrlChange(e.target.value)}
placeholder={`https://example.com/header.${
mediaHeaderType === 'image'
? 'jpg'
: mediaHeaderType === 'video'
? 'mp4'
: 'pdf'
}`}
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
/>
<p className="mt-1.5 text-xs text-muted-foreground">
Public URL of the {mediaHeaderType} sent as the message header.
Used for every recipient in this broadcast.
</p>
{mediaHeaderType === 'image' &&
headerMediaError === null &&
headerMediaUrl.trim() && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={headerMediaUrl.trim()}
alt="Header preview"
className="mt-3 max-h-40 rounded-lg border border-border object-contain"
/>
)}
{headerMediaError && (
<p className="mt-1.5 text-xs text-amber-300">
{headerMediaError === 'missing'
? 'A media URL is required to send this template.'
: 'Enter a valid http(s) URL.'}
</p>
)}
</div>
)}
{placeholders.length === 0 && !mediaHeaderType ? (
<div className="rounded-xl border border-border bg-card/50 p-6 text-center">
<p className="text-sm text-muted-foreground">
This template has no variables to personalize.
</p>
</div>
) : placeholders.length === 0 ? null : (
<div className="space-y-4">
{placeholders.map((placeholder) => {
const key = placeholder.replace(/^\{\{|\}\}$/g, '');
const mapping = variables[key] ?? { type: 'static', value: '' };
return (
<div
key={placeholder}
className="rounded-xl border border-border bg-card/50 p-4"
>
<div className="mb-3 flex items-center gap-2">
<span className="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-xs font-mono font-medium text-primary">
{placeholder}
</span>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">
Mapping Type
</label>
<Select
value={mapping.type}
onValueChange={(val) =>
updateVariable(key, {
type: val as VariableType,
value: '',
})
}
>
<SelectTrigger className="w-full border-border bg-muted text-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent className="border-border bg-popover">
<SelectItem value="static">Static Value</SelectItem>
<SelectItem value="field">Contact Field</SelectItem>
<SelectItem value="custom_field">
Custom Field
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">
{mapping.type === 'static' ? 'Value' : 'Field'}
</label>
{mapping.type === 'static' ? (
<Input
value={mapping.value}
onChange={(e) =>
updateVariable(key, { value: e.target.value })
}
placeholder="Enter value..."
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
/>
) : mapping.type === 'field' ? (
<Select
value={mapping.value || undefined}
onValueChange={(val) =>
updateVariable(key, { value: val || '' })
}
>
<SelectTrigger className="w-full border-border bg-muted text-foreground">
<SelectValue placeholder="Select field..." />
</SelectTrigger>
<SelectContent className="border-border bg-popover">
{contactFields.map((field) => (
<SelectItem key={field.value} value={field.value}>
{field.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Select
value={mapping.value || undefined}
onValueChange={(val) =>
updateVariable(key, { value: val || '' })
}
>
<SelectTrigger className="w-full border-border bg-muted text-foreground">
<SelectValue
placeholder={
loadingFields
? 'Loading…'
: customFields.length === 0
? 'No custom fields'
: 'Select custom field…'
}
/>
</SelectTrigger>
<SelectContent className="border-border bg-popover">
{customFields.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.field_name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
</div>
);
})}
</div>
)}
{/* Live Preview — rendered as a WhatsApp-style bubble so the user
sees approximately what the recipient will see. */}
<div className="rounded-xl border border-border bg-card/50 p-4">
<div className="mb-3 flex items-center gap-2">
<Eye className="h-4 w-4 text-primary" />
<p className="text-sm font-medium text-foreground">Live Preview</p>
<span className="text-xs text-muted-foreground">({previewLabel})</span>
{loadingPreview && (
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
)}
</div>
<div className="rounded-lg bg-[#0e1a12] p-3">
<div className="ml-auto max-w-[85%] rounded-lg bg-primary/30 px-3 py-2 shadow-sm">
<p className="whitespace-pre-wrap text-sm text-primary">
{previewText}
</p>
</div>
</div>
</div>
{unmappedKeys.length > 0 && (
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-300">
Map every placeholder before continuing still missing{' '}
<span className="font-mono font-semibold">
{unmappedKeys.join(', ')}
</span>
. Otherwise those placeholders will ship to Meta as empty strings.
</div>
)}
<div className="flex items-center justify-between border-t border-border pt-4">
<Button
variant="outline"
onClick={onBack}
className="border-border text-muted-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<Button
onClick={onNext}
disabled={unmappedKeys.length > 0 || headerMediaError !== null}
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Next
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,236 @@
'use client';
import { useEffect, useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import { MessageTemplate } from '@/types';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { ArrowLeft, Send, Loader2, Users, Save } from 'lucide-react';
interface AudienceConfig {
type: string;
tagIds?: string[];
csvContacts?: { phone: string; name?: string }[];
}
interface Step4Props {
name: string;
onNameChange: (name: string) => void;
template: MessageTemplate;
audience: AudienceConfig;
onSend: () => void;
onSaveDraft?: () => void;
onBack: () => void;
isProcessing: boolean;
progress: number;
}
export function Step4ScheduleSend({
name,
onNameChange,
template,
audience,
onSend,
onSaveDraft,
onBack,
isProcessing,
progress,
}: Step4Props) {
const [showConfirm, setShowConfirm] = useState(false);
const [estimatedReach, setEstimatedReach] = useState<number>(0);
const [loadingReach, setLoadingReach] = useState(true);
useEffect(() => {
async function calculateReach() {
setLoadingReach(true);
try {
const supabase = createClient();
if (audience.type === 'all') {
const { count } = await supabase
.from('contacts')
.select('*', { count: 'exact', head: true });
setEstimatedReach(count ?? 0);
} else if (audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) {
const { data: contactTags } = await supabase
.from('contact_tags')
.select('contact_id')
.in('tag_id', audience.tagIds);
const uniqueIds = new Set((contactTags ?? []).map((ct) => ct.contact_id));
setEstimatedReach(uniqueIds.size);
} else if (audience.type === 'csv' && audience.csvContacts) {
setEstimatedReach(audience.csvContacts.length);
} else {
setEstimatedReach(0);
}
} finally {
setLoadingReach(false);
}
}
calculateReach();
}, [audience]);
const audienceLabel =
audience.type === 'all'
? 'All Contacts'
: audience.type === 'tags'
? `Tags (${audience.tagIds?.length ?? 0} selected)`
: audience.type === 'csv'
? 'CSV Upload'
: 'Custom';
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-foreground">Review & Send</h2>
<p className="mt-1 text-sm text-muted-foreground">
Name your broadcast, review the details, and send.
</p>
</div>
{/* Broadcast Name */}
<div>
<label className="mb-1.5 block text-sm font-medium text-foreground">Broadcast Name</label>
<Input
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder="e.g. Summer Sale Announcement"
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
/>
</div>
{/* Summary Card */}
<div className="rounded-xl border border-border bg-card/50 p-4 space-y-3">
<p className="text-sm font-medium text-foreground">Summary</p>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-xs text-muted-foreground">Template</p>
<p className="text-foreground">{template.name}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Audience</p>
<p className="text-foreground">{audienceLabel}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Estimated Reach</p>
<div className="flex items-center gap-1.5">
{loadingReach ? (
<Loader2 className="h-3 w-3 animate-spin text-primary" />
) : (
<>
<Users className="h-3.5 w-3.5 text-primary" />
<p className="font-medium text-foreground">{estimatedReach.toLocaleString()}</p>
</>
)}
</div>
</div>
<div>
<p className="text-xs text-muted-foreground">Language</p>
<p className="text-foreground">{template.language ?? 'en_US'}</p>
</div>
</div>
</div>
{/* Processing overlay */}
{isProcessing && (
<div className="rounded-xl border border-primary/20 bg-primary/5 p-4">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<p className="text-sm font-medium text-foreground">Sending broadcast...</p>
</div>
<span className="text-xs font-medium text-primary">{progress}%</span>
</div>
<div className="h-1.5 w-full rounded-full bg-muted">
<div
className="h-1.5 rounded-full bg-primary transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border pt-4">
<Button
variant="outline"
onClick={onBack}
disabled={isProcessing}
className="border-border text-muted-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<div className="flex items-center gap-2">
{onSaveDraft && (
<Button
variant="outline"
onClick={onSaveDraft}
disabled={!name.trim() || isProcessing}
className="border-border text-muted-foreground hover:bg-muted disabled:opacity-50"
>
<Save className="h-4 w-4" />
Save as Draft
</Button>
)}
<Dialog open={showConfirm} onOpenChange={setShowConfirm}>
<DialogTrigger
render={
<Button
disabled={!name.trim() || isProcessing}
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
/>
}
>
<Send className="h-4 w-4" />
Send Broadcast
</DialogTrigger>
<DialogContent className="border-border bg-popover sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-popover-foreground">Confirm Broadcast</DialogTitle>
<DialogDescription className="text-muted-foreground">
You are about to send this broadcast to{' '}
<span className="font-medium text-popover-foreground">{estimatedReach.toLocaleString()}</span>{' '}
contacts using the{' '}
<span className="font-medium text-popover-foreground">{template.name}</span> template.
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowConfirm(false)}
className="border-border text-muted-foreground"
>
Cancel
</Button>
<Button
onClick={() => {
setShowConfirm(false);
onSend();
}}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
<Send className="h-4 w-4" />
Confirm & Send
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,763 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth';
import { formatCurrency } from '@/lib/currency';
import { toast } from 'sonner';
import type { Contact, Tag, ContactTag, ContactNote, CustomField, ContactCustomValue, Deal, MessageTemplate } from '@/types';
import {
TemplatePicker,
type TemplateSendValues,
} from '@/components/inbox/template-picker';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from '@/components/ui/sheet';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Phone,
Mail,
Building2,
Copy,
Check,
Loader2,
Plus,
Trash2,
Save,
X,
DollarSign,
LayoutTemplate,
} from 'lucide-react';
interface ContactDetailViewProps {
open: boolean;
onOpenChange: (open: boolean) => void;
contactId: string | null;
onUpdated: () => void;
}
export function ContactDetailView({
open,
onOpenChange,
contactId,
onUpdated,
}: ContactDetailViewProps) {
const supabase = createClient();
const { accountId, defaultCurrency } = useAuth();
const [contact, setContact] = useState<Contact | null>(null);
const [loading, setLoading] = useState(false);
const [copiedPhone, setCopiedPhone] = useState(false);
// Send template — lets the business initiate (or re-open) a conversation
// with this contact by sending an approved template. The send route
// find-or-creates the conversation, so no inbound message is required.
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
const [sendingTemplate, setSendingTemplate] = useState(false);
// Details tab
const [editName, setEditName] = useState('');
const [editPhone, setEditPhone] = useState('');
const [editEmail, setEditEmail] = useState('');
const [editCompany, setEditCompany] = useState('');
const [savingDetails, setSavingDetails] = useState(false);
// Tags tab
const [allTags, setAllTags] = useState<Tag[]>([]);
const [contactTagIds, setContactTagIds] = useState<string[]>([]);
const [savingTags, setSavingTags] = useState(false);
// Notes tab
const [notes, setNotes] = useState<ContactNote[]>([]);
const [newNote, setNewNote] = useState('');
const [savingNote, setSavingNote] = useState(false);
const [loadingNotes, setLoadingNotes] = useState(false);
// Custom fields tab
const [customFields, setCustomFields] = useState<CustomField[]>([]);
const [customValues, setCustomValues] = useState<Record<string, string>>({});
const [savingCustom, setSavingCustom] = useState(false);
const [loadingCustom, setLoadingCustom] = useState(false);
// Deals tab
const [deals, setDeals] = useState<Deal[]>([]);
const [loadingDeals, setLoadingDeals] = useState(false);
const fetchContact = useCallback(async () => {
if (!contactId) return;
setLoading(true);
const { data } = await supabase
.from('contacts')
.select('*')
.eq('id', contactId)
.single();
if (data) {
setContact(data);
setEditName(data.name ?? '');
setEditPhone(data.phone);
setEditEmail(data.email ?? '');
setEditCompany(data.company ?? '');
}
setLoading(false);
}, [contactId, supabase]);
const fetchTags = useCallback(async () => {
if (!contactId) return;
const [tagsRes, contactTagsRes] = await Promise.all([
supabase.from('tags').select('*').order('name'),
supabase.from('contact_tags').select('tag_id').eq('contact_id', contactId),
]);
if (tagsRes.data) setAllTags(tagsRes.data);
if (contactTagsRes.data) {
setContactTagIds(contactTagsRes.data.map((ct) => ct.tag_id));
}
}, [contactId, supabase]);
const fetchNotes = useCallback(async () => {
if (!contactId) return;
setLoadingNotes(true);
const { data } = await supabase
.from('contact_notes')
.select('*')
.eq('contact_id', contactId)
.order('created_at', { ascending: false });
if (data) setNotes(data);
setLoadingNotes(false);
}, [contactId, supabase]);
const fetchCustomFields = useCallback(async () => {
if (!contactId) return;
setLoadingCustom(true);
const [fieldsRes, valuesRes] = await Promise.all([
supabase.from('custom_fields').select('*').order('field_name'),
supabase
.from('contact_custom_values')
.select('*')
.eq('contact_id', contactId),
]);
if (fieldsRes.data) setCustomFields(fieldsRes.data);
if (valuesRes.data) {
const map: Record<string, string> = {};
valuesRes.data.forEach((v) => {
map[v.custom_field_id] = v.value ?? '';
});
setCustomValues(map);
}
setLoadingCustom(false);
}, [contactId, supabase]);
const fetchDeals = useCallback(async () => {
if (!contactId) return;
setLoadingDeals(true);
const { data } = await supabase
.from('deals')
.select('*, stage:pipeline_stages(*)')
.eq('contact_id', contactId)
.order('created_at', { ascending: false });
setDeals((data ?? []) as Deal[]);
setLoadingDeals(false);
}, [contactId, supabase]);
useEffect(() => {
if (open && contactId) {
fetchContact();
fetchTags();
fetchNotes();
fetchCustomFields();
fetchDeals();
}
}, [open, contactId, fetchContact, fetchTags, fetchNotes, fetchCustomFields, fetchDeals]);
async function copyPhone() {
if (!contact) return;
await navigator.clipboard.writeText(contact.phone);
setCopiedPhone(true);
setTimeout(() => setCopiedPhone(false), 2000);
}
async function saveDetails() {
if (!contactId || !editPhone.trim()) {
toast.error('Phone number is required');
return;
}
setSavingDetails(true);
const { error } = await supabase
.from('contacts')
.update({
name: editName.trim() || null,
phone: editPhone.trim(),
email: editEmail.trim() || null,
company: editCompany.trim() || null,
updated_at: new Date().toISOString(),
})
.eq('id', contactId);
if (error) {
toast.error('Failed to update contact');
} else {
toast.success('Contact updated');
fetchContact();
onUpdated();
}
setSavingDetails(false);
}
async function toggleTag(tagId: string) {
if (!contactId) return;
setSavingTags(true);
const isSelected = contactTagIds.includes(tagId);
if (isSelected) {
const { error } = await supabase
.from('contact_tags')
.delete()
.eq('contact_id', contactId)
.eq('tag_id', tagId);
if (!error) {
setContactTagIds((prev) => prev.filter((id) => id !== tagId));
onUpdated();
}
} else {
const { error } = await supabase
.from('contact_tags')
.insert({ contact_id: contactId, tag_id: tagId });
if (!error) {
setContactTagIds((prev) => [...prev, tagId]);
onUpdated();
}
}
setSavingTags(false);
}
async function addNote() {
if (!contactId || !newNote.trim()) return;
setSavingNote(true);
const {
data: { session },
} = await supabase.auth.getSession();
const user = session?.user;
if (!user || !accountId) {
toast.error('Not authenticated');
setSavingNote(false);
return;
}
const { error } = await supabase.from('contact_notes').insert({
contact_id: contactId,
account_id: accountId,
user_id: user.id,
note_text: newNote.trim(),
});
if (error) {
toast.error('Failed to add note');
} else {
setNewNote('');
fetchNotes();
toast.success('Note added');
}
setSavingNote(false);
}
async function deleteNote(noteId: string) {
const { error } = await supabase
.from('contact_notes')
.delete()
.eq('id', noteId);
if (error) {
toast.error('Failed to delete note');
} else {
setNotes((prev) => prev.filter((n) => n.id !== noteId));
toast.success('Note deleted');
}
}
async function saveCustomFields() {
if (!contactId) return;
setSavingCustom(true);
try {
// Delete existing values and re-insert
await supabase
.from('contact_custom_values')
.delete()
.eq('contact_id', contactId);
const rows = Object.entries(customValues)
.filter(([, val]) => val.trim())
.map(([fieldId, val]) => ({
contact_id: contactId,
custom_field_id: fieldId,
value: val.trim(),
}));
if (rows.length > 0) {
const { error } = await supabase
.from('contact_custom_values')
.insert(rows);
if (error) throw error;
}
toast.success('Custom fields saved');
} catch {
toast.error('Failed to save custom fields');
}
setSavingCustom(false);
}
async function handleSendTemplate(
template: MessageTemplate,
values: TemplateSendValues,
) {
if (!contactId) return;
setSendingTemplate(true);
try {
const res = await fetch('/api/whatsapp/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
// No conversation_id — the route find-or-creates one for this
// contact, mirroring the inbox template-send payload otherwise.
contact_id: contactId,
message_type: 'template',
template_name: template.name,
template_language: template.language,
template_message_params: {
body: values.body,
headerText: values.headerText,
buttonParams: values.buttonParams,
},
template_params: values.body,
}),
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
const reason = payload?.error || `HTTP ${res.status}`;
toast.error(`Failed to send template: ${reason}`);
return;
}
toast.success(`Template "${template.name}" sent`);
} catch (err) {
const reason = err instanceof Error ? err.message : 'network error';
toast.error(`Failed to send template: ${reason}`);
} finally {
setSendingTemplate(false);
}
}
function getInitials(name?: string | null) {
if (!name) return '?';
return name
.split(' ')
.map((w) => w[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
return (
<>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="bg-popover border-border text-popover-foreground sm:max-w-lg w-full p-0"
>
{loading || !contact ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="size-6 animate-spin text-primary" />
</div>
) : (
<div className="flex flex-col h-full">
{/* Header */}
<SheetHeader className="p-4 border-b border-border/50">
<div className="flex items-center gap-3">
<Avatar className="size-12 bg-muted border border-border">
<AvatarFallback className="bg-primary/10 text-primary text-sm font-medium">
{getInitials(contact.name)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<SheetTitle className="text-popover-foreground truncate">
{contact.name || 'Unknown'}
</SheetTitle>
<SheetDescription className="text-muted-foreground text-xs mt-0.5">
Contact details
</SheetDescription>
<div className="flex flex-wrap items-center gap-3 mt-1.5 text-xs text-muted-foreground">
<button
onClick={copyPhone}
className="flex items-center gap-1 hover:text-primary transition-colors cursor-pointer"
>
<Phone className="size-3" />
{contact.phone}
{copiedPhone ? (
<Check className="size-3 text-primary" />
) : (
<Copy className="size-3" />
)}
</button>
{contact.email && (
<span className="flex items-center gap-1">
<Mail className="size-3" />
{contact.email}
</span>
)}
{contact.company && (
<span className="flex items-center gap-1">
<Building2 className="size-3" />
{contact.company}
</span>
)}
</div>
</div>
</div>
<div className="mt-3">
<Button
size="sm"
onClick={() => setTemplatePickerOpen(true)}
disabled={sendingTemplate}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
{sendingTemplate ? (
<Loader2 className="size-4 animate-spin" />
) : (
<LayoutTemplate className="size-4" />
)}
Send template
</Button>
</div>
</SheetHeader>
{/* Tabs */}
<Tabs defaultValue="details" className="flex-1 flex flex-col min-h-0">
<TabsList className="bg-muted/50 border-b border-border mx-4 mt-3">
<TabsTrigger
value="details"
className="data-active:bg-muted data-active:text-primary text-muted-foreground"
>
Details
</TabsTrigger>
<TabsTrigger
value="tags"
className="data-active:bg-muted data-active:text-primary text-muted-foreground"
>
Tags
</TabsTrigger>
<TabsTrigger
value="notes"
className="data-active:bg-muted data-active:text-primary text-muted-foreground"
>
Notes
</TabsTrigger>
<TabsTrigger
value="custom"
className="data-active:bg-muted data-active:text-primary text-muted-foreground"
>
Custom Fields
</TabsTrigger>
<TabsTrigger
value="deals"
className="data-active:bg-muted data-active:text-primary text-muted-foreground"
>
Deals
</TabsTrigger>
</TabsList>
{/* Details Tab */}
<TabsContent value="details" className="flex-1 overflow-y-auto px-4 py-3">
<div className="space-y-3">
<div className="space-y-1.5">
<Label className="text-muted-foreground text-xs">Name</Label>
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="bg-muted border-border text-foreground h-8 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-muted-foreground text-xs">
Phone <span className="text-red-400">*</span>
</Label>
<Input
value={editPhone}
onChange={(e) => setEditPhone(e.target.value)}
className="bg-muted border-border text-foreground h-8 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-muted-foreground text-xs">Email</Label>
<Input
value={editEmail}
onChange={(e) => setEditEmail(e.target.value)}
className="bg-muted border-border text-foreground h-8 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-muted-foreground text-xs">Company</Label>
<Input
value={editCompany}
onChange={(e) => setEditCompany(e.target.value)}
className="bg-muted border-border text-foreground h-8 text-sm"
/>
</div>
<Button
onClick={saveDetails}
disabled={savingDetails}
className="bg-primary hover:bg-primary/90 text-primary-foreground w-full"
size="sm"
>
{savingDetails ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Save className="size-3.5" />
)}
Save Changes
</Button>
</div>
</TabsContent>
{/* Tags Tab */}
<TabsContent value="tags" className="flex-1 overflow-y-auto px-4 py-3">
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
Click a tag to add or remove it from this contact.
</p>
{allTags.length === 0 ? (
<p className="text-sm text-muted-foreground">
No tags available. Create tags in Settings.
</p>
) : (
<div className="flex flex-wrap gap-2">
{allTags.map((tag) => {
const selected = contactTagIds.includes(tag.id);
return (
<button
key={tag.id}
onClick={() => toggleTag(tag.id)}
disabled={savingTags}
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium transition-all cursor-pointer ${
selected
? 'ring-2 ring-primary ring-offset-1 ring-offset-border'
: 'opacity-50 hover:opacity-80'
}`}
style={{
backgroundColor: tag.color + '20',
color: tag.color,
}}
>
{selected && <Check className="size-3 mr-1" />}
{tag.name}
</button>
);
})}
</div>
)}
</div>
</TabsContent>
{/* Notes Tab */}
<TabsContent value="notes" className="flex-1 flex flex-col min-h-0 px-4 py-3">
<div className="space-y-2 mb-3">
<Textarea
value={newNote}
onChange={(e) => setNewNote(e.target.value)}
placeholder="Write a note..."
className="bg-muted border-border text-foreground placeholder:text-muted-foreground min-h-[60px] text-sm resize-none"
/>
<Button
onClick={addNote}
disabled={!newNote.trim() || savingNote}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
size="sm"
>
{savingNote ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Plus className="size-3.5" />
)}
Add Note
</Button>
</div>
<div className="flex-1 overflow-y-auto space-y-2">
{loadingNotes ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : notes.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No notes yet.
</p>
) : (
notes.map((note) => (
<div
key={note.id}
className="rounded-lg bg-muted/50 border border-border/50 p-3 group"
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm text-muted-foreground whitespace-pre-wrap flex-1">
{note.note_text}
</p>
<button
onClick={() => deleteNote(note.id)}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-red-400 transition-all cursor-pointer shrink-0"
>
<Trash2 className="size-3.5" />
</button>
</div>
<p className="text-xs text-muted-foreground mt-1.5">
{new Date(note.created_at).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
))
)}
</div>
</TabsContent>
{/* Custom Fields Tab */}
<TabsContent value="custom" className="flex-1 overflow-y-auto px-4 py-3">
{loadingCustom ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : customFields.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No custom fields defined. Create them in Settings.
</p>
) : (
<div className="space-y-3">
{customFields.map((field) => (
<div key={field.id} className="space-y-1.5">
<Label className="text-muted-foreground text-xs capitalize">
{field.field_name}
</Label>
<Input
value={customValues[field.id] ?? ''}
onChange={(e) =>
setCustomValues((prev) => ({
...prev,
[field.id]: e.target.value,
}))
}
placeholder={`Enter ${field.field_name}...`}
className="bg-muted border-border text-foreground h-8 text-sm placeholder:text-muted-foreground"
/>
</div>
))}
<Button
onClick={saveCustomFields}
disabled={savingCustom}
className="bg-primary hover:bg-primary/90 text-primary-foreground w-full"
size="sm"
>
{savingCustom ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Save className="size-3.5" />
)}
Save Custom Fields
</Button>
</div>
)}
</TabsContent>
{/* Deals Tab */}
<TabsContent value="deals" className="flex-1 overflow-y-auto px-4 py-3">
{loadingDeals ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-5 animate-spin text-primary" />
</div>
) : deals.length === 0 ? (
<p className="text-xs text-muted-foreground">No deals yet</p>
) : (
<div className="space-y-2">
{deals.map((deal) => (
<div
key={deal.id}
className="rounded-lg border border-border bg-muted/50 p-3"
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-foreground">
{deal.title}
</p>
{deal.stage && (
<span
className="shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-medium"
style={{
backgroundColor: `${deal.stage.color}20`,
color: deal.stage.color,
}}
>
{deal.stage.name}
</span>
)}
</div>
<div className="mt-1.5 flex items-center justify-between text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<DollarSign className="size-3" />
{formatCurrency(
deal.value ?? 0,
deal.currency || defaultCurrency,
)}
</span>
{deal.status && deal.status !== 'open' && (
<span
className={
deal.status === 'won'
? 'text-primary'
: 'text-red-400'
}
>
{deal.status}
</span>
)}
</div>
</div>
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
)}
</SheetContent>
</Sheet>
<TemplatePicker
open={templatePickerOpen}
onOpenChange={setTemplatePickerOpen}
onSelect={handleSendTemplate}
/>
</>
);
}

View File

@@ -0,0 +1,388 @@
'use client';
import { useState, useEffect } from 'react';
import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth';
import { toast } from 'sonner';
import type { Contact, Tag, ContactTag } from '@/types';
import {
findExistingContact,
isExactMatch,
isUniqueViolation,
type ExistingContact,
} from '@/lib/contacts/dedupe';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Loader2, AlertTriangle } from 'lucide-react';
interface ContactFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
contact?: Contact | null;
contactTags?: ContactTag[];
onSaved: () => void;
/** Open an existing contact's detail view — used by the duplicate
* notice to jump to the contact that already owns this number. */
onViewExisting?: (contactId: string) => void;
}
export function ContactForm({
open,
onOpenChange,
contact,
contactTags = [],
onSaved,
onViewExisting,
}: ContactFormProps) {
const supabase = createClient();
const { accountId } = useAuth();
const isEdit = !!contact;
const [name, setName] = useState('');
const [phone, setPhone] = useState('');
const [email, setEmail] = useState('');
const [company, setCompany] = useState('');
const [saving, setSaving] = useState(false);
// Duplicate-phone detection for NEW contacts. `exact` (same digits)
// hard-blocks the save; a fuzzy trunk-variant match only warns. The
// DB unique index (migration 022) is the real backstop — this is the
// friendly heads-up before we get there.
const [dupMatch, setDupMatch] = useState<
{ contact: ExistingContact; exact: boolean } | null
>(null);
const [checkingDup, setCheckingDup] = useState(false);
const [tags, setTags] = useState<Tag[]>([]);
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const [loadingTags, setLoadingTags] = useState(false);
useEffect(() => {
if (open) {
setName(contact?.name ?? '');
setPhone(contact?.phone ?? '');
setEmail(contact?.email ?? '');
setCompany(contact?.company ?? '');
setSelectedTagIds(contactTags.map((ct) => ct.tag_id));
setDupMatch(null);
fetchTags();
}
}, [open, contact]);
// Look up an existing contact with this number (new contacts only).
// Runs on blur so we don't query on every keystroke.
async function checkDuplicate() {
if (isEdit || !accountId) return;
const value = phone.trim();
if (!value) {
setDupMatch(null);
return;
}
setCheckingDup(true);
try {
const existing = await findExistingContact(supabase, accountId, value);
setDupMatch(
existing
? { contact: existing, exact: isExactMatch(existing, value) }
: null,
);
} finally {
setCheckingDup(false);
}
}
async function fetchTags() {
setLoadingTags(true);
const { data } = await supabase
.from('tags')
.select('*')
.order('name');
if (data) setTags(data);
setLoadingTags(false);
}
function toggleTag(tagId: string) {
setSelectedTagIds((prev) =>
prev.includes(tagId)
? prev.filter((id) => id !== tagId)
: [...prev, tagId]
);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!phone.trim()) {
toast.error('Phone number is required');
return;
}
// Hard-block an exact duplicate on create (the DB unique index is
// the real backstop; this avoids a round-trip + a raw error toast).
if (!isEdit && dupMatch?.exact) {
toast.error('A contact with this phone number already exists');
return;
}
setSaving(true);
try {
const {
data: { session },
} = await supabase.auth.getSession();
const user = session?.user;
if (!user) throw new Error('Not authenticated');
if (!accountId) throw new Error('Your profile is not linked to an account.');
let contactId = contact?.id;
if (isEdit && contactId) {
const { error } = await supabase
.from('contacts')
.update({
name: name.trim() || null,
phone: phone.trim(),
email: email.trim() || null,
company: company.trim() || null,
updated_at: new Date().toISOString(),
})
.eq('id', contactId);
if (error) throw error;
} else {
const { data, error } = await supabase
.from('contacts')
.insert({
user_id: user.id,
account_id: accountId,
name: name.trim() || null,
phone: phone.trim(),
email: email.trim() || null,
company: company.trim() || null,
})
.select('id')
.single();
if (error) throw error;
contactId = data.id;
}
// Sync tags
if (contactId) {
await supabase
.from('contact_tags')
.delete()
.eq('contact_id', contactId);
if (selectedTagIds.length > 0) {
const tagRows = selectedTagIds.map((tag_id) => ({
contact_id: contactId!,
tag_id,
}));
const { error: tagError } = await supabase
.from('contact_tags')
.insert(tagRows);
if (tagError) throw tagError;
}
}
toast.success(isEdit ? 'Contact updated' : 'Contact created');
onOpenChange(false);
onSaved();
} catch (err: unknown) {
// The unique index (migration 022) rejects a duplicate phone that
// slipped past the on-blur check (race, or a format that
// normalizes equal). Surface it as the friendly duplicate notice
// and, for new contacts, point the user at the existing record.
if (isUniqueViolation(err)) {
toast.error('A contact with this phone number already exists');
if (!isEdit && accountId) {
const existing = await findExistingContact(
supabase,
accountId,
phone.trim(),
);
if (existing) setDupMatch({ contact: existing, exact: true });
}
return;
}
const message = err instanceof Error ? err.message : 'Failed to save contact';
toast.error(message);
} finally {
setSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-popover border-border text-popover-foreground sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-popover-foreground">
{isEdit ? 'Edit Contact' : 'Add Contact'}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{isEdit
? 'Update the contact details below.'
: 'Fill in the details to create a new contact.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="cf-name" className="text-muted-foreground">
Name
</Label>
<Input
id="cf-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="John Doe"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
</div>
<div className="space-y-2">
<Label htmlFor="cf-phone" className="text-muted-foreground">
Phone <span className="text-red-400">*</span>
</Label>
<Input
id="cf-phone"
value={phone}
onChange={(e) => {
setPhone(e.target.value);
if (dupMatch) setDupMatch(null);
}}
onBlur={checkDuplicate}
placeholder="+1 234 567 8900"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
{dupMatch ? (
<div
className={`flex items-start gap-2 rounded-md border px-2.5 py-2 text-xs ${
dupMatch.exact
? 'border-red-500/40 bg-red-500/10 text-red-300'
: 'border-amber-500/40 bg-amber-500/10 text-amber-300'
}`}
>
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<div className="space-y-1">
<p>
{dupMatch.exact
? 'A contact with this phone number already exists.'
: 'A contact with a very similar number already exists.'}
</p>
{onViewExisting && (
<button
type="button"
onClick={() => onViewExisting(dupMatch.contact.id)}
className="font-medium underline underline-offset-2 hover:no-underline"
>
View {dupMatch.contact.name || dupMatch.contact.phone}
</button>
)}
</div>
</div>
) : (
<p className="text-xs text-muted-foreground">
Include country code, e.g. +1 for US
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="cf-email" className="text-muted-foreground">
Email
</Label>
<Input
id="cf-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="john@example.com"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
</div>
<div className="space-y-2">
<Label htmlFor="cf-company" className="text-muted-foreground">
Company
</Label>
<Input
id="cf-company"
value={company}
onChange={(e) => setCompany(e.target.value)}
placeholder="Acme Inc."
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Tags</Label>
{loadingTags ? (
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Loader2 className="size-3 animate-spin" />
Loading tags...
</div>
) : tags.length === 0 ? (
<p className="text-xs text-muted-foreground">
No tags available. Create tags in Settings.
</p>
) : (
<div className="flex flex-wrap gap-1.5">
{tags.map((tag) => {
const selected = selectedTagIds.includes(tag.id);
return (
<button
key={tag.id}
type="button"
onClick={() => toggleTag(tag.id)}
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors cursor-pointer ${
selected
? 'ring-2 ring-primary ring-offset-1 ring-offset-border'
: 'opacity-60 hover:opacity-100'
}`}
style={{
backgroundColor: tag.color + '20',
color: tag.color,
borderColor: tag.color,
}}
>
{tag.name}
</button>
);
})}
</div>
)}
</div>
<DialogFooter className="bg-popover border-border">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
<Button
type="submit"
disabled={saving || checkingDup || (!isEdit && !!dupMatch?.exact)}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
{saving && <Loader2 className="size-4 animate-spin" />}
{isEdit ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,286 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth';
import { toast } from 'sonner';
import type { CustomField } from '@/types';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Loader2, Plus, Trash2 } from 'lucide-react';
interface CustomFieldsManagerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* Dialog wrapper around {@link CustomFieldsPanel}, used on the Contacts page.
* The same panel is rendered inline under Settings → Custom Fields, so the
* editing UI lives in one place. Radix unmounts the dialog content on close,
* so the panel remounts (and refetches) on each open.
*/
export function CustomFieldsManager({
open,
onOpenChange,
}: CustomFieldsManagerProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="border-border bg-popover text-popover-foreground sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-popover-foreground">Custom fields</DialogTitle>
<DialogDescription className="text-muted-foreground">
Define extra contact fields (e.g. ZIP code, lead source). They
appear on every contact and in the Update Contact Field automation
action.
</DialogDescription>
</DialogHeader>
<CustomFieldsPanel />
</DialogContent>
</Dialog>
);
}
/**
* Create / rename / delete account-wide custom contact field definitions.
* Per-contact values are edited elsewhere (contact detail → Custom Fields);
* this only manages the field catalogue. Admin+ gated by the caller — the
* `custom_fields` RLS also rejects non-admin writes as defense in depth.
*/
export function CustomFieldsPanel() {
const supabase = createClient();
const { user, accountId } = useAuth();
const [fields, setFields] = useState<CustomField[]>([]);
const [loading, setLoading] = useState(true);
const [newName, setNewName] = useState('');
const [creating, setCreating] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
const fetchFields = useCallback(async () => {
if (!accountId) return;
setLoading(true);
const { data } = await supabase
.from('custom_fields')
.select('*')
.order('field_name');
setFields((data as CustomField[] | null) ?? []);
setLoading(false);
}, [supabase, accountId]);
// Load the field list on mount once the account is known. The setters
// inside fetchFields run after the Supabase await — not synchronously in
// the effect body — so the cascade the lint rule warns about doesn't apply.
useEffect(() => {
if (accountId) {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchFields();
}
}, [accountId, fetchFields]);
/** Case-insensitive name clash within the loaded list. */
function isDuplicate(name: string, exceptId?: string): boolean {
const lower = name.toLowerCase();
return fields.some(
(f) => f.id !== exceptId && f.field_name.toLowerCase() === lower
);
}
async function handleCreate() {
const name = newName.trim();
if (!name) return;
if (!accountId || !user) {
toast.error('Your profile is not linked to an account.');
return;
}
if (isDuplicate(name)) {
toast.error(`A field named "${name}" already exists.`);
return;
}
setCreating(true);
const { error } = await supabase.from('custom_fields').insert({
field_name: name,
field_type: 'text',
user_id: user.id,
account_id: accountId,
});
setCreating(false);
if (error) {
toast.error('Could not create field. You may not have permission.');
return;
}
toast.success(`Created "${name}".`);
setNewName('');
await fetchFields();
}
/** Returns true on success so the row can keep the new name, false so it
* reverts to the previous one. No-ops (blank / unchanged) count as success. */
async function handleRename(
field: CustomField,
nextName: string
): Promise<boolean> {
const name = nextName.trim();
if (!name || name === field.field_name) return true;
if (isDuplicate(name, field.id)) {
toast.error(`A field named "${name}" already exists.`);
return false;
}
setBusyId(field.id);
const { error } = await supabase
.from('custom_fields')
.update({ field_name: name })
.eq('id', field.id);
setBusyId(null);
if (error) {
toast.error('Could not rename field.');
return false;
}
await fetchFields();
return true;
}
async function handleDelete(field: CustomField) {
if (
!window.confirm(
`Delete "${field.field_name}"? This also removes its stored value on every contact. This cannot be undone.`
)
) {
return;
}
setBusyId(field.id);
const { error } = await supabase
.from('custom_fields')
.delete()
.eq('id', field.id);
setBusyId(null);
if (error) {
toast.error('Could not delete field.');
return;
}
toast.success(`Deleted "${field.field_name}".`);
await fetchFields();
}
return (
<div className="space-y-4">
{/* Create */}
<div className="flex items-center gap-2">
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void handleCreate();
}
}}
placeholder="New field name…"
className="bg-muted text-foreground"
/>
<Button
onClick={handleCreate}
disabled={creating || !newName.trim()}
className="bg-primary hover:bg-primary/90 text-primary-foreground shrink-0"
>
{creating ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Plus className="size-4" />
)}
Add
</Button>
</div>
{/* List */}
<div className="max-h-72 overflow-y-auto rounded-md border border-border">
{loading ? (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading
</div>
) : fields.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No custom fields yet.
</p>
) : (
<ul className="divide-y divide-border">
{fields.map((field) => (
<FieldRow
key={field.id}
field={field}
busy={busyId === field.id}
onRename={handleRename}
onDelete={handleDelete}
/>
))}
</ul>
)}
</div>
</div>
);
}
/** A single editable row. Controlled local state lets us commit on blur /
* Enter and cleanly revert to the last saved name when a rename fails. */
function FieldRow({
field,
busy,
onRename,
onDelete,
}: {
field: CustomField;
busy: boolean;
onRename: (field: CustomField, name: string) => Promise<boolean>;
onDelete: (field: CustomField) => void;
}) {
const [name, setName] = useState(field.field_name);
async function commit() {
if (name.trim() === field.field_name) {
setName(field.field_name); // normalise any whitespace-only edit
return;
}
const ok = await onRename(field, name);
if (!ok) setName(field.field_name);
}
return (
<li className="flex items-center gap-2 px-3 py-2">
<Input
value={name}
disabled={busy}
onChange={(e) => setName(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
}}
aria-label={`Rename ${field.field_name}`}
className="focus:border-primary h-8 border-transparent bg-transparent text-foreground hover:border-border"
/>
<Button
variant="ghost"
size="icon-sm"
disabled={busy}
onClick={() => onDelete(field)}
title="Delete field"
className="shrink-0 text-muted-foreground hover:text-red-400"
>
{busy ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Trash2 className="size-4" />
)}
</Button>
</li>
);
}

View File

@@ -0,0 +1,648 @@
'use client';
import { useMemo, useRef, useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth';
import {
dedupeByPhone,
isUniqueViolation,
normalizeKey,
} from '@/lib/contacts/dedupe';
import {
parseContactCsv,
type ParsedContactRow,
} from '@/lib/contacts/parse-contact-csv';
import {
assignImportedContactTags,
resolveImportTagIds,
type ContactTagAssignment,
} from '@/lib/contacts/resolve-import-tags';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Upload,
FileText,
Loader2,
CheckCircle,
XCircle,
AlertTriangle,
Tag,
} from 'lucide-react';
const DEFAULT_TAG_COLOR = '#3b82f6';
const PREVIEW_LIMIT = 5;
function truncateFilename(name: string, max = 48): string {
if (name.length <= max) return name;
const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')) : '';
const base = name.slice(0, name.length - ext.length);
const keep = max - ext.length - 1;
return `${base.slice(0, Math.max(keep, 12))}${ext}`;
}
function PreviewCell({
value,
mono,
maxWidth = 'max-w-[9rem]',
}: {
value: string;
mono?: boolean;
maxWidth?: string;
}) {
return (
<span
className={cn(
'block truncate',
maxWidth,
mono && 'font-mono text-[11px]'
)}
title={value}
>
{value}
</span>
);
}
function ImportPreviewTags({
tagNames,
tagColorByKey,
}: {
tagNames: string[];
tagColorByKey: Map<string, string>;
}) {
if (tagNames.length === 0) {
return <span className="text-muted-foreground"></span>;
}
return (
<div className="flex min-w-[4.5rem] flex-wrap gap-1">
{tagNames.map((name) => {
const color =
tagColorByKey.get(name.trim().toLowerCase()) ?? DEFAULT_TAG_COLOR;
const isKnown = tagColorByKey.has(name.trim().toLowerCase());
return (
<span
key={name}
className="inline-flex max-w-full items-center gap-1 rounded-full px-2 py-0.5 text-[10px] leading-none font-medium"
style={{
backgroundColor: `${color}18`,
color,
border: `1px solid ${color}${isKnown ? '55' : '30'}`,
}}
title={isKnown ? name : `${name} (will be created on import)`}
>
<span
className="size-1.5 shrink-0 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="truncate">{name}</span>
</span>
);
})}
</div>
);
}
interface ImportModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onImported: () => void;
}
export function ImportModal({
open,
onOpenChange,
onImported,
}: ImportModalProps) {
const supabase = createClient();
const { accountId, canEditSettings } = useAuth();
const fileInputRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null);
const [parsedRows, setParsedRows] = useState<ParsedContactRow[]>([]);
const [hasTagsColumn, setHasTagsColumn] = useState(false);
const [hasCompanyColumn, setHasCompanyColumn] = useState(false);
const [tagColorByKey, setTagColorByKey] = useState<Map<string, string>>(
new Map()
);
const [importing, setImporting] = useState(false);
const [result, setResult] = useState<{
imported: number;
skipped: number;
failed: number;
tagsAssigned: number;
} | null>(null);
function reset() {
setFile(null);
setParsedRows([]);
setHasTagsColumn(false);
setHasCompanyColumn(false);
setTagColorByKey(new Map());
setResult(null);
if (fileInputRef.current) fileInputRef.current.value = '';
}
function handleOpenChange(next: boolean) {
if (!next) reset();
onOpenChange(next);
}
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const selected = e.target.files?.[0];
if (!selected) return;
setFile(selected);
setResult(null);
const text = await selected.text();
const {
rows,
hasTagsColumn: csvHasTags,
hasCompanyColumn: csvHasCompany,
} = parseContactCsv(text);
if (rows.length === 0) {
toast.error(
'No valid rows found. Ensure CSV has a "phone" column header.'
);
setParsedRows([]);
setHasTagsColumn(false);
setHasCompanyColumn(false);
setTagColorByKey(new Map());
return;
}
setParsedRows(rows);
setHasTagsColumn(csvHasTags);
setHasCompanyColumn(csvHasCompany);
if (csvHasTags && accountId) {
const { data: tags } = await supabase
.from('tags')
.select('name, color')
.eq('account_id', accountId);
const colors = new Map<string, string>();
for (const tag of tags ?? []) {
const key = tag.name.trim().toLowerCase();
if (!colors.has(key)) colors.set(key, tag.color);
}
setTagColorByKey(colors);
} else {
setTagColorByKey(new Map());
}
}
async function handleImport() {
if (parsedRows.length === 0) return;
setImporting(true);
try {
const {
data: { session },
} = await supabase.auth.getSession();
const user = session?.user;
if (!user) throw new Error('Not authenticated');
if (!accountId)
throw new Error('Your profile is not linked to an account.');
let imported = 0;
let skipped = 0;
let failed = 0;
// 1) De-dupe within the file by normalized phone (keep first).
const { unique, duplicates: inFileDupes } = dedupeByPhone(parsedRows);
skipped += inFileDupes;
// 2) Skip numbers already in this account. One read of the
// generated `phone_normalized` column (migration 022) → Set.
const { data: existingRows } = await supabase
.from('contacts')
.select('phone_normalized')
.eq('account_id', accountId);
const existing = new Set(
(existingRows ?? [])
.map(
(r) => (r as { phone_normalized: string | null }).phone_normalized
)
.filter((p): p is string => !!p)
);
const toInsert = unique.filter((row) => {
if (existing.has(normalizeKey(row.phone))) {
skipped++;
return false;
}
return true;
});
// 3) Resolve tag names → ids (admin+ may auto-create missing tags).
// Skip the round-trip when the import carries no tag names.
const allTagNames = toInsert.flatMap((row) => row.tagNames);
let tagIdByKey = new Map<string, string>();
let skippedNames: string[] = [];
if (allTagNames.length > 0) {
({ tagIdByKey, skippedNames } = await resolveImportTagIds(supabase, {
accountId,
userId: user.id,
tagNames: allTagNames,
canCreateTags: canEditSettings,
}));
}
const tagAssignments: ContactTagAssignment[] = [];
// 4) Batch insert the genuinely-new rows in chunks of 50. The DB
// unique index is the backstop: a 23505 (race, or a format
// that normalizes equal) counts as skipped, not failed.
const chunkSize = 50;
for (let i = 0; i < toInsert.length; i += chunkSize) {
const chunk = toInsert.slice(i, i + chunkSize);
const rows = chunk.map((row) => ({
user_id: user.id,
account_id: accountId,
phone: row.phone,
name: row.name || null,
email: row.email || null,
company: row.company || null,
}));
const { data, error } = await supabase
.from('contacts')
.insert(rows)
.select('id');
if (error) {
// Retry individually so one bad/duplicate row doesn't sink
// the whole chunk.
for (let j = 0; j < rows.length; j++) {
const row = rows[j];
const source = chunk[j];
const { data: singleData, error: singleErr } = await supabase
.from('contacts')
.insert(row)
.select('id')
.single();
if (!singleErr && singleData) {
imported++;
if (source.tagNames.length > 0) {
tagAssignments.push({
contactId: singleData.id,
tagNames: source.tagNames,
});
}
} else if (isUniqueViolation(singleErr)) {
skipped++;
} else {
failed++;
}
}
} else {
const inserted = data ?? [];
imported += inserted.length;
// inserted[j] ↔ chunk[j] only holds because a single INSERT
// preserves RETURNING order. If this path is ever split into
// parallel inserts, zip by phone or returned id instead.
for (let j = 0; j < inserted.length; j++) {
const source = chunk[j];
if (!source || source.tagNames.length === 0) continue;
tagAssignments.push({
contactId: inserted[j].id,
tagNames: source.tagNames,
});
}
}
}
// 5) Wire tags onto the contacts we just created. Failure here must
// not mask a successful contact import.
let tagsAssigned = 0;
try {
tagsAssigned = await assignImportedContactTags(
supabase,
tagAssignments,
tagIdByKey
);
} catch {
toast.warning('Contacts imported, but some tag assignments failed.');
}
setResult({ imported, skipped, failed, tagsAssigned });
if (imported > 0) {
toast.success(
`${imported} contact${imported !== 1 ? 's' : ''} imported`
);
onImported();
}
if (tagsAssigned > 0) {
toast.success(
`${tagsAssigned} tag assignment${tagsAssigned !== 1 ? 's' : ''} applied`
);
}
if (skippedNames.length > 0) {
const sample = skippedNames.slice(0, 3).join(', ');
const more =
skippedNames.length > 3 ? ` (+${skippedNames.length - 3} more)` : '';
toast.info(
`Unknown tags skipped (create them in Settings first): ${sample}${more}`
);
}
if (skipped > 0) {
toast.info(`${skipped} duplicate${skipped !== 1 ? 's' : ''} skipped`);
}
if (failed > 0) {
toast.error(
`${failed} contact${failed !== 1 ? 's' : ''} failed to import`
);
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Import failed';
toast.error(message);
} finally {
setImporting(false);
}
}
const preview = parsedRows.slice(0, PREVIEW_LIMIT);
// Tags: OR — show when the CSV declares a column or preview rows carry
// values, so an all-empty tags column still renders for validation.
const previewHasTags =
hasTagsColumn || preview.some((row) => row.tagNames.length > 0);
// Company: AND — hide unless the CSV declares it and preview has data,
// avoiding an all-dash column that wastes horizontal space.
const previewHasCompany =
hasCompanyColumn && preview.some((row) => row.company?.trim());
const tagStats = useMemo(() => {
const names = new Set<string>();
let rowsWithTags = 0;
for (const row of parsedRows) {
if (row.tagNames.length === 0) continue;
rowsWithTags++;
for (const name of row.tagNames) names.add(name.trim().toLowerCase());
}
return { unique: names.size, rowsWithTags };
}, [parsedRows]);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="flex max-h-[min(90vh,720px)] flex-col gap-0 overflow-hidden border-border/80 bg-popover p-0 text-popover-foreground sm:max-w-2xl">
<div className="shrink-0 space-y-4 border-b border-border/80 px-6 pt-6 pb-5">
<DialogHeader className="gap-1.5">
<DialogTitle className="text-lg text-popover-foreground">
Import Contacts
</DialogTitle>
<DialogDescription className="leading-relaxed text-muted-foreground">
Upload a CSV with a required{' '}
<code className="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">
phone
</code>{' '}
column. Optional:{' '}
<code className="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">
name
</code>
,{' '}
<code className="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">
email
</code>
,{' '}
<code className="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">
company
</code>
,{' '}
<code className="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">
tags
</code>{' '}
(comma-separated; quote multi-tag cells).
</DialogDescription>
</DialogHeader>
<div
role="button"
tabIndex={0}
onClick={() => fileInputRef.current?.click()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ')
fileInputRef.current?.click();
}}
className={cn(
'group flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border border-dashed p-5 transition-all',
file
? 'border-primary/35 bg-primary/[0.04]'
: 'hover:border-primary/40 border-border/80 bg-background/40 hover:bg-background/70'
)}
>
{file ? (
<>
<div className="bg-primary/15 ring-primary/25 flex size-10 items-center justify-center rounded-lg ring-1">
<FileText className="text-primary size-5" />
</div>
<p
className="max-w-full truncate px-2 text-sm font-medium text-popover-foreground"
title={file.name}
>
{truncateFilename(file.name)}
</p>
<span className="rounded-full bg-muted px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
{parsedRows.length} row{parsedRows.length !== 1 ? 's' : ''}{' '}
ready
</span>
</>
) : (
<>
<div className="flex size-10 items-center justify-center rounded-lg bg-muted/80 ring-1 ring-border/80 transition-colors group-hover:bg-muted">
<Upload className="size-5 text-muted-foreground group-hover:text-foreground" />
</div>
<p className="text-sm text-muted-foreground">
Click to choose a CSV file
</p>
<p className="text-[11px] text-muted-foreground">
.csv up to your browser limit
</p>
</>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept=".csv,text/csv"
onChange={handleFileChange}
className="hidden"
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
{preview.length > 0 && !result && (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-[11px] font-semibold tracking-[0.14em] text-muted-foreground uppercase">
Preview · first {preview.length}
</p>
<div className="flex flex-wrap items-center gap-1.5">
{tagStats.rowsWithTags > 0 && (
<span className="inline-flex items-center gap-1 rounded-md bg-muted/90 px-2 py-0.5 text-[11px] text-muted-foreground">
<Tag className="text-primary/80 size-3" />
{tagStats.unique} tag{tagStats.unique !== 1 ? 's' : ''} ·{' '}
{tagStats.rowsWithTags} contact
{tagStats.rowsWithTags !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
<div className="overflow-hidden rounded-xl border border-border ring-1 ring-border/50">
<div className="overflow-x-auto">
<table className="w-full min-w-[32rem] text-xs">
<thead>
<tr className="border-b border-border bg-background/60">
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
Phone
</th>
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
Name
</th>
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
Email
</th>
{previewHasCompany && (
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
Company
</th>
)}
{previewHasTags && (
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
Tags
</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-border/70">
{preview.map((row, i) => (
<tr
key={i}
className="bg-popover/40 transition-colors hover:bg-muted/30"
>
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
<PreviewCell
value={row.phone}
mono
maxWidth="max-w-[7.5rem]"
/>
</td>
<td className="px-3 py-2 text-popover-foreground">
<PreviewCell
value={row.name || '—'}
maxWidth="max-w-[8.5rem]"
/>
</td>
<td className="px-3 py-2 text-muted-foreground">
<PreviewCell
value={row.email || '—'}
maxWidth="max-w-[10rem]"
/>
</td>
{previewHasCompany && (
<td className="px-3 py-2 text-muted-foreground">
<PreviewCell
value={row.company || '—'}
maxWidth="max-w-[7rem]"
/>
</td>
)}
{previewHasTags && (
<td className="px-3 py-2 align-top">
<ImportPreviewTags
tagNames={row.tagNames}
tagColorByKey={tagColorByKey}
/>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
{parsedRows.length > PREVIEW_LIMIT && (
<p className="text-center text-[11px] text-muted-foreground">
+ {parsedRows.length - PREVIEW_LIMIT} more row
{parsedRows.length - PREVIEW_LIMIT !== 1 ? 's' : ''} not shown
</p>
)}
</div>
)}
{result && (
<div className="rounded-xl border border-border bg-background/50 p-4">
<p className="text-sm font-medium text-popover-foreground">Import complete</p>
<div className="mt-3 flex flex-wrap gap-3">
{result.imported > 0 && (
<div className="text-primary flex items-center gap-1.5 text-sm">
<CheckCircle className="size-4 shrink-0" />
{result.imported} imported
</div>
)}
{result.tagsAssigned > 0 && (
<div className="flex items-center gap-1.5 text-sm text-cyan-400">
<CheckCircle className="size-4 shrink-0" />
{result.tagsAssigned} tag
{result.tagsAssigned !== 1 ? 's' : ''} assigned
</div>
)}
{result.skipped > 0 && (
<div className="flex items-center gap-1.5 text-sm text-amber-400">
<AlertTriangle className="size-4 shrink-0" />
{result.skipped} skipped
</div>
)}
{result.failed > 0 && (
<div className="flex items-center gap-1.5 text-sm text-red-400">
<XCircle className="size-4 shrink-0" />
{result.failed} failed
</div>
)}
</div>
</div>
)}
</div>
<DialogFooter className="mt-0 shrink-0 gap-2 border-t border-border/80 bg-background/50 px-6 py-4 sm:justify-end">
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
className="border-border text-muted-foreground hover:bg-muted"
>
{result ? 'Close' : 'Cancel'}
</Button>
{!result && (
<Button
type="button"
disabled={parsedRows.length === 0 || importing}
onClick={handleImport}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
{importing && <Loader2 className="size-4 animate-spin" />}
Import {parsedRows.length > 0 ? parsedRows.length : ''} contact
{parsedRows.length !== 1 ? 's' : ''}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,167 @@
"use client"
import Link from 'next/link'
import { useState } from 'react'
import {
MessageSquare,
UserPlus,
Briefcase,
Radio,
Zap,
Inbox,
} from 'lucide-react'
import type { ComponentType } from 'react'
import type { ActivityItem, ActivityKind } from '@/lib/dashboard/types'
import { cn } from '@/lib/utils'
import { EmptyState } from './empty-state'
import { Skeleton } from './skeleton'
interface ActivityFeedProps {
items: ActivityItem[] | null
loading: boolean
}
const PAGE_SIZES = [5, 10, 20, 50] as const
type PageSize = (typeof PAGE_SIZES)[number]
interface KindTheme {
icon: ComponentType<{ className?: string }>
/** Tailwind classes for the round icon badge + label color. */
badge: string
}
const KIND_THEME: Record<ActivityKind, KindTheme> = {
message: { icon: MessageSquare, badge: 'bg-blue-500/10 text-blue-400' },
contact: { icon: UserPlus, badge: 'bg-primary/10 text-primary' },
deal: { icon: Briefcase, badge: 'bg-primary/10 text-primary' },
broadcast: { icon: Radio, badge: 'bg-amber-500/10 text-amber-400' },
automation: { icon: Zap, badge: 'bg-rose-500/10 text-rose-400' },
}
export function ActivityFeed({ items, loading }: ActivityFeedProps) {
// Start at 5 — a quick scan of the most recent events without
// dominating vertical real estate. User expands explicitly via the
// footer control when they want deeper history.
const [pageSize, setPageSize] = useState<PageSize>(5)
const totalLoaded = items?.length ?? 0
const visible = items?.slice(0, pageSize) ?? []
// A size option is "useful" if picking it would reveal rows the
// smaller option doesn't already show. With PAGE_SIZES=[5,10,20,50]:
// "10" is useful only once we've loaded ≥6 items, "20" once ≥11, etc.
// The smallest option is always enabled.
const isSizeUseful = (size: PageSize, i: number) =>
i === 0 || totalLoaded > PAGE_SIZES[i - 1]
return (
<section className="rounded-xl border border-border bg-card">
<header className="flex items-center justify-between border-b border-border px-5 py-4">
<h2 className="text-sm font-semibold text-foreground">Recent Activity</h2>
<Link
href="/inbox"
className="text-xs font-medium text-primary hover:text-primary/80"
>
View all
</Link>
</header>
{loading || !items ? (
<div className="space-y-2 p-5">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : items.length === 0 ? (
<div className="p-5">
<EmptyState
icon={Inbox}
title="No activity yet"
hint="Activity from messages, deals, broadcasts, and automations will appear here."
/>
</div>
) : (
<>
<ul className="divide-y divide-border">
{visible.map((it, i) => {
const theme = KIND_THEME[it.kind]
const Icon = theme.icon
// Alternating row background for scanability. bg-muted/40
// keeps the stripe visible in both light and dark modes
// (bg-card/40 vanishes against a white card surface in light).
const stripe = i % 2 === 0 ? 'bg-transparent' : 'bg-muted/40'
const row = (
<div className="flex items-center gap-3 px-5 py-2.5">
<span
className={cn(
'flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full',
theme.badge,
)}
>
<Icon className="h-3.5 w-3.5" />
</span>
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
{it.text}
</span>
<span className="flex-shrink-0 text-xs text-muted-foreground tabular-nums">
{relativeTime(it.at)}
</span>
</div>
)
return (
<li key={it.id} className={cn(stripe, 'transition-colors hover:bg-muted/40')}>
{it.href ? (
<Link href={it.href} className="block">
{row}
</Link>
) : (
row
)}
</li>
)
})}
</ul>
<footer className="flex items-center justify-between border-t border-border px-5 py-3 text-xs">
<span className="text-muted-foreground tabular-nums">
Showing {visible.length} of {totalLoaded}
{totalLoaded === 50 ? '+' : ''}
</span>
<div className="flex items-center gap-1">
<span className="mr-1 text-muted-foreground">Show</span>
{PAGE_SIZES.map((size, i) => {
const disabled = !isSizeUseful(size, i)
return (
<button
key={size}
type="button"
onClick={() => setPageSize(size)}
disabled={disabled}
className={cn(
'rounded-md px-2 py-1 font-medium tabular-nums transition-colors',
pageSize === size
? 'bg-secondary text-secondary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
disabled && 'cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground',
)}
>
{size}
</button>
)
})}
</div>
</footer>
</>
)}
</section>
)
}
function relativeTime(iso: string): string {
const then = new Date(iso).getTime()
if (Number.isNaN(then)) return ''
const diffSec = Math.round((Date.now() - then) / 1000)
if (diffSec < 60) return `${Math.max(1, diffSec)}s ago`
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`
if (diffSec < 2_592_000) return `${Math.floor(diffSec / 86400)}d ago`
return new Date(iso).toLocaleDateString()
}

View File

@@ -0,0 +1,341 @@
"use client"
import { useEffect, useMemo, useRef, useState } from 'react'
import { MessageSquare } from 'lucide-react'
import type { ConversationsSeriesPoint } from '@/lib/dashboard/types'
import { EmptyState } from './empty-state'
import { Skeleton } from './skeleton'
import { cn } from '@/lib/utils'
type RangeDays = 7 | 30 | 90
interface ConversationsChartProps {
/** Per-range data, so switching tabs never re-fetches. */
series: Record<RangeDays, ConversationsSeriesPoint[] | null>
loading: boolean
range: RangeDays
onRangeChange: (r: RangeDays) => void
}
// ------------------------------------------------------------
// Layout constants. The SVG renders into a fixed viewBox and scales
// via CSS (preserveAspectRatio default). Everything inside uses
// viewBox coordinates so the drawing math stays simple even as the
// container resizes.
// ------------------------------------------------------------
const VB_W = 760
const VB_H = 240
const PADDING = { top: 16, right: 16, bottom: 28, left: 40 }
export function ConversationsChart({ series, loading, range, onRangeChange }: ConversationsChartProps) {
const data = series[range]
// Memoise the max so per-day hover math doesn't recompute it.
const { maxY, niceTicks } = useMemo(() => {
const arr = data ?? []
const max = arr.reduce(
(m, p) => Math.max(m, p.incoming, p.outgoing),
0,
)
const ceil = niceCeil(max)
const ticks = [0, ceil / 4, ceil / 2, (3 * ceil) / 4, ceil].map((v) =>
Math.round(v),
)
// De-dupe when the series is flat 0.
return { maxY: ceil, niceTicks: Array.from(new Set(ticks)) }
}, [data])
return (
<section className="flex h-full flex-col rounded-xl border border-border bg-card">
<header className="flex items-center justify-between border-b border-border px-5 py-4">
<div>
<h2 className="text-sm font-semibold text-foreground">Conversations Over Time</h2>
<p className="mt-0.5 text-xs text-muted-foreground">Daily message volume by direction</p>
</div>
<div className="flex items-center gap-1 rounded-lg bg-muted/60 p-1">
{[7, 30, 90].map((r) => (
<button
key={r}
type="button"
onClick={() => onRangeChange(r as RangeDays)}
className={cn(
'rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
range === r
? 'bg-secondary text-secondary-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{r} days
</button>
))}
</div>
</header>
<div className="p-5">
{loading || !data ? (
<Skeleton className="h-[240px] w-full" />
) : data.every((p) => p.incoming === 0 && p.outgoing === 0) ? (
<EmptyState
icon={MessageSquare}
title="No message activity in this range"
hint="Send or receive messages to start populating this chart."
/>
) : (
<LineSvg data={data} maxY={maxY} ticks={niceTicks} />
)}
</div>
<footer className="flex items-center gap-4 border-t border-border px-5 py-3 text-xs text-muted-foreground">
<LegendDot color="#3b82f6" label="Incoming" />
<LegendDot color="#7c3aed" label="Outgoing" />
</footer>
</section>
)
}
// ------------------------------------------------------------
// The actual SVG. Two polylines + per-day hit targets for hover.
// ------------------------------------------------------------
function LineSvg({
data,
maxY,
ticks,
}: {
data: ConversationsSeriesPoint[]
maxY: number
ticks: number[]
}) {
// Hover state: both the snapped index AND the tooltip's pixel
// offset inside the wrapper div. They're stored together so the
// tooltip positions against the chart's actual rendered pixels,
// not against a raw viewBox percentage. See the precision note on
// the onMove handler below.
const [hover, setHover] = useState<{ idx: number; tooltipLeftPx: number } | null>(null)
const svgRef = useRef<SVGSVGElement>(null)
const wrapRef = useRef<HTMLDivElement>(null)
const chartW = VB_W - PADDING.left - PADDING.right
const chartH = VB_H - PADDING.top - PADDING.bottom
// x step can be fractional for 90-day views; points are positioned
// at the center of each "slot" so the first and last points don't
// sit right on the axis.
const stepX = data.length > 1 ? chartW / (data.length - 1) : 0
const yFor = (v: number) =>
maxY === 0 ? PADDING.top + chartH : PADDING.top + chartH - (v / maxY) * chartH
const xFor = (i: number) => PADDING.left + i * stepX
const incomingPath = data.map((p, i) => `${i === 0 ? 'M' : 'L'}${xFor(i)},${yFor(p.incoming)}`).join(' ')
const outgoingPath = data.map((p, i) => `${i === 0 ? 'M' : 'L'}${xFor(i)},${yFor(p.outgoing)}`).join(' ')
// Mouse-move: use the SVG's current screen-CTM to map clientX
// back to viewBox coordinates. The previous rect-based math
// assumed the viewBox filled the SVG DOM box linearly, but
// `preserveAspectRatio="xMidYMid meet"` (the SVG default)
// letterboxes the content horizontally when the container is
// wider than the viewBox aspect — so hover snapped hundreds of
// pixels off on wide layouts. CTM-inverse correctly accounts for
// letterboxing, scaling, and any future transform changes.
useEffect(() => {
const svg = svgRef.current
const wrap = wrapRef.current
if (!svg || !wrap) return
const onMove = (e: MouseEvent) => {
const ctm = svg.getScreenCTM()
if (!ctm) return
const pt = svg.createSVGPoint()
pt.x = e.clientX
pt.y = e.clientY
const local = pt.matrixTransform(ctm.inverse())
const xVb = local.x
if (xVb < PADDING.left - 8 || xVb > VB_W - PADDING.right + 8) {
setHover(null)
return
}
const relative = xVb - PADDING.left
const idx = Math.max(
0,
Math.min(data.length - 1, Math.round(stepX === 0 ? 0 : relative / stepX)),
)
// Map the snapped data-point's viewBox x back to screen, then
// subtract the wrapper's left edge — that pixel offset is what
// the absolutely-positioned tooltip div consumes. `xFor` is
// inlined here so the effect deps stay stable (it's a closure
// that'd otherwise be a new reference every render).
const dataPointVbX = PADDING.left + idx * stepX
const dataPointPt = svg.createSVGPoint()
dataPointPt.x = dataPointVbX
dataPointPt.y = 0
const screen = dataPointPt.matrixTransform(ctm)
const wrapRect = wrap.getBoundingClientRect()
setHover({ idx, tooltipLeftPx: screen.x - wrapRect.left })
}
const onLeave = () => setHover(null)
svg.addEventListener('mousemove', onMove)
svg.addEventListener('mouseleave', onLeave)
return () => {
svg.removeEventListener('mousemove', onMove)
svg.removeEventListener('mouseleave', onLeave)
}
// xFor + yFor close over stepX, so stepX covers them.
}, [data, stepX])
const hovered = hover !== null ? data[hover.idx] : null
const hoverX = hover !== null ? xFor(hover.idx) : 0
// X-axis label strategy: show ~6 evenly-spaced labels regardless
// of range so the axis never looks crowded.
const labelStride = Math.max(1, Math.ceil(data.length / 6))
return (
<div ref={wrapRef} className="relative w-full">
<svg
ref={svgRef}
viewBox={`0 0 ${VB_W} ${VB_H}`}
className="h-[240px] w-full"
role="img"
aria-label="Conversations per day"
>
{/* Y-axis gridlines + labels */}
{ticks.map((t) => {
const y = yFor(t)
return (
<g key={t}>
<line
x1={PADDING.left}
x2={VB_W - PADDING.right}
y1={y}
y2={y}
stroke="var(--border)"
strokeDasharray="3 3"
/>
<text
x={PADDING.left - 8}
y={y}
textAnchor="end"
dominantBaseline="middle"
className="fill-muted-foreground text-[10px]"
>
{t}
</text>
</g>
)
})}
{/* X-axis labels */}
{data.map((p, i) =>
i % labelStride === 0 ? (
<text
key={p.day}
x={xFor(i)}
y={VB_H - 8}
textAnchor="middle"
className="fill-muted-foreground text-[10px]"
>
{shortDayLabel(p.day)}
</text>
) : null,
)}
{/* Outgoing polyline (violet) */}
<path
d={outgoingPath}
fill="none"
stroke="#7c3aed"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Incoming polyline (blue) */}
<path
d={incomingPath}
fill="none"
stroke="#3b82f6"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Hover crosshair */}
{hover !== null && (
<g pointerEvents="none">
<line
x1={hoverX}
x2={hoverX}
y1={PADDING.top}
y2={PADDING.top + chartH}
stroke="var(--muted-foreground)"
strokeDasharray="3 3"
/>
<circle cx={hoverX} cy={yFor(data[hover.idx].incoming)} r={3.5} fill="#3b82f6" />
<circle cx={hoverX} cy={yFor(data[hover.idx].outgoing)} r={3.5} fill="#7c3aed" />
</g>
)}
</svg>
{/* Tooltip — absolute-positioned div so we get crisp text, not
SVG-rendered text. The left offset comes from the CTM-based
mapping so it lines up with the actual crosshair pixel, not a
letterboxed viewBox percentage. */}
{hovered && hover !== null && (
<div
className="pointer-events-none absolute top-0 z-10 -translate-x-1/2 rounded-md border border-border bg-popover px-2.5 py-1.5 text-[11px] shadow-lg"
style={{ left: `${hover.tooltipLeftPx}px` }}
>
<div className="font-medium text-popover-foreground">{longDayLabel(hovered.day)}</div>
<div className="mt-1 flex flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-blue-300">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-blue-500" />
{hovered.incoming} incoming
</span>
<span className="flex items-center gap-1.5 text-primary">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-primary" />
{hovered.outgoing} outgoing
</span>
</div>
</div>
)}
</div>
)
}
function LegendDot({ color, label }: { color: string; label: string }) {
return (
<span className="flex items-center gap-1.5">
<span className="inline-block h-1.5 w-1.5 rounded-full" style={{ background: color }} />
{label}
</span>
)
}
function shortDayLabel(key: string): string {
// key is YYYY-MM-DD; return "Apr 17"-style. Using Date with an
// appended time avoids timezone-shift surprises across midnight.
const [y, m, d] = key.split('-').map(Number)
const date = new Date(y, m - 1, d)
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
function longDayLabel(key: string): string {
const [y, m, d] = key.split('-').map(Number)
const date = new Date(y, m - 1, d)
return date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
}
/**
* Round `max` up to a "nice" number so Y-axis ticks feel natural
* (1, 2, 5, 10, 20, 50, …). Keeps the chart readable even when the
* series is small (max=3 becomes ceil=4, not 3).
*/
function niceCeil(max: number): number {
if (max <= 0) return 4
const pow = Math.pow(10, Math.floor(Math.log10(max)))
const normalised = max / pow
let nice: number
if (normalised <= 1) nice = 1
else if (normalised <= 2) nice = 2
else if (normalised <= 5) nice = 5
else nice = 10
return nice * pow
}

View File

@@ -0,0 +1,36 @@
import { BarChart3 } from 'lucide-react'
import type { ComponentType } from 'react'
import { cn } from '@/lib/utils'
/**
* Shared empty-state panel for charts that can't render meaningfully
* without a minimum amount of data. Kept minimal and uniform so the
* three empty states on the dashboard don't each feel like a
* different widget.
*/
export function EmptyState({
title = 'Not enough data yet',
hint,
icon: Icon = BarChart3,
className,
}: {
title?: string
hint?: string
icon?: ComponentType<{ className?: string }>
className?: string
}) {
return (
<div
className={cn(
'flex h-full min-h-40 flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border bg-card/40 px-4 py-6 text-center',
className,
)}
>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Icon className="h-5 w-5" />
</div>
<p className="text-sm font-medium text-muted-foreground">{title}</p>
{hint && <p className="max-w-xs text-xs text-muted-foreground">{hint}</p>}
</div>
)
}

View File

@@ -0,0 +1,57 @@
import { ArrowDown, ArrowUp, Minus } from 'lucide-react'
import type { ComponentType } from 'react'
import { cn } from '@/lib/utils'
interface MetricCardProps {
title: string
/** Pre-formatted value for display (e.g. "42" or "$1,250"). */
value: string
icon: ComponentType<{ className?: string }>
/**
* Delta-mode secondary row: arrow + delta text. Omit when the metric
* doesn't have a sensible comparison (e.g. total pipeline value).
*/
delta?: {
/** Positive / negative / zero drives arrow + color. */
sign: number
/** Pre-formatted delta, e.g. "+3 vs yesterday". */
label: string
}
/** Used instead of `delta` when the metric has a static subtitle. */
subtitle?: string
}
export function MetricCard({ title, value, icon: Icon, delta, subtitle }: MetricCardProps) {
return (
<div className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between">
<p className="text-sm font-medium text-muted-foreground">{title}</p>
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Icon className="h-4 w-4" />
</div>
</div>
<p className="mt-3 text-[28px] leading-none font-bold tabular-nums text-foreground">
{value}
</p>
{delta ? <DeltaRow sign={delta.sign} label={delta.label} /> : subtitle ? (
<p className="mt-2 text-sm text-muted-foreground">{subtitle}</p>
) : null}
</div>
)
}
function DeltaRow({ sign, label }: { sign: number; label: string }) {
const tone =
sign > 0
? 'text-primary'
: sign < 0
? 'text-red-400'
: 'text-muted-foreground'
const Arrow = sign > 0 ? ArrowUp : sign < 0 ? ArrowDown : Minus
return (
<div className={cn('mt-2 flex items-center gap-1 text-sm', tone)}>
<Arrow className="h-4 w-4" aria-hidden />
<span className="tabular-nums">{label}</span>
</div>
)
}

View File

@@ -0,0 +1,141 @@
"use client"
import { GitBranch } from 'lucide-react'
import type { PipelineDonutData } from '@/lib/dashboard/types'
import { formatCurrencyShort } from '@/lib/currency'
import { EmptyState } from './empty-state'
import { Skeleton } from './skeleton'
interface PipelineDonutProps {
data: PipelineDonutData | null
loading: boolean
/** Account default currency for the totals. */
currency: string
}
export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
return (
<section className="flex h-full flex-col rounded-xl border border-border bg-card">
<header className="border-b border-border px-5 py-4">
<h2 className="text-sm font-semibold text-foreground">Pipeline Value</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
Open deals by stage
</p>
</header>
<div className="flex flex-1 flex-col p-5">
{loading || !data ? (
<Skeleton className="h-56 w-full" />
) : data.stages.length === 0 ? (
<EmptyState
icon={GitBranch}
title="No open deals yet"
hint="Create deals in Pipelines to see stage breakdowns here."
/>
) : (
<>
<Donut data={data} currency={currency} />
<ul className="mt-5 space-y-2">
{data.stages.map((s) => (
<li key={s.id} className="flex items-center gap-3 text-xs">
<span
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
style={{ background: s.color }}
aria-hidden
/>
<span className="flex-1 truncate text-muted-foreground">{s.name}</span>
<span className="text-muted-foreground tabular-nums">
{s.dealCount} deal{s.dealCount === 1 ? '' : 's'}
</span>
<span className="w-20 text-right text-muted-foreground tabular-nums">
{formatCurrencyShort(s.totalValue, currency)}
</span>
</li>
))}
</ul>
</>
)}
</div>
</section>
)
}
// ------------------------------------------------------------
// SVG ring. 200×200 viewBox, 12px ring width. We draw one <path>
// per stage using an SVG arc from startAngle → endAngle. Gaps
// between segments are implied by a thin slate-900 stroke between
// them for a cleaner look.
// ------------------------------------------------------------
function Donut({ data, currency }: { data: PipelineDonutData; currency: string }) {
const size = 200
const r = 80
const ringWidth = 18
const cx = size / 2
const cy = size / 2
// Small slices would render as slivers that disappear into stroke
// rounding. We give each stage a floor share purely for rendering,
// but keep the labels/legend honest with the actual totals.
const totalRaw = data.totalValue || 1
const minFrac = 0.02
const rawShares = data.stages.map((s) => s.totalValue / totalRaw)
const floored = rawShares.map((x) => Math.max(x, minFrac))
const floorSum = floored.reduce((a, b) => a + b, 0)
const shares = floored.map((x) => x / floorSum)
// Build a cumulative-offset array, then map stages → arc paths. Using
// a pre-computed offsets array avoids the Next 16 React Compiler's
// "Cannot reassign variable after render completes" rule.
const offsets: number[] = [0]
for (let i = 0; i < shares.length; i++) offsets.push(offsets[i] + shares[i])
const segments = data.stages.map((s, i) => {
const start = offsets[i] * Math.PI * 2 - Math.PI / 2
const end = offsets[i + 1] * Math.PI * 2 - Math.PI / 2
return { path: arcPath(cx, cy, r, start, end), color: s.color, id: s.id }
})
return (
<div className="flex items-center justify-center">
<svg viewBox={`0 0 ${size} ${size}`} className="h-48 w-48" role="img" aria-label="Pipeline value by stage">
{/* background ring */}
<circle cx={cx} cy={cy} r={r} fill="none" stroke="var(--muted)" strokeWidth={ringWidth} />
{segments.map((seg) => (
<path
key={seg.id}
d={seg.path}
fill="none"
stroke={seg.color}
strokeWidth={ringWidth}
strokeLinecap="butt"
/>
))}
{/* center label */}
<text
x={cx}
y={cy - 6}
textAnchor="middle"
className="fill-muted-foreground text-[11px]"
>
Total
</text>
<text
x={cx}
y={cy + 14}
textAnchor="middle"
className="fill-foreground text-[18px] font-semibold tabular-nums"
>
{formatCurrencyShort(data.totalValue, currency)}
</text>
</svg>
</div>
)
}
function arcPath(cx: number, cy: number, r: number, startRad: number, endRad: number): string {
const x1 = cx + r * Math.cos(startRad)
const y1 = cy + r * Math.sin(startRad)
const x2 = cx + r * Math.cos(endRad)
const y2 = cy + r * Math.sin(endRad)
const largeArc = endRad - startRad > Math.PI ? 1 : 0
return `M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2}`
}

View File

@@ -0,0 +1,45 @@
"use client"
import Link from 'next/link'
import { UserPlus, Briefcase, Radio, Zap } from 'lucide-react'
import type { ComponentType } from 'react'
// Quick-action shortcuts. Each navigates to the page that owns the
// relevant "create" flow. We deliberately don't try to auto-open any
// modal on the target page — that'd require touching those pages,
// which is out of scope here.
interface Action {
label: string
href: string
icon: ComponentType<{ className?: string }>
tint: string
}
const ACTIONS: Action[] = [
{ label: 'New Contact', href: '/contacts', icon: UserPlus, tint: 'text-primary' },
{ label: 'New Deal', href: '/pipelines', icon: Briefcase, tint: 'text-blue-400' },
{ label: 'New Broadcast', href: '/broadcasts/new', icon: Radio, tint: 'text-amber-400' },
{ label: 'New Automation', href: '/automations/new', icon: Zap, tint: 'text-primary' },
]
export function QuickActions() {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{ACTIONS.map((a) => {
const Icon = a.icon
return (
<Link
key={a.href}
href={a.href}
className="group flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3 transition-colors hover:border-border hover:bg-muted/60"
>
<div className={`flex h-9 w-9 items-center justify-center rounded-lg bg-muted ${a.tint}`}>
<Icon className="h-4 w-4" />
</div>
<span className="text-sm font-medium text-foreground">{a.label}</span>
</Link>
)
})}
</div>
)
}

View File

@@ -0,0 +1,116 @@
"use client"
import { Clock } from 'lucide-react'
import { DOW_SHORT_MON_FIRST } from '@/lib/dashboard/date-utils'
import type { ResponseTimeSummary } from '@/lib/dashboard/types'
import { BarChart } from '@/components/tremor/bar-chart'
import { EmptyState } from './empty-state'
import { Skeleton } from './skeleton'
interface ResponseTimeChartProps {
data: ResponseTimeSummary | null
loading: boolean
/** Minutes. Surfaced as a "target" pill in the header. The
* hand-rolled SVG version drew this as a horizontal dashed
* line on the chart; Tremor BarChart doesn't expose Recharts
* primitives, so we promote it to the header for now. A
* follow-up can introduce an overlay or extend the vendored
* BarChart with a `referenceLines` prop. */
thresholdMinutes?: number
}
// Single category, single colour — the data is "average minutes
// per weekday". Tremor expects categories as the second tuple in
// the row object, so we shape the buckets into
// `{ day: 'Mon', 'Avg minutes': 4.2 }` rows below.
const CATEGORY = 'Avg minutes'
export function ResponseTimeChart({
data,
loading,
thresholdMinutes = 5,
}: ResponseTimeChartProps) {
const hasData = data?.buckets.some((b) => b.avgMinutes != null) ?? false
// Map buckets → Tremor rows. Null `avgMinutes` (no samples)
// collapses to 0; the chart will render an empty slot for it.
// We attach `samples` on the row so a future customTooltip can
// surface "no samples" copy without losing the data shape.
const chartData =
data?.buckets.map((b, i) => ({
day: DOW_SHORT_MON_FIRST[i],
[CATEGORY]: b.avgMinutes ?? 0,
samples: b.samples,
})) ?? []
return (
<section className="rounded-xl border border-border bg-card">
<header className="flex items-center justify-between gap-3 border-b border-border px-5 py-4">
<div>
<h2 className="text-sm font-semibold text-foreground">
Average First Response Time
</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
Minutes to reply to a customer&apos;s first unreplied message, by
weekday
</p>
</div>
<div className="flex items-center gap-3 text-right text-xs">
{thresholdMinutes > 0 && (
<span className="rounded-full border border-rose-500/40 bg-rose-500/10 px-2 py-0.5 font-medium text-rose-300 tabular-nums">
target {thresholdMinutes}m
</span>
)}
{data && (data.thisWeekAvg != null || data.lastWeekAvg != null) && (
<div>
<div className="text-muted-foreground">
This week:{' '}
<span className="font-medium text-foreground tabular-nums">
{fmt(data.thisWeekAvg)}
</span>
</div>
<div className="text-muted-foreground">
Last week:{' '}
<span className="tabular-nums">{fmt(data.lastWeekAvg)}</span>
</div>
</div>
)}
</div>
</header>
<div className="p-5">
{loading || !data ? (
<Skeleton className="h-[260px] w-full" />
) : !hasData ? (
<EmptyState
icon={Clock}
title="No replies recorded yet"
hint="This chart fills in as you reply to customer messages."
/>
) : (
<BarChart
data={chartData}
index="day"
categories={[CATEGORY]}
// 'violet' maps to Tailwind's `fill-violet-500` — matches
// the brand accent the hand-rolled bars used (#7c3aed).
colors={['violet']}
valueFormatter={(value) => `${value.toFixed(1)}m`}
showLegend={false}
yAxisWidth={48}
// Compact height so the chart sits well inside the card
// without dominating the row alongside the donut + activity feed.
className="h-[260px]"
/>
)}
</div>
</section>
)
}
function fmt(mins: number | null): string {
if (mins == null) return '—'
if (mins < 1) return `${Math.max(1, Math.round(mins * 60))}s`
if (mins < 60) return `${mins.toFixed(1)}m`
return `${(mins / 60).toFixed(1)}h`
}

Some files were not shown because too many files have changed in this diff Show More