243ccb3e00
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 in-memory. - `sendPushToUser()` honours `notificationsMuted` + per-channel prefs (orders/meetings/notes), prunes 404/410 endpoints, truncates payload bodies to ~3500 bytes so over-sized notes don't kill delivery. - `upsertSubscription()` deletes a stale row first when the same browser endpoint flips to a different user (account switch on 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 the 4 existing emit sites: service-orders `notifyUser`, notes new-note + reply, executive-meeting broadcast. Frontend - `artifacts/tx-os/public/sw.js`: push + notificationclick handlers only (no asset caching). Focuses an existing tab or opens a new one at the payload URL. - SW registration in `main.tsx` scoped to `BASE_URL`. - `use-push-subscription` hook (enable/disable/refresh + status: unsupported / denied / default / subscribed). - New `PushToggleRow` in `NotificationSettingsContent` with ar/en strings (notifSettings.push.*). iPad copy explains that the user must add to Home Screen first for iOS to allow Web Push. Plumbing - OpenAPI: 3 new operations under `notifications` tag; 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 generated key; subscribe endpoint 401s without auth as expected. - Architect review surfaced two HIGH issues (account-switch leak, payload size); both fixed before completion.
83 lines
2.4 KiB
JavaScript
83 lines
2.4 KiB
JavaScript
/* 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.
|
|
*
|
|
* 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.
|
|
*/
|
|
|
|
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 options = {
|
|
body: payload.body || "",
|
|
tag: payload.tag || payload.type || "tx-os",
|
|
renotify: true,
|
|
icon: "/icons/icon-192.png",
|
|
badge: "/icons/icon-192.png",
|
|
data: {
|
|
url: payload.url || "/",
|
|
type: payload.type || null,
|
|
},
|
|
};
|
|
|
|
event.waitUntil(self.registration.showNotification(title, options));
|
|
});
|
|
|
|
self.addEventListener("notificationclick", (event) => {
|
|
event.notification.close();
|
|
const targetPath = (event.notification.data && event.notification.data.url) || "/";
|
|
|
|
event.waitUntil(
|
|
(async () => {
|
|
const allClients = await self.clients.matchAll({
|
|
type: "window",
|
|
includeUncontrolled: true,
|
|
});
|
|
// Prefer an already-open Tx OS tab; navigate it to targetPath
|
|
// 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);
|
|
}
|
|
})(),
|
|
);
|
|
});
|