CarboAny / src /hooks /useNotifications.ts
Esketch's picture
deploy: [P4] Strangler Pattern API Adapter Release (Orphan Clean Build v2)
daaf9d7
Raw
History Blame Contribute Delete
3.78 kB
// Notifications hook with Realtime subscription + unread count
import { useEffect, useState, useCallback, useRef } from 'react';
import { supabase } from '../lib/supabase';
import type { RealtimeChannel } from '@supabase/supabase-js';
import type { Notification } from '../lib/notificationStore';
import {
getNotifications,
getUnreadCount,
markAsRead as markAsReadApi,
markAllAsRead as markAllAsReadApi,
} from '../lib/notificationStore';
type Row = { [key: string]: unknown };
function rowToNotif(row: Row): Notification {
return {
id: row.id as string,
userId: row.user_id as string,
type: row.type as Notification['type'],
title: row.title as string,
body: row.body as string,
linkTo: (row.link_to as string) ?? undefined,
relatedApprovalId: (row.related_approval_id as string) ?? undefined,
read: row.read as boolean,
createdAt: row.created_at as string,
};
}
interface UseNotificationsResult {
notifications: Notification[];
unreadCount: number;
loading: boolean;
markAsRead: (id: string) => Promise<void>;
markAllAsRead: () => Promise<void>;
refetch: () => Promise<void>;
}
export function useNotifications(userId: string | undefined): UseNotificationsResult {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [loading, setLoading] = useState(true);
const channelRef = useRef<RealtimeChannel | null>(null);
const fetchAll = useCallback(async () => {
if (!userId) return;
setLoading(true);
const [notifs, count] = await Promise.all([
getNotifications(userId),
getUnreadCount(userId),
]);
setNotifications(notifs);
setUnreadCount(count);
setLoading(false);
}, [userId]);
const markAsRead = useCallback(async (id: string) => {
await markAsReadApi(id);
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, read: true } : n)),
);
setUnreadCount((prev) => Math.max(0, prev - 1));
}, []);
const markAllAsRead = useCallback(async () => {
if (!userId) return;
await markAllAsReadApi(userId);
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
setUnreadCount(0);
}, [userId]);
useEffect(() => {
if (!userId) return;
void Promise.resolve().then(fetchAll);
// Subscribe to notifications for this user
const channel = supabase
.channel(`notifications-${userId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'notifications',
filter: `user_id=eq.${userId}`,
},
(payload) => {
const newNotif = rowToNotif(payload.new as Row);
setNotifications((prev) => [newNotif, ...prev]);
if (!newNotif.read) {
setUnreadCount((prev) => prev + 1);
}
},
)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'notifications',
filter: `user_id=eq.${userId}`,
},
(payload) => {
const updated = rowToNotif(payload.new as Row);
setNotifications((prev) => {
const next = prev.map((n) => (n.id === updated.id ? updated : n));
setUnreadCount(next.filter((n) => !n.read).length);
return next;
});
},
)
.subscribe();
channelRef.current = channel;
return () => {
if (channelRef.current) {
supabase.removeChannel(channelRef.current);
channelRef.current = null;
}
};
}, [userId, fetchAll]);
return {
notifications,
unreadCount,
loading,
markAsRead,
markAllAsRead,
refetch: fetchAll,
};
}