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[]) {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/* Tx OS service worker — Web Push only.
|
||||
*
|
||||
* Scope: served from the SPA's base path (e.g. `/sw.js`). The SPA
|
||||
* registers it with `scope: BASE_URL` so the SW controls the full
|
||||
* Tx OS surface but doesn't intercept anything outside the artifact.
|
||||
*
|
||||
* We deliberately do NOT cache app assets here — Tx OS is self-hosted
|
||||
* on a single machine, so the network is fast and reliable. Adding
|
||||
* cache layers would risk shipping stale React bundles after an
|
||||
* upgrade.
|
||||
*
|
||||
* Events:
|
||||
* - install / activate: claim clients immediately so the first push
|
||||
* after registration lands without a reload.
|
||||
* - push: render a system notification (lock-screen + banner on iPad
|
||||
* PWA, system tray on macOS, etc.).
|
||||
* - notificationclick: focus an existing Tx OS tab (or open one) and
|
||||
* navigate to the payload's URL.
|
||||
*/
|
||||
|
||||
self.addEventListener("install", () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
/** @type {{title?: string, body?: string, tag?: string, url?: string, type?: string}} */
|
||||
let payload = {};
|
||||
try {
|
||||
payload = event.data ? event.data.json() : {};
|
||||
} catch {
|
||||
payload = { title: event.data ? event.data.text() : "Tx OS" };
|
||||
}
|
||||
|
||||
const title = payload.title || "Tx OS";
|
||||
const options = {
|
||||
body: payload.body || "",
|
||||
tag: payload.tag || payload.type || "tx-os",
|
||||
renotify: true,
|
||||
icon: "/icons/icon-192.png",
|
||||
badge: "/icons/icon-192.png",
|
||||
data: {
|
||||
url: payload.url || "/",
|
||||
type: payload.type || null,
|
||||
},
|
||||
};
|
||||
|
||||
event.waitUntil(self.registration.showNotification(title, options));
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const targetPath = (event.notification.data && event.notification.data.url) || "/";
|
||||
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: "window",
|
||||
includeUncontrolled: true,
|
||||
});
|
||||
// Prefer an already-open Tx OS tab; navigate it to targetPath
|
||||
// and focus.
|
||||
for (const client of allClients) {
|
||||
try {
|
||||
await client.focus();
|
||||
if ("navigate" in client) {
|
||||
await client.navigate(targetPath);
|
||||
}
|
||||
return;
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
if (self.clients.openWindow) {
|
||||
await self.clients.openWindow(targetPath);
|
||||
}
|
||||
})(),
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");
|
||||
|
||||
type PushStatus =
|
||||
| "unsupported"
|
||||
| "denied"
|
||||
| "default"
|
||||
| "subscribed";
|
||||
|
||||
function urlBase64ToUint8Array(base64: string): Uint8Array {
|
||||
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
|
||||
const b64 = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const raw = atob(b64);
|
||||
const out = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function isSupported(): boolean {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
"serviceWorker" in navigator &&
|
||||
"PushManager" in window &&
|
||||
"Notification" in window
|
||||
);
|
||||
}
|
||||
|
||||
async function getRegistration(): Promise<ServiceWorkerRegistration | null> {
|
||||
if (!("serviceWorker" in navigator)) return null;
|
||||
// Wait for the SPA's registration in main.tsx to settle. `ready`
|
||||
// resolves once an active SW controls the page.
|
||||
return navigator.serviceWorker.ready;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that exposes the user's current Web Push subscription state and
|
||||
* `enable()` / `disable()` actions. Talks to the backend at
|
||||
* `${BASE}/api/push/*`.
|
||||
*
|
||||
* Web Push on iOS Safari only works when the page is installed as a
|
||||
* PWA (Add to Home Screen). The hook surfaces "unsupported" in that
|
||||
* case so the UI can prompt the user to install first.
|
||||
*/
|
||||
export function usePushSubscription() {
|
||||
const [status, setStatus] = useState<PushStatus>(() =>
|
||||
!isSupported()
|
||||
? "unsupported"
|
||||
: Notification.permission === "denied"
|
||||
? "denied"
|
||||
: "default",
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!isSupported()) {
|
||||
setStatus("unsupported");
|
||||
return;
|
||||
}
|
||||
if (Notification.permission === "denied") {
|
||||
setStatus("denied");
|
||||
return;
|
||||
}
|
||||
const reg = await getRegistration();
|
||||
const sub = await reg?.pushManager.getSubscription();
|
||||
if (sub) setStatus("subscribed");
|
||||
else setStatus(Notification.permission === "granted" ? "default" : "default");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const enable = useCallback(async (): Promise<boolean> => {
|
||||
if (!isSupported()) return false;
|
||||
setBusy(true);
|
||||
try {
|
||||
const perm = await Notification.requestPermission();
|
||||
if (perm !== "granted") {
|
||||
setStatus(perm === "denied" ? "denied" : "default");
|
||||
return false;
|
||||
}
|
||||
const reg = await getRegistration();
|
||||
if (!reg) return false;
|
||||
|
||||
const keyRes = await fetch(`${BASE}/api/push/vapid-public-key`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!keyRes.ok) return false;
|
||||
const { publicKey } = (await keyRes.json()) as { publicKey: string };
|
||||
|
||||
// If a stale subscription exists from a previous VAPID key,
|
||||
// unsubscribe locally first so applicationServerKey isn't
|
||||
// mismatched.
|
||||
const existing = await reg.pushManager.getSubscription();
|
||||
if (existing) {
|
||||
try {
|
||||
await existing.unsubscribe();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
});
|
||||
const json = sub.toJSON();
|
||||
const subRes = await fetch(`${BASE}/api/push/subscribe`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
endpoint: json.endpoint,
|
||||
keys: json.keys,
|
||||
}),
|
||||
});
|
||||
if (!subRes.ok) {
|
||||
try {
|
||||
await sub.unsubscribe();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return false;
|
||||
}
|
||||
setStatus("subscribed");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disable = useCallback(async (): Promise<void> => {
|
||||
if (!isSupported()) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const reg = await getRegistration();
|
||||
const sub = await reg?.pushManager.getSubscription();
|
||||
if (sub) {
|
||||
const endpoint = sub.endpoint;
|
||||
try {
|
||||
await sub.unsubscribe();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
await fetch(`${BASE}/api/push/unsubscribe`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ endpoint }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
setStatus(Notification.permission === "denied" ? "denied" : "default");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { status, busy, enable, disable, refresh };
|
||||
}
|
||||
@@ -240,6 +240,16 @@
|
||||
"autoplayHintTitle": "تحتاج الأصوات إلى نقرة",
|
||||
"autoplayHint": "انقر في أي مكان لتفعيل أصوات الإشعارات.",
|
||||
"autoplayHintIos": "اضغط في أي مكان لتفعيل الأصوات. إذا ما زلت لا تسمعها، تأكد إن مستوى صوت الوسائط مرفوع (نفس الصوت اللي يشغّل اليوتيوب).",
|
||||
"push": {
|
||||
"title": "إشعارات شاشة القفل",
|
||||
"desc": "استقبل التنبيهات على الشاشة الرئيسية أو شاشة القفل حتى لو كان Tx مغلقاً.",
|
||||
"enable": "تفعيل",
|
||||
"disable": "إيقاف",
|
||||
"enabled": "مفعّل",
|
||||
"denied": "محظور — فعّله من إعدادات الجهاز",
|
||||
"unsupported": "غير مدعوم على هذا الجهاز. على الآيباد، أضف Tx إلى الشاشة الرئيسية أولاً.",
|
||||
"enableFailed": "تعذّر تفعيل الإشعارات. حاول مرة أخرى."
|
||||
},
|
||||
"slot": {
|
||||
"order": "الطلبات",
|
||||
"meeting": "الاجتماعات",
|
||||
|
||||
@@ -246,6 +246,16 @@
|
||||
"autoplayHintTitle": "Sounds need a click",
|
||||
"autoplayHint": "Click anywhere on the page to enable notification sounds.",
|
||||
"autoplayHintIos": "Tap anywhere to enable sounds. If you still don't hear them, raise the *media* volume (the same one that controls YouTube).",
|
||||
"push": {
|
||||
"title": "Lock-screen notifications",
|
||||
"desc": "Get alerts on the home/lock screen when Tx OS is closed.",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"enabled": "Enabled",
|
||||
"denied": "Blocked — enable from device settings",
|
||||
"unsupported": "Not supported on this device. On iPad, add Tx OS to the Home Screen first.",
|
||||
"enableFailed": "Could not enable notifications. Try again."
|
||||
},
|
||||
"slot": {
|
||||
"order": "Orders",
|
||||
"meeting": "Meetings",
|
||||
|
||||
@@ -17,4 +17,25 @@ if (typeof document !== "undefined" && document.fonts?.load) {
|
||||
void document.fonts.load('700 1em "DIN Next LT Arabic"');
|
||||
}
|
||||
|
||||
// Register the Web Push service worker scoped to the SPA's base path.
|
||||
// The SW only handles `push` + `notificationclick` — no asset caching —
|
||||
// so it's safe to register unconditionally as soon as the page boots.
|
||||
// Without registration, iOS PWAs cannot receive lock-screen notifications.
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
"serviceWorker" in navigator &&
|
||||
window.isSecureContext
|
||||
) {
|
||||
const base = import.meta.env.BASE_URL.endsWith("/")
|
||||
? import.meta.env.BASE_URL
|
||||
: `${import.meta.env.BASE_URL}/`;
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker
|
||||
.register(`${base}sw.js`, { scope: base })
|
||||
.catch(() => {
|
||||
/* SW registration is best-effort — push will just stay disabled */
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
|
||||
@@ -74,6 +74,9 @@ services:
|
||||
SMTP_PASS: ${SMTP_PASS:-}
|
||||
SMTP_FROM: ${SMTP_FROM:-}
|
||||
SMTP_SECURE: ${SMTP_SECURE:-}
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||
VAPID_SUBJECT: ${VAPID_SUBJECT:-}
|
||||
volumes:
|
||||
- app_storage:/app/storage
|
||||
|
||||
|
||||
@@ -1303,6 +1303,24 @@ export type DeleteServiceParams = {
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
export type GetPushVapidPublicKey200 = {
|
||||
publicKey: string;
|
||||
};
|
||||
|
||||
export type SubscribePushBodyKeys = {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
|
||||
export type SubscribePushBody = {
|
||||
endpoint: string;
|
||||
keys: SubscribePushBodyKeys;
|
||||
};
|
||||
|
||||
export type UnsubscribePushBody = {
|
||||
endpoint: string;
|
||||
};
|
||||
|
||||
export type ListUsersParams = {
|
||||
/**
|
||||
* Free-text search across username, email, and display names
|
||||
|
||||
@@ -64,6 +64,7 @@ import type {
|
||||
GetAdminUserDependentOrdersParams,
|
||||
GetAppPermissionAuditParams,
|
||||
GetGroupPermissionAuditParams,
|
||||
GetPushVapidPublicKey200,
|
||||
GetRolePermissionAuditParams,
|
||||
GetUserPermissionAuditParams,
|
||||
Group,
|
||||
@@ -106,7 +107,9 @@ import type {
|
||||
ServiceDeletionConflict,
|
||||
ServiceDependentOrdersPage,
|
||||
ServiceOrder,
|
||||
SubscribePushBody,
|
||||
SuccessResponse,
|
||||
UnsubscribePushBody,
|
||||
UpdateAppBody,
|
||||
UpdateAppSettingsBody,
|
||||
UpdateClockHour12Body,
|
||||
@@ -3573,6 +3576,253 @@ export const useMarkAllNotificationsRead = <
|
||||
return useMutation(getMarkAllNotificationsReadMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Get the server's VAPID public key for Web Push subscriptions
|
||||
*/
|
||||
export const getGetPushVapidPublicKeyUrl = () => {
|
||||
return `/api/push/vapid-public-key`;
|
||||
};
|
||||
|
||||
export const getPushVapidPublicKey = async (
|
||||
options?: RequestInit,
|
||||
): Promise<GetPushVapidPublicKey200> => {
|
||||
return customFetch<GetPushVapidPublicKey200>(getGetPushVapidPublicKeyUrl(), {
|
||||
...options,
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetPushVapidPublicKeyQueryKey = () => {
|
||||
return [`/api/push/vapid-public-key`] as const;
|
||||
};
|
||||
|
||||
export const getGetPushVapidPublicKeyQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getPushVapidPublicKey>>,
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPushVapidPublicKey>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}) => {
|
||||
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetPushVapidPublicKeyQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getPushVapidPublicKey>>
|
||||
> = ({ signal }) => getPushVapidPublicKey({ signal, ...requestOptions });
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPushVapidPublicKey>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetPushVapidPublicKeyQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getPushVapidPublicKey>>
|
||||
>;
|
||||
export type GetPushVapidPublicKeyQueryError = ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary Get the server's VAPID public key for Web Push subscriptions
|
||||
*/
|
||||
|
||||
export function useGetPushVapidPublicKey<
|
||||
TData = Awaited<ReturnType<typeof getPushVapidPublicKey>>,
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPushVapidPublicKey>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetPushVapidPublicKeyQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Register a Web Push subscription for the current user
|
||||
*/
|
||||
export const getSubscribePushUrl = () => {
|
||||
return `/api/push/subscribe`;
|
||||
};
|
||||
|
||||
export const subscribePush = async (
|
||||
subscribePushBody: SubscribePushBody,
|
||||
options?: RequestInit,
|
||||
): Promise<SuccessResponse> => {
|
||||
return customFetch<SuccessResponse>(getSubscribePushUrl(), {
|
||||
...options,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(subscribePushBody),
|
||||
});
|
||||
};
|
||||
|
||||
export const getSubscribePushMutationOptions = <
|
||||
TError = ErrorType<unknown>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof subscribePush>>,
|
||||
TError,
|
||||
{ data: BodyType<SubscribePushBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof subscribePush>>,
|
||||
TError,
|
||||
{ data: BodyType<SubscribePushBody> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["subscribePush"];
|
||||
const { mutation: mutationOptions, request: requestOptions } = options
|
||||
? options.mutation &&
|
||||
"mutationKey" in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey }, request: undefined };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof subscribePush>>,
|
||||
{ data: BodyType<SubscribePushBody> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return subscribePush(data, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type SubscribePushMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof subscribePush>>
|
||||
>;
|
||||
export type SubscribePushMutationBody = BodyType<SubscribePushBody>;
|
||||
export type SubscribePushMutationError = ErrorType<unknown>;
|
||||
|
||||
/**
|
||||
* @summary Register a Web Push subscription for the current user
|
||||
*/
|
||||
export const useSubscribePush = <
|
||||
TError = ErrorType<unknown>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof subscribePush>>,
|
||||
TError,
|
||||
{ data: BodyType<SubscribePushBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof subscribePush>>,
|
||||
TError,
|
||||
{ data: BodyType<SubscribePushBody> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getSubscribePushMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Remove a Web Push subscription for the current user
|
||||
*/
|
||||
export const getUnsubscribePushUrl = () => {
|
||||
return `/api/push/unsubscribe`;
|
||||
};
|
||||
|
||||
export const unsubscribePush = async (
|
||||
unsubscribePushBody: UnsubscribePushBody,
|
||||
options?: RequestInit,
|
||||
): Promise<SuccessResponse> => {
|
||||
return customFetch<SuccessResponse>(getUnsubscribePushUrl(), {
|
||||
...options,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(unsubscribePushBody),
|
||||
});
|
||||
};
|
||||
|
||||
export const getUnsubscribePushMutationOptions = <
|
||||
TError = ErrorType<unknown>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof unsubscribePush>>,
|
||||
TError,
|
||||
{ data: BodyType<UnsubscribePushBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof unsubscribePush>>,
|
||||
TError,
|
||||
{ data: BodyType<UnsubscribePushBody> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["unsubscribePush"];
|
||||
const { mutation: mutationOptions, request: requestOptions } = options
|
||||
? options.mutation &&
|
||||
"mutationKey" in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey }, request: undefined };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof unsubscribePush>>,
|
||||
{ data: BodyType<UnsubscribePushBody> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return unsubscribePush(data, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UnsubscribePushMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof unsubscribePush>>
|
||||
>;
|
||||
export type UnsubscribePushMutationBody = BodyType<UnsubscribePushBody>;
|
||||
export type UnsubscribePushMutationError = ErrorType<unknown>;
|
||||
|
||||
/**
|
||||
* @summary Remove a Web Push subscription for the current user
|
||||
*/
|
||||
export const useUnsubscribePush = <
|
||||
TError = ErrorType<unknown>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof unsubscribePush>>,
|
||||
TError,
|
||||
{ data: BodyType<UnsubscribePushBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof unsubscribePush>>,
|
||||
TError,
|
||||
{ data: BodyType<UnsubscribePushBody> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUnsubscribePushMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary List all users (admin)
|
||||
*/
|
||||
|
||||
@@ -972,6 +972,83 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SuccessResponse"
|
||||
|
||||
/push/vapid-public-key:
|
||||
get:
|
||||
operationId: getPushVapidPublicKey
|
||||
tags: [notifications]
|
||||
summary: Get the server's VAPID public key for Web Push subscriptions
|
||||
responses:
|
||||
"200":
|
||||
description: VAPID public key
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
publicKey:
|
||||
type: string
|
||||
required: [publicKey]
|
||||
"503":
|
||||
description: Web Push not configured on this server
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/push/subscribe:
|
||||
post:
|
||||
operationId: subscribePush
|
||||
tags: [notifications]
|
||||
summary: Register a Web Push subscription for the current user
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
endpoint:
|
||||
type: string
|
||||
keys:
|
||||
type: object
|
||||
properties:
|
||||
p256dh:
|
||||
type: string
|
||||
auth:
|
||||
type: string
|
||||
required: [p256dh, auth]
|
||||
required: [endpoint, keys]
|
||||
responses:
|
||||
"200":
|
||||
description: Subscribed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SuccessResponse"
|
||||
|
||||
/push/unsubscribe:
|
||||
post:
|
||||
operationId: unsubscribePush
|
||||
tags: [notifications]
|
||||
summary: Remove a Web Push subscription for the current user
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
endpoint:
|
||||
type: string
|
||||
required: [endpoint]
|
||||
responses:
|
||||
"200":
|
||||
description: Unsubscribed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SuccessResponse"
|
||||
|
||||
# Users (admin)
|
||||
/users:
|
||||
get:
|
||||
|
||||
@@ -1458,6 +1458,39 @@ export const MarkAllNotificationsReadResponse = zod.object({
|
||||
success: zod.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Get the server's VAPID public key for Web Push subscriptions
|
||||
*/
|
||||
export const GetPushVapidPublicKeyResponse = zod.object({
|
||||
publicKey: zod.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Register a Web Push subscription for the current user
|
||||
*/
|
||||
export const SubscribePushBody = zod.object({
|
||||
endpoint: zod.string(),
|
||||
keys: zod.object({
|
||||
p256dh: zod.string(),
|
||||
auth: zod.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const SubscribePushResponse = zod.object({
|
||||
success: zod.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Remove a Web Push subscription for the current user
|
||||
*/
|
||||
export const UnsubscribePushBody = zod.object({
|
||||
endpoint: zod.string(),
|
||||
});
|
||||
|
||||
export const UnsubscribePushResponse = zod.object({
|
||||
success: zod.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary List all users (admin)
|
||||
*/
|
||||
|
||||
@@ -14,3 +14,4 @@ export * from "./audit-logs";
|
||||
export * from "./role-audit";
|
||||
export * from "./permission-audit";
|
||||
export * from "./executive-meetings";
|
||||
export * from "./push-subscriptions";
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
pgTable,
|
||||
serial,
|
||||
integer,
|
||||
text,
|
||||
varchar,
|
||||
timestamp,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { usersTable } from "./users";
|
||||
|
||||
export const pushSubscriptionsTable = pgTable(
|
||||
"push_subscriptions",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.references(() => usersTable.id, { onDelete: "cascade" }),
|
||||
endpoint: text("endpoint").notNull().unique(),
|
||||
p256dh: text("p256dh").notNull(),
|
||||
auth: text("auth").notNull(),
|
||||
userAgent: varchar("user_agent", { length: 400 }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
userIdx: index("push_subscriptions_user_idx").on(t.userId),
|
||||
}),
|
||||
);
|
||||
|
||||
export type PushSubscriptionRow = typeof pushSubscriptionsTable.$inferSelect;
|
||||
Generated
+100
@@ -138,6 +138,9 @@ importers:
|
||||
socket.io:
|
||||
specifier: ^4.8.3
|
||||
version: 4.8.3
|
||||
web-push:
|
||||
specifier: ^3.6.7
|
||||
version: 3.6.7
|
||||
zod:
|
||||
specifier: 'catalog:'
|
||||
version: 3.25.76
|
||||
@@ -172,6 +175,9 @@ importers:
|
||||
'@types/sanitize-html':
|
||||
specifier: ^2.16.1
|
||||
version: 2.16.1
|
||||
'@types/web-push':
|
||||
specifier: ^3.6.4
|
||||
version: 3.6.4
|
||||
esbuild:
|
||||
specifier: ^0.27.3
|
||||
version: 0.27.3
|
||||
@@ -3002,6 +3008,9 @@ packages:
|
||||
'@types/use-sync-external-store@0.0.6':
|
||||
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
||||
|
||||
'@types/web-push@3.6.4':
|
||||
resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
@@ -3093,6 +3102,10 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
agent-base@7.1.4:
|
||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
ajv-draft-04@1.0.0:
|
||||
resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==}
|
||||
peerDependencies:
|
||||
@@ -3125,6 +3138,9 @@ packages:
|
||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
asn1.js@5.4.1:
|
||||
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
|
||||
|
||||
atomic-sleep@1.0.0:
|
||||
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
@@ -3155,6 +3171,9 @@ packages:
|
||||
bidi-js@1.0.3:
|
||||
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
|
||||
|
||||
bn.js@4.12.3:
|
||||
resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==}
|
||||
|
||||
body-parser@2.2.2:
|
||||
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3180,6 +3199,9 @@ packages:
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
hasBin: true
|
||||
|
||||
buffer-equal-constant-time@1.0.1:
|
||||
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
@@ -3508,6 +3530,9 @@ packages:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||
|
||||
ee-first@1.1.1:
|
||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||
|
||||
@@ -3812,6 +3837,14 @@ packages:
|
||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
http_ece@1.2.0:
|
||||
resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
human-signals@8.0.1:
|
||||
resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
@@ -3933,6 +3966,12 @@ packages:
|
||||
resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
jwa@2.0.1:
|
||||
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
|
||||
|
||||
jws@4.0.1:
|
||||
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
||||
|
||||
leven@4.1.0:
|
||||
resolution: {integrity: sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
@@ -4091,6 +4130,9 @@ packages:
|
||||
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
minimalistic-assert@1.0.1:
|
||||
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
|
||||
|
||||
minimatch@9.0.9:
|
||||
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
@@ -4942,6 +4984,11 @@ packages:
|
||||
w3c-keyname@2.2.8:
|
||||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||
|
||||
web-push@3.6.7:
|
||||
resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==}
|
||||
engines: {node: '>= 16'}
|
||||
hasBin: true
|
||||
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -7506,6 +7553,10 @@ snapshots:
|
||||
|
||||
'@types/use-sync-external-store@0.0.6': {}
|
||||
|
||||
'@types/web-push@3.6.4':
|
||||
dependencies:
|
||||
'@types/node': 25.3.5
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 25.3.5
|
||||
@@ -7615,6 +7666,8 @@ snapshots:
|
||||
|
||||
acorn@8.16.0: {}
|
||||
|
||||
agent-base@7.1.4: {}
|
||||
|
||||
ajv-draft-04@1.0.0(ajv@8.18.0):
|
||||
optionalDependencies:
|
||||
ajv: 8.18.0
|
||||
@@ -7642,6 +7695,13 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
asn1.js@5.4.1:
|
||||
dependencies:
|
||||
bn.js: 4.12.3
|
||||
inherits: 2.0.4
|
||||
minimalistic-assert: 1.0.1
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
atomic-sleep@1.0.0: {}
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
@@ -7660,6 +7720,8 @@ snapshots:
|
||||
dependencies:
|
||||
require-from-string: 2.0.2
|
||||
|
||||
bn.js@4.12.3: {}
|
||||
|
||||
body-parser@2.2.2:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
@@ -7700,6 +7762,8 @@ snapshots:
|
||||
node-releases: 2.0.36
|
||||
update-browserslist-db: 1.2.3(browserslist@4.28.1)
|
||||
|
||||
buffer-equal-constant-time@1.0.1: {}
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
bytes@3.1.2: {}
|
||||
@@ -7912,6 +7976,10 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
ee-first@1.1.1: {}
|
||||
|
||||
electron-to-chromium@1.5.307: {}
|
||||
@@ -8344,6 +8412,15 @@ snapshots:
|
||||
statuses: 2.0.2
|
||||
toidentifier: 1.0.1
|
||||
|
||||
http_ece@1.2.0: {}
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
human-signals@8.0.1: {}
|
||||
|
||||
i18next@26.0.6(typescript@5.9.3):
|
||||
@@ -8421,6 +8498,17 @@ snapshots:
|
||||
|
||||
jsonpointer@5.0.1: {}
|
||||
|
||||
jwa@2.0.1:
|
||||
dependencies:
|
||||
buffer-equal-constant-time: 1.0.1
|
||||
ecdsa-sig-formatter: 1.0.11
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
jws@4.0.1:
|
||||
dependencies:
|
||||
jwa: 2.0.1
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
leven@4.1.0: {}
|
||||
|
||||
lightningcss-android-arm64@1.31.1:
|
||||
@@ -8547,6 +8635,8 @@ snapshots:
|
||||
dependencies:
|
||||
mime-db: 1.54.0
|
||||
|
||||
minimalistic-assert@1.0.1: {}
|
||||
|
||||
minimatch@9.0.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.2
|
||||
@@ -9440,6 +9530,16 @@ snapshots:
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
|
||||
web-push@3.6.7:
|
||||
dependencies:
|
||||
asn1.js: 5.4.1
|
||||
http_ece: 1.2.0
|
||||
https-proxy-agent: 7.0.6
|
||||
jws: 4.0.1
|
||||
minimist: 1.2.8
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
||||
Reference in New Issue
Block a user