diff --git a/artifacts/api-server/src/lib/executive-meeting-notify.ts b/artifacts/api-server/src/lib/executive-meeting-notify.ts index 6468f06d..deddecc7 100644 --- a/artifacts/api-server/src/lib/executive-meeting-notify.ts +++ b/artifacts/api-server/src/lib/executive-meeting-notify.ts @@ -215,6 +215,10 @@ export async function broadcastExecutiveMeetingNotifications( void sendPushToUser(uid, { title: "تنبيه اجتماع", body: "لديك تنبيه اجتماع تنفيذي", + titleAr: "تنبيه اجتماع", + titleEn: "Meeting alert", + bodyAr: "لديك تنبيه اجتماع تنفيذي", + bodyEn: "You have an executive meeting alert", type: "executive_meeting", relatedId: meetingId, tag: meetingId ? `meeting-${meetingId}-${notificationType}` : `meeting-${notificationType}`, diff --git a/artifacts/api-server/src/lib/push.ts b/artifacts/api-server/src/lib/push.ts index 86fa6750..79da4154 100644 --- a/artifacts/api-server/src/lib/push.ts +++ b/artifacts/api-server/src/lib/push.ts @@ -116,9 +116,23 @@ export function getVapid() { return initPromise; } +/** + * Push payload. Two shapes are accepted so call sites can either: + * 1. Pass `title` + `body` directly (legacy / single-language), or + * 2. Pass `titleAr`+`titleEn` and `bodyAr`+`bodyEn` and let + * `sendPushToUser` pick the matching strings based on the + * recipient's `preferredLanguage` column. This way an English- + * preferring user no longer gets Arabic on their lock screen. + * + * When both shapes are provided, the bilingual fields win. + */ export type PushPayload = { title: string; body: string; + titleAr?: string; + titleEn?: string; + bodyAr?: string; + bodyEn?: string; tag?: string; url?: string; type?: string; @@ -127,33 +141,64 @@ export type PushPayload = { type ChannelType = "order" | "executive_meeting" | "note" | "info"; +type UserPushPrefs = { + muted: boolean; + orders: boolean; + meetings: boolean; + notes: boolean; + language: string; +}; + /** - * 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. + * Load the user's notification gating prefs AND their preferred UI + * language in a single round-trip. The language is used downstream to + * localise the push title/body when the caller supplied bilingual + * fields. Mirrors the client-side gating in `useNotificationsSocket`. */ -async function userAllowsChannel( - userId: number, - channel: ChannelType, -): Promise { +async function loadUserPushPrefs(userId: number): Promise { const [u] = await db .select({ muted: usersTable.notificationsMuted, orders: usersTable.notifyOrdersEnabled, meetings: usersTable.notifyMeetingsEnabled, notes: usersTable.notifyNotesEnabled, + language: usersTable.preferredLanguage, }) .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; + if (!u) return null; + return u; +} + +function userAllowsChannel(prefs: UserPushPrefs, channel: ChannelType): boolean { + if (prefs.muted) return false; + if (channel === "order") return prefs.orders; + if (channel === "executive_meeting") return prefs.meetings; + if (channel === "note") return prefs.notes; return true; } +/** + * Pick the title/body in the recipient's preferred language. Falls + * back to the legacy `title`/`body` fields when the caller didn't pass + * bilingual copies, and falls back to the other language when only one + * side is provided (so a half-localised emit site is still readable). + */ +function localisePayload( + payload: PushPayload, + language: string, +): { title: string; body: string } { + const preferEn = language === "en"; + const title = preferEn + ? payload.titleEn ?? payload.titleAr ?? payload.title + : payload.titleAr ?? payload.titleEn ?? payload.title; + const body = preferEn + ? payload.bodyEn ?? payload.bodyAr ?? payload.body + : payload.bodyAr ?? payload.bodyEn ?? payload.body; + return { title, body }; +} + /** * Send a Web Push notification to every active subscription a user * owns. Drops 404/410 subscriptions on the fly. Honours the user's @@ -192,8 +237,9 @@ export async function sendPushToUser( ): 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 prefs = await loadUserPushPrefs(userId); + if (!prefs) return; + if (!userAllowsChannel(prefs, channel)) return; // De-dup: if the user is online (any tab/device with an active socket) // they're already getting the in-app notification via Socket.IO, so @@ -210,12 +256,32 @@ export async function sendPushToUser( .where(eq(pushSubscriptionsTable.userId, userId)); if (subs.length === 0) return; + // Localise once, up-front: every subscription for this user is the + // same human, so they all get the same language. The bilingual + // fields (titleAr/titleEn/bodyAr/bodyEn) win over the legacy + // single-language ones when both are present. + const localised = localisePayload(payload, prefs.language); + // 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; + // Strip the bilingual fields from the wire payload — the SW only + // needs the resolved title/body — and override with the localised + // pair. This also keeps the payload small. + const { + titleAr: _ta, + titleEn: _te, + bodyAr: _ba, + bodyEn: _be, + ...rest + } = payload; + void _ta; + void _te; + void _ba; + void _be; + let safePayload: PushPayload = { ...rest, title: localised.title, body: localised.body }; let json = JSON.stringify(safePayload); if (Buffer.byteLength(json, "utf8") > MAX_BYTES) { const overhead = Buffer.byteLength( diff --git a/artifacts/api-server/src/routes/notes.ts b/artifacts/api-server/src/routes/notes.ts index dffe527a..595e6342 100644 --- a/artifacts/api-server/src/routes/notes.ts +++ b/artifacts/api-server/src/routes/notes.ts @@ -1136,6 +1136,10 @@ router.post("/notes/:id/send", requireAuth, async (req, res): Promise => { void sendPushToUser(r.recipientUserId, { title: "ملاحظة جديدة", body: `${senderNameAr} أرسل لك ملاحظة`, + titleAr: "ملاحظة جديدة", + titleEn: "New note", + bodyAr: `${senderNameAr} أرسل لك ملاحظة`, + bodyEn: `${senderName} sent you a note`, type: "note", relatedId: note.id, tag: `note-${note.id}`, @@ -1490,6 +1494,10 @@ router.post("/notes/:id/reply", requireAuth, async (req, res): Promise => void sendPushToUser(otherPartyUserId, { title: isOwner ? "رد جديد على ملاحظة" : "رد على ملاحظتك", body: `${replierNameAr} رد على ملاحظة`, + titleAr: isOwner ? "رد جديد على ملاحظة" : "رد على ملاحظتك", + titleEn: isOwner ? "New reply on a note" : "Reply to your note", + bodyAr: `${replierNameAr} رد على ملاحظة`, + bodyEn: `${replierName} replied on a note`, type: "note", relatedId: id.id, tag: `note-reply-${reply.id}`, diff --git a/artifacts/api-server/src/routes/service-orders.ts b/artifacts/api-server/src/routes/service-orders.ts index 97d0f167..8d5e87a6 100644 --- a/artifacts/api-server/src/routes/service-orders.ts +++ b/artifacts/api-server/src/routes/service-orders.ts @@ -137,6 +137,10 @@ async function notifyUser( void sendPushToUser(userId, { title: titleAr, body: bodyAr, + titleAr, + titleEn, + bodyAr, + bodyEn, type: "order", relatedId: orderId, tag: `order-${orderId}`, diff --git a/artifacts/api-server/tests/push-410-unit.test.ts b/artifacts/api-server/tests/push-410-unit.test.ts index 8905aa1c..be3c1ba6 100644 --- a/artifacts/api-server/tests/push-410-unit.test.ts +++ b/artifacts/api-server/tests/push-410-unit.test.ts @@ -26,7 +26,7 @@ if (!DATABASE_URL) { } // Track sendNotification calls and let each test rig the next response. -const sendCalls: Array<{ endpoint: string }> = []; +const sendCalls: Array<{ endpoint: string; payload: string }> = []; let nextThrow: { statusCode: number } | null = null; mock.module("web-push", { @@ -36,8 +36,8 @@ mock.module("web-push", { publicKey: "B".padEnd(87, "A"), privateKey: "C".padEnd(43, "A"), }), - sendNotification: async (sub: { endpoint: string }) => { - sendCalls.push({ endpoint: sub.endpoint }); + sendNotification: async (sub: { endpoint: string }, payload: string) => { + sendCalls.push({ endpoint: sub.endpoint, payload }); if (nextThrow) { const err: Error & { statusCode?: number } = new Error("stubbed"); err.statusCode = nextThrow.statusCode; @@ -182,3 +182,146 @@ test("non-410/404 errors leave the subscription intact", async () => { "transient 5xx must NOT delete the subscription", ); }); + +test("notificationsMuted=true silences every channel", async () => { + const userId = await createUser("pmute"); + const endpoint = `https://push.example.test/mute-${Date.now()}`; + await insertSub(userId, endpoint); + await pool.query( + `UPDATE users SET notifications_muted = true WHERE id = $1`, + [userId], + ); + + const before = sendCalls.length; + await sendPushToUser(userId, { + title: "t", + body: "b", + type: "note", + }); + + assert.equal( + sendCalls.length, + before, + "muted user must not trigger sendNotification at all", + ); + assert.equal( + await rowExists(endpoint), + true, + "muting must not delete the subscription", + ); +}); + +test("per-channel flag off skips that channel only", async () => { + const userId = await createUser("pchan"); + const endpoint = `https://push.example.test/chan-${Date.now()}`; + await insertSub(userId, endpoint); + await pool.query( + `UPDATE users SET notify_notes_enabled = false WHERE id = $1`, + [userId], + ); + + const before = sendCalls.length; + await sendPushToUser(userId, { + title: "t", + body: "b", + type: "note", + }); + assert.equal( + sendCalls.length, + before, + "notes-disabled user must not receive a note push", + ); + + // Same user, different channel — order push must still fire. + await sendPushToUser(userId, { + title: "t", + body: "b", + type: "order", + }); + assert.equal( + sendCalls.length, + before + 1, + "order channel must still fire when only notes is off", + ); +}); + +test("oversized body is truncated so the wire payload stays under 4096 bytes", async () => { + const userId = await createUser("ptrunc"); + const endpoint = `https://push.example.test/trunc-${Date.now()}`; + await insertSub(userId, endpoint); + + // 8000 bytes of ASCII — comfortably above the 3500-byte budget and + // way past the 4096-byte web-push cap, so truncation MUST kick in. + const huge = "x".repeat(8000); + await sendPushToUser(userId, { + title: "t", + body: huge, + type: "note", + }); + + const sent = sendCalls.at(-1); + assert.ok(sent, "send stub should have been called"); + const size = Buffer.byteLength(sent.payload, "utf8"); + assert.ok( + size < 4096, + `truncated payload must fit under 4096 bytes, got ${size}`, + ); + const parsed = JSON.parse(sent.payload) as { body: string }; + assert.ok( + parsed.body.length < huge.length, + "body must have been shortened", + ); +}); + +test("preferredLanguage=en picks the English title/body", async () => { + const userId = await createUser("plangen"); + await pool.query( + `UPDATE users SET preferred_language = 'en' WHERE id = $1`, + [userId], + ); + const endpoint = `https://push.example.test/langen-${Date.now()}`; + await insertSub(userId, endpoint); + + await sendPushToUser(userId, { + title: "ar-fallback", + body: "ar-fallback", + titleAr: "ملاحظة جديدة", + titleEn: "New note", + bodyAr: "أرسل لك ملاحظة", + bodyEn: "sent you a note", + type: "note", + }); + + const sent = sendCalls.at(-1); + assert.ok(sent, "send stub should have been called"); + const parsed = JSON.parse(sent.payload) as { title: string; body: string }; + assert.equal(parsed.title, "New note"); + assert.equal(parsed.body, "sent you a note"); +}); + +test("preferredLanguage=ar picks the Arabic title/body", async () => { + const userId = await createUser("plangar"); + // Default is already 'ar' but be explicit so the test documents intent. + await pool.query( + `UPDATE users SET preferred_language = 'ar' WHERE id = $1`, + [userId], + ); + const endpoint = `https://push.example.test/langar-${Date.now()}`; + await insertSub(userId, endpoint); + + await sendPushToUser(userId, { + title: "ignored", + body: "ignored", + titleAr: "ملاحظة جديدة", + titleEn: "New note", + bodyAr: "أرسل لك ملاحظة", + bodyEn: "sent you a note", + type: "note", + }); + + const sent = sendCalls.at(-1); + assert.ok(sent, "send stub should have been called"); + const parsed = JSON.parse(sent.payload) as { title: string; body: string }; + assert.equal(parsed.title, "ملاحظة جديدة"); + assert.equal(parsed.body, "أرسل لك ملاحظة"); +}); diff --git a/docker-compose.yml b/docker-compose.yml index 2a3f3feb..9b688a38 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -102,15 +102,29 @@ services: LOCAL_DOMAIN: ${LOCAL_DOMAIN:-tx.local} LOCAL_IP: ${LOCAL_IP:-127.0.0.1} HTTPS_MODE: ${HTTPS_MODE:-local} - # Pick the Caddyfile at boot. We auto-fall-back to the cert-free - # Caddyfile.skip when HTTPS_MODE=skip OR when /certs is missing - # the keypair, so a fresh `./start.sh` works on hosts that don't - # have mkcert without any extra wiring. + PUBLIC_DOMAIN: ${PUBLIC_DOMAIN:-} + ACME_EMAIL: ${ACME_EMAIL:-} + # Pick the Caddyfile at boot: + # * HTTPS_MODE=auto → Caddyfile.auto (Let's Encrypt for PUBLIC_DOMAIN) + # * HTTPS_MODE=skip → Caddyfile.skip (plain HTTP, dev only) + # * /certs missing → Caddyfile.skip (graceful fall-back) + # * otherwise → Caddyfile (BYO cert under /certs) entrypoint: - /bin/sh - -c - | - if [ "$${HTTPS_MODE}" = "skip" ] || [ ! -f /certs/local-cert.pem ] || [ ! -f /certs/local-key.pem ]; then + if [ "$${HTTPS_MODE}" = "auto" ]; then + if [ -z "$${PUBLIC_DOMAIN}" ]; then + echo "HTTPS_MODE=auto requires PUBLIC_DOMAIN to be set (e.g. tx.example.com)" >&2 + exit 1 + fi + if [ -n "$${ACME_EMAIL}" ]; then + export ACME_EMAIL_DIRECTIVE="email $${ACME_EMAIL}" + else + export ACME_EMAIL_DIRECTIVE="" + fi + exec caddy run --config /etc/caddy/Caddyfile.auto --adapter caddyfile + elif [ "$${HTTPS_MODE}" = "skip" ] || [ ! -f /certs/local-cert.pem ] || [ ! -f /certs/local-key.pem ]; then exec caddy run --config /etc/caddy/Caddyfile.skip --adapter caddyfile else exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile @@ -129,6 +143,7 @@ services: volumes: - ./docker/Caddyfile:/etc/caddy/Caddyfile:ro - ./docker/Caddyfile.skip:/etc/caddy/Caddyfile.skip:ro + - ./docker/Caddyfile.auto:/etc/caddy/Caddyfile.auto:ro - ./certs:/certs:ro - caddy_data:/data - caddy_config:/config diff --git a/docker/Caddyfile.auto b/docker/Caddyfile.auto new file mode 100644 index 00000000..e3fe5fb0 --- /dev/null +++ b/docker/Caddyfile.auto @@ -0,0 +1,54 @@ +# Tx OS — Caddy reverse proxy in HTTPS_MODE=auto. +# +# Issues + renews a real Let's Encrypt certificate for $PUBLIC_DOMAIN. +# Used when the operator points a stable custom domain (e.g. +# `tx.example.com`) at the box and wants automatic TLS — this keeps +# iPad Web Push subscriptions valid forever, because subscriptions +# are bound to the origin (host) they were created under and a stable +# hostname means they never get orphaned when Tailscale or the LAN IP +# changes. +# +# Requirements: +# - PUBLIC_DOMAIN env var must be set (e.g. tx.example.com). +# - DNS A/AAAA record for PUBLIC_DOMAIN points at this host. +# - Ports 80 + 443 are reachable from the public internet (ACME +# HTTP-01 / TLS-ALPN challenge). Behind a NAT/firewall you must +# forward both. If only 443 is reachable, switch ACME to DNS-01 +# by extending this file with a DNS provider plugin. +# - ACME_EMAIL env var SHOULD be set for renewal notifications. +# +# The site MUST stay single-origin so the API session cookie +# (sameSite=lax) keeps working across SPA + /api requests. + +{ +{$ACME_EMAIL_DIRECTIVE} +admin off +} + +(api_proxy) { +@websocket { +header Connection *Upgrade* +header Upgrade websocket +} +reverse_proxy /api/socket.io/* api:8080 +reverse_proxy /api/* api:8080 +} + +(spa_proxy) { +reverse_proxy web:80 +} + +# ---------- HTTPS site for the public custom domain ---------- +# Caddy auto-issues + renews the cert via ACME when this host is +# reached over HTTPS for the first time. +{$PUBLIC_DOMAIN} { +encode zstd gzip +import api_proxy +import spa_proxy +} + +# Plain HTTP → permanent redirect to HTTPS. Caddy still needs :80 +# bound so the ACME HTTP-01 challenge can be answered. +:80 { +redir https://{host}{uri} permanent +}