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 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.
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
type NotificationSoundId,
|
||||
} from "@workspace/api-client-react";
|
||||
import { Bell, BellOff, Check, Volume2, VolumeX, Play } from "lucide-react";
|
||||
import { usePushSubscription } from "@/hooks/use-push-subscription";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
Popover,
|
||||
@@ -315,6 +316,10 @@ export function NotificationSettingsContent() {
|
||||
|
||||
<div className="h-px bg-foreground/10 mx-1 my-1" />
|
||||
|
||||
<PushToggleRow />
|
||||
|
||||
<div className="h-px bg-foreground/10 mx-1 my-1" />
|
||||
|
||||
<SoundList
|
||||
current={user[cfg.soundKey]}
|
||||
onPick={(id) =>
|
||||
@@ -325,6 +330,69 @@ export function NotificationSettingsContent() {
|
||||
);
|
||||
}
|
||||
|
||||
function PushToggleRow() {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const { status, busy, enable, disable } = usePushSubscription();
|
||||
const subscribed = status === "subscribed";
|
||||
const disabled = busy || status === "unsupported" || status === "denied";
|
||||
const subtitle =
|
||||
status === "unsupported"
|
||||
? t("notifSettings.push.unsupported")
|
||||
: status === "denied"
|
||||
? t("notifSettings.push.denied")
|
||||
: subscribed
|
||||
? t("notifSettings.push.enabled")
|
||||
: t("notifSettings.push.desc");
|
||||
|
||||
return (
|
||||
<div className="px-2 py-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-semibold text-foreground truncate">
|
||||
{t("notifSettings.push.title")}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5 leading-snug">
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
data-testid="notif-push-toggle"
|
||||
onClick={async () => {
|
||||
if (subscribed) {
|
||||
await disable();
|
||||
} else {
|
||||
const ok = await enable();
|
||||
if (!ok) {
|
||||
toast({ title: t("notifSettings.push.enableFailed") });
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${
|
||||
subscribed ? "bg-primary" : "bg-foreground/15"
|
||||
} ${disabled ? "opacity-50" : ""}`}
|
||||
aria-pressed={subscribed}
|
||||
aria-label={
|
||||
subscribed
|
||||
? t("notifSettings.push.disable")
|
||||
: t("notifSettings.push.enable")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
|
||||
subscribed
|
||||
? "translate-x-[18px] rtl:translate-x-0.5"
|
||||
: "translate-x-0.5 rtl:translate-x-[18px]"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotificationSettingsPicker() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
|
||||
Reference in New Issue
Block a user