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:
131
wacrm/src/app/(auth)/forgot-password/page.tsx
Normal file
131
wacrm/src/app/(auth)/forgot-password/page.tsx
Normal 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'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'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>
|
||||
);
|
||||
}
|
||||
24
wacrm/src/app/(auth)/layout.tsx
Normal file
24
wacrm/src/app/(auth)/layout.tsx
Normal 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;
|
||||
}
|
||||
161
wacrm/src/app/(auth)/login/page.tsx
Normal file
161
wacrm/src/app/(auth)/login/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
244
wacrm/src/app/(auth)/signup/page.tsx
Normal file
244
wacrm/src/app/(auth)/signup/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
73
wacrm/src/app/(dashboard)/agents/page.tsx
Normal file
73
wacrm/src/app/(dashboard)/agents/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
wacrm/src/app/(dashboard)/automations/[id]/edit/page.tsx
Normal file
74
wacrm/src/app/(dashboard)/automations/[id]/edit/page.tsx
Normal 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} />
|
||||
}
|
||||
205
wacrm/src/app/(dashboard)/automations/[id]/logs/page.tsx
Normal file
205
wacrm/src/app/(dashboard)/automations/[id]/logs/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
90
wacrm/src/app/(dashboard)/automations/new/page.tsx
Normal file
90
wacrm/src/app/(dashboard)/automations/new/page.tsx
Normal 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
|
||||
}
|
||||
363
wacrm/src/app/(dashboard)/automations/page.tsx
Normal file
363
wacrm/src/app/(dashboard)/automations/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
528
wacrm/src/app/(dashboard)/broadcasts/[id]/page.tsx
Normal file
528
wacrm/src/app/(dashboard)/broadcasts/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
233
wacrm/src/app/(dashboard)/broadcasts/new/page.tsx
Normal file
233
wacrm/src/app/(dashboard)/broadcasts/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
288
wacrm/src/app/(dashboard)/broadcasts/page.tsx
Normal file
288
wacrm/src/app/(dashboard)/broadcasts/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
836
wacrm/src/app/(dashboard)/contacts/page.tsx
Normal file
836
wacrm/src/app/(dashboard)/contacts/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
wacrm/src/app/(dashboard)/dashboard-shell.tsx
Normal file
63
wacrm/src/app/(dashboard)/dashboard-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
225
wacrm/src/app/(dashboard)/dashboard/page.tsx
Normal file
225
wacrm/src/app/(dashboard)/dashboard/page.tsx
Normal 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}`
|
||||
}
|
||||
88
wacrm/src/app/(dashboard)/flows/[id]/page.tsx
Normal file
88
wacrm/src/app/(dashboard)/flows/[id]/page.tsx
Normal 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} />;
|
||||
}
|
||||
340
wacrm/src/app/(dashboard)/flows/[id]/runs/page.tsx
Normal file
340
wacrm/src/app/(dashboard)/flows/[id]/runs/page.tsx
Normal 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'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 "";
|
||||
}
|
||||
437
wacrm/src/app/(dashboard)/flows/page.tsx
Normal file
437
wacrm/src/app/(dashboard)/flows/page.tsx
Normal 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";
|
||||
}
|
||||
628
wacrm/src/app/(dashboard)/inbox/page.tsx
Normal file
628
wacrm/src/app/(dashboard)/inbox/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
wacrm/src/app/(dashboard)/layout.tsx
Normal file
28
wacrm/src/app/(dashboard)/layout.tsx
Normal 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>;
|
||||
}
|
||||
268
wacrm/src/app/(dashboard)/notifications/page.tsx
Normal file
268
wacrm/src/app/(dashboard)/notifications/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
492
wacrm/src/app/(dashboard)/pipelines/page.tsx
Normal file
492
wacrm/src/app/(dashboard)/pipelines/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
wacrm/src/app/(dashboard)/settings/page.tsx
Normal file
84
wacrm/src/app/(dashboard)/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
wacrm/src/app/api/account/api-keys/[id]/route.ts
Normal file
71
wacrm/src/app/api/account/api-keys/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
159
wacrm/src/app/api/account/api-keys/route.ts
Normal file
159
wacrm/src/app/api/account/api-keys/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
73
wacrm/src/app/api/account/invitations/[id]/route.ts
Normal file
73
wacrm/src/app/api/account/invitations/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
253
wacrm/src/app/api/account/invitations/route.ts
Normal file
253
wacrm/src/app/api/account/invitations/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
122
wacrm/src/app/api/account/members/[userId]/route.ts
Normal file
122
wacrm/src/app/api/account/members/[userId]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
73
wacrm/src/app/api/account/members/route.ts
Normal file
73
wacrm/src/app/api/account/members/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
103
wacrm/src/app/api/account/route.ts
Normal file
103
wacrm/src/app/api/account/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
94
wacrm/src/app/api/account/transfer-ownership/route.ts
Normal file
94
wacrm/src/app/api/account/transfer-ownership/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
253
wacrm/src/app/api/ai/config/route.ts
Normal file
253
wacrm/src/app/api/ai/config/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
116
wacrm/src/app/api/ai/draft/route.ts
Normal file
116
wacrm/src/app/api/ai/draft/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
132
wacrm/src/app/api/ai/knowledge/[id]/route.ts
Normal file
132
wacrm/src/app/api/ai/knowledge/[id]/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
79
wacrm/src/app/api/ai/knowledge/reindex/route.ts
Normal file
79
wacrm/src/app/api/ai/knowledge/reindex/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
110
wacrm/src/app/api/ai/knowledge/route.ts
Normal file
110
wacrm/src/app/api/ai/knowledge/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
98
wacrm/src/app/api/ai/playground/route.ts
Normal file
98
wacrm/src/app/api/ai/playground/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
94
wacrm/src/app/api/ai/test/route.ts
Normal file
94
wacrm/src/app/api/ai/test/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
75
wacrm/src/app/api/automations/[id]/duplicate/route.ts
Normal file
75
wacrm/src/app/api/automations/[id]/duplicate/route.ts
Normal 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 })
|
||||
}
|
||||
138
wacrm/src/app/api/automations/[id]/route.ts
Normal file
138
wacrm/src/app/api/automations/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
68
wacrm/src/app/api/automations/cron/route.ts
Normal file
68
wacrm/src/app/api/automations/cron/route.ts
Normal 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 })
|
||||
}
|
||||
33
wacrm/src/app/api/automations/engine/route.ts
Normal file
33
wacrm/src/app/api/automations/engine/route.ts
Normal 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 })
|
||||
}
|
||||
125
wacrm/src/app/api/automations/route.ts
Normal file
125
wacrm/src/app/api/automations/route.ts
Normal 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 })
|
||||
}
|
||||
108
wacrm/src/app/api/flows/[id]/activate/route.ts
Normal file
108
wacrm/src/app/api/flows/[id]/activate/route.ts
Normal 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 })
|
||||
}
|
||||
194
wacrm/src/app/api/flows/[id]/route.ts
Normal file
194
wacrm/src/app/api/flows/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
|
||||
86
wacrm/src/app/api/flows/[id]/runs/route.ts
Normal file
86
wacrm/src/app/api/flows/[id]/runs/route.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
112
wacrm/src/app/api/flows/cron/route.ts
Normal file
112
wacrm/src/app/api/flows/cron/route.ts
Normal 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 })
|
||||
}
|
||||
169
wacrm/src/app/api/flows/route.ts
Normal file
169
wacrm/src/app/api/flows/route.ts
Normal 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 })
|
||||
}
|
||||
34
wacrm/src/app/api/flows/templates/route.ts
Normal file
34
wacrm/src/app/api/flows/templates/route.ts
Normal 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 })
|
||||
}
|
||||
87
wacrm/src/app/api/invitations/[token]/peek/route.ts
Normal file
87
wacrm/src/app/api/invitations/[token]/peek/route.ts
Normal 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);
|
||||
}
|
||||
91
wacrm/src/app/api/invitations/[token]/redeem/route.ts
Normal file
91
wacrm/src/app/api/invitations/[token]/redeem/route.ts
Normal 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 });
|
||||
}
|
||||
52
wacrm/src/app/api/v1/account/members/route.ts
Normal file
52
wacrm/src/app/api/v1/account/members/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
41
wacrm/src/app/api/v1/broadcasts/[id]/route.ts
Normal file
41
wacrm/src/app/api/v1/broadcasts/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
105
wacrm/src/app/api/v1/broadcasts/route.ts
Normal file
105
wacrm/src/app/api/v1/broadcasts/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
102
wacrm/src/app/api/v1/contacts/[id]/route.ts
Normal file
102
wacrm/src/app/api/v1/contacts/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
149
wacrm/src/app/api/v1/contacts/route.ts
Normal file
149
wacrm/src/app/api/v1/contacts/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
78
wacrm/src/app/api/v1/conversations/[id]/assign/route.ts
Normal file
78
wacrm/src/app/api/v1/conversations/[id]/assign/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
65
wacrm/src/app/api/v1/conversations/[id]/messages/route.ts
Normal file
65
wacrm/src/app/api/v1/conversations/[id]/messages/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
40
wacrm/src/app/api/v1/conversations/[id]/route.ts
Normal file
40
wacrm/src/app/api/v1/conversations/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
66
wacrm/src/app/api/v1/conversations/route.ts
Normal file
66
wacrm/src/app/api/v1/conversations/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
31
wacrm/src/app/api/v1/me/route.ts
Normal file
31
wacrm/src/app/api/v1/me/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
136
wacrm/src/app/api/v1/messages/route.ts
Normal file
136
wacrm/src/app/api/v1/messages/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
114
wacrm/src/app/api/v1/messages/simulate/route.ts
Normal file
114
wacrm/src/app/api/v1/messages/simulate/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
146
wacrm/src/app/api/v1/webhooks/[id]/route.ts
Normal file
146
wacrm/src/app/api/v1/webhooks/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
102
wacrm/src/app/api/v1/webhooks/route.ts
Normal file
102
wacrm/src/app/api/v1/webhooks/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
263
wacrm/src/app/api/whatsapp/broadcast/route.ts
Normal file
263
wacrm/src/app/api/whatsapp/broadcast/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
480
wacrm/src/app/api/whatsapp/config/route.ts
Normal file
480
wacrm/src/app/api/whatsapp/config/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
156
wacrm/src/app/api/whatsapp/config/verify-registration/route.ts
Normal file
156
wacrm/src/app/api/whatsapp/config/verify-registration/route.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
90
wacrm/src/app/api/whatsapp/media/[mediaId]/route.ts
Normal file
90
wacrm/src/app/api/whatsapp/media/[mediaId]/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
193
wacrm/src/app/api/whatsapp/react/route.ts
Normal file
193
wacrm/src/app/api/whatsapp/react/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
261
wacrm/src/app/api/whatsapp/send/route.test.ts
Normal file
261
wacrm/src/app/api/whatsapp/send/route.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
249
wacrm/src/app/api/whatsapp/send/route.ts
Normal file
249
wacrm/src/app/api/whatsapp/send/route.ts
Normal 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
|
||||
}
|
||||
330
wacrm/src/app/api/whatsapp/templates/[id]/route.ts
Normal file
330
wacrm/src/app/api/whatsapp/templates/[id]/route.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
261
wacrm/src/app/api/whatsapp/templates/submit/route.ts
Normal file
261
wacrm/src/app/api/whatsapp/templates/submit/route.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
322
wacrm/src/app/api/whatsapp/templates/sync/route.ts
Normal file
322
wacrm/src/app/api/whatsapp/templates/sync/route.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
1107
wacrm/src/app/api/whatsapp/webhook/route.ts
Normal file
1107
wacrm/src/app/api/whatsapp/webhook/route.ts
Normal file
File diff suppressed because it is too large
Load Diff
230
wacrm/src/app/globals.css
Normal file
230
wacrm/src/app/globals.css
Normal 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
45
wacrm/src/app/icon.tsx
Normal 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 },
|
||||
);
|
||||
}
|
||||
431
wacrm/src/app/join/[token]/page.tsx
Normal file
431
wacrm/src/app/join/[token]/page.tsx
Normal 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 doesn’t 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 wasn’t 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 couldn’t 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're invited to{' '}
|
||||
<span className="text-primary">{peek.account_name}</span>
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
You'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'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'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 & 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>
|
||||
);
|
||||
}
|
||||
44
wacrm/src/app/join/layout.tsx
Normal file
44
wacrm/src/app/join/layout.tsx
Normal 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
113
wacrm/src/app/layout.tsx
Normal 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
5
wacrm/src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function RootPage() {
|
||||
redirect('/dashboard')
|
||||
}
|
||||
198
wacrm/src/components/agents/ai-playground.tsx
Normal file
198
wacrm/src/components/agents/ai-playground.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
wacrm/src/components/auth/require-role.tsx
Normal file
47
wacrm/src/components/auth/require-role.tsx
Normal 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}</>;
|
||||
}
|
||||
1614
wacrm/src/components/automations/automation-builder.tsx
Normal file
1614
wacrm/src/components/automations/automation-builder.tsx
Normal file
File diff suppressed because it is too large
Load Diff
135
wacrm/src/components/broadcasts/step1-choose-template.tsx
Normal file
135
wacrm/src/components/broadcasts/step1-choose-template.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
472
wacrm/src/components/broadcasts/step2-select-audience.tsx
Normal file
472
wacrm/src/components/broadcasts/step2-select-audience.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
460
wacrm/src/components/broadcasts/step3-personalize.tsx
Normal file
460
wacrm/src/components/broadcasts/step3-personalize.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
236
wacrm/src/components/broadcasts/step4-schedule-send.tsx
Normal file
236
wacrm/src/components/broadcasts/step4-schedule-send.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
763
wacrm/src/components/contacts/contact-detail-view.tsx
Normal file
763
wacrm/src/components/contacts/contact-detail-view.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
388
wacrm/src/components/contacts/contact-form.tsx
Normal file
388
wacrm/src/components/contacts/contact-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
286
wacrm/src/components/contacts/custom-fields-manager.tsx
Normal file
286
wacrm/src/components/contacts/custom-fields-manager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
648
wacrm/src/components/contacts/import-modal.tsx
Normal file
648
wacrm/src/components/contacts/import-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
167
wacrm/src/components/dashboard/activity-feed.tsx
Normal file
167
wacrm/src/components/dashboard/activity-feed.tsx
Normal 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()
|
||||
}
|
||||
341
wacrm/src/components/dashboard/conversations-chart.tsx
Normal file
341
wacrm/src/components/dashboard/conversations-chart.tsx
Normal 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
|
||||
}
|
||||
36
wacrm/src/components/dashboard/empty-state.tsx
Normal file
36
wacrm/src/components/dashboard/empty-state.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
wacrm/src/components/dashboard/metric-card.tsx
Normal file
57
wacrm/src/components/dashboard/metric-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
141
wacrm/src/components/dashboard/pipeline-donut.tsx
Normal file
141
wacrm/src/components/dashboard/pipeline-donut.tsx
Normal 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}`
|
||||
}
|
||||
45
wacrm/src/components/dashboard/quick-actions.tsx
Normal file
45
wacrm/src/components/dashboard/quick-actions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
116
wacrm/src/components/dashboard/response-time-chart.tsx
Normal file
116
wacrm/src/components/dashboard/response-time-chart.tsx
Normal 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'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
Reference in New Issue
Block a user