feat: advanced in-app notification system (closes #139)#293
feat: advanced in-app notification system (closes #139)#293Ogstevyn merged 2 commits intoOgstevyn:mainfrom
Conversation
## Summary
Build a full in-app notification system with real-time delivery,
toast alerts, bell badge, notification center, and user preferences.
## Changes
### Database
- migrations/013_create_notifications.sql
- table with type enum, read/unread status, action URL
- table with per-type + sound/email toggles
- RLS policies (users own their notifications & preferences)
- Indexes for efficient unread queries
- Supabase Realtime publication for live delivery
### Types
- lib/types/database.ts: NotificationType, Notification, NotificationRow,
NotificationInsert/Update, NotificationPreferences(Row/Update)
- lib/types/index.ts: barrel-export all new types
### API Routes
- GET/POST/DELETE /api/notifications – list, create, bulk-clear read
- PATCH/DELETE /api/notifications/[id] – mark read, delete single
- POST /api/notifications/mark-all-read
- GET/PUT /api/notifications/preferences – upsert prefs
### Context & Provider
- contexts/NotificationContext.tsx: typed context shape
- providers/NotificationProvider.tsx:
- Loads notifications + preferences on mount
- Supabase Realtime subscription (INSERT/UPDATE/DELETE)
- Respects per-type preferences before showing toasts
- Web AudioContext sound alerts (sound_enabled pref)
- Optimistic updates for all mutations
### Hook
- hooks/useNotifications.ts: consumer hook with error guard
- hooks/index.ts: barrel export added
### Components
- components/NotificationToast.tsx: rich custom toast (type icon, action link)
- components/NotificationBell.tsx: bell icon with animated unread badge,
aria-expanded, toggles notification center
- components/NotificationCenter.tsx: slide-over drawer with:
- All/Unread tabs
- Per-item mark-as-read & delete (revealed on hover)
- Bulk mark-all-read & clear-read
- Inline preferences panel toggle
- Empty states, loading skeletons
- Keyboard (Escape) close, body scroll lock on mobile
- components/NotificationPreferencesPanel.tsx:
- Toggle per category (messages/payments/listings/agreements/favorites/system)
- Sound & email global options
- Custom accessible toggle switch component
### Layout
- app/layout.tsx: wrap tree with NotificationProvider; mount NotificationCenter
### Tests
- __tests__/components/NotificationCenter.test.tsx:
- NotificationBell: badge, 99+ cap, setIsOpen, aria-expanded
- NotificationCenter: open/close, empty state, items, loading, bulk actions,
Escape key, tab switching
- NotificationPreferencesPanel: toggles, null guard, onClose
Resolves: Ogstevyn#139
Points: 150
Labels: features, type:ui, complexity:MEDIUM
There was a problem hiding this comment.
Pull request overview
This PR implements a comprehensive in-app notification system with real-time delivery via Supabase Realtime, a notification center drawer, toast notifications, and user preferences. The system integrates database tables, API routes, React context/providers, UI components, and automated tests.
Changes:
- Database schema with
notificationsandnotification_preferencestables, RLS policies, indexes, and realtime publication - Complete API layer with 7 endpoints for CRUD operations and preference management
- React context and provider for state management with Supabase Realtime subscriptions
- Four UI components: NotificationBell, NotificationCenter, NotificationToast, and NotificationPreferencesPanel
- Integration into app layout with global provider wrapping
- Comprehensive test suite covering all major components
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 20 comments.
Show a summary per file
| File | Description |
|---|---|
| migrations/013_create_notifications.sql | Database schema for notifications and preferences with RLS and indexes |
| lib/types/database.ts | TypeScript type definitions for notification entities |
| lib/types/index.ts | Barrel export of notification types |
| app/api/notifications/route.ts | List, create, and bulk delete endpoints |
| app/api/notifications/[id]/route.ts | Update and delete single notification endpoints |
| app/api/notifications/mark-all-read/route.ts | Mark all notifications as read endpoint |
| app/api/notifications/preferences/route.ts | Get and update user preferences endpoint |
| contexts/NotificationContext.tsx | React context type definition |
| providers/NotificationProvider.tsx | State management with Realtime subscriptions and preference filtering |
| hooks/useNotifications.ts | Custom hook for accessing notification context |
| hooks/index.ts | Barrel export for useNotifications hook |
| components/NotificationBell.tsx | Bell icon with unread badge |
| components/NotificationCenter.tsx | Slide-over drawer with notification list and preferences |
| components/NotificationToast.tsx | Toast notification component |
| components/NotificationPreferencesPanel.tsx | Preferences panel with category toggles |
| app/layout.tsx | Integration of NotificationProvider and NotificationCenter |
| tests/components/NotificationCenter.test.tsx | Test suite for notification components |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export async function POST(request: Request) { | ||
| const supabase = await createClient() | ||
| const { data: { user } } = await supabase.auth.getUser() | ||
|
|
||
| if (!user) { | ||
| return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) | ||
| } | ||
|
|
||
| const body: Partial<NotificationInsert> = await request.json() | ||
|
|
||
| const { data, error } = await supabase | ||
| .from('notifications') | ||
| .insert({ | ||
| user_id: user.id, | ||
| type: body.type ?? 'system', | ||
| title: body.title ?? '', | ||
| message: body.message ?? '', | ||
| action_url: body.action_url ?? null, | ||
| action_label: body.action_label ?? null, | ||
| metadata: body.metadata ?? {}, | ||
| is_read: false, | ||
| }) | ||
| .select() | ||
| .single() |
There was a problem hiding this comment.
Missing input validation: The POST endpoint doesn't validate that required fields like title and message are non-empty strings. The code defaults to empty strings if these fields are missing, which could allow creation of notifications with no content.
Consider adding validation to ensure title and message are present and non-empty before inserting into the database.
| {notification.action_url && notification.action_label && ( | ||
| <Link | ||
| href={notification.action_url} | ||
| onClick={() => !notification.is_read && onRead(notification.id)} | ||
| className="mt-1 inline-flex w-fit items-center gap-1 text-xs font-medium text-primary-400 hover:text-primary-300 hover:underline underline-offset-2" | ||
| > | ||
| {notification.action_label} | ||
| <ExternalLink className="h-3 w-3" aria-hidden="true" /> | ||
| </Link> |
There was a problem hiding this comment.
Potential security issue: The action_url from the notification is passed directly to Next.js Link without validation. This could allow malicious URLs like javascript: protocols if a notification is created with a crafted action_url.
Consider validating the action_url to ensure it's a safe URL (e.g., starts with http://, https://, or is a relative path starting with /) before rendering the Link component.
| setPreferences((currentPrefs) => { | ||
| const typeKey = `${newNotif.type}_enabled` as keyof NotificationPreferences | ||
| const enabled = currentPrefs ? (currentPrefs[typeKey] as boolean ?? true) : true | ||
|
|
||
| if (enabled) { | ||
| const icon = TYPE_ICON[newNotif.type] ?? '🔔' | ||
| toast(newNotif.title, { | ||
| icon, | ||
| duration: 5000, | ||
| }) | ||
|
|
||
| if (currentPrefs?.sound_enabled) { | ||
| playNotificationSound() | ||
| } | ||
| } | ||
|
|
||
| return currentPrefs | ||
| }) | ||
| }, |
There was a problem hiding this comment.
Potential race condition: Using setPreferences with a callback inside the realtime INSERT handler to access current preferences could lead to stale closures. The preferences value captured in the callback might not be the most up-to-date if multiple notifications arrive rapidly or if preferences are updated concurrently.
Consider using a ref for preferences (e.g., preferencesRef.current) to always access the latest value, or restructure the logic to avoid this callback pattern.
| function playNotificationSound() { | ||
| try { | ||
| const ctx = new AudioContext() | ||
| const oscillator = ctx.createOscillator() | ||
| const gainNode = ctx.createGain() | ||
| oscillator.connect(gainNode) | ||
| gainNode.connect(ctx.destination) | ||
| oscillator.type = 'sine' | ||
| oscillator.frequency.setValueAtTime(880, ctx.currentTime) | ||
| oscillator.frequency.exponentialRampToValueAtTime(440, ctx.currentTime + 0.15) | ||
| gainNode.gain.setValueAtTime(0.3, ctx.currentTime) | ||
| gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3) | ||
| oscillator.start(ctx.currentTime) | ||
| oscillator.stop(ctx.currentTime + 0.3) | ||
| } catch { | ||
| // AudioContext not available (e.g. SSR / restricted environments) | ||
| } | ||
| } |
There was a problem hiding this comment.
Potential memory leak: Each AudioContext instance created in playNotificationSound() is never explicitly closed. While modern browsers do eventually garbage collect these, creating a new AudioContext for every notification can accumulate resources unnecessarily.
Consider reusing a single AudioContext instance, or explicitly calling ctx.close() after the sound completes (e.g., in a setTimeout after 0.3s).
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
|
this is nice |
🔔 Advanced In-App Notification System
Resolves #139 · 150 points · Labels:
featurestype:uicomplexity:MEDIUMOverview
Full end-to-end notification system with real-time delivery, toast alerts, a notification center drawer, per-type preferences, read/unread state management, and action buttons.
Changes
🗄️ Database (
migrations/013_create_notifications.sql)notificationstable — type enum, read/unread, action URL/label, metadata, read_atnotification_preferencestable — per-type toggles + sound/email globals🔑 Types
lib/types/database.ts: NotificationType, Notification, NotificationRow, NotificationInsert/Update, NotificationPreferences, NotificationPreferencesRow/Updatelib/types/index.ts: barrel exports added🌐 API Routes
/api/notifications/api/notifications/api/notifications/api/notifications/[id]/api/notifications/[id]/api/notifications/mark-all-read/api/notifications/preferences⚛️ State Management
contexts/NotificationContext.tsx— typed context shapeproviders/NotificationProvider.tsx— Supabase Realtime subscription (INSERT/UPDATE/DELETE), optimistic updates, per-type preference filtering, Web AudioContext soundshooks/useNotifications.ts+ barrel export inhooks/index.ts🎨 UI Components
NotificationBell.tsxaria-expanded, toggles drawerNotificationToast.tsxNotificationCenter.tsxNotificationPreferencesPanel.tsx🏗️ Layout
app/layout.tsx—NotificationProviderwraps the app tree;NotificationCentermounted globally🧪 Tests (
__tests__/components/NotificationCenter.test.tsx)16 test cases covering:
Acceptance Criteria