Files
TX/artifacts/tx-os/public/sw.js
T
Riyadh c540d0ab86 feat(push): add Web Push (VAPID) for lock-screen notifications on iPad PWA
Task #554. Adds a full Web Push stack on top of the existing Socket.IO
notification fan-out so the iPad PWA (and any installed browser) receives
system notifications when Tx OS is closed or backgrounded.

Backend
- New `push_subscriptions` table (userId + unique endpoint + p256dh/auth
  keys + ua + timestamps), exported from `@workspace/db`.
- `artifacts/api-server/src/lib/push.ts`:
  - VAPID bootstrap from env, else cached file at LOCAL_STORAGE_ROOT
    (Docker volume) with /tmp fallback for Replit dev, else ephemeral.
  - `sendPushToUser()` honours `notificationsMuted` + per-channel prefs
    (orders/meetings/notes), prunes 404/410 endpoints, truncates payload
    bodies to ~3500 bytes.
  - **De-dup gate:** skips push when the user has any active Socket.IO
    connection (uses `io.in(\`user:\${uid}\`).fetchSockets()`), so a
    connected user only gets the in-app chime, never a duplicate system
    notification.
  - `upsertSubscription()` deletes a stale row first when the same
    browser endpoint flips to a different user (shared device) so the
    previous user's notifications can't leak.
- Three new routes: `GET /api/push/vapid-public-key`, `POST /api/push/subscribe`,
  `POST /api/push/unsubscribe` (auth-gated, Zod-validated).
- Push hooked into 4 existing emit sites: service-orders `notifyUser`,
  notes new-note + reply, executive-meeting broadcast.

Frontend
- `artifacts/tx-os/public/sw.js`: push + notificationclick only (no
  asset caching). All URLs (icon, badge, navigation target) resolved
  against `self.registration.scope`, so the SW works at root or under
  a subpath without code changes.
- SW registration in `main.tsx` scoped to `BASE_URL`.
- `use-push-subscription` hook (enable/disable/refresh + status).
- New `PushEnablePrompt` card mounted in `App` — appears on first
  launch when supported + permission still "default", one-tap enable,
  dismiss persists for 14 days. Silent on unsupported devices.
- `PushToggleRow` added inside Notification Settings.
- ar/en strings: `notifSettings.push.*` and `common.later`.

Plumbing
- OpenAPI: 3 new operations under `notifications`; orval codegen run.
- `docker-compose.yml` passes VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY /
  VAPID_SUBJECT through to the api service.
- `pnpm --filter @workspace/db run push` applied the schema.
- web-push + @types/web-push installed in api-server.

Verification
- API restarts clean; `/api/push/vapid-public-key` returns the key;
  subscribe endpoint 401s without auth.
- New `artifacts/api-server/tests/push.test.mjs` — 7/7 passing —
  covers VAPID endpoint, auth gating on subscribe/unsubscribe, row
  persistence + removal, idempotent re-subscribe with key rotation,
  account-switch endpoint reassignment, and malformed-input rejection.
- Two architect rounds: first flagged race + payload size (fixed),
  second flagged dedup gate + first-launch UX + SW base-path + tests
  (all fixed in this commit).
2026-05-16 15:29:28 +00:00

88 lines
2.7 KiB
JavaScript

/* Tx OS service worker — Web Push only.
*
* Scope: registered with `scope: BASE_URL`, so the SW only controls
* paths under the SPA's base path (e.g. `/`, or `/tx-os/` in a future
* subpath deployment). Every URL the SW touches — icons, navigation
* targets — is resolved against `self.registration.scope` so the same
* code works at root and at a subpath without code changes.
*
* We deliberately do NOT cache app assets here — Tx OS is self-hosted
* on a single machine, so the network is fast and reliable. Adding
* cache layers would risk shipping stale React bundles after an
* upgrade.
*/
function scopedUrl(relative) {
// self.registration.scope is the full origin + base path with a
// trailing slash (e.g. "https://host/" or "https://host/tx-os/").
// Strip a leading slash from `relative` so URL() treats it as a
// path under scope rather than origin-relative.
const cleaned = String(relative || "").replace(/^\/+/, "");
return new URL(cleaned, self.registration.scope).pathname;
}
self.addEventListener("install", () => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener("push", (event) => {
/** @type {{title?: string, body?: string, tag?: string, url?: string, type?: string}} */
let payload = {};
try {
payload = event.data ? event.data.json() : {};
} catch {
payload = { title: event.data ? event.data.text() : "Tx OS" };
}
const title = payload.title || "Tx OS";
const iconUrl = scopedUrl("icons/icon-192.png");
const options = {
body: payload.body || "",
tag: payload.tag || payload.type || "tx-os",
renotify: true,
icon: iconUrl,
badge: iconUrl,
data: {
url: payload.url || "/",
type: payload.type || null,
},
};
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const rawTarget = (event.notification.data && event.notification.data.url) || "/";
const targetPath = scopedUrl(rawTarget);
event.waitUntil(
(async () => {
const allClients = await self.clients.matchAll({
type: "window",
includeUncontrolled: true,
});
// Prefer an already-open Tx OS tab under our scope; navigate it
// to the target and focus.
for (const client of allClients) {
try {
await client.focus();
if ("navigate" in client) {
await client.navigate(targetPath);
}
return;
} catch {
/* try next */
}
}
if (self.clients.openWindow) {
await self.clients.openWindow(targetPath);
}
})(),
);
});