# ADR-087: Push Notification Implementation Contract — SW Handler, Subscription Payload, and Notification Format

## Status

Accepted, 2026-06-27.

## Status History

```yaml
status_history:
  - date: 2026-06-27
    status: Proposed
    changed_by: hkl
    reason: Documenting push notification implementation standard (SW handler, subscription payload, notification format contract) — self-hosted VAPID server already live (ADR-023)
    changed_via: adr-kit (360lm)
  - date: 2026-06-27
    status: Accepted
    changed_by: hkl
    reason: Implementation pattern stable; expense PWA uses it; pattern will be replicated across HR, Finance, Activity PWAs
    changed_via: adr-kit (360lm)
```

## Context

ADR-023 decided to use self-hosted VAPID push notifications. This ADR documents the **implementation standard**: how push is wired in the service worker, what the subscription payload must contain, what notification options are mandatory, and how `notificationclick` is handled. Consistency across all PWAs that implement push is critical — the pattern must be identical so field teams (using Android Chrome in standalone PWA mode) receive predictable, reliable notifications.

Current state:
- Push server running at `push-server` on port 8770, exposing `/push/send/` and `/push/subscribe/` endpoints.
- Expense PWA already implements push subscriptions for approval alerts.
- HR, Finance, Activity, and other PWAs will follow the same pattern for salary alerts, transfer confirmations, and activity updates.
- iOS Safari push is NOT a target (home screen PWA adoption is inconsistent on iOS).
- **Subscriptions stored in `hub.push_subscriptions` table** (cross-PWA, centralized), not in per-PWA schemas.

## Decision

Every PWA that implements push notifications MUST follow this contract:

### 1. Subscription Flow (Client-Side)

```javascript
// NEVER request permission on page load — only on explicit user action
const subscribeButton = document.getElementById('subscribe');
subscribeButton.addEventListener('click', async () => {
  if (Notification.permission === 'granted') {
    // Already permitted; proceed to subscription
    await subscribe();
  } else if (Notification.permission !== 'denied') {
    // permission is 'default' — request it
    const perm = await Notification.requestPermission();
    if (perm === 'granted') {
      await subscribe();
    }
  }
  // If permission is 'denied', silent failure — do not show error to user
});

async function subscribe() {
  const registration = await navigator.serviceWorker.ready;
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,  // MANDATORY: all push must be user-visible
    applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
  });
  
  // POST subscription to hub RPC
  const res = await fetch('/db/rpc/save_push_subscription', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      employee_id: parseInt(localStorage.getItem('employee_id')),
      subscription_json: JSON.stringify(subscription)
    })
  });
  
  if (res.ok) {
    // Show success UI
  } else {
    // Server rejected or RPC failed; unsubscribe and show error
    await subscription.unsubscribe();
  }
}

// Helper: VAPID public key is base64url-encoded; browser needs Uint8Array
function urlBase64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - base64String.length % 4) % 4);
  const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/');
  const rawData = window.atob(base64);
  return new Uint8Array([...rawData].map(char => char.charCodeAt(0)));
}
```

### 2. Service Worker Push Handler (Mandatory Structure)

The SW MUST implement a `push` event listener with this exact structure:

```javascript
self.addEventListener('push', event => {
  // Notification payload is JSON: { title, body, icon, badge, tag, data }
  const data = event.data.json();
  
  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: data.icon || '/hub/icons/icon-192.png',
      badge: '/hub/icons/badge-72.png',
      tag: data.tag,                    // Deduplication: same tag replaces prior notification
      data: { url: data.data?.url || '/' },
      requireInteraction: false         // Notification auto-dismisses after 4 seconds on Android
    })
  );
});
```

### 3. Service Worker Notification Click Handler (Mandatory)

The SW MUST implement a `notificationclick` event listener:

```javascript
self.addEventListener('notificationclick', event => {
  event.notification.close();
  
  event.waitUntil(
    clients.openWindow(event.notification.data.url)
      .then(clientWindow => {
        // If PWA is already open, focus it; otherwise new tab is created
        if (clientWindow === null) {
          console.warn('[SW] notificationclick: unable to open window');
        }
      })
  );
});
```

