Add bilingual support for notifications and automatic HTTPS

Implement bilingual text for push notifications, allowing users to receive them in their preferred language. Automatically configure HTTPS with Let's Encrypt for custom domains to ensure persistent subscriptions.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 94f06aee-a5cf-45df-979e-f940cce77214
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/yEFtMKN
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
riyadhafraa
2026-05-16 16:28:42 +00:00
parent d78818b88a
commit 55d19298e7
7 changed files with 317 additions and 23 deletions
@@ -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}`,
+81 -15
View File
@@ -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<boolean> {
async function loadUserPushPrefs(userId: number): Promise<UserPushPrefs | null> {
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<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 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(
+8
View File
@@ -1136,6 +1136,10 @@ router.post("/notes/:id/send", requireAuth, async (req, res): Promise<void> => {
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> =>
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}`,
@@ -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}`,
@@ -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, "أرسل لك ملاحظة");
});
+20 -5
View File
@@ -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
+54
View File
@@ -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
}
}