diff --git a/artifacts/api-server/package.json b/artifacts/api-server/package.json index 2996c78c..a62bdcb2 100644 --- a/artifacts/api-server/package.json +++ b/artifacts/api-server/package.json @@ -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", diff --git a/artifacts/api-server/src/lib/executive-meeting-notify.ts b/artifacts/api-server/src/lib/executive-meeting-notify.ts index 86840b71..6468f06d 100644 --- a/artifacts/api-server/src/lib/executive-meeting-notify.ts +++ b/artifacts/api-server/src/lib/executive-meeting-notify.ts @@ -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, diff --git a/artifacts/api-server/src/lib/push.ts b/artifacts/api-server/src/lib/push.ts new file mode 100644 index 00000000..c3d7f423 --- /dev/null +++ b/artifacts/api-server/src/lib/push.ts @@ -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 { + 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 { + 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 { + 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 { + await db + .delete(pushSubscriptionsTable) + .where( + and( + eq(pushSubscriptionsTable.userId, userId), + eq(pushSubscriptionsTable.endpoint, endpoint), + ), + ); +} diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index fca11a7f..10c62dd3 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -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; diff --git a/artifacts/api-server/src/routes/notes.ts b/artifacts/api-server/src/routes/notes.ts index 409080a4..dffe527a 100644 --- a/artifacts/api-server/src/routes/notes.ts +++ b/artifacts/api-server/src/routes/notes.ts @@ -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 => { }) .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 => }) .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. diff --git a/artifacts/api-server/src/routes/push.ts b/artifacts/api-server/src/routes/push.ts new file mode 100644 index 00000000..3aef111f --- /dev/null +++ b/artifacts/api-server/src/routes/push.ts @@ -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 => { + 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 => { + 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 => { + 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; diff --git a/artifacts/api-server/src/routes/service-orders.ts b/artifacts/api-server/src/routes/service-orders.ts index 701567b2..97d0f167 100644 --- a/artifacts/api-server/src/routes/service-orders.ts +++ b/artifacts/api-server/src/routes/service-orders.ts @@ -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[]) { diff --git a/artifacts/tx-os/public/sw.js b/artifacts/tx-os/public/sw.js new file mode 100644 index 00000000..f8137ac7 --- /dev/null +++ b/artifacts/tx-os/public/sw.js @@ -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); + } + })(), + ); +}); diff --git a/artifacts/tx-os/src/components/notification-settings.tsx b/artifacts/tx-os/src/components/notification-settings.tsx index 891d059c..3ce2870e 100644 --- a/artifacts/tx-os/src/components/notification-settings.tsx +++ b/artifacts/tx-os/src/components/notification-settings.tsx @@ -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() {
+ + +
+ @@ -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 ( +
+
+
+
+ {t("notifSettings.push.title")} +
+
+ {subtitle} +
+
+ +
+
+ ); +} + export function NotificationSettingsPicker() { const { t } = useTranslation(); const { user } = useAuth(); diff --git a/artifacts/tx-os/src/hooks/use-push-subscription.ts b/artifacts/tx-os/src/hooks/use-push-subscription.ts new file mode 100644 index 00000000..99b3ebda --- /dev/null +++ b/artifacts/tx-os/src/hooks/use-push-subscription.ts @@ -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 { + 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(() => + !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 => { + 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 => { + 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 }; +} diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 7dfa508e..fb104694 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -240,6 +240,16 @@ "autoplayHintTitle": "تحتاج الأصوات إلى نقرة", "autoplayHint": "انقر في أي مكان لتفعيل أصوات الإشعارات.", "autoplayHintIos": "اضغط في أي مكان لتفعيل الأصوات. إذا ما زلت لا تسمعها، تأكد إن مستوى صوت الوسائط مرفوع (نفس الصوت اللي يشغّل اليوتيوب).", + "push": { + "title": "إشعارات شاشة القفل", + "desc": "استقبل التنبيهات على الشاشة الرئيسية أو شاشة القفل حتى لو كان Tx مغلقاً.", + "enable": "تفعيل", + "disable": "إيقاف", + "enabled": "مفعّل", + "denied": "محظور — فعّله من إعدادات الجهاز", + "unsupported": "غير مدعوم على هذا الجهاز. على الآيباد، أضف Tx إلى الشاشة الرئيسية أولاً.", + "enableFailed": "تعذّر تفعيل الإشعارات. حاول مرة أخرى." + }, "slot": { "order": "الطلبات", "meeting": "الاجتماعات", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 339d4465..4f17524f 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -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", diff --git a/artifacts/tx-os/src/main.tsx b/artifacts/tx-os/src/main.tsx index 5670a847..f4addaf9 100644 --- a/artifacts/tx-os/src/main.tsx +++ b/artifacts/tx-os/src/main.tsx @@ -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(); diff --git a/docker-compose.yml b/docker-compose.yml index d7638a7c..2a3f3feb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 4a6ca5fa..98aa81c0 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -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 diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index be21d74c..fa2c7b17 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -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 => { + return customFetch(getGetPushVapidPublicKeyUrl(), { + ...options, + method: "GET", + }); +}; + +export const getGetPushVapidPublicKeyQueryKey = () => { + return [`/api/push/vapid-public-key`] as const; +}; + +export const getGetPushVapidPublicKeyQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>(options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetPushVapidPublicKeyQueryKey(); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => getPushVapidPublicKey({ signal, ...requestOptions }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type GetPushVapidPublicKeyQueryResult = NonNullable< + Awaited> +>; +export type GetPushVapidPublicKeyQueryError = ErrorType; + +/** + * @summary Get the server's VAPID public key for Web Push subscriptions + */ + +export function useGetPushVapidPublicKey< + TData = Awaited>, + TError = ErrorType, +>(options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; +}): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getGetPushVapidPublicKeyQueryOptions(options); + + const query = useQuery(queryOptions) as UseQueryResult & { + 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 => { + return customFetch(getSubscribePushUrl(), { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(subscribePushBody), + }); +}; + +export const getSubscribePushMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + 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>, + { data: BodyType } + > = (props) => { + const { data } = props ?? {}; + + return subscribePush(data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type SubscribePushMutationResult = NonNullable< + Awaited> +>; +export type SubscribePushMutationBody = BodyType; +export type SubscribePushMutationError = ErrorType; + +/** + * @summary Register a Web Push subscription for the current user + */ +export const useSubscribePush = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { data: BodyType }, + 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 => { + return customFetch(getUnsubscribePushUrl(), { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(unsubscribePushBody), + }); +}; + +export const getUnsubscribePushMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + 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>, + { data: BodyType } + > = (props) => { + const { data } = props ?? {}; + + return unsubscribePush(data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type UnsubscribePushMutationResult = NonNullable< + Awaited> +>; +export type UnsubscribePushMutationBody = BodyType; +export type UnsubscribePushMutationError = ErrorType; + +/** + * @summary Remove a Web Push subscription for the current user + */ +export const useUnsubscribePush = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { data: BodyType }, + TContext +> => { + return useMutation(getUnsubscribePushMutationOptions(options)); +}; + /** * @summary List all users (admin) */ diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index f44c9599..8c239106 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -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: diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 35f08cb8..b2e82727 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -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) */ diff --git a/lib/db/src/schema/index.ts b/lib/db/src/schema/index.ts index 2381f68d..25a67738 100644 --- a/lib/db/src/schema/index.ts +++ b/lib/db/src/schema/index.ts @@ -14,3 +14,4 @@ export * from "./audit-logs"; export * from "./role-audit"; export * from "./permission-audit"; export * from "./executive-meetings"; +export * from "./push-subscriptions"; diff --git a/lib/db/src/schema/push-subscriptions.ts b/lib/db/src/schema/push-subscriptions.ts new file mode 100644 index 00000000..887e4758 --- /dev/null +++ b/lib/db/src/schema/push-subscriptions.ts @@ -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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7375e6ac..790ea3f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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