Files
accounted/dev_docs/base_architecture/PUSH-NOTIFICATIONS-EXTENSION.md
T
2026-02-19 14:32:54 +01:00

313 lines
13 KiB
Markdown

# Push Notifications Extension
## Overview
The `push-notifications` extension converts system events into instant push notifications delivered via the Web Push API. It is the third first-party extension, following `receipt-ocr` and `ai-categorization`.
The extension operates in two modes:
1. **Event-driven** — Instant notifications triggered by the event bus (`period.locked`, `receipt.matched`, etc.)
2. **Cron-based** — Scheduled checks for time-dependent conditions (upcoming tax deadlines, overdue invoices)
Both modes share a single send pipeline that handles settings checks, quiet hours, duplicate prevention, delivery, logging, and subscription cleanup.
---
## Architecture
```
Event Bus
|
+---------+---------+
| |
period.locked receipt.matched
invoice.sent receipt.extracted
period.year_closed
| |
v v
+-----------------------------+
| Event Handlers (index.ts) |
| gate: check setting |
| build: payload-builders |
+-------------+---------------+
|
v
+-----------------------------+ +---------------------------+
| sendNotificationToUser() | <---- | Cron Scheduler |
| (notification-sender.ts) | | (notification-scheduler) |
| | | tax deadlines, invoices |
| 1. push_enabled? | +---------------------------+
| 2. quiet hours? |
| 3. duplicate? |
| 4. get subscriptions |
| 5. web-push send |
| 6. log to notification_log |
| 7. disable 410 subs |
+-----------------------------+
```
### File Structure
```
extensions/push-notifications/
index.ts # Extension object, settings, 5 event handlers
notification-sender.ts # Unified send pipeline, VAPID config, helpers
payload-builders.ts # All notification payload constructors
notification-scheduler.ts # Cron-based tax deadline & invoice scheduling
app/api/extensions/push-notifications/
settings/route.ts # GET/PATCH settings API
cron/route.ts # Daily cron (imports from extension)
subscribe/route.ts # Subscription management (imports VAPID from extension)
```
---
## Event Subscriptions
| Event | Handler | Default Enabled | Notification Content |
|-------|---------|-----------------|---------------------|
| `period.locked` | `handlePeriodLocked` | Yes | "{period.name} har lasts" |
| `period.year_closed` | `handleYearClosed` | Yes | "Arsbokslut klart for {period.name}" |
| `invoice.sent` | `handleInvoiceSent` | No | "Faktura {number} skickad" |
| `receipt.extracted` | `handleReceiptExtracted` | Yes | "Kvitto analyserat: {merchant}" |
| `receipt.matched` | `handleReceiptMatched` | Yes | "Kvitto matchat mot transaktion" |
Each event handler follows the **gate pattern**:
1. Extract `userId` from event payload
2. Load extension settings from `extension_data`
3. Check if the specific notification type is enabled
4. Build the payload via `payload-builders.ts`
5. Call `sendNotificationToUser()` from the sender module
---
## Cron Scheduling
The cron endpoint (`GET /api/extensions/push-notifications/cron`) runs daily at 09:00 via Vercel Cron and handles two time-dependent notification types:
### Tax Deadlines
Queries the `deadlines` table for uncompleted tax deadlines due in 7 days, 1 day, or today. Checks the user-level `tax_deadlines_enabled` setting in `notification_settings` before sending.
### Invoice Reminders
Queries the `invoices` table for sent/overdue invoices due in 3 days, today, or overdue by 3/7 days. Checks the user-level `invoice_reminders_enabled` setting before sending.
Both call `sendNotificationToUser()` which handles the full pipeline (quiet hours, duplicate check, send, log).
---
## Settings
### Extension Settings (event-driven toggles)
Stored in the `extension_data` table under `extension_id = 'push-notifications'`, `key = 'settings'`.
```typescript
interface PushNotificationSettings {
periodLockedEnabled: boolean // default: true
periodYearClosedEnabled: boolean // default: true
invoiceSentEnabled: boolean // default: false
receiptExtractedEnabled: boolean // default: true
receiptMatchedEnabled: boolean // default: true
}
```
### First-Party Settings (existing tables)
The `notification_settings` table controls transport-level and category-level preferences:
| Column | Type | Purpose |
|--------|------|---------|
| `push_enabled` | boolean | Master push toggle |
| `tax_deadlines_enabled` | boolean | Cron: tax deadline notifications |
| `invoice_reminders_enabled` | boolean | Cron: invoice due/overdue |
| `quiet_start` | text | Quiet hours start (e.g., "21:00") |
| `quiet_end` | text | Quiet hours end (e.g., "08:00") |
Both layers are checked before sending. Extension settings gate event-driven notifications; `notification_settings` gates the transport and cron-based categories.
---
## API
### GET /api/extensions/push-notifications/settings
Returns the current user's event-driven notification toggles.
**Response 200:**
```json
{
"data": {
"periodLockedEnabled": true,
"periodYearClosedEnabled": true,
"invoiceSentEnabled": false,
"receiptExtractedEnabled": true,
"receiptMatchedEnabled": true
}
}
```
**Response 401:** `{ "error": "Unauthorized" }`
### PATCH /api/extensions/push-notifications/settings
Updates one or more event-driven notification toggles. Only the provided keys are updated; others remain unchanged.
**Request:**
```json
{
"receiptExtractedEnabled": false,
"invoiceSentEnabled": true
}
```
**Response 200:**
```json
{
"data": {
"periodLockedEnabled": true,
"periodYearClosedEnabled": true,
"invoiceSentEnabled": true,
"receiptExtractedEnabled": false,
"receiptMatchedEnabled": true
}
}
```
**Response 400:** `{ "error": "No valid settings provided" }`
**Response 401:** `{ "error": "Unauthorized" }`
**Allowed keys:** `periodLockedEnabled`, `periodYearClosedEnabled`, `invoiceSentEnabled`, `receiptExtractedEnabled`, `receiptMatchedEnabled`
---
## Notification Types
The `NotificationType` union is defined in `extensions/push-notifications/types.ts` (re-exported from `types/index.ts` for convenience):
| Type | Source | Description |
|------|--------|-------------|
| `tax_deadline` | Cron | Upcoming tax deadline |
| `invoice_due` | Cron | Invoice approaching due date |
| `invoice_overdue` | Cron | Invoice past due date |
| `period_locked` | Event | Fiscal period was locked |
| `period_year_closed` | Event | Year-end closing completed |
| `receipt_extracted` | Event | Receipt OCR completed |
| `receipt_matched` | Event | Receipt matched to transaction |
| `invoice_sent` | Event | Invoice was sent |
These are stored as text in `notification_log.notification_type` (no migration needed).
---
## Database Tables Used
No new tables or migrations were required. The extension uses existing tables:
| Table | Usage |
|-------|-------|
| `push_subscriptions` | User's active Web Push subscriptions |
| `notification_settings` | Transport-level and category-level preferences |
| `notification_log` | Sent notification history (duplicate prevention) |
| `extension_data` | Event-driven toggle settings |
| `deadlines` | Tax deadline queries (cron) |
| `invoices` | Invoice due/overdue queries (cron) |
---
## Send Pipeline
`sendNotificationToUser(supabase, userId, payload, notificationType, referenceId, daysBefore?)` executes the following steps:
1. **Settings check** — Load `notification_settings` for the user. If `push_enabled` is false, skip.
2. **Quiet hours** — Convert current time to Sweden timezone (`Europe/Stockholm`). If within the user's quiet hours window, skip.
3. **Duplicate check** — Query `notification_log` for a matching `(user_id, notification_type, reference_id, days_before)` tuple. If found, skip.
4. **Get subscriptions** — Fetch all active push subscriptions from `push_subscriptions`. If none, skip.
5. **Send** — Deliver via the `web-push` library to all subscriptions using `Promise.allSettled`.
6. **Log** — Insert a record into `notification_log` with `delivery_status: 'sent'`.
7. **Cleanup** — Any subscription returning HTTP 410 (Gone) is marked `is_active: false`.
Returns `{ sent: boolean, reason?: string }` where reason can be: `push_disabled`, `quiet_hours`, `duplicate`, `no_subscriptions`, `send_failed`, or `error`.
---
## Registration
The extension is registered in `lib/extensions/loader.ts` alongside the other first-party extensions:
```typescript
const FIRST_PARTY_EXTENSIONS: Extension[] = [
receiptOcrExtension,
aiCategorizationExtension,
pushNotificationsExtension,
]
```
When `loadExtensions()` is called, the registry wires up all 5 event handlers to the event bus.
---
## Refactoring Notes
### Phase 1: Logic extraction (original)
The following files were deleted. All logic was moved into the extension:
| Deleted File | Logic Moved To |
|-------------|----------------|
| `lib/push/web-push.ts` | `extensions/push-notifications/notification-sender.ts` (VAPID config, send functions, types) + `extensions/push-notifications/payload-builders.ts` (payload constructors) |
| `lib/push/notification-scheduler.ts` | `extensions/push-notifications/notification-scheduler.ts` (cron scheduling) + `extensions/push-notifications/notification-sender.ts` (quiet hours, duplicate check, logging, subscription management) |
### Phase 2: Route relocation
The API routes were moved from `app/api/push/` into the extension namespace to enforce the architecture rule that the core never depends on extension code:
| Old Path | New Path |
|----------|----------|
| `app/api/push/cron/route.ts` | `app/api/extensions/push-notifications/cron/route.ts` |
| `app/api/push/subscribe/route.ts` | `app/api/extensions/push-notifications/subscribe/route.ts` |
Updated consumers:
- `components/push/PushPrompt.tsx` — fetch URLs updated to `/api/extensions/push-notifications/subscribe`
- `components/settings/NotificationSettings.tsx` — fetch URLs updated to `/api/extensions/push-notifications/subscribe`
- `vercel.json` — cron path updated to `/api/extensions/push-notifications/cron`
### Phase 3: Type extraction
Push notification types (`PushSubscription`, `NotificationSettings`, `NotificationType`, `NotificationLog`) were moved from `types/index.ts` to `extensions/push-notifications/types.ts`. Re-exports in `types/index.ts` preserve backward compatibility.
---
## Environment Variables
The extension requires the following environment variables (unchanged from before):
| Variable | Purpose |
|----------|---------|
| `NEXT_PUBLIC_VAPID_PUBLIC_KEY` | VAPID public key for client-side subscription |
| `VAPID_PRIVATE_KEY` | VAPID private key for server-side sending |
| `VAPID_SUBJECT` | VAPID subject (defaults to `mailto:support@erp-base.se`) |
| `CRON_SECRET` | Secret for authenticating cron requests |
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase project URL (used by cron service client) |
| `SUPABASE_SERVICE_ROLE_KEY` | Supabase service role key (used by cron service client) |
---
## Testing
### Automated
- `npx tsc --noEmit` — Zero TypeScript errors
- `npx vitest run` — All 78 existing tests pass
### Manual Verification
1. **Period locked** — Lock a period via the UI. If the user has an active push subscription and `periodLockedEnabled` is true, they receive a push notification.
2. **Receipt extracted** — Upload a receipt image. After OCR completes, the `receipt.extracted` event fires and triggers a push notification.
3. **Receipt matched** — When a receipt is auto-matched to a transaction, the `receipt.matched` event triggers a push notification.
4. **Invoice sent** — Send an invoice. If `invoiceSentEnabled` is true (default: false), a push notification is sent.
5. **Year closed** — Complete a year-end closing. The `period.year_closed` event triggers a push notification.
6. **Settings API** — `GET /api/extensions/push-notifications/settings` returns default settings. `PATCH` with `{ "receiptExtractedEnabled": false }` disables that specific notification type.
7. **Cron** — `GET /api/extensions/push-notifications/cron` (with Bearer token) runs the tax deadline and invoice reminder checks using the extension's scheduler module.
8. **Disable toggle** — Set `receiptExtractedEnabled: false` via PATCH, then upload a receipt. No push notification should be sent for the extraction event.