From cd863cf9ee4deed8e526e97e99733219c7faa90a Mon Sep 17 00:00:00 2001 From: AlexTPaul Date: Tue, 30 Jun 2026 11:16:20 +0530 Subject: [PATCH] feat(notifications): implement account-based notification retrieval and update logic --- src/app/(dashboard)/notifications/page.tsx | 75 +++++++++++-------- src/components/layout/sidebar.tsx | 2 +- src/hooks/use-unread-notifications.ts | 11 ++- src/types/index.ts | 2 +- ...otifications.sql => 027_notifications.sql} | 16 +++- 5 files changed, 64 insertions(+), 42 deletions(-) rename supabase/migrations/{023_notifications.sql => 027_notifications.sql} (84%) diff --git a/src/app/(dashboard)/notifications/page.tsx b/src/app/(dashboard)/notifications/page.tsx index 1a9f90e..c658fa9 100644 --- a/src/app/(dashboard)/notifications/page.tsx +++ b/src/app/(dashboard)/notifications/page.tsx @@ -3,6 +3,7 @@ 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"; @@ -18,6 +19,7 @@ const TYPE_ICON: Record = { export default function NotificationsPage() { const router = useRouter(); + const { accountId } = useAuth(); const [notifications, setNotifications] = useState( null, ); @@ -25,10 +27,12 @@ export default function NotificationsPage() { 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) { @@ -36,7 +40,7 @@ export default function NotificationsPage() { return; } setNotifications((data ?? []) as Notification[]); - }, []); + }, [accountId]); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect @@ -81,24 +85,31 @@ export default function NotificationsPage() { }; }, []); - 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 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) => { @@ -134,7 +145,7 @@ export default function NotificationsPage() { if (error) { return (
-

{error}

+

{error}

@@ -154,8 +165,8 @@ export default function NotificationsPage() {
-

Notifications

-

+

Notifications

+

Conversations other teammates assign to you show up here.

@@ -175,14 +186,14 @@ export default function NotificationsPage() {
{notifications.length === 0 ? ( -
+
-

+

No notifications yet

-

+

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

@@ -201,20 +212,20 @@ export default function NotificationsPage() { "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", + : "border-border bg-card hover:border-border/70", )} >
@@ -223,7 +234,7 @@ export default function NotificationsPage() { {n.title} @@ -236,11 +247,11 @@ export default function NotificationsPage() { )}
{n.body && ( -

+

{n.body}

)} -

+

{formatDistanceToNow(new Date(n.created_at), { addSuffix: true, })} @@ -254,4 +265,4 @@ export default function NotificationsPage() { )}

); -} \ No newline at end of file +} diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 2180552..3ea489e 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -393,4 +393,4 @@ export function Sidebar({ open = false, onClose }: SidebarProps) { ); -} \ No newline at end of file +} diff --git a/src/hooks/use-unread-notifications.ts b/src/hooks/use-unread-notifications.ts index d4f68eb..c3ca86e 100644 --- a/src/hooks/use-unread-notifications.ts +++ b/src/hooks/use-unread-notifications.ts @@ -40,12 +40,11 @@ export function useUnreadNotifications(): number { 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; + // 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; - 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); + 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)); @@ -61,4 +60,4 @@ export function useUnreadNotifications(): number { }, []); return count; -} \ No newline at end of file +} diff --git a/src/types/index.ts b/src/types/index.ts index d80cf75..440da21 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -159,7 +159,7 @@ export interface Conversation { } // ============================================================ -// Notifications (migration 023) +// Notifications (migration 027) // ============================================================ export type NotificationType = 'conversation_assigned'; diff --git a/supabase/migrations/023_notifications.sql b/supabase/migrations/027_notifications.sql similarity index 84% rename from supabase/migrations/023_notifications.sql rename to supabase/migrations/027_notifications.sql index 08f6650..475f33d 100644 --- a/supabase/migrations/023_notifications.sql +++ b/supabase/migrations/027_notifications.sql @@ -1,4 +1,4 @@ -- ============================================================ +-- ============================================================ -- NOTIFICATIONS -- ============================================================ CREATE TABLE IF NOT EXISTS notifications ( @@ -25,6 +25,11 @@ 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. @@ -34,10 +39,17 @@ 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 -- ============================================================ @@ -116,4 +128,4 @@ BEGIN ) THEN ALTER PUBLICATION supabase_realtime ADD TABLE notifications; END IF; -END $$; \ No newline at end of file +END $$;