Notifications System
The DAGGH notifications system provides real-time, interactive notifications to keep users informed about group invitations, comments, matches, and other activities. Built with React Query, Supabase real-time subscriptions, and optimistic updates for a seamless user experience.
๐ฏ Architecture Overviewโ
graph TB
A[User Action] --> B[API Route]
B --> C[Database Update]
B --> D[Notification Service]
D --> E[Create Notification Record]
C --> F[Supabase Real-time]
F --> G[Client Subscription]
G --> H[React Query Cache Update]
H --> I[UI Updates Instantly]
J[NotificationBell] --> K[NotificationsDropdown]
K --> L[NotificationsPage]
M[useNotifications] --> N[API Calls]
O[useNotificationSubscription] --> P[Supabase Channel]
Q[useUnreadNotifications] --> R[Badge Count]
๐๏ธ Core Componentsโ
Database Schemaโ
The notifications system uses a single notifications table with the following structure:
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
recipient_id UUID NOT NULL REFERENCES auth.users(id),
sender_id UUID REFERENCES auth.users(id),
type TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT NOT NULL,
action_url TEXT,
data JSONB,
priority TEXT DEFAULT 'normal',
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
delivered_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ
);
Key fields:
recipient_id: Who receives the notificationsender_id: Who triggered the notification (nullable for system notifications)type: Category of notification (group_invitation,comment_reply, etc.)action_url: Where to navigate when clickeddata: Additional context as JSONread_at: Timestamp when marked as read (null = unread)
๐ฃ Hooks Architectureโ
useNotifications Hookโ
The primary hook for fetching and managing notifications with pagination and filtering.
interface NotificationsResponse {
notifications: Notification[]
total: number
page: number
pageSize: number
}
interface NotificationFilters {
unreadOnly?: boolean
type?: string
}
export function useNotifications(
page = 1,
pageSize = 10,
filters: NotificationFilters = {}
) {
// Returns:
// - notifications: Array of notification objects
// - total: Total count for pagination
// - markAsRead: Function to mark single notification as read
// - markAllAsRead: Function to mark all notifications as read
// - isLoading, isError, error: Standard query states
}
Key Features:
- Optimistic Updates: UI updates immediately before server confirmation
- Cache Management: Automatically updates React Query cache
- Error Handling: Reverts optimistic updates on failure
- Pagination Support: Built-in pagination with page/pageSize
- Filtering: Support for unread-only and type filtering
Implementation Details:
// Optimistic update example for mark as read
onMutate: async (notificationId) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ["notifications"] })
// Snapshot previous value
const previousNotifications = queryClient.getQueriesData({
queryKey: ["notifications"]
})
// Optimistically update the cache
queryClient.setQueriesData(
{ queryKey: ["notifications"] },
(old: NotificationsResponse | undefined) => {
if (!old) return old
return {
...old,
notifications: old.notifications.map((notification) =>
notification.id === notificationId
? { ...notification, read_at: new Date().toISOString() }
: notification
),
}
}
)
// Update unread count simultaneously
queryClient.setQueryData(
["notifications", "unread-count"],
(old: number | undefined) => (old ? Math.max(0, old - 1) : 0)
)
return { previousNotifications }
}
useNotificationSubscription Hookโ
Handles real-time updates via Supabase subscriptions with automatic cache synchronization.
interface UseNotificationSubscriptionProps {
userId?: string
enabled?: boolean
onNewNotification?: (notification: Notification) => void
}
export function useNotificationSubscription({
userId,
enabled = true,
onNewNotification
}: UseNotificationSubscriptionProps = {}) {
// Returns:
// - isSubscribed: Boolean indicating active subscription
// - unsubscribe: Function to manually close subscription
}
Real-time Implementation:
useEffect(() => {
if (!enabled || !userId) return
const channel = supabase
.channel('notifications')
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'notifications',
filter: `recipient_id=eq.${userId}`,
}, (payload) => {
const newNotification = payload.new as Notification
// Update React Query cache with new notification
queryClient.setQueriesData(
{ queryKey: ["notifications"] },
(old: any) => {
if (!old) return old
return {
...old,
notifications: [newNotification, ...old.notifications],
total: old.total + 1,
}
}
)
// Update unread count
queryClient.setQueryData(
["notifications", "unread-count"],
(old: { count: number } | undefined) => ({
count: (old?.count || 0) + 1
})
)
// Optional callback for additional actions (toasts, etc.)
onNewNotification?.(newNotification)
})
.subscribe()
return () => supabase.removeChannel(channel)
}, [userId, enabled, queryClient, onNewNotification, supabase])
Why This Pattern Works:
- Automatic Cache Updates: New notifications appear instantly without refetching
- Pessimistic Real-time: Server-driven updates ensure data consistency
- Memory Management: Proper cleanup prevents subscription leaks
- Conditional Subscriptions: Only subscribes when user is authenticated
useUnreadNotifications Hookโ
Optimized hook for fetching just the unread count for badge display.
export function useUnreadNotifications() {
const unreadCountQuery = useQuery<{ count: number }>({
queryKey: ["notifications", "unread-count"],
queryFn: async () => {
const response = await fetch("/api/notifications/unread-count")
return response.json()
},
refetchInterval: 30000, // Fallback polling every 30s
})
return {
unreadCount: unreadCountQuery.data?.count || 0,
isLoading: unreadCountQuery.isLoading,
// ...
}
}
Performance Considerations:
- Separate Endpoint: Lightweight query that only returns count
- Shared Cache Key: Real-time updates automatically update this cache
- Fallback Polling: Ensures count stays accurate even if real-time fails
๐ UI Componentsโ
NotificationBell Componentโ
The main trigger component with controlled dropdown state for proper navigation handling.
export function NotificationBell({ className }: NotificationBellProps) {
const { user } = useCurrentUser()
const { unreadCount, isLoading } = useUnreadNotifications()
const [isOpen, setIsOpen] = useState(false)
// Real-time subscription
useNotificationSubscription({
userId: user?.id,
enabled: !!user?.id,
})
const handleCloseDropdown = () => {
setIsOpen(false)
}
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Bell className="h-5 w-5" />
{hasUnread && (
<Badge variant="destructive" className="...">
{unreadCount > 99 ? "99+" : unreadCount}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<NotificationsDropdown onNavigate={handleCloseDropdown} />
</DropdownMenuContent>
</DropdownMenu>
)
}
Key Features:
- Controlled State: Manual dropdown control for navigation handling
- Badge Logic: Shows count with 99+ cap
- Real-time Updates: Badge updates instantly via subscription
- Accessibility: Proper ARIA labels with dynamic unread count
NotificationsDropdown Componentโ
Compact dropdown showing recent notifications with quick actions.
interface NotificationsDropdownProps {
onNavigate?: () => void
}
export function NotificationsDropdown({ onNavigate }: NotificationsDropdownProps) {
const {
notifications,
markAsRead,
markAllAsRead,
// ...
} = useNotifications(1, 10) // Latest 10 notifications
const handleClick = () => {
if (isUnread) {
onMarkAsRead(notification.id)
}
if (notification.action_url) {
onNavigate?.() // Close dropdown BEFORE navigation
router.push(notification.action_url)
}
}
return (
<div className="max-h-96">
{/* Header with bulk actions */}
<div className="flex items-center justify-between p-4 border-b">
<h3 className="font-semibold">Notifications</h3>
{hasUnread && (
<Button onClick={markAllAsRead}>
Mark all read
</Button>
)}
</div>
{/* Scrollable notifications list */}
<ScrollArea className="max-h-80">
{notifications.map((notification) => (
<NotificationItem
key={notification.id}
notification={notification}
onMarkAsRead={markAsRead}
onNavigate={onNavigate}
/>
))}
</ScrollArea>
{/* Footer with "View All" link */}
<div className="p-3 border-t">
<Button asChild>
<Link href="/notifications" onClick={onNavigate}>
View all notifications
</Link>
</Button>
</div>
</div>
)
}
UX Considerations:
- Limited Height: Scrollable with max height to prevent overflow
- Quick Actions: Mark as read directly from dropdown
- Visual Hierarchy: Clear separation between sections
- Navigation Closure: Dropdown closes before navigation for smooth UX
๐ Real-time Data Flowโ
How Real-time Updates Workโ
-
User Action Triggers Notification:
// Example: Group invitation
const { data: sentInvitations } = await supabase
.from("group_memberships")
.insert(invitationRecords)
// Send notification
await notificationService.sendGroupInvitationNotification(
recipientId,
senderId,
groupName,
groupId
) -
Notification Created in Database:
// NotificationService creates record
const { data } = await supabase
.from("notifications")
.insert({
recipient_id: recipientId,
sender_id: senderId,
type: "group_invitation",
title: "New Group Invitation",
message: `You've been invited to join "${groupName}"`,
action_url: `/groups/${groupId}`,
data: { group_id: groupId, group_name: groupName }
}) -
Supabase Triggers Real-time Event:
// Client subscription receives INSERT event
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'notifications',
filter: `recipient_id=eq.${userId}`,
}, (payload) => {
// payload.new contains the new notification
}) -
Client Updates Cache Immediately:
// Add to existing notifications list
queryClient.setQueriesData(
{ queryKey: ["notifications"] },
(old) => ({
...old,
notifications: [newNotification, ...old.notifications],
total: old.total + 1,
})
)
// Update unread count
queryClient.setQueryData(
["notifications", "unread-count"],
(old) => ({ count: (old?.count || 0) + 1 })
) -
UI Updates Automatically:
- Bell badge shows new count
- Dropdown shows new notification
- No manual refresh needed
Why This Approach is Effectiveโ
Immediate Feedback: Users see updates instantly without polling or manual refresh.
Efficient: Only updates relevant data, doesn't refetch entire lists.
Consistent: Server-driven updates ensure all clients see the same data.
Scalable: Supabase handles connection management and broadcasting.
Fallback Safe: If real-time fails, polling and manual refresh still work.
๐ก API Integrationโ
NotificationService Classโ
Centralized service for creating notifications with consistent formatting.
export class NotificationService {
private supabase = createServiceClient()
async sendNotification(notificationData: NotificationData) {
const { data, error } = await (await this.supabase)
.from("notifications")
.insert({
recipient_id: notificationData.recipient_id,
sender_id: notificationData.sender_id,
type: notificationData.type,
title: notificationData.title,
message: notificationData.message,
action_url: notificationData.action_url,
data: notificationData.data || null,
priority: notificationData.priority || "normal",
})
.select()
.single()
if (error) throw error
return data
}
// Specific notification types with consistent formatting
async sendGroupInvitationNotification(
recipientId: string,
senderId: string,
groupName: string,
groupId: string
) {
return this.sendNotification({
recipient_id: recipientId,
sender_id: senderId,
type: "group_invitation",
title: "New Group Invitation",
message: `You've been invited to join the group "${groupName}"`,
action_url: `/groups/${groupId}`,
data: { group_id: groupId, group_name: groupName },
priority: "high",
})
}
}
export const notificationService = new NotificationService()
Integration in Existing Featuresโ
Group Invitations Example:
// In /api/groups/invitations route
export async function POST(request: NextRequest) {
// ... create invitations logic ...
// Send notifications to invited users
try {
const notifications = newUserIds.map((userId) => ({
recipient_id: userId,
sender_id: user.id,
type: "group_invitation",
title: "New Group Invitation",
message: `You've been invited to join the group "${group.title}"`,
action_url: `/profile/groups/my-invites`,
data: { group_id, group_name: group.title },
priority: "high",
}))
await notificationService.sendBulkNotifications(notifications)
} catch (notificationError) {
// Log but don't fail the invitation creation
console.error("Failed to send invitation notifications:", notificationError)
}
return NextResponse.json({ /* success response */ })
}
๐จ Styling and Designโ
Notification Types and Visual Designโ
Each notification type has distinctive styling for quick recognition:
const getNotificationIcon = (type: string) => {
switch (type) {
case "group_invitation": return "๐ฅ"
case "comment_reply": return "๐ฌ"
case "swipe_match": return "๐ซ"
case "group_activity": return "๐ฌ"
default: return "๐"
}
}
const getNotificationColor = (type: string) => {
switch (type) {
case "group_invitation":
return "bg-blue-50 dark:bg-blue-950/20 border-blue-200 dark:border-blue-800"
case "comment_reply":
return "bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800"
// ...
}
}
Responsive Designโ
- Desktop: Bell in header with dropdown
- Mobile: Bell in mobile navigation menu
- Tablet: Adaptive dropdown sizing
Dark Mode Supportโ
All components support automatic dark mode switching via your theme system:
// Example of dark mode aware styling
className={cn(
"bg-background border-border", // Adapts to theme
isUnread && "shadow-sm",
"hover:bg-accent/50" // Theme-aware hover states
)}
๐ Performance Optimizationsโ
React Query Optimizationsโ
Stale Time Management: Different cache strategies for different data types:
// Long-lived data
queryClient.setQueryDefaults(["notifications", "unread-count"], {
staleTime: 2 * 60 * 1000, // 2 minutes
})
// Frequently changing data
queryClient.setQueryDefaults(["notifications"], {
staleTime: 30 * 1000, // 30 seconds
})
Optimistic Updates: Immediate UI feedback with automatic rollback on failure.
Selective Updates: Real-time subscriptions only update affected cache entries.
Supabase Optimizationsโ
Filtered Subscriptions: Only listen to relevant changes:
filter: `recipient_id=eq.${userId}` // Only this user's notifications
Connection Pooling: Reuse Supabase connections across components.
Cleanup Management: Proper subscription cleanup prevents memory leaks.
Bundle Size Optimizationโ
Lazy Loading: Notifications page is code-split:
const NotificationsPage = lazy(() => import("./NotificationsPage"))
Tree Shaking: Only import needed utilities:
import { formatDistanceToNow } from "date-fns"
// Instead of importing entire date-fns library
๐งช Testing Strategiesโ
Unit Testing Hooksโ
import { renderHook } from '@testing-library/react'
import { useNotifications } from '../useNotifications'
test('should mark notification as read optimistically', async () => {
const { result } = renderHook(() => useNotifications())
act(() => {
result.current.markAsRead('notification-id')
})
// Should update immediately (optimistic)
expect(result.current.notifications[0].read_at).toBeTruthy()
})
Integration Testingโ
test('should receive real-time notifications', async () => {
// Setup test user and subscription
// Trigger notification creation
// Assert UI updates automatically
})
E2E Testingโ
test('notification flow end-to-end', async () => {
// User A invites User B to group
// User B should see notification immediately
// Clicking notification navigates to correct page
// Notification marked as read
})
๐ฎ Future Enhancementsโ
Planned Featuresโ
- Push Notifications: Browser/mobile push for offline users
- Email Digests: Periodic email summaries for inactive users
- Notification Preferences: Per-type notification settings
- Bulk Actions: Select multiple notifications for actions
- Notification Templates: Customizable notification formats
Scalability Considerationsโ
- Database Indexing: Optimize queries with proper indexes
- Archival Strategy: Move old notifications to archive tables
- Rate Limiting: Prevent notification spam
- Batching: Group similar notifications to reduce noise
๐ Related Documentationโ
- Database Schema: See the notifications table structure in the Core Components section above
- Real-time Features: Implemented via Supabase subscriptions as documented in the Real-time Data Flow section
- Group Management: Integration points covered in the API Integration section above
- API Reference: Covered in the API Integration section above
๐ค Contributingโ
When adding new notification types:
- Add to NotificationService: Create specific method for the type
- Update UI Components: Add icon and styling for the type
- Add Integration: Update relevant API routes to send notifications
- Test Thoroughly: Ensure real-time updates work correctly
- Document: Update this documentation with new patterns
The notifications system is designed to be extensible - follow these patterns for consistent implementation across all features.