feat(notifications): implement account-based notification retrieval and update logic

This commit is contained in:
AlexTPaul 2026-06-30 11:16:20 +05:30
parent cf842fcee2
commit cd863cf9ee
5 changed files with 64 additions and 42 deletions

View File

@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { createClient } from "@/lib/supabase/client"; import { createClient } from "@/lib/supabase/client";
import { useAuth } from "@/hooks/use-auth";
import type { Notification } from "@/types"; import type { Notification } from "@/types";
import { Bell, CheckCheck, Loader2, UserPlus } from "lucide-react"; import { Bell, CheckCheck, Loader2, UserPlus } from "lucide-react";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
@ -18,6 +19,7 @@ const TYPE_ICON: Record<Notification["type"], typeof Bell> = {
export default function NotificationsPage() { export default function NotificationsPage() {
const router = useRouter(); const router = useRouter();
const { accountId } = useAuth();
const [notifications, setNotifications] = useState<Notification[] | null>( const [notifications, setNotifications] = useState<Notification[] | null>(
null, null,
); );
@ -25,10 +27,12 @@ export default function NotificationsPage() {
const [markingAll, setMarkingAll] = useState(false); const [markingAll, setMarkingAll] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
if (!accountId) return;
const supabase = createClient(); const supabase = createClient();
const { data, error: fetchErr } = await supabase const { data, error: fetchErr } = await supabase
.from("notifications") .from("notifications")
.select("*") .select("*")
.eq("account_id", accountId)
.order("created_at", { ascending: false }) .order("created_at", { ascending: false })
.limit(100); .limit(100);
if (fetchErr) { if (fetchErr) {
@ -36,7 +40,7 @@ export default function NotificationsPage() {
return; return;
} }
setNotifications((data ?? []) as Notification[]); setNotifications((data ?? []) as Notification[]);
}, []); }, [accountId]);
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
@ -81,24 +85,31 @@ export default function NotificationsPage() {
}; };
}, []); }, []);
const markRead = useCallback(async (id: string) => { const markRead = useCallback(
// Optimistic — the row is already visually "read" by the time the async (id: string) => {
// request lands, so the UI doesn't wait on the round-trip. // Optimistic — the row is already visually "read" by the time the
setNotifications( // request lands, so the UI doesn't wait on the round-trip.
(prev) => setNotifications(
prev?.map((n) => (prev) =>
n.id === id && !n.read_at prev?.map((n) =>
? { ...n, read_at: new Date().toISOString() } n.id === id && !n.read_at
: n, ? { ...n, read_at: new Date().toISOString() }
) ?? prev, : n,
); ) ?? prev,
const supabase = createClient(); );
await supabase const supabase = createClient();
.from("notifications") const { error: updateErr } = await supabase
.update({ read_at: new Date().toISOString() }) .from("notifications")
.eq("id", id) .update({ read_at: new Date().toISOString() })
.is("read_at", null); .eq("id", id)
}, []); .is("read_at", null);
if (updateErr) {
toast.error("Failed to mark notification as read");
load();
}
},
[load],
);
const handleClick = useCallback( const handleClick = useCallback(
(n: Notification) => { (n: Notification) => {
@ -134,7 +145,7 @@ export default function NotificationsPage() {
if (error) { if (error) {
return ( return (
<div className="flex h-64 flex-col items-center justify-center gap-2"> <div className="flex h-64 flex-col items-center justify-center gap-2">
<p className="text-sm text-red-400">{error}</p> <p className="text-sm text-destructive">{error}</p>
<Button variant="outline" onClick={() => window.location.reload()}> <Button variant="outline" onClick={() => window.location.reload()}>
Retry Retry
</Button> </Button>
@ -154,8 +165,8 @@ export default function NotificationsPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-white">Notifications</h1> <h1 className="text-2xl font-bold text-foreground">Notifications</h1>
<p className="mt-1 text-sm text-slate-400"> <p className="mt-1 text-sm text-muted-foreground">
Conversations other teammates assign to you show up here. Conversations other teammates assign to you show up here.
</p> </p>
</div> </div>
@ -175,14 +186,14 @@ export default function NotificationsPage() {
</div> </div>
{notifications.length === 0 ? ( {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-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"> <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
<Bell className="h-6 w-6 text-primary" /> <Bell className="h-6 w-6 text-primary" />
</div> </div>
<p className="mt-3 text-sm font-medium text-white"> <p className="mt-3 text-sm font-medium text-foreground">
No notifications yet No notifications yet
</p> </p>
<p className="mt-1 text-xs text-slate-400"> <p className="mt-1 text-xs text-muted-foreground">
You&apos;ll see an alert here when someone assigns you a You&apos;ll see an alert here when someone assigns you a
conversation. conversation.
</p> </p>
@ -201,20 +212,20 @@ export default function NotificationsPage() {
"flex w-full items-start gap-3 rounded-xl border p-4 text-left transition-colors", "flex w-full items-start gap-3 rounded-xl border p-4 text-left transition-colors",
isUnread isUnread
? "border-primary/30 bg-primary/5 hover:border-primary/50" ? "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",
)} )}
> >
<div <div
className={cn( className={cn(
"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg", "flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg",
isUnread ? "bg-primary/15" : "bg-slate-800", isUnread ? "bg-primary/15" : "bg-muted",
)} )}
aria-hidden aria-hidden
> >
<Icon <Icon
className={cn( className={cn(
"h-5 w-5", "h-5 w-5",
isUnread ? "text-primary" : "text-slate-400", isUnread ? "text-primary" : "text-muted-foreground",
)} )}
/> />
</div> </div>
@ -223,7 +234,7 @@ export default function NotificationsPage() {
<span <span
className={cn( className={cn(
"truncate text-sm font-semibold", "truncate text-sm font-semibold",
isUnread ? "text-white" : "text-slate-300", isUnread ? "text-foreground" : "text-muted-foreground",
)} )}
> >
{n.title} {n.title}
@ -236,11 +247,11 @@ export default function NotificationsPage() {
)} )}
</div> </div>
{n.body && ( {n.body && (
<p className="mt-0.5 truncate text-xs text-slate-400"> <p className="mt-0.5 truncate text-xs text-muted-foreground">
{n.body} {n.body}
</p> </p>
)} )}
<p className="mt-1 text-[11px] text-slate-500"> <p className="mt-1 text-[11px] text-muted-foreground/70">
{formatDistanceToNow(new Date(n.created_at), { {formatDistanceToNow(new Date(n.created_at), {
addSuffix: true, addSuffix: true,
})} })}

View File

@ -40,12 +40,11 @@ export function useUnreadNotifications(): number {
const row = payload.new as Notification; const row = payload.new as Notification;
if (!row.read_at) setCount((n) => n + 1); if (!row.read_at) setCount((n) => n + 1);
} else if (payload.eventType === "UPDATE") { } else if (payload.eventType === "UPDATE") {
const oldRow = payload.old as Partial<Notification>; // 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 newRow = payload.new as Notification;
const wasUnread = !oldRow.read_at; if (newRow.read_at) setCount((n) => Math.max(0, n - 1));
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") { } else if (payload.eventType === "DELETE") {
const oldRow = payload.old as Partial<Notification>; const oldRow = payload.old as Partial<Notification>;
if (!oldRow.read_at) setCount((n) => Math.max(0, n - 1)); if (!oldRow.read_at) setCount((n) => Math.max(0, n - 1));

View File

@ -159,7 +159,7 @@ export interface Conversation {
} }
// ============================================================ // ============================================================
// Notifications (migration 023) // Notifications (migration 027)
// ============================================================ // ============================================================
export type NotificationType = 'conversation_assigned'; export type NotificationType = 'conversation_assigned';

View File

@ -1,4 +1,4 @@
- ============================================================ -- ============================================================
-- NOTIFICATIONS -- NOTIFICATIONS
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS notifications ( CREATE TABLE IF NOT EXISTS notifications (
@ -25,6 +25,11 @@ CREATE INDEX IF NOT EXISTS idx_notifications_user_unread
ON notifications(user_id) ON notifications(user_id)
WHERE read_at IS NULL; 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; ALTER TABLE notifications ENABLE ROW LEVEL SECURITY;
-- Recipients can read and mark their own notifications as read. -- 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; DROP POLICY IF EXISTS notifications_update ON notifications;
CREATE POLICY notifications_select ON notifications FOR SELECT CREATE POLICY notifications_select ON notifications FOR SELECT
USING (auth.uid() = user_id); 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 CREATE POLICY notifications_update ON notifications FOR UPDATE
USING (auth.uid() = user_id) USING (auth.uid() = user_id)
WITH CHECK (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 -- TRIGGER — notify on conversation assignment
-- ============================================================ -- ============================================================