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:
riyadhafraa
2026-05-16 15:29:28 +00:00
parent 243ccb3e00
commit 8f3b750961
7 changed files with 408 additions and 16 deletions
+23
View File
@@ -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<boolean> {
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;
+233
View File
@@ -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);
});
+21 -16
View File
@@ -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();
+2
View File
@@ -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() {
<NotificationsSocketBridge />
<UpcomingMeetingAlert />
<IncomingNotePopup />
<PushEnablePrompt />
<Switch>
<Route path="/login" component={LoginPage} />
<Route path="/register" component={RegisterPage} />
@@ -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 (
<div
role="dialog"
aria-live="polite"
data-testid="push-enable-prompt"
className="fixed inset-x-3 bottom-3 z-50 md:left-auto md:right-4 md:bottom-4 md:max-w-sm rounded-2xl border border-foreground/10 bg-background/95 backdrop-blur shadow-lg p-3"
>
<div className="flex items-start gap-3">
<div className="shrink-0 rounded-full bg-primary/15 p-2">
<Bell size={18} className="text-primary" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-foreground">
{t("notifSettings.push.title")}
</div>
<div className="text-xs text-muted-foreground mt-0.5 leading-snug">
{t("notifSettings.push.desc")}
</div>
<div className="mt-2 flex items-center gap-2">
<button
type="button"
disabled={busy}
data-testid="push-enable-prompt-enable"
onClick={async () => {
const ok = await enable();
if (ok) {
dismiss();
} else {
toast({ title: t("notifSettings.push.enableFailed") });
}
}}
className="text-xs font-semibold px-3 py-1.5 rounded-md bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-50"
>
{t("notifSettings.push.enable")}
</button>
<button
type="button"
data-testid="push-enable-prompt-dismiss"
onClick={dismiss}
className="text-xs font-medium px-3 py-1.5 rounded-md text-muted-foreground hover:bg-foreground/5"
>
{t("common.later")}
</button>
</div>
</div>
<button
type="button"
aria-label={t("common.close")}
onClick={dismiss}
className="shrink-0 text-muted-foreground hover:text-foreground p-1 rounded-md hover:bg-foreground/5"
>
<X size={14} />
</button>
</div>
</div>
);
}
+1
View File
@@ -930,6 +930,7 @@
"edit": "تعديل",
"add": "إضافة",
"close": "إغلاق",
"later": "لاحقاً",
"loading": "جاري التحميل...",
"error": "حدث خطأ",
"success": "تمت العملية بنجاح",
+1
View File
@@ -842,6 +842,7 @@
"edit": "Edit",
"add": "Add",
"close": "Close",
"later": "Later",
"loading": "Loading...",
"error": "An error occurred",
"success": "Success",