diff --git a/artifacts/api-server/src/lib/executive-meeting-notify.ts b/artifacts/api-server/src/lib/executive-meeting-notify.ts index 873cbef2..dcee210f 100644 --- a/artifacts/api-server/src/lib/executive-meeting-notify.ts +++ b/artifacts/api-server/src/lib/executive-meeting-notify.ts @@ -1,9 +1,10 @@ import { createHash } from "node:crypto"; -import { eq, inArray } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import nodemailer, { type Transporter } from "nodemailer"; import { db } from "@workspace/db"; import { executiveMeetingNotificationsTable, + executiveMeetingNotificationPrefsTable, notificationsTable, rolesTable, userRolesTable, @@ -13,6 +14,75 @@ import { } from "@workspace/db"; import { logger } from "./logger"; +/** + * Canonical list of notification event types the Executive Meetings + * module fans out. Kept here (instead of in routes) because both the + * fan-out helpers and the preferences API need it. The preferences UI + * iterates this list to render one row per type. New event types must be + * appended here so users can opt in/out of them. + */ +export const EXECUTIVE_MEETING_NOTIFICATION_TYPES = [ + "meeting_created", + "request_submitted", + "request_approved", + "request_rejected", + "request_needs_edit", + "task_assigned", + "task_completed", +] as const; + +export type ExecutiveMeetingNotificationType = + (typeof EXECUTIVE_MEETING_NOTIFICATION_TYPES)[number]; + +export type ExecutiveMeetingNotificationChannel = "inApp" | "email"; + +/** + * Filter a list of recipient user ids down to those who have NOT + * explicitly opted out of the (notificationType, channel) pair. + * + * Default-on semantics: if a user has no preference row at all for + * this notificationType, they are kept in the list. Only an explicit + * `false` on the relevant column removes them. This preserves legacy + * behaviour for users who never visit the preferences UI. + * + * Recipient ids that fail validation (non-positive, non-integer) are + * also dropped, matching `recordExecutiveMeetingNotifications`. + */ +export async function filterRecipientsByNotificationPref( + recipientUserIds: ReadonlyArray, + notificationType: string, + channel: ExecutiveMeetingNotificationChannel, +): Promise { + const valid = Array.from( + new Set( + recipientUserIds.filter((id) => Number.isInteger(id) && id > 0), + ), + ); + if (valid.length === 0) return []; + const prefs = await db + .select({ + userId: executiveMeetingNotificationPrefsTable.userId, + inApp: executiveMeetingNotificationPrefsTable.inApp, + email: executiveMeetingNotificationPrefsTable.email, + }) + .from(executiveMeetingNotificationPrefsTable) + .where( + and( + inArray(executiveMeetingNotificationPrefsTable.userId, valid), + eq( + executiveMeetingNotificationPrefsTable.notificationType, + notificationType, + ), + ), + ); + const optedOut = new Set(); + for (const p of prefs) { + const allowed = channel === "inApp" ? p.inApp : p.email; + if (allowed === false) optedOut.add(p.userId); + } + return valid.filter((id) => !optedOut.has(id)); +} + type DbExecutor = | typeof db | Parameters[0]>[0]; @@ -56,13 +126,22 @@ export async function recordExecutiveMeetingNotifications( input: ExecMeetingNotificationInput, ): Promise { const exclude = input.excludeUserId ?? null; - const recipients = Array.from( + const candidates = Array.from( new Set( input.recipientUserIds.filter( (id) => Number.isInteger(id) && id > 0 && id !== exclude, ), ), ); + if (candidates.length === 0) return []; + + // Drop anyone who has explicitly muted the in-app channel for this + // event type. Users with no preference row stay in (default = on). + const recipients = await filterRecipientsByNotificationPref( + candidates, + input.notificationType, + "inApp", + ); if (recipients.length === 0) return []; const now = new Date(); @@ -266,6 +345,14 @@ export async function sendExecutiveMeetingEmail( ): Promise { if (recipientUserIds.length === 0) return; try { + // Drop anyone who has explicitly muted the email channel for this + // event type. Users with no preference row stay in (default = on). + const allowedIds = await filterRecipientsByNotificationPref( + recipientUserIds, + notificationType, + "email", + ); + if (allowedIds.length === 0) return; const recipients = await db .select({ id: usersTable.id, @@ -273,7 +360,7 @@ export async function sendExecutiveMeetingEmail( preferredLanguage: usersTable.preferredLanguage, }) .from(usersTable) - .where(inArray(usersTable.id, recipientUserIds)); + .where(inArray(usersTable.id, allowedIds)); const addressed = recipients.filter( (u) => typeof u.email === "string" && u.email.length > 0, ); diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 6453f899..afe4ad86 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -20,6 +20,7 @@ import { executiveMeetingRequestsTable, executiveMeetingTasksTable, executiveMeetingNotificationsTable, + executiveMeetingNotificationPrefsTable, executiveMeetingAuditLogsTable, executiveMeetingPdfArchivesTable, executiveMeetingFontSettingsTable, @@ -45,6 +46,7 @@ import { getUserIdsForRoleNames, sendExecutiveMeetingEmail, getUserDisplay as getUserDisplayForNotify, + EXECUTIVE_MEETING_NOTIFICATION_TYPES, } from "../lib/executive-meeting-notify"; import { ExecutiveMeetingsReorderBody } from "@workspace/api-zod"; import type { Request, Response, NextFunction } from "express"; @@ -2242,6 +2244,97 @@ router.get( }, ); +// ===================================================================== +// NOTIFICATION PREFERENCES (per-user, per-event-type) +// ===================================================================== + +// One row per (notificationType, channel) toggle. Missing rows mean +// "default on" — see executive_meeting_notification_prefs in the schema. +const notificationPrefEntrySchema = z.object({ + notificationType: z.enum(EXECUTIVE_MEETING_NOTIFICATION_TYPES), + inApp: z.boolean(), + email: z.boolean(), +}); + +const notificationPrefsBodySchema = z.object({ + prefs: z.array(notificationPrefEntrySchema).max(64), +}); + +// GET returns the current user's preferences merged with defaults so the +// frontend can render every event type as a toggle without having to +// know which rows exist. `types` echoes the canonical list so the UI +// stays in sync if a new event type is added on the server. +router.get( + "/executive-meetings/notification-prefs", + requireExecutiveAccess, + async (req, res): Promise => { + const userId = req.session.userId!; + const rows = await db + .select({ + notificationType: executiveMeetingNotificationPrefsTable.notificationType, + inApp: executiveMeetingNotificationPrefsTable.inApp, + email: executiveMeetingNotificationPrefsTable.email, + }) + .from(executiveMeetingNotificationPrefsTable) + .where(eq(executiveMeetingNotificationPrefsTable.userId, userId)); + const byType = new Map(rows.map((r) => [r.notificationType, r])); + const prefs = EXECUTIVE_MEETING_NOTIFICATION_TYPES.map((type) => { + const existing = byType.get(type); + return { + notificationType: type, + inApp: existing ? existing.inApp : true, + email: existing ? existing.email : true, + }; + }); + res.json({ + types: EXECUTIVE_MEETING_NOTIFICATION_TYPES, + prefs, + }); + }, +); + +// PUT replaces all preferences for the current user. We upsert each +// supplied row and leave any other rows untouched (the default-on +// fallback handles unspecified types). The request only carries +// preferences the user actually toggled, so this is a partial update. +router.put( + "/executive-meetings/notification-prefs", + requireExecutiveAccess, + async (req, res): Promise => { + const data = parseBody(res, notificationPrefsBodySchema, req.body); + if (!data) return; + const userId = req.session.userId!; + if (data.prefs.length === 0) { + res.json({ ok: true, count: 0 }); + return; + } + await db.transaction(async (tx) => { + for (const p of data.prefs) { + await tx + .insert(executiveMeetingNotificationPrefsTable) + .values({ + userId, + notificationType: p.notificationType, + inApp: p.inApp, + email: p.email, + }) + .onConflictDoUpdate({ + target: [ + executiveMeetingNotificationPrefsTable.userId, + executiveMeetingNotificationPrefsTable.notificationType, + ], + set: { + inApp: p.inApp, + email: p.email, + updatedAt: new Date(), + }, + }); + } + }); + res.json({ ok: true, count: data.prefs.length }); + }, +); + // ===================================================================== // PDF GENERATION (server-side) // ===================================================================== diff --git a/artifacts/api-server/tests/executive-meetings.test.mjs b/artifacts/api-server/tests/executive-meetings.test.mjs index c8ed90a8..38210df4 100644 --- a/artifacts/api-server/tests/executive-meetings.test.mjs +++ b/artifacts/api-server/tests/executive-meetings.test.mjs @@ -131,6 +131,13 @@ after(async () => { await pool.query(`DELETE FROM executive_meeting_requests WHERE requested_by = ANY($1::int[])`, [ created.userIds, ]); + // Notification prefs are owned by users with ON DELETE CASCADE, + // but be explicit so we don't rely on the cascade if the FK is ever + // changed. + await pool.query( + `DELETE FROM executive_meeting_notification_prefs WHERE user_id = ANY($1::int[])`, + [created.userIds], + ); await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [ created.userIds, ]); @@ -1322,3 +1329,172 @@ test("Transactional safety: a failing audit insert rolls back the parent DELETE" // Now clean up for real — register the id so afterAll deletes it. created.meetingIds.push(meetingId); }); + +test("Notification prefs: GET defaults every event type to in-app + email on", async () => { + // Use a brand-new coordinator so we know there are no pre-existing rows. + const fresh = await createUser("em_pref_a", "executive_coordinator"); + const cookie = await login(fresh.username, TEST_PASSWORD); + + const res = await api(cookie, "GET", "/api/executive-meetings/notification-prefs"); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(Array.isArray(body.types) && body.types.length > 0, + "response must include the canonical types list"); + assert.ok(Array.isArray(body.prefs) && body.prefs.length === body.types.length, + "response must return one merged pref per canonical type"); + for (const p of body.prefs) { + assert.equal(p.inApp, true, `default inApp must be true for ${p.notificationType}`); + assert.equal(p.email, true, `default email must be true for ${p.notificationType}`); + } +}); + +test("Notification prefs: PUT upserts and is reflected in subsequent GET", async () => { + const fresh = await createUser("em_pref_b", "executive_coordinator"); + const cookie = await login(fresh.username, TEST_PASSWORD); + + // Pick a known type from the canonical list returned by GET. + const initial = await (await api(cookie, "GET", + "/api/executive-meetings/notification-prefs")).json(); + const targetType = initial.types[0]; + + const put = await api(cookie, "PUT", + "/api/executive-meetings/notification-prefs", + { prefs: [{ notificationType: targetType, inApp: false, email: true }] }); + assert.equal(put.status, 200); + const putBody = await put.json(); + assert.equal(putBody.ok, true); + assert.equal(putBody.count, 1); + + const after = await (await api(cookie, "GET", + "/api/executive-meetings/notification-prefs")).json(); + const row = after.prefs.find((p) => p.notificationType === targetType); + assert.ok(row, "updated pref must come back in GET"); + assert.equal(row.inApp, false); + assert.equal(row.email, true); + + // Other types must remain at default (true / true). + for (const p of after.prefs) { + if (p.notificationType === targetType) continue; + assert.equal(p.inApp, true, `${p.notificationType} should remain default-on`); + assert.equal(p.email, true, `${p.notificationType} should remain default-on`); + } + + // Re-PUT the same type should overwrite (upsert), not duplicate. + const put2 = await api(cookie, "PUT", + "/api/executive-meetings/notification-prefs", + { prefs: [{ notificationType: targetType, inApp: true, email: false }] }); + assert.equal(put2.status, 200); + const after2 = await (await api(cookie, "GET", + "/api/executive-meetings/notification-prefs")).json(); + const row2 = after2.prefs.find((p) => p.notificationType === targetType); + assert.equal(row2.inApp, true); + assert.equal(row2.email, false); +}); + +test("Notification prefs: PUT rejects unknown notificationType with 400", async () => { + const fresh = await createUser("em_pref_c", "executive_coordinator"); + const cookie = await login(fresh.username, TEST_PASSWORD); + + const bad = await api(cookie, "PUT", + "/api/executive-meetings/notification-prefs", + { prefs: [{ notificationType: "totally_made_up", inApp: false, email: false }] }); + assert.equal(bad.status, 400); +}); + +test("Notification prefs: in-app fan-out skips users who muted that channel", async () => { + // request_submitted notifications fan out to APPROVE_ROLES (admin, + // executive_office_manager). Create two approver users — mute one, + // leave the other on — then submit a request and verify only the + // non-muted user receives a new in-app notification row. + const muted = await createUser("em_pref_muted_appr", "executive_office_manager"); + const control = await createUser("em_pref_control_appr", "executive_office_manager"); + const mutedCookie = await login(muted.username, TEST_PASSWORD); + + const muteRes = await api(mutedCookie, "PUT", + "/api/executive-meetings/notification-prefs", + { prefs: [{ notificationType: "request_submitted", inApp: false, email: false }] }); + assert.equal(muteRes.status, 200); + + async function countFor(userId) { + const r = await pool.query( + `SELECT COUNT(*)::int AS n + FROM executive_meeting_notifications + WHERE user_id = $1 + AND notification_type = 'request_submitted'`, + [userId], + ); + return r.rows[0].n; + } + + const beforeMuted = await countFor(muted.id); + const beforeControl = await countFor(control.id); + + const submit = await api(coordCookie, "POST", + "/api/executive-meetings/requests", + { requestType: "note", requestDetails: { note: "fanout-mute-test" } }); + assert.equal(submit.status, 201); + const submitted = await submit.json(); + created.requestIds.push(submitted.id); + + const afterMuted = await countFor(muted.id); + const afterControl = await countFor(control.id); + + assert.equal(afterMuted, beforeMuted, + "muted approver must not receive an in-app notification row"); + assert.ok(afterControl > beforeControl, + "non-muted approver must still receive an in-app notification row " + + `(before=${beforeControl}, after=${afterControl})`); +}); + +test("Notification prefs: channels are independent (email-only mute leaves in-app delivery intact)", async () => { + // Muting one channel must not affect the other. This complements the + // both-channels-off test above by exercising the email-channel filter + // path (sendExecutiveMeetingEmail uses filterRecipientsByNotificationPref + // with channel="email") while in-app stays on. + const approver = await createUser("em_pref_email_only", "executive_office_manager"); + const approverCookie = await login(approver.username, TEST_PASSWORD); + + // Email off, in-app on. + const muteEmail = await api(approverCookie, "PUT", + "/api/executive-meetings/notification-prefs", + { prefs: [{ notificationType: "request_submitted", inApp: true, email: false }] }); + assert.equal(muteEmail.status, 200); + + async function inAppCount(userId) { + const r = await pool.query( + `SELECT COUNT(*)::int AS n + FROM executive_meeting_notifications + WHERE user_id = $1 + AND notification_type = 'request_submitted'`, + [userId], + ); + return r.rows[0].n; + } + + const before = await inAppCount(approver.id); + const submit = await api(coordCookie, "POST", + "/api/executive-meetings/requests", + { requestType: "note", requestDetails: { note: "email-mute-only-test" } }); + assert.equal(submit.status, 201); + const submitted = await submit.json(); + created.requestIds.push(submitted.id); + + const after = await inAppCount(approver.id); + assert.ok(after > before, + "user who muted only the email channel must still receive in-app rows " + + `(before=${before}, after=${after})`); + + // Confirm the saved row really is email=false / in-app=true so that + // sendExecutiveMeetingEmail (which calls the same helper with + // channel="email") will see the explicit opt-out. + const { rows } = await pool.query( + `SELECT in_app, email + FROM executive_meeting_notification_prefs + WHERE user_id = $1 + AND notification_type = 'request_submitted'`, + [approver.id], + ); + assert.equal(rows.length, 1); + assert.equal(rows[0].in_app, true); + assert.equal(rows[0].email, false); +}); diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index efdba1fc..011a01ee 100644 Binary files a/artifacts/tx-os/public/opengraph.jpg and b/artifacts/tx-os/public/opengraph.jpg differ diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 97b54e82..9f8a5f04 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -1322,6 +1322,18 @@ "sent": "أُرسل", "skipped": "متخطى", "failed": "فشل" + }, + "prefs": { + "heading": "تفضيلات التنبيهات الخاصة بي", + "intro": "اختر الأحداث التي تريد تنبيهات داخل النظام لها وأيها يصلك عبر البريد. إيقاف المفتاح يكتم تلك القناة لذلك النوع من الأحداث فقط.", + "col": { + "event": "الحدث", + "inApp": "داخل النظام", + "email": "بريد إلكتروني" + }, + "save": "حفظ التفضيلات", + "reset": "إعادة", + "saved": "تم حفظ التفضيلات" } } } diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 164e68a7..c80e05ad 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -1223,6 +1223,18 @@ "sent": "Sent", "skipped": "Skipped", "failed": "Failed" + }, + "prefs": { + "heading": "My notification preferences", + "intro": "Choose which executive-meeting events ping you in-app and which ones email you. Turning a switch off mutes that channel for that event type only.", + "col": { + "event": "Event", + "inApp": "In-app", + "email": "Email" + }, + "save": "Save preferences", + "reset": "Reset", + "saved": "Preferences saved" } } } diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index f10ea6c6..13c34dfb 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -6042,6 +6042,9 @@ function NotificationsSection({

{t("executiveMeetings.notificationsPage.intro")}

+ + +
@@ -6105,6 +6108,207 @@ function NotificationsSection({ ); } +type NotificationPref = { + notificationType: string; + inApp: boolean; + email: boolean; +}; + +type NotificationPrefsResponse = { + types: string[]; + prefs: NotificationPref[]; +}; + +/** + * Per-user notification preferences card. Toggles in-app vs email + * delivery for each event type the Executive Meetings module fans out. + * + * Rendering rules: + * - One row per event type returned by the server (server is the + * source of truth for "which event types exist"). + * - Both channels default ON; an explicit off toggle persists a + * mute-row server-side so the fan-out helpers skip the user. + * - Saves are batched: we accumulate edits in local state and flush + * them together via "Save preferences". + */ +function NotificationPrefsCard({ + isRtl, + t, +}: { + isRtl: boolean; + t: (k: string) => string; +}) { + const qc = useQueryClient(); + const { toast } = useToast(); + const { data, isLoading } = useQuery({ + queryKey: ["/api/executive-meetings/notification-prefs"], + queryFn: () => + apiJson( + "/api/executive-meetings/notification-prefs", + ), + }); + const [draft, setDraft] = useState | null>( + null, + ); + // When the server payload arrives (or refetches), seed local edit state. + // We only seed when local state is null so that an in-flight edit isn't + // clobbered by a background refetch. + useEffect(() => { + if (!data || draft) return; + const m = new Map(); + for (const p of data.prefs) m.set(p.notificationType, { ...p }); + setDraft(m); + }, [data, draft]); + + const dirty = useMemo(() => { + if (!data || !draft) return false; + for (const p of data.prefs) { + const cur = draft.get(p.notificationType); + if (!cur) continue; + if (cur.inApp !== p.inApp || cur.email !== p.email) return true; + } + return false; + }, [data, draft]); + + function update(type: string, channel: "inApp" | "email", v: boolean) { + setDraft((prev) => { + if (!prev) return prev; + const next = new Map(prev); + const cur = next.get(type); + if (!cur) return prev; + next.set(type, { ...cur, [channel]: v }); + return next; + }); + } + + async function save() { + if (!draft) return; + const prefs = Array.from(draft.values()); + try { + await apiJson("/api/executive-meetings/notification-prefs", { + method: "PUT", + body: { prefs }, + }); + await qc.invalidateQueries({ + queryKey: ["/api/executive-meetings/notification-prefs"], + }); + // Clear local state so the next render reseeds from the freshly + // refetched payload — keeps client and server in lockstep. + setDraft(null); + toast({ title: t("executiveMeetings.notificationsPage.prefs.saved") }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + toast({ title: msg, variant: "destructive" }); + } + } + + function reset() { + if (!data) return; + const m = new Map(); + for (const p of data.prefs) m.set(p.notificationType, { ...p }); + setDraft(m); + } + + const types = data?.types ?? []; + + return ( +
+
+

+ {t("executiveMeetings.notificationsPage.prefs.heading")} +

+
+

+ {t("executiveMeetings.notificationsPage.prefs.intro")} +

+ {isLoading || !draft ? ( +
{t("common.loading")}
+ ) : ( + <> +
+
+ + + + + + + + + {types.map((type) => { + const cur = draft.get(type); + if (!cur) return null; + return ( + + + + + + ); + })} + +
+ {t("executiveMeetings.notificationsPage.prefs.col.event")} + + {t("executiveMeetings.notificationsPage.prefs.col.inApp")} + + {t("executiveMeetings.notificationsPage.prefs.col.email")} +
+ {tWithFallback( + t, + `executiveMeetings.notificationsPage.type.${type}`, + type, + )} + + update(type, "inApp", !!v)} + aria-label={t( + "executiveMeetings.notificationsPage.prefs.col.inApp", + )} + data-testid={`em-pref-inapp-${type}`} + /> + + update(type, "email", !!v)} + aria-label={t( + "executiveMeetings.notificationsPage.prefs.col.email", + )} + data-testid={`em-pref-email-${type}`} + /> +
+
+
+ + +
+ + )} + + ); +} + // ============ PDF / PRINT ============ function PdfSection({ diff --git a/lib/db/src/schema/executive-meetings.ts b/lib/db/src/schema/executive-meetings.ts index b5b785f1..ddf01c51 100644 --- a/lib/db/src/schema/executive-meetings.ts +++ b/lib/db/src/schema/executive-meetings.ts @@ -8,6 +8,7 @@ import { date, time, jsonb, + boolean, index, uniqueIndex, } from "drizzle-orm/pg-core"; @@ -163,6 +164,52 @@ export const executiveMeetingNotificationsTable = pgTable( }, ); +/** + * Per-user notification preferences for the Executive Meetings module. + * + * Each row toggles a single (userId, notificationType) pair on or off + * for the in-app bell channel and the email channel independently. + * + * Default behaviour when no row exists: BOTH channels ON. This keeps the + * legacy "fan out to everyone, every time" semantics intact for users + * who never visit the preferences UI. Only an explicit opt-out (row + * present + flag set to false) suppresses delivery. + * + * notificationType mirrors the keys passed to + * `recordExecutiveMeetingNotifications` / `sendExecutiveMeetingEmail`: + * - meeting_created + * - request_submitted + * - request_approved + * - request_rejected + * - request_needs_edit + * - task_assigned + * - task_completed + */ +export const executiveMeetingNotificationPrefsTable = pgTable( + "executive_meeting_notification_prefs", + { + id: serial("id").primaryKey(), + userId: integer("user_id") + .notNull() + .references(() => usersTable.id, { onDelete: "cascade" }), + notificationType: varchar("notification_type", { length: 64 }).notNull(), + inApp: boolean("in_app").notNull().default(true), + email: boolean("email").notNull().default(true), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (t) => ({ + userTypeUnique: uniqueIndex( + "executive_meeting_notification_prefs_user_type_unique", + ).on(t.userId, t.notificationType), + }), +); + export const executiveMeetingAuditLogsTable = pgTable( "executive_meeting_audit_logs", {