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:
riyadhafraa
2026-05-16 15:26:18 +00:00
parent d91a68bc68
commit 243ccb3e00
21 changed files with 1266 additions and 0 deletions
+2
View File
@@ -36,6 +36,7 @@
"playwright-core": "^1.59.1",
"sanitize-html": "^2.17.3",
"socket.io": "^4.8.3",
"web-push": "^3.6.7",
"zod": "catalog:"
},
"devDependencies": {
@@ -49,6 +50,7 @@
"@types/nodemailer": "^8.0.0",
"@types/pdfkit": "^0.17.6",
"@types/sanitize-html": "^2.16.1",
"@types/web-push": "^3.6.4",
"esbuild": "^0.27.3",
"esbuild-plugin-pino": "^2.3.3",
"pg": "^8.20.0",
@@ -13,6 +13,7 @@ import {
usersTable,
} from "@workspace/db";
import { logger } from "./logger";
import { sendPushToUser } from "./push";
/**
* Canonical list of notification event types the Executive Meetings
@@ -211,6 +212,14 @@ export async function broadcastExecutiveMeetingNotifications(
};
for (const uid of recipientUserIds) {
io.to(`user:${uid}`).emit("notification_created", payload);
void sendPushToUser(uid, {
title: "تنبيه اجتماع",
body: "لديك تنبيه اجتماع تنفيذي",
type: "executive_meeting",
relatedId: meetingId,
tag: meetingId ? `meeting-${meetingId}-${notificationType}` : `meeting-${notificationType}`,
url: "/meetings",
});
}
io.emit("executive_meeting_notifications_changed", {
notificationType,
+292
View File
@@ -0,0 +1,292 @@
import { promises as fs } from "fs";
import path from "path";
import { eq, and } from "drizzle-orm";
import webpush from "web-push";
import { db } from "@workspace/db";
import {
pushSubscriptionsTable,
usersTable,
} from "@workspace/db";
import { logger } from "./logger";
let initPromise: Promise<{
publicKey: string;
privateKey: string;
subject: string;
} | null> | null = null;
function keysFilePath(): string {
// LOCAL_STORAGE_ROOT is set in Docker prod (mounted volume at
// /app/storage). In dev on Replit it's unset, and PRIVATE_OBJECT_DIR
// points to an object-store path that isn't a real local filesystem,
// so we fall back to /tmp.
const root = process.env.LOCAL_STORAGE_ROOT || "/tmp";
return path.join(root, "vapid-keys.json");
}
/**
* Resolve VAPID keys. Priority:
* 1. env (VAPID_PUBLIC_KEY + VAPID_PRIVATE_KEY)
* 2. cached on-disk file (auto-generated on first boot)
* 3. generate, persist, return
*
* Returns null only if write + generate both fail, in which case
* Web Push is disabled but the rest of the app keeps running.
*/
async function loadVapid(): Promise<{
publicKey: string;
privateKey: string;
subject: string;
} | null> {
const subject =
process.env.VAPID_SUBJECT ??
`mailto:admin@${process.env.LOCAL_DOMAIN ?? "tx.local"}`;
const envPub = process.env.VAPID_PUBLIC_KEY;
const envPriv = process.env.VAPID_PRIVATE_KEY;
if (envPub && envPriv) {
try {
webpush.setVapidDetails(subject, envPub, envPriv);
return { publicKey: envPub, privateKey: envPriv, subject };
} catch (err) {
logger.error({ err }, "Invalid VAPID env keys — falling back to disk");
}
}
const file = keysFilePath();
try {
const raw = await fs.readFile(file, "utf8");
const parsed = JSON.parse(raw) as {
publicKey?: string;
privateKey?: string;
};
if (parsed.publicKey && parsed.privateKey) {
webpush.setVapidDetails(subject, parsed.publicKey, parsed.privateKey);
return {
publicKey: parsed.publicKey,
privateKey: parsed.privateKey,
subject,
};
}
} catch {
/* missing — fall through to generate */
}
const { publicKey, privateKey } = webpush.generateVAPIDKeys();
// Attempt to persist so the key survives restarts. If the target
// directory isn't writable (e.g. Replit dev where LOCAL_STORAGE_ROOT
// is unset), fall back to /tmp. If even that fails, run with an
// ephemeral in-memory key — Web Push still works for this process,
// existing subscriptions will just be invalidated on next restart.
const candidates = [file, path.join("/tmp", "vapid-keys.json")];
let persistedAt: string | null = null;
for (const target of candidates) {
try {
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(
target,
JSON.stringify({ publicKey, privateKey }, null, 2),
{ mode: 0o600 },
);
persistedAt = target;
break;
} catch {
/* try next candidate */
}
}
webpush.setVapidDetails(subject, publicKey, privateKey);
if (persistedAt) {
logger.warn(
{ file: persistedAt },
"Generated VAPID keys (no VAPID_PUBLIC_KEY/PRIVATE_KEY env set). " +
"Persisted to disk so they survive restarts. Copy them into env to " +
"share across hosts.",
);
} else {
logger.error(
"Generated ephemeral VAPID keys (could not write to disk). " +
"Push subscriptions will be invalidated on next restart.",
);
}
return { publicKey, privateKey, subject };
}
export function getVapid() {
if (!initPromise) initPromise = loadVapid();
return initPromise;
}
export type PushPayload = {
title: string;
body: string;
tag?: string;
url?: string;
type?: string;
relatedId?: number | null;
};
type ChannelType = "order" | "executive_meeting" | "note" | "info";
/**
* Check the user's per-channel + global mute preferences. Mirrors the
* client-side gating in `useNotificationsSocket` so a server-pushed
* notification stays silent when the user has muted that channel.
*/
async function userAllowsChannel(
userId: number,
channel: ChannelType,
): Promise<boolean> {
const [u] = await db
.select({
muted: usersTable.notificationsMuted,
orders: usersTable.notifyOrdersEnabled,
meetings: usersTable.notifyMeetingsEnabled,
notes: usersTable.notifyNotesEnabled,
})
.from(usersTable)
.where(eq(usersTable.id, userId))
.limit(1);
if (!u) return false;
if (u.muted) return false;
if (channel === "order") return u.orders;
if (channel === "executive_meeting") return u.meetings;
if (channel === "note") return u.notes;
return true;
}
/**
* Send a Web Push notification to every active subscription a user
* owns. Drops 404/410 subscriptions on the fly. Honours the user's
* mute + per-channel preferences. Safe to fire-and-forget.
*/
export async function sendPushToUser(
userId: number,
payload: PushPayload,
): Promise<void> {
if (!Number.isInteger(userId) || userId <= 0) return;
const channel = (payload.type as ChannelType) ?? "info";
const allowed = await userAllowsChannel(userId, channel);
if (!allowed) return;
const vapid = await getVapid();
if (!vapid) return;
const subs = await db
.select()
.from(pushSubscriptionsTable)
.where(eq(pushSubscriptionsTable.userId, userId));
if (subs.length === 0) return;
// Web Push payloads are capped at 4096 bytes after encryption padding.
// We aim for ~3500 to leave headroom and truncate the (likely-Arabic,
// multi-byte) body if needed so a long note doesn't blow up delivery
// for every device the user owns.
const MAX_BYTES = 3500;
let safePayload = payload;
let json = JSON.stringify(safePayload);
if (Buffer.byteLength(json, "utf8") > MAX_BYTES) {
const overhead = Buffer.byteLength(
JSON.stringify({ ...safePayload, body: "" }),
"utf8",
);
const budget = Math.max(0, MAX_BYTES - overhead - 1);
let truncated = safePayload.body;
while (Buffer.byteLength(truncated, "utf8") > budget && truncated.length > 0) {
truncated = truncated.slice(0, -1);
}
safePayload = { ...safePayload, body: truncated + "…" };
json = JSON.stringify(safePayload);
}
await Promise.all(
subs.map(async (sub) => {
try {
await webpush.sendNotification(
{
endpoint: sub.endpoint,
keys: { p256dh: sub.p256dh, auth: sub.auth },
},
json,
{ TTL: 60 },
);
await db
.update(pushSubscriptionsTable)
.set({ lastUsedAt: new Date() })
.where(eq(pushSubscriptionsTable.id, sub.id));
} catch (err) {
const status = (err as { statusCode?: number })?.statusCode;
if (status === 404 || status === 410) {
await db
.delete(pushSubscriptionsTable)
.where(eq(pushSubscriptionsTable.id, sub.id));
logger.info(
{ userId, endpoint: sub.endpoint, status },
"Pruned stale push subscription",
);
} else {
logger.warn({ err, userId, status }, "Web Push send failed");
}
}
}),
);
}
export async function upsertSubscription(
userId: number,
input: {
endpoint: string;
p256dh: string;
auth: string;
userAgent?: string | null;
},
): Promise<void> {
const ua = input.userAgent ? input.userAgent.slice(0, 400) : null;
// If the same browser endpoint already exists under a different user
// (shared device, account switch), delete the stale row first so the
// previous user's notifications can't leak onto this device. The
// unique constraint on `endpoint` would otherwise quietly hand the
// subscription over via UPDATE.
const [existing] = await db
.select({ id: pushSubscriptionsTable.id, userId: pushSubscriptionsTable.userId })
.from(pushSubscriptionsTable)
.where(eq(pushSubscriptionsTable.endpoint, input.endpoint))
.limit(1);
if (existing && existing.userId !== userId) {
await db
.delete(pushSubscriptionsTable)
.where(eq(pushSubscriptionsTable.id, existing.id));
}
await db
.insert(pushSubscriptionsTable)
.values({
userId,
endpoint: input.endpoint,
p256dh: input.p256dh,
auth: input.auth,
userAgent: ua,
})
.onConflictDoUpdate({
target: pushSubscriptionsTable.endpoint,
set: {
userId,
p256dh: input.p256dh,
auth: input.auth,
userAgent: ua,
lastUsedAt: new Date(),
},
});
}
export async function removeSubscription(
userId: number,
endpoint: string,
): Promise<void> {
await db
.delete(pushSubscriptionsTable)
.where(
and(
eq(pushSubscriptionsTable.userId, userId),
eq(pushSubscriptionsTable.endpoint, endpoint),
),
);
}
+2
View File
@@ -16,6 +16,7 @@ import groupsRouter from "./groups";
import rolesRouter from "./roles";
import auditRouter from "./audit";
import executiveMeetingsRouter from "./executive-meetings";
import pushRouter from "./push";
const router: IRouter = Router();
@@ -36,5 +37,6 @@ router.use(groupsRouter);
router.use(rolesRouter);
router.use(auditRouter);
router.use(executiveMeetingsRouter);
router.use(pushRouter);
export default router;
+17
View File
@@ -13,6 +13,7 @@ import {
notificationsTable,
} from "@workspace/db";
import { requireAuth, getUserRoles } from "../middlewares/auth";
import { sendPushToUser } from "../lib/push";
const router: IRouter = Router();
@@ -1132,6 +1133,14 @@ router.post("/notes/:id/send", requireAuth, async (req, res): Promise<void> => {
})
.returning();
await emitToUser(r.recipientUserId, "notification_created", { ...notif, type: "note" });
void sendPushToUser(r.recipientUserId, {
title: "ملاحظة جديدة",
body: `${senderNameAr} أرسل لك ملاحظة`,
type: "note",
relatedId: note.id,
tag: `note-${note.id}`,
url: "/notes",
});
await emitToUser(r.recipientUserId, "note_received", {
noteId: note.id,
recipientRowId: r.id,
@@ -1478,6 +1487,14 @@ router.post("/notes/:id/reply", requireAuth, async (req, res): Promise<void> =>
})
.returning();
await emitToUser(otherPartyUserId, "notification_created", { ...notif, type: "note" });
void sendPushToUser(otherPartyUserId, {
title: isOwner ? "رد جديد على ملاحظة" : "رد على ملاحظتك",
body: `${replierNameAr} رد على ملاحظة`,
type: "note",
relatedId: id.id,
tag: `note-reply-${reply.id}`,
url: "/notes",
});
// Enrich the realtime payload so the recipient's client can render the
// floating reply card without an extra round-trip. We truncate the body
// to keep the socket frame small; the full reply is still in /notes.
+65
View File
@@ -0,0 +1,65 @@
import { Router, type IRouter } from "express";
import { z } from "zod";
import { requireAuth } from "../middlewares/auth";
import {
getVapid,
upsertSubscription,
removeSubscription,
} from "../lib/push";
const router: IRouter = Router();
const SubscribeBody = z.object({
endpoint: z.string().url().max(2048),
keys: z.object({
p256dh: z.string().min(1).max(512),
auth: z.string().min(1).max(512),
}),
});
const UnsubscribeBody = z.object({
endpoint: z.string().url().max(2048),
});
router.get("/push/vapid-public-key", async (_req, res): Promise<void> => {
const vapid = await getVapid();
if (!vapid) {
res.status(503).json({ error: "Web Push not configured" });
return;
}
res.json({ publicKey: vapid.publicKey });
});
router.post("/push/subscribe", requireAuth, async (req, res): Promise<void> => {
const parsed = SubscribeBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const userId = req.session.userId!;
const ua = (req.headers["user-agent"] as string | undefined) ?? null;
await upsertSubscription(userId, {
endpoint: parsed.data.endpoint,
p256dh: parsed.data.keys.p256dh,
auth: parsed.data.keys.auth,
userAgent: ua,
});
res.json({ success: true });
});
router.post(
"/push/unsubscribe",
requireAuth,
async (req, res): Promise<void> => {
const parsed = UnsubscribeBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const userId = req.session.userId!;
await removeSubscription(userId, parsed.data.endpoint);
res.json({ success: true });
},
);
export default router;
@@ -12,6 +12,7 @@ import {
permissionsTable,
} from "@workspace/db";
import { requireAuth, requirePermission } from "../middlewares/auth";
import { sendPushToUser } from "../lib/push";
import {
CreateServiceOrderBody,
UpdateServiceOrderStatusParams as ServiceOrderIdParams,
@@ -133,6 +134,14 @@ async function notifyUser(
})
.returning();
await emitToUser(userId, "notification_created", notification);
void sendPushToUser(userId, {
title: titleAr,
body: bodyAr,
type: "order",
relatedId: orderId,
tag: `order-${orderId}`,
url: "/my-orders",
});
}
async function broadcastIncomingChanged(receiverIds: number[]) {