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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user