feat: Notifications

This commit is contained in:
AlexTPaul 2026-06-22 13:12:28 +05:30
parent f36cd23123
commit ab6a511d31
6 changed files with 483 additions and 1 deletions

View 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&apos;ll see an alert here when someone assigns you a
conversation.
</p>
</div>
) : (
<ul className="space-y-2">
{notifications.map((n) => {
const Icon = TYPE_ICON[n.type] ?? Bell;
const isUnread = !n.read_at;
return (
<li key={n.id}>
<button
type="button"
onClick={() => handleClick(n)}
className={cn(
"flex w-full items-start gap-3 rounded-xl border p-4 text-left transition-colors",
isUnread
? "border-primary/30 bg-primary/5 hover:border-primary/50"
: "border-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>
);
}

View File

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

View File

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

View 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;
}

View File

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

View 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 $$;