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:
@@ -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}`,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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, "أرسل لك ملاحظة");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user