mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
feat: Notifications
This commit is contained in:
parent
f36cd23123
commit
ab6a511d31
257
src/app/(dashboard)/notifications/page.tsx
Normal file
257
src/app/(dashboard)/notifications/page.tsx
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
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 [notifications, setNotifications] = useState<Notification[] | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [markingAll, setMarkingAll] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const supabase = createClient();
|
||||||
|
const { data, error: fetchErr } = await supabase
|
||||||
|
.from("notifications")
|
||||||
|
.select("*")
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.limit(100);
|
||||||
|
if (fetchErr) {
|
||||||
|
setError(fetchErr.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setNotifications((data ?? []) as Notification[]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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();
|
||||||
|
await supabase
|
||||||
|
.from("notifications")
|
||||||
|
.update({ read_at: new Date().toISOString() })
|
||||||
|
.eq("id", id)
|
||||||
|
.is("read_at", null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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-red-400">{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-white">Notifications</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
|
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-slate-800 bg-slate-900/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-white">
|
||||||
|
No notifications yet
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-400">
|
||||||
|
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-slate-800 bg-slate-900 hover:border-slate-700",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg",
|
||||||
|
isUnread ? "bg-primary/15" : "bg-slate-800",
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
"h-5 w-5",
|
||||||
|
isUnread ? "text-primary" : "text-slate-400",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</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-white" : "text-slate-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{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-slate-400">
|
||||||
|
{n.body}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 text-[11px] text-slate-500">
|
||||||
|
{formatDistanceToNow(new Date(n.created_at), {
|
||||||
|
addSuffix: true,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -20,6 +20,7 @@ import {
|
|||||||
const pageTitles: Record<string, string> = {
|
const pageTitles: Record<string, string> = {
|
||||||
"/dashboard": "Dashboard",
|
"/dashboard": "Dashboard",
|
||||||
"/inbox": "Inbox",
|
"/inbox": "Inbox",
|
||||||
|
"/notifications": "Notifications",
|
||||||
"/contacts": "Contacts",
|
"/contacts": "Contacts",
|
||||||
"/pipelines": "Pipelines",
|
"/pipelines": "Pipelines",
|
||||||
"/broadcasts": "Broadcasts",
|
"/broadcasts": "Broadcasts",
|
||||||
|
|||||||
@ -6,7 +6,9 @@ import { useEffect } from "react";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { useTotalUnread } from "@/hooks/use-total-unread";
|
import { useTotalUnread } from "@/hooks/use-total-unread";
|
||||||
|
import { useUnreadNotifications } from "@/hooks/use-unread-notifications";
|
||||||
import {
|
import {
|
||||||
|
Bell,
|
||||||
Crown,
|
Crown,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@ -89,6 +91,7 @@ interface NavItem {
|
|||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||||
{ href: "/inbox", label: "Inbox", icon: MessageSquare },
|
{ href: "/inbox", label: "Inbox", icon: MessageSquare },
|
||||||
|
{ href: "/notifications", label: "Notifications", icon: Bell },
|
||||||
{ href: "/contacts", label: "Contacts", icon: Users },
|
{ href: "/contacts", label: "Contacts", icon: Users },
|
||||||
{ href: "/pipelines", label: "Pipelines", icon: GitBranch },
|
{ href: "/pipelines", label: "Pipelines", icon: GitBranch },
|
||||||
{ href: "/broadcasts", label: "Broadcasts", icon: Radio },
|
{ href: "/broadcasts", label: "Broadcasts", icon: Radio },
|
||||||
@ -110,6 +113,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { profile, profileLoading, account, accountRole, signOut } = useAuth();
|
const { profile, profileLoading, account, accountRole, signOut } = useAuth();
|
||||||
const totalUnread = useTotalUnread();
|
const totalUnread = useTotalUnread();
|
||||||
|
const unreadNotifications = useUnreadNotifications();
|
||||||
// Only surface the account-name strip when it actually carries
|
// Only surface the account-name strip when it actually carries
|
||||||
// information. A solo user's personal account is named after them
|
// information. A solo user's personal account is named after them
|
||||||
// (the 017 signup trigger seeds it from `full_name`), so showing it
|
// (the 017 signup trigger seeds it from `full_name`), so showing it
|
||||||
@ -207,6 +211,13 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
|||||||
const showUnreadDot =
|
const showUnreadDot =
|
||||||
item.href === "/inbox" && totalUnread > 0 && !isActive;
|
item.href === "/inbox" && totalUnread > 0 && !isActive;
|
||||||
|
|
||||||
|
// Unlike the inbox dot, the notifications count stays visible
|
||||||
|
// even while the page is active — it reflects unread state
|
||||||
|
// (cleared by marking notifications read), not "currently
|
||||||
|
// viewing this section".
|
||||||
|
const showNotificationBadge =
|
||||||
|
item.href === "/notifications" && unreadNotifications > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={item.href}>
|
<li key={item.href}>
|
||||||
<Link
|
<Link
|
||||||
@ -238,6 +249,14 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
|||||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary" />
|
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{showNotificationBadge && (
|
||||||
|
<span
|
||||||
|
aria-label={`${unreadNotifications} unread notification${unreadNotifications === 1 ? "" : "s"}`}
|
||||||
|
className="flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold text-primary-foreground"
|
||||||
|
>
|
||||||
|
{unreadNotifications > 9 ? "9+" : unreadNotifications}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@ -374,4 +393,4 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
|||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
64
src/hooks/use-unread-notifications.ts
Normal file
64
src/hooks/use-unread-notifications.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
import type { Notification } from "@/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count of unread notifications for the current user. Used by the
|
||||||
|
* sidebar to surface a badge on the Notifications nav entry.
|
||||||
|
*
|
||||||
|
* RLS on `notifications` already scopes every read to `auth.uid() =
|
||||||
|
* user_id`, so no explicit filter is needed here — same pattern as
|
||||||
|
* `useTotalUnread` for conversations.
|
||||||
|
*/
|
||||||
|
export function useUnreadNotifications(): number {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const supabase = createClient();
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
// head:true skips fetching rows — we only need the `count`
|
||||||
|
// supabase-js returns alongside the (empty) response body.
|
||||||
|
const { count: unreadCount, error } = await supabase
|
||||||
|
.from("notifications")
|
||||||
|
.select("*", { count: "exact", head: true })
|
||||||
|
.is("read_at", null);
|
||||||
|
if (cancelled || error) return;
|
||||||
|
setCount(unreadCount ?? 0);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const channel = supabase
|
||||||
|
.channel("notifications-unread-count")
|
||||||
|
.on(
|
||||||
|
"postgres_changes",
|
||||||
|
{ event: "*", schema: "public", table: "notifications" },
|
||||||
|
(payload) => {
|
||||||
|
if (payload.eventType === "INSERT") {
|
||||||
|
const row = payload.new as Notification;
|
||||||
|
if (!row.read_at) setCount((n) => n + 1);
|
||||||
|
} else if (payload.eventType === "UPDATE") {
|
||||||
|
const oldRow = payload.old as Partial<Notification>;
|
||||||
|
const newRow = payload.new as Notification;
|
||||||
|
const wasUnread = !oldRow.read_at;
|
||||||
|
const isUnread = !newRow.read_at;
|
||||||
|
if (wasUnread && !isUnread) setCount((n) => Math.max(0, n - 1));
|
||||||
|
if (!wasUnread && isUnread) setCount((n) => n + 1);
|
||||||
|
} else if (payload.eventType === "DELETE") {
|
||||||
|
const oldRow = payload.old as Partial<Notification>;
|
||||||
|
if (!oldRow.read_at) setCount((n) => Math.max(0, n - 1));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
supabase.removeChannel(channel);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
@ -158,6 +158,28 @@ export interface Conversation {
|
|||||||
contact?: Contact;
|
contact?: Contact;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Notifications (migration 023)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export type NotificationType = 'conversation_assigned';
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
account_id: string;
|
||||||
|
/** Recipient — the agent this notification is for. */
|
||||||
|
user_id: string;
|
||||||
|
type: NotificationType;
|
||||||
|
conversation_id?: string;
|
||||||
|
contact_id?: string;
|
||||||
|
/** Who triggered it. Null when an automation/system assigned it. */
|
||||||
|
actor_user_id?: string;
|
||||||
|
title: string;
|
||||||
|
body?: string;
|
||||||
|
read_at?: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type SenderType = 'customer' | 'agent' | 'bot';
|
export type SenderType = 'customer' | 'agent' | 'bot';
|
||||||
export type ContentType =
|
export type ContentType =
|
||||||
| 'text'
|
| 'text'
|
||||||
|
|||||||
119
supabase/migrations/023_notifications.sql
Normal file
119
supabase/migrations/023_notifications.sql
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
- ============================================================
|
||||||
|
-- NOTIFICATIONS
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
-- Recipient — the agent this notification is for.
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
type TEXT NOT NULL DEFAULT 'conversation_assigned'
|
||||||
|
CHECK (type IN ('conversation_assigned')),
|
||||||
|
conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE,
|
||||||
|
contact_id UUID REFERENCES contacts(id) ON DELETE SET NULL,
|
||||||
|
-- Who triggered the notification. NULL means an automation / the
|
||||||
|
-- system did it rather than a signed-in teammate.
|
||||||
|
actor_user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT,
|
||||||
|
read_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_user_created
|
||||||
|
ON notifications(user_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_user_unread
|
||||||
|
ON notifications(user_id)
|
||||||
|
WHERE read_at IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE notifications ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Recipients can read and mark their own notifications as read.
|
||||||
|
-- No client INSERT/DELETE policy — rows are created exclusively by
|
||||||
|
-- the SECURITY DEFINER trigger function below.
|
||||||
|
DROP POLICY IF EXISTS notifications_select ON notifications;
|
||||||
|
DROP POLICY IF EXISTS notifications_update ON notifications;
|
||||||
|
CREATE POLICY notifications_select ON notifications FOR SELECT
|
||||||
|
USING (auth.uid() = user_id);
|
||||||
|
CREATE POLICY notifications_update ON notifications FOR UPDATE
|
||||||
|
USING (auth.uid() = user_id)
|
||||||
|
WITH CHECK (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- TRIGGER — notify on conversation assignment
|
||||||
|
-- ============================================================
|
||||||
|
CREATE OR REPLACE FUNCTION notify_conversation_assigned()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_contact_name TEXT;
|
||||||
|
v_actor_name TEXT;
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'INSERT' THEN
|
||||||
|
IF NEW.assigned_agent_id IS NULL THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
ELSE
|
||||||
|
IF NEW.assigned_agent_id IS NULL
|
||||||
|
OR NEW.assigned_agent_id IS NOT DISTINCT FROM OLD.assigned_agent_id THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Skip self-assignment — nothing to notify the agent about.
|
||||||
|
IF auth.uid() IS NOT NULL AND auth.uid() = NEW.assigned_agent_id THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT COALESCE(NULLIF(name, ''), phone) INTO v_contact_name
|
||||||
|
FROM contacts WHERE id = NEW.contact_id;
|
||||||
|
|
||||||
|
IF auth.uid() IS NOT NULL THEN
|
||||||
|
SELECT full_name INTO v_actor_name
|
||||||
|
FROM profiles WHERE user_id = auth.uid();
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO notifications (
|
||||||
|
account_id, user_id, type, conversation_id, contact_id,
|
||||||
|
actor_user_id, title, body
|
||||||
|
) VALUES (
|
||||||
|
NEW.account_id,
|
||||||
|
NEW.assigned_agent_id,
|
||||||
|
'conversation_assigned',
|
||||||
|
NEW.id,
|
||||||
|
NEW.contact_id,
|
||||||
|
auth.uid(),
|
||||||
|
'New conversation assigned',
|
||||||
|
COALESCE(v_actor_name, 'Someone') || ' assigned you a conversation with '
|
||||||
|
|| COALESCE(v_contact_name, 'a contact')
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
-- Never let a notification failure block the assignment itself.
|
||||||
|
RAISE WARNING 'Failed to create assignment notification for conversation %: %', NEW.id, SQLERRM;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
ALTER FUNCTION notify_conversation_assigned() OWNER TO postgres;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS on_conversation_assigned ON conversations;
|
||||||
|
CREATE TRIGGER on_conversation_assigned
|
||||||
|
AFTER INSERT OR UPDATE OF assigned_agent_id ON conversations
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION notify_conversation_assigned();
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- ENABLE REALTIME
|
||||||
|
-- ============================================================
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_publication_tables
|
||||||
|
WHERE pubname = 'supabase_realtime' AND tablename = 'notifications'
|
||||||
|
) THEN
|
||||||
|
ALTER PUBLICATION supabase_realtime ADD TABLE notifications;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
Loading…
Reference in New Issue
Block a user