### 4. Notification Payload Contract (Server → Push Server → SW)

The **push server** sends notifications as JSON POST to browser push endpoints. The payload structure is:

```json
{
  "title": "string, max 50 characters",
  "body": "string, max 120 characters (wrap long text with newlines)",
  "icon": "optional; URL to 192px PNG icon; default: /hub/icons/icon-192.png",
  "badge": "optional; URL to 72px monochrome badge; omit if using default",
  "tag": "string; deduplication key — same tag replaces prior notification in the tray",
  "data": {
    "url": "relative path to open on click; e.g., /finance/, /expense/, /activity/"
  }
}
```

**Example payloads:**

```json
{
  "title": "Expense Approved",
  "body": "₹5,240 approved. Impress deducted.",
  "tag": "expense_approval_emp_123",
  "data": { "url": "/expense/" }
}
```

```json
{
  "title": "Salary Ready",
  "body": "June salary credited. View in Finance.",
  "tag": "salary_notif_2026_06",
  "data": { "url": "/finance/salary/" }
}
```

### 5. Storage: hub.push_subscriptions Table

Subscriptions are stored in the hub schema (not per-PWA schema):

```sql
CREATE TABLE hub.push_subscriptions (
  id BIGSERIAL PRIMARY KEY,
  employee_id BIGINT NOT NULL,
  subscription_json JSONB NOT NULL,  -- { endpoint, keys: { p256dh, auth } }
  created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
  FOREIGN KEY (employee_id) REFERENCES hub.employees(id) ON DELETE CASCADE
);
```

RPC to save subscription (idempotent):

```sql
CREATE OR REPLACE FUNCTION hub.save_push_subscription(
  p_employee_id BIGINT,
  p_subscription_json JSONB
)
RETURNS TABLE (id BIGINT, created BOOLEAN)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = hub, public
AS $$
DECLARE
  v_endpoint TEXT;
  v_id BIGINT;
  v_created BOOLEAN;
BEGIN
  v_endpoint := p_subscription_json->>'endpoint';
  
  -- Upsert: if subscription already exists (same employee + endpoint), update; else insert
  INSERT INTO push_subscriptions (employee_id, subscription_json)
  VALUES (p_employee_id, p_subscription_json)
  ON CONFLICT (employee_id, (subscription_json->>'endpoint'))
  DO UPDATE SET updated_at = now()
  RETURNING push_subscriptions.id INTO v_id;
  
  v_created := NOT FOUND;  -- True if inserted, false if updated
  RETURN QUERY SELECT v_id, v_created;
END $$;
```

### 6. Rules and Mandatory Constraints

| Rule | Rationale |
|---|---|
| `userVisibleOnly: true` is MANDATORY in `pushManager.subscribe()` | Chrome spec requirement; silent push is not supported |
| Never request Notification permission on page load | Browsers penalize intrusive permission prompts with negative UX signals |
| Request permission only on user action (e.g., button tap) | Users are more likely to grant when they initiated the flow |
| `notificationclick` MUST open a relative URL (e.g., `/expense/`, not full URL) | Same-origin relative paths allow hub session to persist; full URLs may lose auth context |
| Same `tag` value replaces prior notification in the tray | Prevents notification spam from rapid updates (e.g., multiple approval alerts → one notification updated in-place) |
| Subscription stored in `hub.push_subscriptions`, not per-PWA schema | Centralized subscription lifecycle; survives PWA version updates; accessible to any PWA that needs to cross-notify |
| Payload is always valid JSON; never send empty or null | Some browsers discard malformed push events |

### 7. Error Handling

- **Subscription fails (RPC error):** unsubscribe and show user-friendly alert ("Notifications unavailable. Try again later.")
- **Push delivery fails (410 Gone from browser relay):** push server should cleanup dead subscriptions; PWA will show app-level badge on next open as fallback.
- **notificationclick fails to open window:** SW logs warning; user can manually open PWA if needed.
- **Permission denied by browser:** silent failure; do not nag user; show "Enable notifications in Chrome settings" help text only if user asks.

