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).
This commit is contained in:
@@ -1,23 +1,26 @@
|
||||
/* Tx OS service worker — Web Push only.
|
||||
*
|
||||
* Scope: served from the SPA's base path (e.g. `/sw.js`). The SPA
|
||||
* registers it with `scope: BASE_URL` so the SW controls the full
|
||||
* Tx OS surface but doesn't intercept anything outside the artifact.
|
||||
* 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.
|
||||
*
|
||||
* Events:
|
||||
* - install / activate: claim clients immediately so the first push
|
||||
* after registration lands without a reload.
|
||||
* - push: render a system notification (lock-screen + banner on iPad
|
||||
* PWA, system tray on macOS, etc.).
|
||||
* - notificationclick: focus an existing Tx OS tab (or open one) and
|
||||
* navigate to the payload's URL.
|
||||
*/
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -36,12 +39,13 @@ self.addEventListener("push", (event) => {
|
||||
}
|
||||
|
||||
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: "/icons/icon-192.png",
|
||||
badge: "/icons/icon-192.png",
|
||||
icon: iconUrl,
|
||||
badge: iconUrl,
|
||||
data: {
|
||||
url: payload.url || "/",
|
||||
type: payload.type || null,
|
||||
@@ -53,7 +57,8 @@ self.addEventListener("push", (event) => {
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const targetPath = (event.notification.data && event.notification.data.url) || "/";
|
||||
const rawTarget = (event.notification.data && event.notification.data.url) || "/";
|
||||
const targetPath = scopedUrl(rawTarget);
|
||||
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
@@ -61,8 +66,8 @@ self.addEventListener("notificationclick", (event) => {
|
||||
type: "window",
|
||||
includeUncontrolled: true,
|
||||
});
|
||||
// Prefer an already-open Tx OS tab; navigate it to targetPath
|
||||
// and focus.
|
||||
// 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();
|
||||
|
||||
Reference in New Issue
Block a user