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