**Decision Maker:** hkl

## Alternatives Considered

- **Each PWA stores its own subscriptions in its schema.** Rejected: breaks when users switch PWAs; subscriptions become orphaned after 6+ months of inactivity; cross-PWA notifications (e.g., HR to notify Finance about a transfer) require cross-schema joins. Centralized `hub.push_subscriptions` eliminates duplication and enables hub-driven campaigns.
  
- **Use `requireInteraction: true` for all notifications.** Rejected: notifications persist in the tray indefinitely, cluttering the notification shade after a few hours. `false` (auto-dismiss after 4s) is correct for alerts; only use `true` if a specific PWA needs persistent notifications (e.g., active shift timer).
  
- **Serve notification content from an external CMS.** Rejected: adds complexity; push server becomes dependent on another service; payloads already small and generated by trusted backend RPC.
  
- **Cache subscription endpoint URLs locally.** Rejected: endpoints are opaque tokens managed by the browser; caching them across app updates risks sending push to stale endpoints. Store in DB, not localStorage.

## Consequences

**Positive:**
- Consistent push experience across all PWAs — users never encounter malformed notifications or broken click handlers.
- Centralized subscription lifecycle in `hub.push_subscriptions` — easier to query "how many employees are subscribed" or "send a broadcast to all vendors."
- Subscription survives PWA updates and browser session resets — endpoints are persisted server-side.
- `tag` deduplication prevents notification spam — rapid updates (multiple approvals in 2 seconds) show as a single "3 expenses approved" notification.
- SW handler is minimal and reusable — PWAs copy the same 15 lines of code.

**Negative / Trade-offs:**
- Subscriptions accumulate in the DB after app uninstalls (410 responses indicate dead endpoints, but cleanup is server-side, not automatic).
- Each PWA must still request Notification permission separately (Chrome/Android scope is per-origin, not per-browser-vendor).
- iOS Safari push requires PWA to be installed to home screen — many field workers don't do this (see ADR-023 Consequences).

**Risks and mitigations:**
- **Malformed push payload causes SW crash:** JSON parse errors are caught by try-catch in showNotification; browser logs a warning but does not crash. Mitigated: validate all payloads server-side before sending to push server.
- **Subscription endpoint expires:** browser vendor relays eventually purge old endpoints (Chrome: ~1 year). Push server receives 410 Gone → should delete the subscription. Mitigated: push-server logs 410 responses and cleans up subscriptions via separate cron job.
- **User revokes Notification permission in Settings:** `Notification.permission` becomes 'denied'; future calls to `requestPermission()` are silently ignored (browser remembers the denial). Mitigated: check `Notification.permission` before showing UI buttons; help text directs users to Settings to re-enable.

## Related Decisions

- **ADR-023 (self-hosted VAPID push):** This ADR documents the *implementation* of the decision made in ADR-023. ADR-023 handles the server-side; this ADR handles the client-side contract.
- **ADR-021 (SW cache-first):** Push handler runs in the SW; must coexist with cache-first fetch handler. Both are independent listeners — no conflict.
- **ADR-012 (hub as SSO):** Subscriptions are tied to `employee_id` stored in `hub.push_subscriptions`; `employee_id` comes from hub session.
- **ADR-080 (hub session registry):** Subscriptions are created during hub session setup (after login); stored in hub schema as a cross-PWA resource.

## References

- `expense/index.html` — First PWA to implement push notifications (approval alerts) — reference implementation
- `docs/adr/ADR-023-push-notifications-self-hosted-vapid.md` — Push infrastructure decision
- `push-server/` — Self-hosted Flask/pywebpush server on port 8770
- `shared/vapid-keys.env` — VAPID_PUBLIC_KEY (shared across all PWAs)
- Memory: `dbt_archive.md` — expense PWA push implementation record

---

*Last updated: 2026-06-27*
