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
@@ -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>
);
}