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:
@@ -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