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:
riyadhafraa
2026-04-30 18:56:05 +00:00
parent 96993df361
commit fb2d75ecd7
8 changed files with 634 additions and 3 deletions
@@ -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

+12
View File
@@ -1322,6 +1322,18 @@
"sent": "أُرسل",
"skipped": "متخطى",
"failed": "فشل"
},
"prefs": {
"heading": "تفضيلات التنبيهات الخاصة بي",
"intro": "اختر الأحداث التي تريد تنبيهات داخل النظام لها وأيها يصلك عبر البريد. إيقاف المفتاح يكتم تلك القناة لذلك النوع من الأحداث فقط.",
"col": {
"event": "الحدث",
"inApp": "داخل النظام",
"email": "بريد إلكتروني"
},
"save": "حفظ التفضيلات",
"reset": "إعادة",
"saved": "تم حفظ التفضيلات"
}
}
}
+12
View File
@@ -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({
+47
View File
@@ -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",
{