Task #164: Per-user notification preferences for Executive Meetings
Lets each user choose whether to receive in-app and/or email notifications
for each executive-meeting event type (meeting_created, request_submitted,
request_approved/rejected/needs_edit, task_assigned, task_completed).
Defaults to "everything on" when no preference row exists, preserving the
prior fan-out behavior for users who never visit the new UI.
Schema:
- New executive_meeting_notification_prefs table (user_id FK CASCADE,
notification_type varchar(64), in_app bool default true, email bool
default true, plus a unique index on (user_id, notification_type)).
- Pushed to dev DB via `pnpm --filter @workspace/db push`.
Backend:
- Exported EXECUTIVE_MEETING_NOTIFICATION_TYPES (canonical list) +
filterRecipientsByNotificationPref(ids, type, channel) helper that
returns only recipients whose row says the channel is on (default-on
semantics for missing rows).
- recordExecutiveMeetingNotifications now filters recipients by
channel="inApp" before inserting; sendExecutiveMeetingEmail filters
by channel="email" before SMTP delivery.
- New endpoints under /executive-meetings/notification-prefs:
GET → { types, prefs } merged with defaults.
PUT → upserts each supplied (type, channel) pair via
onConflictDoUpdate inside a transaction.
Frontend:
- New NotificationPrefsCard at the top of the Notifications section in
artifacts/tx-os/src/pages/executive-meetings.tsx. Renders a Switch per
(event type × channel) with batched save, dirty-state tracking, reset
button, and useToast feedback.
- Translation keys for the card added to en.json and ar.json under
executiveMeetings.notificationsPage.prefs.
Tests:
- 5 new tests in artifacts/api-server/tests/executive-meetings.test.mjs:
GET defaults, PUT roundtrip + upsert, 400 on unknown type, in-app
fan-out filtering (muted approver gets no row, control approver still
does), and channel-independence (muting only the email channel leaves
in-app delivery intact while persisting email=false in the DB row that
sendExecutiveMeetingEmail's filter reads).
- All 36 executive-meetings tests pass. Full suite shows only one
pre-existing flaky test elsewhere (groups-crud count assertion),
unrelated to these changes.
- Added e2e UI test that logs in as admin, toggles a preference, saves,
refreshes, and confirms persistence.
- After-hook cleans up new prefs rows for created users.
Follow-ups proposed: #236 (one-click reset to defaults), #237 (admin
view/override of any user's prefs).
Replit-Task-Id: 284ce15d-40d7-447e-90ca-090b44d8227b
This commit is contained in:
@@ -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<number>,
|
||||
notificationType: string,
|
||||
channel: ExecutiveMeetingNotificationChannel,
|
||||
): Promise<number[]> {
|
||||
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<number>();
|
||||
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<Parameters<typeof db.transaction>[0]>[0];
|
||||
@@ -56,13 +126,22 @@ export async function recordExecutiveMeetingNotifications(
|
||||
input: ExecMeetingNotificationInput,
|
||||
): Promise<number[]> {
|
||||
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<void> {
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -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<void> => {
|
||||
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<void> => {
|
||||
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)
|
||||
// =====================================================================
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 25 KiB |
@@ -1322,6 +1322,18 @@
|
||||
"sent": "أُرسل",
|
||||
"skipped": "متخطى",
|
||||
"failed": "فشل"
|
||||
},
|
||||
"prefs": {
|
||||
"heading": "تفضيلات التنبيهات الخاصة بي",
|
||||
"intro": "اختر الأحداث التي تريد تنبيهات داخل النظام لها وأيها يصلك عبر البريد. إيقاف المفتاح يكتم تلك القناة لذلك النوع من الأحداث فقط.",
|
||||
"col": {
|
||||
"event": "الحدث",
|
||||
"inApp": "داخل النظام",
|
||||
"email": "بريد إلكتروني"
|
||||
},
|
||||
"save": "حفظ التفضيلات",
|
||||
"reset": "إعادة",
|
||||
"saved": "تم حفظ التفضيلات"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6042,6 +6042,9 @@ function NotificationsSection({
|
||||
<p className="text-sm text-gray-600 max-w-2xl">
|
||||
{t("executiveMeetings.notificationsPage.intro")}
|
||||
</p>
|
||||
|
||||
<NotificationPrefsCard isRtl={isRtl} t={t} />
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-md overflow-hidden">
|
||||
<table className="w-full text-sm" dir={isRtl ? "rtl" : "ltr"}>
|
||||
<thead className="bg-gray-50 text-[#0B1E3F]">
|
||||
@@ -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<NotificationPrefsResponse>({
|
||||
queryKey: ["/api/executive-meetings/notification-prefs"],
|
||||
queryFn: () =>
|
||||
apiJson<NotificationPrefsResponse>(
|
||||
"/api/executive-meetings/notification-prefs",
|
||||
),
|
||||
});
|
||||
const [draft, setDraft] = useState<Map<string, NotificationPref> | 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<string, NotificationPref>();
|
||||
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<string, NotificationPref>();
|
||||
for (const p of data.prefs) m.set(p.notificationType, { ...p });
|
||||
setDraft(m);
|
||||
}
|
||||
|
||||
const types = data?.types ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-white border border-gray-200 rounded-md p-4"
|
||||
dir={isRtl ? "rtl" : "ltr"}
|
||||
data-testid="em-notification-prefs"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 mb-1">
|
||||
<h3 className="text-sm sm:text-base font-semibold text-[#0B1E3F]">
|
||||
{t("executiveMeetings.notificationsPage.prefs.heading")}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mb-3 leading-relaxed">
|
||||
{t("executiveMeetings.notificationsPage.prefs.intro")}
|
||||
</p>
|
||||
{isLoading || !draft ? (
|
||||
<div className="py-4 text-sm text-gray-500">{t("common.loading")}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-gray-500">
|
||||
<tr>
|
||||
<th className="px-2 py-1 text-start font-medium">
|
||||
{t("executiveMeetings.notificationsPage.prefs.col.event")}
|
||||
</th>
|
||||
<th className="px-2 py-1 text-center font-medium w-24">
|
||||
{t("executiveMeetings.notificationsPage.prefs.col.inApp")}
|
||||
</th>
|
||||
<th className="px-2 py-1 text-center font-medium w-24">
|
||||
{t("executiveMeetings.notificationsPage.prefs.col.email")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{types.map((type) => {
|
||||
const cur = draft.get(type);
|
||||
if (!cur) return null;
|
||||
return (
|
||||
<tr key={type} className="border-t border-gray-100">
|
||||
<td className="px-2 py-2 text-[#0B1E3F]">
|
||||
{tWithFallback(
|
||||
t,
|
||||
`executiveMeetings.notificationsPage.type.${type}`,
|
||||
type,
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center">
|
||||
<Switch
|
||||
checked={cur.inApp}
|
||||
onCheckedChange={(v) => update(type, "inApp", !!v)}
|
||||
aria-label={t(
|
||||
"executiveMeetings.notificationsPage.prefs.col.inApp",
|
||||
)}
|
||||
data-testid={`em-pref-inapp-${type}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center">
|
||||
<Switch
|
||||
checked={cur.email}
|
||||
onCheckedChange={(v) => update(type, "email", !!v)}
|
||||
aria-label={t(
|
||||
"executiveMeetings.notificationsPage.prefs.col.email",
|
||||
)}
|
||||
data-testid={`em-pref-email-${type}`}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={save}
|
||||
disabled={!dirty}
|
||||
className="bg-[#0B1E3F]"
|
||||
data-testid="em-pref-save"
|
||||
>
|
||||
{t("executiveMeetings.notificationsPage.prefs.save")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={reset}
|
||||
disabled={!dirty}
|
||||
data-testid="em-pref-reset"
|
||||
>
|
||||
{t("executiveMeetings.notificationsPage.prefs.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ PDF / PRINT ============
|
||||
|
||||
function PdfSection({
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user