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. - Four new routes: `GET /api/push/vapid-public-key`, `POST /api/push/subscribe`, `POST /api/push/unsubscribe`, and a spec-aligned alias `DELETE /api/push/subscribe?endpoint=...` (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`, gated on `serviceWorker` AND `PushManager` support so older browsers no-op cleanly. - `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. - New `artifacts/api-server/tests/push-410-unit.test.ts` — 3/3 passing — true in-process unit test (`node --import tsx --experimental-test-module-mocks`) that mocks `web-push` to verify the prune path: 410 deletes the row, 404 deletes the row, and a transient 500 leaves the row intact. `tsx` added as a devDep and the test:run script picks up `*.test.ts` alongside the existing `.mjs` suites. - OpenAPI updated: `DELETE /push/subscribe` documented with an `endpoint` query parameter alongside the existing POST routes; orval codegen re-run so the generated client + zod schemas stay in sync. - Restored the unrelated serial tests (`executive-meetings-notifications.test.mjs`, `setup-wizard.test.mjs`) that an earlier cleanup pass dropped — they are unchanged from prior green state. - Architect rounds addressed: 1. race + payload size. 2. dedup gate + first-launch UX + SW base-path + initial tests. 3. DELETE alias + PushManager check + restored serial tests + real 410/404 cleanup test + OpenAPI parity. 4. .env.docker.example + replit.md operational docs for VAPID, DELETE-alias integration tests (auth + happy-path + bad input), and an explicit design-note comment in `push.ts` documenting the intentional user-wide dedup policy.
This commit is contained in:
@@ -163,6 +163,16 @@ async function userAllowsChannel(
|
||||
* 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.
|
||||
*
|
||||
* Design note (intentional, user-wide rather than per-device): the
|
||||
* primary deployment is a single user with one iPad PWA plus an
|
||||
* occasional desktop browser. The product preference is "don't
|
||||
* double-ring me on the desktop I'm staring at, even if the iPad is
|
||||
* also subscribed" — so when ANY socket is live we trust the in-app
|
||||
* chime to surface the notification, and skip system push for every
|
||||
* device the user owns. If multi-device per-session push becomes a
|
||||
* requirement, narrow this to the specific socket-id / device-id
|
||||
* registered alongside each push subscription.
|
||||
*/
|
||||
async function isUserConnected(userId: number): Promise<boolean> {
|
||||
try {
|
||||
|
||||
@@ -231,3 +231,50 @@ test("subscribe rejects malformed input", async () => {
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
test("DELETE /api/push/subscribe?endpoint=... removes the row (spec alias)", async () => {
|
||||
const user = await createUser("push_del");
|
||||
const cookie = await login(user.username);
|
||||
const sub = fakeSub("del");
|
||||
|
||||
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 before = await pool.query(
|
||||
`SELECT 1 FROM push_subscriptions WHERE endpoint = $1`,
|
||||
[sub.endpoint],
|
||||
);
|
||||
assert.equal(before.rowCount, 1);
|
||||
|
||||
const delRes = await fetch(
|
||||
`${API_BASE}/api/push/subscribe?endpoint=${encodeURIComponent(sub.endpoint)}`,
|
||||
{ method: "DELETE", headers: { Cookie: cookie } },
|
||||
);
|
||||
assert.equal(delRes.status, 200);
|
||||
|
||||
const after = await pool.query(
|
||||
`SELECT 1 FROM push_subscriptions WHERE endpoint = $1`,
|
||||
[sub.endpoint],
|
||||
);
|
||||
assert.equal(after.rowCount, 0, "DELETE alias must remove the subscription row");
|
||||
});
|
||||
|
||||
test("DELETE /api/push/subscribe requires auth and a valid endpoint", async () => {
|
||||
const noAuth = await fetch(
|
||||
`${API_BASE}/api/push/subscribe?endpoint=https://x.example/abc`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
assert.equal(noAuth.status, 401);
|
||||
|
||||
const user = await createUser("push_del_bad");
|
||||
const cookie = await login(user.username);
|
||||
const bad = await fetch(
|
||||
`${API_BASE}/api/push/subscribe?endpoint=not-a-url`,
|
||||
{ method: "DELETE", headers: { Cookie: cookie } },
|
||||
);
|
||||
assert.equal(bad.status, 400);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user