diff --git a/src/app/(dashboard)/notifications/page.tsx b/src/app/(dashboard)/notifications/page.tsx new file mode 100644 index 0000000..c658fa9 --- /dev/null +++ b/src/app/(dashboard)/notifications/page.tsx @@ -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 = { + conversation_assigned: UserPlus, +}; + +export default function NotificationsPage() { + const router = useRouter(); + const { accountId } = useAuth(); + const [notifications, setNotifications] = useState( + null, + ); + const [error, setError] = useState(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; + 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 ( +
+

{error}

+ +
+ ); + } + + if (notifications === null) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+

Notifications

+

+ Conversations other teammates assign to you show up here. +

+
+ +
+ + {notifications.length === 0 ? ( +
+
+ +
+

+ No notifications yet +

+

+ You'll see an alert here when someone assigns you a + conversation. +

+
+ ) : ( +
    + {notifications.map((n) => { + const Icon = TYPE_ICON[n.type] ?? Bell; + const isUnread = !n.read_at; + return ( +
  • + +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx index 92ed026..45d615b 100644 --- a/src/components/layout/header.tsx +++ b/src/components/layout/header.tsx @@ -21,6 +21,7 @@ import { ModeToggle } from "@/components/layout/mode-toggle"; const pageTitles: Record = { "/dashboard": "Dashboard", "/inbox": "Inbox", + "/notifications": "Notifications", "/contacts": "Contacts", "/pipelines": "Pipelines", "/broadcasts": "Broadcasts", diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 0d5f644..3ea489e 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -6,7 +6,9 @@ import { useEffect } from "react"; import { cn } from "@/lib/utils"; import { useAuth } from "@/hooks/use-auth"; import { useTotalUnread } from "@/hooks/use-total-unread"; +import { useUnreadNotifications } from "@/hooks/use-unread-notifications"; import { + Bell, Crown, GitBranch, LayoutDashboard, @@ -89,6 +91,7 @@ interface NavItem { const navItems: NavItem[] = [ { href: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, { href: "/inbox", label: "Inbox", icon: MessageSquare }, + { href: "/notifications", label: "Notifications", icon: Bell }, { href: "/contacts", label: "Contacts", icon: Users }, { href: "/pipelines", label: "Pipelines", icon: GitBranch }, { href: "/broadcasts", label: "Broadcasts", icon: Radio }, @@ -110,6 +113,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) { const pathname = usePathname(); const { profile, profileLoading, account, accountRole, signOut } = useAuth(); const totalUnread = useTotalUnread(); + const unreadNotifications = useUnreadNotifications(); // Only surface the account-name strip when it actually carries // information. A solo user's personal account is named after them // (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 = 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 (
  • )} + {showNotificationBadge && ( + + {unreadNotifications > 9 ? "9+" : unreadNotifications} + + )}
  • ); diff --git a/src/hooks/use-unread-notifications.ts b/src/hooks/use-unread-notifications.ts new file mode 100644 index 0000000..c3ca86e --- /dev/null +++ b/src/hooks/use-unread-notifications.ts @@ -0,0 +1,63 @@ +"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") { + // Updates here only ever set read_at (marking a notification + // read). Derive purely from the new row so we don't rely on + // payload.old columns, which require REPLICA IDENTITY FULL. + const newRow = payload.new as Notification; + if (newRow.read_at) setCount((n) => Math.max(0, n - 1)); + } else if (payload.eventType === "DELETE") { + const oldRow = payload.old as Partial; + if (!oldRow.read_at) setCount((n) => Math.max(0, n - 1)); + } + }, + ) + .subscribe(); + + return () => { + cancelled = true; + supabase.removeChannel(channel); + }; + }, []); + + return count; +} diff --git a/src/types/index.ts b/src/types/index.ts index 66e0b8a..440da21 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -158,6 +158,28 @@ export interface Conversation { contact?: Contact; } +// ============================================================ +// Notifications (migration 027) +// ============================================================ + +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 ContentType = | 'text' diff --git a/supabase/migrations/027_notifications.sql b/supabase/migrations/027_notifications.sql new file mode 100644 index 0000000..475f33d --- /dev/null +++ b/supabase/migrations/027_notifications.sql @@ -0,0 +1,131 @@ +-- ============================================================ +-- 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; + +-- Full replica identity so realtime UPDATE payloads include old column +-- values. Without this, payload.old only carries the primary key, which +-- makes it impossible to derive whether a row was unread before the update. +ALTER TABLE notifications REPLICA IDENTITY FULL; + +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); +-- Only read_at updates are meaningful from the client; restrict via a +-- column-level security policy so other fields cannot be rewritten. +CREATE POLICY notifications_update ON notifications FOR UPDATE + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); + +-- Restrict to read_at column only at the column-privilege level so +-- clients cannot overwrite title, body, or other immutable fields. +REVOKE UPDATE ON notifications FROM authenticated; +GRANT UPDATE (read_at) ON notifications TO authenticated; + +-- ============================================================ +-- 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 $$;