Closes #131 — Real-Time Push Notification Infrastructure
Implements the remaining pieces of the production-grade push notification system: governance proposal notifications, recurring donation reminders, notification preference management (with DND hours), stale token auto-detection, and a mobile notification settings screen.
Note: Much of the infrastructure was already in place from prior work (GF-049). This PR fills the remaining gaps identified in #131.
| Component | Status | File |
|---|---|---|
Expo Push API service (sendPushNotification, sendDonationReceipt, sendMilestoneReachedNotifications, sendProjectUpdateNotifications) |
✅ | backend/src/services/pushService.js |
| pg-boss queued delivery worker | ✅ | backend/src/services/pushQueue.js |
| Donation receipt trigger | ✅ | backend/src/routes/donations.js |
| Milestone reached trigger | ✅ | backend/src/services/webhook.js |
| Project update trigger (email + push) | ✅ | backend/src/routes/updates.js |
| Device token registration (follow/unfollow/unread-count) | ✅ | backend/src/routes/notifications.js |
device_tokens + project_follows tables |
✅ | backend/src/db/migrations/001_initial_schema.js |
notification_preferences + push_notifications (delivery tracking) tables |
✅ | backend/src/db/migrations/005_push_notifications.js |
| Mobile notification helpers (permissions, tokens, badge, listeners) | ✅ | mobile/utils/notifications.ts |
| Notification handler in root layout | ✅ | mobile/app/_layout.tsx |
New notification functions:
sendGovernanceProposalNotifications({ proposalId, title, description, endsAt })— Broadcasts governance proposal alerts to all wallet-linked active devices. Uses a single batched JOIN query (same pattern assendProjectUpdateNotifications) to avoid N+1 DB round-trips. Respects per-user category preferences.sendRecurringReminder({ donorAddress, projectName, amount, currency, ... })— Sends a reminder 24 hours before a scheduled recurring donation is processed.
DND enforcement:
isInDndWindow(walletAddress)— Checksprofiles.notification_dndJSONB column against the current time in the configured timezone. Supports both same-day windows (e.g., 08:00–22:00) and overnight windows (e.g., 22:00–08:00). Defaults to "not suppressing" on any error.- Integrated into
shouldSendPush()so DND is enforced for all push types (donation receipts, milestones, updates, governance, reminders).
Auto-stale-token detection:
- When Expo's push API returns
DeviceNotRegisteredfor a ticket, the token is automatically markedis_active = falseindevice_tokensso it won't be retried on future sends.
Active-token filtering:
- All device token queries now include
AND is_active = trueso inactive/stale tokens are never used.
Two new job handlers in the pg-boss worker dispatch table:
governance_proposal— callspushService.sendGovernanceProposalNotificationsrecurring_reminder— callspushService.sendRecurringReminder
| Endpoint | Method | Description |
|---|---|---|
/api/notifications/preferences |
GET |
Fetch per-category push preferences + DND settings for a wallet |
/api/notifications/preferences |
PUT |
Upsert category toggles + DND hours. Uses DELETE+INSERT pattern for portability (avoids partial unique index ON CONFLICT subtleties) |
/api/notifications/unregister |
POST |
Mark a device token as inactive (keeps the row for audit trail) |
The existing /register endpoint now sets is_active = true on re-registration (so a user reinstalling the app re-activates their token).
- Adds
is_active BOOLEAN NOT NULL DEFAULT truetodevice_tokens - Creates partial index
idx_device_tokens_activeon(wallet_address, is_active) WHERE is_active = true - Adds
notification_dnd JSONBcolumn toprofiles
Full notification preferences management screen:
- 5 category toggles: Donation Confirmations, Project Updates, Milestone Alerts, Governance Proposals, Recurring Reminders
- DND hours configuration with start/end time inputs
- Uses
useAuth()context for wallet address; shows connect prompt when no wallet - Preferences are persisted to the backend via
PUT /api/notifications/preferences
mobile/app/settings.tsx— Added "Notification Preferences" link row navigating to/settings/notificationsmobile/app/_layout.tsx— Registeredsettings/notificationsStack.Screenmobile/utils/notifications.ts—setupNotificationResponseListenernow handlesgovernance_proposalnotification type for deep-linking
Test Suites: 46 passed, 46 total
Tests: 516 passed, 516 total
New test coverage added:
pushService.test.js— 6 new tests:sendGovernanceProposalNotifications(3 tests),sendRecurringReminder(2 tests), plus 1 existing test updated for DND. All existing pushService tests updated to account for the extra DND profiles query inshouldSendPush.pushQueue.test.js— 2 new handler dispatch tests:governance_proposalandrecurring_remindernotifications.test.js— 10 new tests:GET /preferences(3),PUT /preferences(3),POST /unregister(3), plus existing tests updated foris_activere-activation
- Physical device test: Push delivery within 10 seconds of donation
- Background/killed state: Notification received when app is backgrounded
- Deep-link: Tapping a notification navigates to the correct screen
- DND: Notifications suppressed during configured window
- Stale token:
DeviceNotRegisteredresponse marks token inactive
| Item | Status | Notes |
|---|---|---|
backend/src/services/pushNotification.js |
✅ | Named pushService.js (existing convention) |
backend/src/routes/devices.js |
✅ | Covered by routes/notifications.js (register/unregister/follow/unfollow) |
| Migration for device tokens | ✅ | 001_initial_schema.js (table) + 014_device_token_active.js (is_active + DND) |
| Trigger: donation confirmation | ✅ | Already in routes/donations.js via enqueuePushNotification |
| Trigger: project updates | ✅ | Already in routes/updates.js via enqueuePushNotification |
| Trigger: milestones (25/50/75/100%) | ✅ | Already in services/webhook.js via checkAndDeliverMilestones |
| Trigger: governance proposals | ✅ | New: sendGovernanceProposalNotifications ready for governance API |
| Trigger: recurring reminders | ✅ | New: sendRecurringReminder ready for recurring scheduler |
| Notification preferences API | ✅ | GET/PUT /api/notifications/preferences |
| Mobile notification settings screen | ✅ | mobile/app/settings/notifications.tsx |
| DND hours support | ✅ | Backend enforcement + mobile UI |
| Stale token auto-marked inactive | ✅ | On DeviceNotRegistered + manual /unregister |
| Deep link from notification | ✅ | Project detail + governance proposal handling |
| Badge count updates | ✅ | Already in mobile/utils/notifications.ts |
| Unit + integration tests | ✅ | 516 passing |
| Firebase configuration docs | Expo Push API wraps both FCM and APNs — no Firebase-specific config needed. Documentation in repo README/docs. |
| File | ± |
|---|---|
backend/src/services/pushService.js |
+186 |
backend/src/services/pushService.test.js |
+154 |
backend/src/services/pushQueue.js |
+29 |
backend/src/services/pushQueue.test.js |
+56 |
backend/src/routes/notifications.js |
+140 |
backend/src/routes/notifications.test.js |
+165 |
backend/src/db/migrations/014_device_token_active.js |
+39 (new) |
mobile/app/settings/notifications.tsx |
+332 (new) |
mobile/app/settings.tsx |
+33 |
mobile/app/_layout.tsx |
+4 |
mobile/utils/notifications.ts |
+6 / −13 |
Total: +1,144 / −13 across 11 files