From 8f3b7509613f86ad15a55076c1e0f7cd747958a8 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Sat, 16 May 2026 15:29:28 +0000 Subject: [PATCH] feat(push): add Web Push (VAPID) for lock-screen notifications on iPad PWA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- artifacts/api-server/src/lib/push.ts | 23 ++ artifacts/api-server/tests/push.test.mjs | 233 ++++++++++++++++++ artifacts/tx-os/public/sw.js | 37 +-- artifacts/tx-os/src/App.tsx | 2 + .../src/components/push-enable-prompt.tsx | 127 ++++++++++ artifacts/tx-os/src/locales/ar.json | 1 + artifacts/tx-os/src/locales/en.json | 1 + 7 files changed, 408 insertions(+), 16 deletions(-) create mode 100644 artifacts/api-server/tests/push.test.mjs create mode 100644 artifacts/tx-os/src/components/push-enable-prompt.tsx diff --git a/artifacts/api-server/src/lib/push.ts b/artifacts/api-server/src/lib/push.ts index c3d7f423..c05d53da 100644 --- a/artifacts/api-server/src/lib/push.ts +++ b/artifacts/api-server/src/lib/push.ts @@ -159,6 +159,23 @@ async function userAllowsChannel( * owns. Drops 404/410 subscriptions on the fly. Honours the user's * mute + per-channel preferences. Safe to fire-and-forget. */ +/** + * Returns true if the user has at least one active Socket.IO connection + * (any tab/device). Used to gate Web Push so a connected user doesn't + * get the in-app chime AND a system notification for the same event. + */ +async function isUserConnected(userId: number): Promise { + try { + const { io } = await import("../index.js"); + const sockets = await io.in(`user:${userId}`).fetchSockets(); + return sockets.length > 0; + } catch { + // If the socket layer isn't reachable, fall through and send push — + // better to over-notify than to silently drop. + return false; + } +} + export async function sendPushToUser( userId: number, payload: PushPayload, @@ -168,6 +185,12 @@ export async function sendPushToUser( const allowed = await userAllowsChannel(userId, channel); if (!allowed) return; + // De-dup: if the user is online (any tab/device with an active socket) + // they're already getting the in-app notification via Socket.IO, so + // we skip Web Push to avoid double-alerts. Push is for the + // backgrounded/closed PWA case. + if (await isUserConnected(userId)) return; + const vapid = await getVapid(); if (!vapid) return; diff --git a/artifacts/api-server/tests/push.test.mjs b/artifacts/api-server/tests/push.test.mjs new file mode 100644 index 00000000..2fa93caa --- /dev/null +++ b/artifacts/api-server/tests/push.test.mjs @@ -0,0 +1,233 @@ +// API tests for the Web Push subsystem: +// - GET /api/push/vapid-public-key +// - POST /api/push/subscribe (auth + upsert) +// - POST /api/push/unsubscribe (auth + removal) +// - account-switch row reassignment for the same browser endpoint +// +// The full delivery path (web-push -> 410 cleanup) is exercised via a +// direct DB-level simulation: we insert a subscription with a known +// endpoint, then assert it can be looked up + removed through the +// public API. The 410 cleanup itself runs in the server when push +// providers reject — covered by reading the helper from +// `dist/index.mjs` would require a separate integration runner, so +// here we cover the DB/HTTP contract. +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import pg from "pg"; + +const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080"; +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) { + throw new Error("DATABASE_URL must be set to run these tests"); +} + +const TEST_PASSWORD = "TestPass123!"; +const TEST_PASSWORD_HASH = + "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu"; + +const pool = new pg.Pool({ connectionString: DATABASE_URL }); + +const createdUserIds = []; +const createdEndpoints = []; + +function uniq() { + return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; +} + +async function createUser(prefix) { + const username = `${prefix}_${uniq()}`; + const { rows } = await pool.query( + `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) + VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`, + [username, `${username}@example.com`, TEST_PASSWORD_HASH, prefix], + ); + const id = rows[0].id; + createdUserIds.push(id); + await pool.query( + `INSERT INTO user_roles (user_id, role_id) + SELECT $1, id FROM roles WHERE name = 'user'`, + [id], + ); + return { id, username }; +} + +async function login(username) { + const res = await fetch(`${API_BASE}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password: TEST_PASSWORD }), + }); + assert.equal(res.status, 200, `login expected 200, got ${res.status}`); + const setCookie = res.headers.get("set-cookie"); + const sid = setCookie + .split(",") + .map((c) => c.split(";")[0].trim()) + .find((c) => c.startsWith("connect.sid=")); + assert.ok(sid, "expected connect.sid cookie"); + return sid; +} + +function fakeSub(suffix) { + const endpoint = `https://fcm.example.test/push/${suffix}_${uniq()}`; + createdEndpoints.push(endpoint); + return { + endpoint, + keys: { + p256dh: "BPubKey-" + suffix, + auth: "auth-" + suffix, + }, + }; +} + +before(async () => { + // Sanity: server up? + const r = await fetch(`${API_BASE}/api/healthz`).catch(() => null); + if (!r || !r.ok) { + throw new Error(`API at ${API_BASE} is not reachable`); + } +}); + +after(async () => { + if (createdEndpoints.length > 0) { + await pool.query( + `DELETE FROM push_subscriptions WHERE endpoint = ANY($1::text[])`, + [createdEndpoints], + ); + } + if (createdUserIds.length > 0) { + await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [ + createdUserIds, + ]); + } + await pool.end(); +}); + +test("GET /api/push/vapid-public-key returns a base64url key", async () => { + const res = await fetch(`${API_BASE}/api/push/vapid-public-key`); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok( + typeof body.publicKey === "string" && body.publicKey.length > 40, + "expected non-empty VAPID public key", + ); +}); + +test("POST /api/push/subscribe requires auth", async () => { + const res = await fetch(`${API_BASE}/api/push/subscribe`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(fakeSub("noauth")), + }); + assert.equal(res.status, 401); +}); + +test("POST /api/push/unsubscribe requires auth", async () => { + const res = await fetch(`${API_BASE}/api/push/unsubscribe`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ endpoint: "https://example.test/x" }), + }); + assert.equal(res.status, 401); +}); + +test("subscribe persists a row and unsubscribe removes it", async () => { + const user = await createUser("push_basic"); + const cookie = await login(user.username); + const sub = fakeSub("basic"); + + const subRes = await fetch(`${API_BASE}/api/push/subscribe`, { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: cookie }, + body: JSON.stringify(sub), + }); + assert.equal(subRes.status, 200); + + const rowsAfter = await pool.query( + `SELECT user_id, p256dh, auth FROM push_subscriptions WHERE endpoint = $1`, + [sub.endpoint], + ); + assert.equal(rowsAfter.rowCount, 1, "expected one row after subscribe"); + assert.equal(rowsAfter.rows[0].user_id, user.id); + assert.equal(rowsAfter.rows[0].p256dh, sub.keys.p256dh); + assert.equal(rowsAfter.rows[0].auth, sub.keys.auth); + + const unsubRes = await fetch(`${API_BASE}/api/push/unsubscribe`, { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: cookie }, + body: JSON.stringify({ endpoint: sub.endpoint }), + }); + assert.equal(unsubRes.status, 200); + + const rowsGone = await pool.query( + `SELECT 1 FROM push_subscriptions WHERE endpoint = $1`, + [sub.endpoint], + ); + assert.equal(rowsGone.rowCount, 0, "expected row removed after unsubscribe"); +}); + +test("subscribe is idempotent — re-subscribing updates keys without duplicating", async () => { + const user = await createUser("push_idem"); + const cookie = await login(user.username); + const sub = fakeSub("idem"); + + for (const variant of [sub, { ...sub, keys: { p256dh: "rotated", auth: "rotated" } }]) { + const res = await fetch(`${API_BASE}/api/push/subscribe`, { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: cookie }, + body: JSON.stringify(variant), + }); + assert.equal(res.status, 200); + } + + const rows = await pool.query( + `SELECT p256dh, auth FROM push_subscriptions WHERE endpoint = $1`, + [sub.endpoint], + ); + assert.equal(rows.rowCount, 1); + assert.equal(rows.rows[0].p256dh, "rotated"); + assert.equal(rows.rows[0].auth, "rotated"); +}); + +test("subscribe reassigns endpoint to the new user when a different user signs in on the same browser", async () => { + const userA = await createUser("push_a"); + const userB = await createUser("push_b"); + const cookieA = await login(userA.username); + const cookieB = await login(userB.username); + const sub = fakeSub("switch"); + + let res = await fetch(`${API_BASE}/api/push/subscribe`, { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: cookieA }, + body: JSON.stringify(sub), + }); + assert.equal(res.status, 200); + + res = await fetch(`${API_BASE}/api/push/subscribe`, { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: cookieB }, + body: JSON.stringify(sub), + }); + assert.equal(res.status, 200); + + const rows = await pool.query( + `SELECT user_id FROM push_subscriptions WHERE endpoint = $1`, + [sub.endpoint], + ); + assert.equal(rows.rowCount, 1, "endpoint should never duplicate across users"); + assert.equal( + rows.rows[0].user_id, + userB.id, + "endpoint should now belong to the most recent signer-in", + ); +}); + +test("subscribe rejects malformed input", async () => { + const user = await createUser("push_bad"); + const cookie = await login(user.username); + const res = await fetch(`${API_BASE}/api/push/subscribe`, { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: cookie }, + body: JSON.stringify({ endpoint: "not-a-url", keys: {} }), + }); + assert.equal(res.status, 400); +}); diff --git a/artifacts/tx-os/public/sw.js b/artifacts/tx-os/public/sw.js index f8137ac7..5f332e91 100644 --- a/artifacts/tx-os/public/sw.js +++ b/artifacts/tx-os/public/sw.js @@ -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(); diff --git a/artifacts/tx-os/src/App.tsx b/artifacts/tx-os/src/App.tsx index 79f894b7..8f16e222 100644 --- a/artifacts/tx-os/src/App.tsx +++ b/artifacts/tx-os/src/App.tsx @@ -9,6 +9,7 @@ import { useNotificationsSocket } from "@/hooks/use-notifications-socket"; import { useAudioUnlock } from "@/hooks/use-audio-unlock"; import { useAutoplayHint } from "@/hooks/use-autoplay-hint"; import { UpcomingMeetingAlert } from "@/components/executive-meetings/upcoming-meeting-alert"; +import { PushEnablePrompt } from "@/components/push-enable-prompt"; import NotFound from "@/pages/not-found"; import LoginPage from "@/pages/login"; import RegisterPage from "@/pages/register"; @@ -55,6 +56,7 @@ function Router() { + diff --git a/artifacts/tx-os/src/components/push-enable-prompt.tsx b/artifacts/tx-os/src/components/push-enable-prompt.tsx new file mode 100644 index 00000000..4bbbba7d --- /dev/null +++ b/artifacts/tx-os/src/components/push-enable-prompt.tsx @@ -0,0 +1,127 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Bell, X } from "lucide-react"; +import { useAuth } from "@/contexts/AuthContext"; +import { usePushSubscription } from "@/hooks/use-push-subscription"; +import { useToast } from "@/hooks/use-toast"; + +const DISMISS_KEY = "tx.pushPrompt.dismissedAt"; +// Re-prompt after 14 days if the user dismissed but never opted in. +const RE_PROMPT_AFTER_MS = 14 * 24 * 60 * 60 * 1000; + +/** + * One-tap "Enable lock-screen alerts" card surfaced after the user logs + * in for the first time on a device. Renders only when: + * - the user is authenticated + * - Web Push is supported (Notifications API + SW + PushManager) + * - permission is still "default" (we haven't asked yet) + * - the user has no active subscription on this device + * - they haven't dismissed the prompt recently + * + * On unsupported devices (notably iOS Safari before "Add to Home + * Screen") we stay silent — the toggle inside Notification Settings + * exposes the relevant copy for power users. We don't want a + * permanent "not supported" banner cluttering the home screen. + */ +export function PushEnablePrompt() { + const { t } = useTranslation(); + const { user } = useAuth(); + const { toast } = useToast(); + const { status, busy, enable } = usePushSubscription(); + const [hidden, setHidden] = useState(true); + + useEffect(() => { + if (!user) { + setHidden(true); + return; + } + if (status !== "default") { + setHidden(true); + return; + } + try { + const raw = localStorage.getItem(DISMISS_KEY); + if (raw) { + const ts = Number(raw); + if ( + Number.isFinite(ts) && + Date.now() - ts < RE_PROMPT_AFTER_MS + ) { + setHidden(true); + return; + } + } + } catch { + /* localStorage unavailable — just show the prompt */ + } + setHidden(false); + }, [user, status]); + + if (hidden) return null; + + const dismiss = () => { + try { + localStorage.setItem(DISMISS_KEY, String(Date.now())); + } catch { + /* ignore */ + } + setHidden(true); + }; + + return ( +
+
+
+ +
+
+
+ {t("notifSettings.push.title")} +
+
+ {t("notifSettings.push.desc")} +
+
+ + +
+
+ +
+
+ ); +} diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index fb104694..9a32d2f0 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -930,6 +930,7 @@ "edit": "تعديل", "add": "إضافة", "close": "إغلاق", + "later": "لاحقاً", "loading": "جاري التحميل...", "error": "حدث خطأ", "success": "تمت العملية بنجاح", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 4f17524f..1b9e2a3d 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -842,6 +842,7 @@ "edit": "Edit", "add": "Add", "close": "Close", + "later": "Later", "loading": "Loading...", "error": "An error occurred", "success": "Success",