#262: remove Requests / Approvals / Tasks tabs from Executive Meetings
Full-stack removal of the three retired sections — UI, locales, realtime
invalidations, backend routes, role lists, capability flags, schema
tables, notify lib, and tests.
Backend (artifacts/api-server)
- routes/executive-meetings.ts: deleted /requests* + /tasks* handler
block, REQUEST_ROLES / TASK_VIEW_ROLES / TASK_BROAD_VIEW_ROLES,
canSubmitRequest / canViewTasks / canViewAllTasks from /me, retired
table imports, and dead schemas (detailsByType, requestPayloadSchemas,
request*Schema, taskCreateSchema, taskPatchSchema, dueAtSchema,
dateOnly, timeHm). canApprove kept (still used by FontSettings).
- lib/executive-meeting-notify.ts: EXECUTIVE_MEETING_NOTIFICATION_TYPES
collapsed to ['meeting_created'].
Frontend (artifacts/tx-os)
- pages/executive-meetings.tsx: deleted RequestsSection /
ApprovalsSection / TasksSection / RequestListRow, pruned SECTIONS,
MeCapabilities / MeRoles types, isSectionVisible cases, icon imports.
- hooks/use-notifications-socket.ts: dropped the two retired query
invalidations.
- locales/{ar,en}.json: removed nav.{requests,approvals,tasks},
executiveMeetings.{requests,approvals,tasks} subtrees, and the 6
retired notification.type entries.
Schema + DB
- lib/db/src/schema/executive-meetings.ts: tables + relations + types
for requests/tasks removed.
- artifacts/api-server/scripts/cleanup-em-requests-tasks.sql:
idempotent BEGIN/COMMIT — deletes orphan prefs / notifications /
audit rows, then DROP TABLE … CASCADE for both retired tables.
Applied to dev DB and `db push` re-synced.
Tests
- executive-meetings.test.mjs: deleted 9 retired blocks + 2 covered
prefs duplicates, rewrote /me capability test to assert flags absent,
rewrote DELETE-wipe test to use meeting_created via POST
/api/executive-meetings, removed /requests + /tasks router.param
entries.
- executive-meetings-notifications.test.mjs: deleted 7 blocks
(request_*, task_*, cross-event-mute), updated before/after
cleanup to skip dropped tables, kept setPref/clearPref helpers
(still used by surviving meeting_created opt-out tests).
Drift / pre-existing
- 3 test failures observed under the parallel `node --test` workflow
(meeting_created fan-out count, pref opt-out daily-number conflict,
service-orders JSON-vs-HTML) are pre-existing parallel-file
pollution between executive-meetings.test.mjs and
executive-meetings-notifications.test.mjs. Verified by running
`node --test --test-concurrency=1 'tests/**/*.test.mjs'` →
226/226 pass. Out of scope for #262.
- Pre-existing tsc warnings at routes/executive-meetings.ts L509/625
(boolean/number on isHighlighted) and L1594 (font-settings scope
query) untouched.
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
-- #262: Executive Meetings Requests/Approvals/Tasks removal cleanup.
|
||||
--
|
||||
-- Drops the two retired tables and scrubs orphaned rows from the
|
||||
-- surviving Executive Meetings tables that referenced them by string.
|
||||
-- Idempotent: safe to re-run. Wraps everything in a single transaction
|
||||
-- so a partial failure leaves the DB unchanged.
|
||||
--
|
||||
-- Run order:
|
||||
-- 1) Apply this script to dev / prod databases.
|
||||
-- 2) Run `pnpm --filter @workspace/db push` to confirm the Drizzle
|
||||
-- schema and the database are in sync (should be a no-op).
|
||||
--
|
||||
-- Run command (dev):
|
||||
-- psql "$DATABASE_URL" -f artifacts/api-server/scripts/cleanup-em-requests-tasks.sql
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. Notification preferences rows for retired event types.
|
||||
DELETE FROM executive_meeting_notification_prefs
|
||||
WHERE notification_type IN (
|
||||
'request_submitted',
|
||||
'request_approved',
|
||||
'request_rejected',
|
||||
'request_needs_edit',
|
||||
'task_assigned',
|
||||
'task_completed'
|
||||
);
|
||||
|
||||
-- 2. Notifications rows for retired event types (executive-meeting domain only).
|
||||
DELETE FROM executive_meeting_notifications
|
||||
WHERE notification_type IN (
|
||||
'request_submitted',
|
||||
'request_approved',
|
||||
'request_rejected',
|
||||
'request_needs_edit',
|
||||
'task_assigned',
|
||||
'task_completed'
|
||||
);
|
||||
|
||||
-- 3. Audit logs that targeted request/task entities.
|
||||
DELETE FROM executive_meeting_audit_logs
|
||||
WHERE entity_type IN ('request', 'task');
|
||||
|
||||
-- 4. Drop the retired tables themselves. CASCADE removes any FK indices
|
||||
-- that referenced them; nothing in the surviving schema depends on
|
||||
-- these two tables.
|
||||
DROP TABLE IF EXISTS executive_meeting_tasks CASCADE;
|
||||
DROP TABLE IF EXISTS executive_meeting_requests CASCADE;
|
||||
|
||||
COMMIT;
|
||||
@@ -21,14 +21,11 @@ import { logger } from "./logger";
|
||||
* iterates this list to render one row per type. New event types must be
|
||||
* appended here so users can opt in/out of them.
|
||||
*/
|
||||
// #262: collapsed to a single event after Requests/Approvals/Tasks were
|
||||
// removed. Old types (request_*, task_*) are scrubbed from the prefs and
|
||||
// notifications tables by cleanup-em-requests-tasks.sql.
|
||||
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 =
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,12 +15,11 @@ const TEST_PASSWORD = "TestPass123!";
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
|
||||
// #262: requestIds + taskIds dropped with their tables.
|
||||
const created = {
|
||||
userIds: [],
|
||||
groupIds: [],
|
||||
meetingIds: [],
|
||||
requestIds: [],
|
||||
taskIds: [],
|
||||
};
|
||||
|
||||
function uniqueName(prefix) {
|
||||
@@ -271,18 +270,8 @@ after(async () => {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (created.taskIds.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM executive_meeting_tasks WHERE id = ANY($1::int[])`,
|
||||
[created.taskIds],
|
||||
);
|
||||
}
|
||||
if (created.requestIds.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM executive_meeting_requests WHERE id = ANY($1::int[])`,
|
||||
[created.requestIds],
|
||||
);
|
||||
}
|
||||
// #262: executive_meeting_tasks + executive_meeting_requests cleanup
|
||||
// removed alongside the tables themselves.
|
||||
if (created.meetingIds.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`,
|
||||
@@ -290,7 +279,7 @@ after(async () => {
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM executive_meeting_audit_logs WHERE entity_id = ANY($1::int[])
|
||||
AND entity_type IN ('meeting','request','task')`,
|
||||
AND entity_type = 'meeting'`,
|
||||
[created.meetingIds],
|
||||
);
|
||||
await pool.query(
|
||||
@@ -303,14 +292,8 @@ after(async () => {
|
||||
);
|
||||
}
|
||||
if (created.userIds.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM executive_meeting_tasks WHERE assigned_to = ANY($1::int[])`,
|
||||
[created.userIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM executive_meeting_requests WHERE requested_by = ANY($1::int[])`,
|
||||
[created.userIds],
|
||||
);
|
||||
// #262: executive_meeting_tasks + executive_meeting_requests cleanup
|
||||
// removed alongside the tables themselves.
|
||||
await pool.query(
|
||||
`DELETE FROM executive_meeting_notifications WHERE user_id = ANY($1::int[])`,
|
||||
[created.userIds],
|
||||
@@ -519,214 +502,12 @@ test("meeting_created: fan-out excludes actor, dedupes direct+group, writes both
|
||||
expectNoSocketEventFor(coord2Sock, "meeting_created");
|
||||
});
|
||||
|
||||
test("request_submitted: notifies approvers (deduped + actor excluded), writes both tables, emits sockets", async () => {
|
||||
for (const s of [approver1Sock, approver2Sock, coord1Sock, coord2Sock]) {
|
||||
clearSocketEvents(s);
|
||||
}
|
||||
const before = await snapshotMaxIds();
|
||||
|
||||
const create = await api(coord1.cookie, "POST", "/api/executive-meetings/requests", {
|
||||
requestType: "note",
|
||||
requestDetails: { note: "fan-out test" },
|
||||
});
|
||||
assert.equal(create.status, 201);
|
||||
const request = await create.json();
|
||||
created.requestIds.push(request.id);
|
||||
|
||||
await waitFor(
|
||||
() =>
|
||||
approver1Sock.events.created.some(
|
||||
(p) => p.notificationType === "request_submitted",
|
||||
) &&
|
||||
approver2Sock.events.created.some(
|
||||
(p) => p.notificationType === "request_submitted",
|
||||
),
|
||||
{ timeoutMs: 2000 },
|
||||
);
|
||||
|
||||
const diff = scopeDiff(await newRowsAfter(before), {
|
||||
notificationType: "request_submitted",
|
||||
relatedType: "executive_meeting_request",
|
||||
relatedId: request.id,
|
||||
});
|
||||
assertRecipientGotOneOfEach(diff, approver1.id, {
|
||||
notificationType: "request_submitted",
|
||||
relatedType: "executive_meeting_request",
|
||||
relatedId: request.id,
|
||||
});
|
||||
assertRecipientGotOneOfEach(diff, approver2.id, {
|
||||
notificationType: "request_submitted",
|
||||
relatedType: "executive_meeting_request",
|
||||
relatedId: request.id,
|
||||
});
|
||||
assertActorExcluded(diff, coord1.id);
|
||||
|
||||
await expectSocketEventsFor(
|
||||
[approver1Sock, approver2Sock],
|
||||
"request_submitted",
|
||||
null,
|
||||
{ expectExactlyOne: true },
|
||||
);
|
||||
expectNoSocketEventFor(coord1Sock, "request_submitted");
|
||||
});
|
||||
|
||||
// Each review variant needs its own request still in 'new' status — the
|
||||
// route refuses to re-review. Submit as coord1, review as admin.
|
||||
async function runReviewTest(reviewStatus) {
|
||||
const submit = await api(coord1.cookie, "POST", "/api/executive-meetings/requests", {
|
||||
requestType: "note",
|
||||
requestDetails: { note: `review-test-${reviewStatus}` },
|
||||
});
|
||||
assert.equal(submit.status, 201);
|
||||
const request = await submit.json();
|
||||
created.requestIds.push(request.id);
|
||||
|
||||
for (const s of [approver1Sock, approver2Sock, coord1Sock, coord2Sock]) {
|
||||
clearSocketEvents(s);
|
||||
}
|
||||
const before = await snapshotMaxIds();
|
||||
|
||||
const review = await api(
|
||||
adminCookie,
|
||||
"PATCH",
|
||||
`/api/executive-meetings/requests/${request.id}`,
|
||||
{ status: reviewStatus, reviewNotes: "fan-out test" },
|
||||
);
|
||||
assert.equal(review.status, 200, `review ${reviewStatus} should succeed`);
|
||||
|
||||
const expectedType = `request_${reviewStatus}`;
|
||||
await waitFor(
|
||||
() =>
|
||||
coord1Sock.events.created.some(
|
||||
(p) => p.notificationType === expectedType,
|
||||
),
|
||||
{ timeoutMs: 2000 },
|
||||
);
|
||||
|
||||
const diff = scopeDiff(await newRowsAfter(before), {
|
||||
notificationType: expectedType,
|
||||
relatedType: "executive_meeting_request",
|
||||
relatedId: request.id,
|
||||
});
|
||||
assertRecipientGotOneOfEach(diff, coord1.id, {
|
||||
notificationType: expectedType,
|
||||
relatedType: "executive_meeting_request",
|
||||
relatedId: request.id,
|
||||
});
|
||||
assertActorExcluded(diff, adminUserId);
|
||||
assert.equal(rowsForUser(diff.emn, approver1.id).length, 0);
|
||||
assert.equal(rowsForUser(diff.emn, approver2.id).length, 0);
|
||||
|
||||
await expectSocketEventsFor([coord1Sock], expectedType, null);
|
||||
}
|
||||
|
||||
test("request_approved: notifies the requester only, writes both tables, emits sockets", async () => {
|
||||
await runReviewTest("approved");
|
||||
});
|
||||
|
||||
test("request_rejected: notifies the requester only, writes both tables, emits sockets", async () => {
|
||||
await runReviewTest("rejected");
|
||||
});
|
||||
|
||||
test("request_needs_edit: notifies the requester only, writes both tables, emits sockets", async () => {
|
||||
await runReviewTest("needs_edit");
|
||||
});
|
||||
|
||||
test("task_assigned: notifies the assignee, excludes actor, writes both tables, emits sockets", async () => {
|
||||
for (const s of [approver1Sock, approver2Sock, coord1Sock, coord2Sock]) {
|
||||
clearSocketEvents(s);
|
||||
}
|
||||
const before = await snapshotMaxIds();
|
||||
|
||||
const create = await api(adminCookie, "POST", "/api/executive-meetings/tasks", {
|
||||
taskType: "follow_up",
|
||||
assignedTo: coord2.id,
|
||||
notes: "fan-out task",
|
||||
});
|
||||
assert.equal(create.status, 201);
|
||||
const task = await create.json();
|
||||
created.taskIds.push(task.id);
|
||||
|
||||
await waitFor(
|
||||
() =>
|
||||
coord2Sock.events.created.some(
|
||||
(p) => p.notificationType === "task_assigned",
|
||||
),
|
||||
{ timeoutMs: 2000 },
|
||||
);
|
||||
|
||||
const diff = scopeDiff(await newRowsAfter(before), {
|
||||
notificationType: "task_assigned",
|
||||
relatedType: "executive_meeting_task",
|
||||
relatedId: task.id,
|
||||
});
|
||||
assertRecipientGotOneOfEach(diff, coord2.id, {
|
||||
notificationType: "task_assigned",
|
||||
relatedType: "executive_meeting_task",
|
||||
relatedId: task.id,
|
||||
});
|
||||
assertActorExcluded(diff, adminUserId);
|
||||
assert.equal(rowsForUser(diff.emn, coord1.id).length, 0);
|
||||
|
||||
await expectSocketEventsFor([coord2Sock], "task_assigned", null);
|
||||
expectNoSocketEventFor(coord1Sock, "task_assigned");
|
||||
});
|
||||
|
||||
test("task_completed: notifies the prior assignee, excludes actor, writes both tables, emits sockets", async () => {
|
||||
const create = await api(adminCookie, "POST", "/api/executive-meetings/tasks", {
|
||||
taskType: "follow_up",
|
||||
assignedTo: coord2.id,
|
||||
notes: "for completion fan-out",
|
||||
});
|
||||
assert.equal(create.status, 201);
|
||||
const task = await create.json();
|
||||
created.taskIds.push(task.id);
|
||||
|
||||
// Drain the task_assigned events from setup before snapshotting.
|
||||
await waitFor(
|
||||
() =>
|
||||
coord2Sock.events.created.some(
|
||||
(p) => p.notificationType === "task_assigned",
|
||||
),
|
||||
{ timeoutMs: 2000 },
|
||||
);
|
||||
for (const s of [approver1Sock, approver2Sock, coord1Sock, coord2Sock]) {
|
||||
clearSocketEvents(s);
|
||||
}
|
||||
const before = await snapshotMaxIds();
|
||||
|
||||
const complete = await api(
|
||||
adminCookie,
|
||||
"PATCH",
|
||||
`/api/executive-meetings/tasks/${task.id}`,
|
||||
{ status: "completed" },
|
||||
);
|
||||
assert.equal(complete.status, 200);
|
||||
|
||||
await waitFor(
|
||||
() =>
|
||||
coord2Sock.events.created.some(
|
||||
(p) => p.notificationType === "task_completed",
|
||||
),
|
||||
{ timeoutMs: 2000 },
|
||||
);
|
||||
|
||||
const diff = scopeDiff(await newRowsAfter(before), {
|
||||
notificationType: "task_completed",
|
||||
relatedType: "executive_meeting_task",
|
||||
relatedId: task.id,
|
||||
});
|
||||
assertRecipientGotOneOfEach(diff, coord2.id, {
|
||||
notificationType: "task_completed",
|
||||
relatedType: "executive_meeting_task",
|
||||
relatedId: task.id,
|
||||
});
|
||||
assertActorExcluded(diff, adminUserId);
|
||||
assert.equal(rowsForUser(diff.emn, coord1.id).length, 0);
|
||||
assert.equal(rowsForUser(diff.emn, approver1.id).length, 0);
|
||||
|
||||
await expectSocketEventsFor([coord2Sock], "task_completed", null);
|
||||
});
|
||||
// #262: removed test block previously at L522-622 (Requests/Tasks event type).
|
||||
// #262: removed test block previously at L623-626 (Requests/Tasks event type).
|
||||
// #262: removed test block previously at L627-630 (Requests/Tasks event type).
|
||||
// #262: removed test block previously at L631-634 (Requests/Tasks event type).
|
||||
// #262: removed test block previously at L635-674 (Requests/Tasks event type).
|
||||
// #262: removed test block previously at L675-754 (Requests/Tasks event type).
|
||||
|
||||
// #238: opt-out coverage for filterRecipientsByNotificationPref. The HTTP
|
||||
// surface gives us black-box access — set a pref row, trigger the event,
|
||||
@@ -852,52 +633,7 @@ test("pref opt-out: missing pref row defaults to ON (user receives delivery)", a
|
||||
});
|
||||
});
|
||||
|
||||
test("pref opt-out: inApp=false on event A does NOT mute event B", async () => {
|
||||
// Approver1 mutes meeting_created but is still an approver who should
|
||||
// receive request_submitted. Verifies the filter is keyed on event type.
|
||||
await setPref(approver1.id, "meeting_created", { inApp: false, email: true });
|
||||
try {
|
||||
for (const s of [approver1Sock, approver2Sock, coord1Sock]) {
|
||||
clearSocketEvents(s);
|
||||
}
|
||||
const before = await snapshotMaxIds();
|
||||
|
||||
const create = await api(
|
||||
coord1.cookie,
|
||||
"POST",
|
||||
"/api/executive-meetings/requests",
|
||||
{
|
||||
requestType: "note",
|
||||
requestDetails: { note: "cross-event opt-out" },
|
||||
},
|
||||
);
|
||||
assert.equal(create.status, 201);
|
||||
const request = await create.json();
|
||||
created.requestIds.push(request.id);
|
||||
|
||||
await waitFor(
|
||||
() =>
|
||||
approver1Sock.events.created.some(
|
||||
(p) => p.notificationType === "request_submitted",
|
||||
),
|
||||
{ timeoutMs: 2000 },
|
||||
);
|
||||
|
||||
const diff = scopeDiff(await newRowsAfter(before), {
|
||||
notificationType: "request_submitted",
|
||||
relatedType: "executive_meeting_request",
|
||||
relatedId: request.id,
|
||||
});
|
||||
assertRecipientGotOneOfEach(diff, approver1.id, {
|
||||
notificationType: "request_submitted",
|
||||
relatedType: "executive_meeting_request",
|
||||
relatedId: request.id,
|
||||
});
|
||||
} finally {
|
||||
await clearPref(approver1.id, "meeting_created");
|
||||
}
|
||||
});
|
||||
|
||||
// #262: removed test block previously at L855-900 (Requests/Tasks event type).
|
||||
test("pref opt-out: email=false does NOT affect the in-app channel", async () => {
|
||||
// Channel independence — muting email must leave in-app delivery alone.
|
||||
await setPref(approver1.id, "meeting_created", { inApp: true, email: false });
|
||||
|
||||
@@ -14,11 +14,10 @@ const TEST_PASSWORD = "TestPass123!";
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
|
||||
// #262: requestIds + taskIds dropped with their tables.
|
||||
const created = {
|
||||
userIds: [],
|
||||
meetingIds: [],
|
||||
requestIds: [],
|
||||
taskIds: [],
|
||||
};
|
||||
|
||||
function uniqueName(prefix) {
|
||||
@@ -103,21 +102,13 @@ before(async () => {
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
if (created.taskIds.length > 0) {
|
||||
await pool.query(`DELETE FROM executive_meeting_tasks WHERE id = ANY($1::int[])`, [
|
||||
created.taskIds,
|
||||
]);
|
||||
}
|
||||
if (created.requestIds.length > 0) {
|
||||
await pool.query(`DELETE FROM executive_meeting_requests WHERE id = ANY($1::int[])`, [
|
||||
created.requestIds,
|
||||
]);
|
||||
}
|
||||
// #262: executive_meeting_tasks + executive_meeting_requests cleanup
|
||||
// removed alongside the tables themselves.
|
||||
if (created.meetingIds.length > 0) {
|
||||
await pool.query(`DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`, [
|
||||
created.meetingIds,
|
||||
]);
|
||||
await pool.query(`DELETE FROM executive_meeting_audit_logs WHERE entity_id = ANY($1::int[]) AND entity_type IN ('meeting','request','task')`, [
|
||||
await pool.query(`DELETE FROM executive_meeting_audit_logs WHERE entity_id = ANY($1::int[]) AND entity_type = 'meeting'`, [
|
||||
created.meetingIds,
|
||||
]);
|
||||
await pool.query(`DELETE FROM executive_meetings WHERE id = ANY($1::int[])`, [
|
||||
@@ -125,12 +116,6 @@ after(async () => {
|
||||
]);
|
||||
}
|
||||
if (created.userIds.length > 0) {
|
||||
await pool.query(`DELETE FROM executive_meeting_tasks WHERE assigned_to = ANY($1::int[])`, [
|
||||
created.userIds,
|
||||
]);
|
||||
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.
|
||||
@@ -153,7 +138,10 @@ const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
|
||||
test("GET /me exposes capability flags including canViewAllTasks", async () => {
|
||||
// #262: canSubmitRequest, canViewTasks, canViewAllTasks dropped from /me
|
||||
// when the Requests/Approvals/Tasks tabs were removed. The surviving
|
||||
// flags are canRead, canMutate, canApprove, canViewAudit.
|
||||
test("GET /me exposes the surviving capability flags", async () => {
|
||||
const res = await api(adminCookie, "GET", "/api/executive-meetings/me");
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
@@ -163,19 +151,20 @@ test("GET /me exposes capability flags including canViewAllTasks", async () => {
|
||||
"canRead",
|
||||
"canMutate",
|
||||
"canApprove",
|
||||
"canSubmitRequest",
|
||||
"canViewAudit",
|
||||
"canViewTasks",
|
||||
"canViewAllTasks",
|
||||
]) {
|
||||
assert.ok(key in body, `missing ${key} in /me response`);
|
||||
}
|
||||
assert.equal(body.canViewAllTasks, true);
|
||||
// The retired flags must not leak back in.
|
||||
for (const removed of ["canSubmitRequest", "canViewTasks", "canViewAllTasks"]) {
|
||||
assert.ok(!(removed in body), `unexpected ${removed} in /me response`);
|
||||
}
|
||||
assert.equal(body.canApprove, true);
|
||||
|
||||
const coordRes = await api(coordCookie, "GET", "/api/executive-meetings/me");
|
||||
const coordMe = await coordRes.json();
|
||||
assert.equal(coordMe.canViewTasks, true);
|
||||
assert.equal(coordMe.canViewAllTasks, false);
|
||||
assert.equal(coordMe.canRead, true);
|
||||
assert.equal(coordMe.canApprove, false);
|
||||
});
|
||||
|
||||
test("Meetings: bilingual title required (titleEn cannot be empty)", async () => {
|
||||
@@ -606,180 +595,11 @@ test("Sanitization: duplicate path re-sanitizes location/meetingUrl/notes and pr
|
||||
);
|
||||
});
|
||||
|
||||
test("Sanitization: change_location approved request round-trips URL ampersands and strips tags", async () => {
|
||||
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "ج",
|
||||
titleEn: "J",
|
||||
meetingDate: today,
|
||||
location: "Old Room",
|
||||
meetingUrl: null,
|
||||
attendees: [],
|
||||
});
|
||||
assert.equal(create.status, 201);
|
||||
const meeting = await create.json();
|
||||
created.meetingIds.push(meeting.id);
|
||||
|
||||
const reqRes = await api(
|
||||
adminCookie,
|
||||
"POST",
|
||||
"/api/executive-meetings/requests",
|
||||
{
|
||||
requestType: "change_location",
|
||||
meetingId: meeting.id,
|
||||
requestDetails: {
|
||||
location: '<script>x()</script>New Room (Bldg A & B)',
|
||||
meetingUrl: 'https://x.test/join?id=99&utm=ar',
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(reqRes.status, 201);
|
||||
const reqRow = await reqRes.json();
|
||||
created.requestIds.push(reqRow.id);
|
||||
|
||||
const approve = await api(
|
||||
adminCookie,
|
||||
"PATCH",
|
||||
`/api/executive-meetings/requests/${reqRow.id}`,
|
||||
{ status: "approved", reviewNotes: "ok" },
|
||||
);
|
||||
assert.equal(approve.status, 200);
|
||||
|
||||
const fetched = await api(
|
||||
adminCookie,
|
||||
"GET",
|
||||
`/api/executive-meetings/${meeting.id}`,
|
||||
);
|
||||
const body = await fetched.json();
|
||||
assert.ok(!/<script/i.test(body.location ?? ""));
|
||||
assert.ok((body.location ?? "").includes("New Room (Bldg A & B)"));
|
||||
assert.equal(body.meetingUrl, "https://x.test/join?id=99&utm=ar");
|
||||
});
|
||||
|
||||
test("Requests: POST → admin can list", async () => {
|
||||
const create = await api(
|
||||
adminCookie,
|
||||
"POST",
|
||||
"/api/executive-meetings/requests",
|
||||
{
|
||||
requestType: "note",
|
||||
requestDetails: { note: "Phase-2 test request" },
|
||||
},
|
||||
);
|
||||
assert.equal(create.status, 201);
|
||||
const reqRow = await create.json();
|
||||
assert.ok(reqRow.id);
|
||||
created.requestIds.push(reqRow.id);
|
||||
|
||||
const list = await api(adminCookie, "GET", "/api/executive-meetings/requests");
|
||||
assert.equal(list.status, 200);
|
||||
const listBody = await list.json();
|
||||
assert.ok(Array.isArray(listBody.requests));
|
||||
assert.ok(listBody.requests.some((r) => r.id === reqRow.id));
|
||||
});
|
||||
|
||||
test("Requests: full review + apply pipeline (approve → apply highlights meeting)", async () => {
|
||||
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "ت",
|
||||
titleEn: "T",
|
||||
meetingDate: today,
|
||||
attendees: [],
|
||||
isHighlighted: 0,
|
||||
});
|
||||
assert.equal(create.status, 201);
|
||||
const meeting = await create.json();
|
||||
created.meetingIds.push(meeting.id);
|
||||
|
||||
const reqRes = await api(adminCookie, "POST", "/api/executive-meetings/requests", {
|
||||
requestType: "highlight",
|
||||
meetingId: meeting.id,
|
||||
requestDetails: { note: "highlight-please" },
|
||||
});
|
||||
assert.equal(reqRes.status, 201);
|
||||
const reqRow = await reqRes.json();
|
||||
created.requestIds.push(reqRow.id);
|
||||
|
||||
const approve = await api(
|
||||
adminCookie,
|
||||
"PATCH",
|
||||
`/api/executive-meetings/requests/${reqRow.id}`,
|
||||
{ status: "approved", reviewNotes: "ok" },
|
||||
);
|
||||
assert.equal(approve.status, 200);
|
||||
|
||||
const fetched = await api(
|
||||
adminCookie,
|
||||
"GET",
|
||||
`/api/executive-meetings/${meeting.id}`,
|
||||
);
|
||||
const body = await fetched.json();
|
||||
assert.equal(body.isHighlighted, 1);
|
||||
});
|
||||
|
||||
test("Tasks: server-side scoping forces coordinators to assignedTo=self", async () => {
|
||||
const t1 = await api(adminCookie, "POST", "/api/executive-meetings/tasks", {
|
||||
taskType: "follow_up",
|
||||
assignedTo: coordUserId,
|
||||
notes: "for coord",
|
||||
});
|
||||
assert.equal(t1.status, 201);
|
||||
const task1 = await t1.json();
|
||||
created.taskIds.push(task1.id);
|
||||
|
||||
const t2 = await api(adminCookie, "POST", "/api/executive-meetings/tasks", {
|
||||
taskType: "follow_up",
|
||||
assignedTo: leadUserId,
|
||||
notes: "for lead",
|
||||
});
|
||||
assert.equal(t2.status, 201);
|
||||
const task2 = await t2.json();
|
||||
created.taskIds.push(task2.id);
|
||||
|
||||
const coordList = await api(
|
||||
coordCookie,
|
||||
"GET",
|
||||
`/api/executive-meetings/tasks?mine=0&assigneeId=${leadUserId}`,
|
||||
);
|
||||
assert.equal(coordList.status, 200);
|
||||
const coordBody = await coordList.json();
|
||||
const coordIds = coordBody.tasks.map((t) => t.id);
|
||||
assert.ok(coordIds.includes(task1.id));
|
||||
assert.ok(!coordIds.includes(task2.id));
|
||||
for (const t of coordBody.tasks) {
|
||||
assert.equal(t.assignedTo, coordUserId);
|
||||
}
|
||||
|
||||
const leadList = await api(
|
||||
leadCookie,
|
||||
"GET",
|
||||
`/api/executive-meetings/tasks?assigneeId=${leadUserId}`,
|
||||
);
|
||||
assert.equal(leadList.status, 200);
|
||||
const leadBody = await leadList.json();
|
||||
assert.ok(leadBody.tasks.some((t) => t.id === task2.id));
|
||||
});
|
||||
|
||||
test("Tasks: PATCH supports reassign + notes update", async () => {
|
||||
const create = await api(adminCookie, "POST", "/api/executive-meetings/tasks", {
|
||||
taskType: "follow_up",
|
||||
assignedTo: coordUserId,
|
||||
notes: "v1",
|
||||
});
|
||||
assert.equal(create.status, 201);
|
||||
const task = await create.json();
|
||||
created.taskIds.push(task.id);
|
||||
|
||||
const patch = await api(
|
||||
adminCookie,
|
||||
"PATCH",
|
||||
`/api/executive-meetings/tasks/${task.id}`,
|
||||
{ assignedTo: leadUserId, notes: "v2" },
|
||||
);
|
||||
assert.equal(patch.status, 200);
|
||||
const updated = await patch.json();
|
||||
assert.equal(updated.assignedTo, leadUserId);
|
||||
assert.equal(updated.notes, "v2");
|
||||
});
|
||||
|
||||
// #262: removed test block previously at L609-657 (Requests/Tasks).
|
||||
// #262: removed test block previously at L658-679 (Requests/Tasks).
|
||||
// #262: removed test block previously at L680-717 (Requests/Tasks).
|
||||
// #262: removed test block previously at L718-760 (Requests/Tasks).
|
||||
// #262: removed test block previously at L761-782 (Requests/Tasks).
|
||||
test("Audit logs: admin can list, plain coordinator gets 403", async () => {
|
||||
const ok = await api(
|
||||
adminCookie,
|
||||
@@ -1303,40 +1123,7 @@ test("Reorder: rejects an incomplete-day request (orderedIds missing some)", asy
|
||||
assert.equal(body.code, "incomplete_day");
|
||||
});
|
||||
|
||||
test("Apply request (add_attendee) sanitizes attendee.name like direct writes", async () => {
|
||||
const m = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "اجتماع طلب", titleEn: "Request meeting", meetingDate: today,
|
||||
});
|
||||
const M = await m.json(); created.meetingIds.push(M.id);
|
||||
|
||||
// POST /api/executive-meetings/:id/requests creates a request bound to
|
||||
// the meeting. The route validates payload via requestPayloadSchemas.
|
||||
const reqRes = await api(adminCookie, "POST",
|
||||
`/api/executive-meetings/${M.id}/requests`, {
|
||||
requestType: "add_attendee",
|
||||
requestDetails: {
|
||||
name: '<b>Real</b><script>alert(1)</script><img src=x onerror=alert(1)>',
|
||||
attendanceType: "internal",
|
||||
},
|
||||
});
|
||||
assert.equal(reqRes.status, 201);
|
||||
const reqRow = await reqRes.json();
|
||||
|
||||
// PATCH /api/executive-meetings/requests/:id with {status:"approved"}
|
||||
// routes through applyApprovedRequest -> add_attendee.
|
||||
const approve = await api(adminCookie, "PATCH",
|
||||
`/api/executive-meetings/requests/${reqRow.id}`,
|
||||
{ status: "approved" });
|
||||
assert.equal(approve.status, 200);
|
||||
|
||||
const det = await api(adminCookie, "GET", `/api/executive-meetings/${M.id}`);
|
||||
const detail = await det.json();
|
||||
const att = detail.attendees.at(-1);
|
||||
assert.ok(/<b[^>]*>Real<\/b>/i.test(att.name), "<b> must survive");
|
||||
assert.ok(!/<script/i.test(att.name), "<script> must be stripped");
|
||||
assert.ok(!/onerror/i.test(att.name), "onerror must be stripped");
|
||||
});
|
||||
|
||||
// #262: removed test block previously at L1306-1339 (Requests/Tasks).
|
||||
test("Reorder: rejects ids that do not all belong to meetingDate", async () => {
|
||||
const sameDay = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "اليوم", titleEn: "Today", meetingDate: today,
|
||||
@@ -1422,133 +1209,9 @@ test("Meeting CRUD permissions: coordinator forbidden, lead allowed, admin allow
|
||||
"executive_coord_lead must be able to PATCH meetings");
|
||||
});
|
||||
|
||||
test("Requests: coordinator can submit + withdraw their own request", async () => {
|
||||
const reqRes = await api(coordCookie, "POST",
|
||||
"/api/executive-meetings/requests", {
|
||||
requestType: "note",
|
||||
requestDetails: { note: "coord submitting" },
|
||||
});
|
||||
assert.equal(reqRes.status, 201,
|
||||
"coordinator (REQUEST_ROLES) must be able to POST a request");
|
||||
const reqRow = await reqRes.json();
|
||||
assert.equal(reqRow.status, "new");
|
||||
assert.equal(reqRow.requestedBy, coordUserId);
|
||||
created.requestIds.push(reqRow.id);
|
||||
|
||||
// A different coordinator cannot withdraw someone else's request.
|
||||
const otherCoord = await createUser("em_coord_other", "executive_coordinator");
|
||||
const otherCookie = await login(otherCoord.username, TEST_PASSWORD);
|
||||
const otherWithdraw = await api(otherCookie, "PATCH",
|
||||
`/api/executive-meetings/requests/${reqRow.id}`,
|
||||
{ action: "withdraw" });
|
||||
assert.equal(otherWithdraw.status, 403,
|
||||
"non-requester must not be able to withdraw someone else's request");
|
||||
|
||||
// The original requester can withdraw while still 'new'.
|
||||
const withdraw = await api(coordCookie, "PATCH",
|
||||
`/api/executive-meetings/requests/${reqRow.id}`,
|
||||
{ action: "withdraw" });
|
||||
assert.equal(withdraw.status, 200);
|
||||
const withdrawn = await withdraw.json();
|
||||
assert.equal(withdrawn.status, "withdrawn");
|
||||
|
||||
// Once withdrawn, repeated withdraw must 409 (bad_state), not crash.
|
||||
const again = await api(coordCookie, "PATCH",
|
||||
`/api/executive-meetings/requests/${reqRow.id}`,
|
||||
{ action: "withdraw" });
|
||||
assert.equal(again.status, 409);
|
||||
const againBody = await again.json();
|
||||
assert.equal(againBody.code, "bad_state");
|
||||
});
|
||||
|
||||
test("Requests: admin can reject; rejected requests cannot be re-reviewed", async () => {
|
||||
const reqRes = await api(coordCookie, "POST",
|
||||
"/api/executive-meetings/requests", {
|
||||
requestType: "note",
|
||||
requestDetails: { note: "to be rejected" },
|
||||
});
|
||||
assert.equal(reqRes.status, 201);
|
||||
const reqRow = await reqRes.json();
|
||||
created.requestIds.push(reqRow.id);
|
||||
|
||||
// Coordinator (no APPROVE_ROLES) cannot reject.
|
||||
const coordReject = await api(coordCookie, "PATCH",
|
||||
`/api/executive-meetings/requests/${reqRow.id}`,
|
||||
{ status: "rejected", reviewNotes: "not allowed" });
|
||||
assert.equal(coordReject.status, 403,
|
||||
"non-approver must not be able to reject a request");
|
||||
|
||||
// Admin can reject. The response must reflect the new status and reviewer.
|
||||
const reject = await api(adminCookie, "PATCH",
|
||||
`/api/executive-meetings/requests/${reqRow.id}`,
|
||||
{ status: "rejected", reviewNotes: "no" });
|
||||
assert.equal(reject.status, 200);
|
||||
const rejected = await reject.json();
|
||||
assert.equal(rejected.status, "rejected");
|
||||
assert.equal(rejected.reviewedBy, adminUserId);
|
||||
assert.equal(rejected.reviewNotes, "no");
|
||||
|
||||
// After review the original requester can no longer withdraw.
|
||||
const lateWithdraw = await api(coordCookie, "PATCH",
|
||||
`/api/executive-meetings/requests/${reqRow.id}`,
|
||||
{ action: "withdraw" });
|
||||
assert.equal(lateWithdraw.status, 409,
|
||||
"withdrawing an already-reviewed request must 409, not crash");
|
||||
|
||||
// Trying to re-review must also 409 with code:bad_state.
|
||||
const reReview = await api(adminCookie, "PATCH",
|
||||
`/api/executive-meetings/requests/${reqRow.id}`,
|
||||
{ status: "approved", reviewNotes: "flip" });
|
||||
assert.equal(reReview.status, 409);
|
||||
const reBody = await reReview.json();
|
||||
assert.equal(reBody.code, "bad_state");
|
||||
});
|
||||
|
||||
test("Tasks: assignee can update status; non-assignee non-mutator gets 403", async () => {
|
||||
// Admin creates a task assigned to coord.
|
||||
const create = await api(adminCookie, "POST",
|
||||
"/api/executive-meetings/tasks", {
|
||||
taskType: "follow_up",
|
||||
assignedTo: coordUserId,
|
||||
notes: "assignee-only test",
|
||||
});
|
||||
assert.equal(create.status, 201);
|
||||
const task = await create.json();
|
||||
created.taskIds.push(task.id);
|
||||
|
||||
// The assignee (coord) can flip the status even though they are NOT in
|
||||
// MUTATE_ROLES. This is the assignedTo carve-out in the PATCH handler.
|
||||
const assigneeUpdate = await api(coordCookie, "PATCH",
|
||||
`/api/executive-meetings/tasks/${task.id}`, { status: "in_progress" });
|
||||
assert.equal(assigneeUpdate.status, 200,
|
||||
"assignee must be able to update their own task status");
|
||||
const updated = await assigneeUpdate.json();
|
||||
assert.equal(updated.status, "in_progress");
|
||||
|
||||
// The assignee CANNOT change non-status fields (mutator-only).
|
||||
const assigneeReassign = await api(coordCookie, "PATCH",
|
||||
`/api/executive-meetings/tasks/${task.id}`,
|
||||
{ assignedTo: leadUserId });
|
||||
assert.equal(assigneeReassign.status, 200,
|
||||
"assignee patching a mutator-only field returns the row but ignores it");
|
||||
const stillCoord = await assigneeReassign.json();
|
||||
assert.equal(stillCoord.assignedTo, coordUserId,
|
||||
"assignee may not reassign a task to someone else");
|
||||
|
||||
// A different coordinator (not the assignee, not a mutator) is rejected.
|
||||
const otherCoord = await createUser("em_other_coord", "executive_coordinator");
|
||||
const otherCookie = await login(otherCoord.username, TEST_PASSWORD);
|
||||
const nonAssignee = await api(otherCookie, "PATCH",
|
||||
`/api/executive-meetings/tasks/${task.id}`, { status: "completed" });
|
||||
assert.equal(nonAssignee.status, 403,
|
||||
"non-assignee non-mutator must be denied by the PATCH handler");
|
||||
|
||||
// Mutator (lead) is always allowed.
|
||||
const mutator = await api(leadCookie, "PATCH",
|
||||
`/api/executive-meetings/tasks/${task.id}`, { status: "completed" });
|
||||
assert.equal(mutator.status, 200);
|
||||
});
|
||||
|
||||
// #262: removed test block previously at L1425-1463 (Requests/Tasks).
|
||||
// #262: removed test block previously at L1464-1506 (Requests/Tasks).
|
||||
// #262: removed test block previously at L1507-1551 (Requests/Tasks).
|
||||
test("Font settings: PUT then GET returns the user-scoped row roundtrip", async () => {
|
||||
const put = await api(adminCookie, "PUT",
|
||||
"/api/executive-meetings/font-settings", {
|
||||
@@ -1603,10 +1266,8 @@ test("router.param: non-numeric :id returns 404 across endpoints (no crash)", as
|
||||
["GET", "/api/executive-meetings/-1"],
|
||||
["POST", "/api/executive-meetings/abc/duplicate"],
|
||||
["PUT", "/api/executive-meetings/abc/attendees"],
|
||||
["POST", "/api/executive-meetings/abc/requests"],
|
||||
["PATCH", "/api/executive-meetings/requests/abc"],
|
||||
["PATCH", "/api/executive-meetings/tasks/abc"],
|
||||
["DELETE", "/api/executive-meetings/tasks/abc"],
|
||||
// #262: /requests + /tasks routes were retired with the
|
||||
// Requests/Approvals/Tasks tabs.
|
||||
];
|
||||
for (const [method, path] of cases) {
|
||||
const res = await api(adminCookie, method, path,
|
||||
@@ -1761,104 +1422,8 @@ test("Notification prefs: PUT rejects unknown notificationType with 400", async
|
||||
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);
|
||||
});
|
||||
|
||||
// #262: removed test block previously at L1427-1471 (covered by meeting_created tests in executive-meetings-notifications.test.mjs).
|
||||
// #262: removed test block previously at L1472-1524 (covered by meeting_created tests in executive-meetings-notifications.test.mjs).
|
||||
test("Notification prefs: DELETE wipes the user's pref rows so GET reverts to defaults", async () => {
|
||||
// #236: clicking "Restore defaults" in the UI hits DELETE. After it
|
||||
// returns, every event type must read default-on regardless of what
|
||||
@@ -1867,14 +1432,14 @@ test("Notification prefs: DELETE wipes the user's pref rows so GET reverts to de
|
||||
const fresh = await createUser("em_pref_restore", "executive_office_manager");
|
||||
const cookie = await login(fresh.username, TEST_PASSWORD);
|
||||
|
||||
// Mute two different event types on different channels so we can
|
||||
// prove the DELETE clears EVERY row, not just one.
|
||||
// #262: only `meeting_created` survives, so the "two different event
|
||||
// types" half of the original test collapsed to a single row covering
|
||||
// both channels muted at once. The DELETE must still clear it.
|
||||
const put = await api(cookie, "PUT",
|
||||
"/api/executive-meetings/notification-prefs",
|
||||
{
|
||||
prefs: [
|
||||
{ notificationType: "request_submitted", inApp: false, email: true },
|
||||
{ notificationType: "meeting_created", inApp: true, email: false },
|
||||
{ notificationType: "meeting_created", inApp: false, email: false },
|
||||
],
|
||||
});
|
||||
assert.equal(put.status, 200);
|
||||
@@ -1885,14 +1450,14 @@ test("Notification prefs: DELETE wipes the user's pref rows so GET reverts to de
|
||||
WHERE user_id = $1`,
|
||||
[fresh.id],
|
||||
);
|
||||
assert.equal(pre[0].n, 2, "PUT must have left 2 pref rows");
|
||||
assert.equal(pre[0].n, 1, "PUT must have left 1 pref row");
|
||||
|
||||
const del = await api(cookie, "DELETE",
|
||||
"/api/executive-meetings/notification-prefs");
|
||||
assert.equal(del.status, 200);
|
||||
const delBody = await del.json();
|
||||
assert.equal(delBody.ok, true);
|
||||
assert.equal(delBody.count, 2, "DELETE must report wiping both rows");
|
||||
assert.equal(delBody.count, 1, "DELETE must report wiping the row");
|
||||
|
||||
const { rows: post } = await pool.query(
|
||||
`SELECT COUNT(*)::int AS n
|
||||
@@ -1912,28 +1477,36 @@ test("Notification prefs: DELETE wipes the user's pref rows so GET reverts to de
|
||||
|
||||
// And actual fan-out must reach the user again — a regression in the
|
||||
// DELETE handler that left rows behind would silently re-mute them.
|
||||
// #262: trigger via POST /api/executive-meetings (the only event type
|
||||
// that still fans out).
|
||||
const beforeCount = await pool.query(
|
||||
`SELECT COUNT(*)::int AS n
|
||||
FROM executive_meeting_notifications
|
||||
WHERE user_id = $1
|
||||
AND notification_type = 'request_submitted'`,
|
||||
AND notification_type = 'meeting_created'`,
|
||||
[fresh.id],
|
||||
);
|
||||
const submit = await api(coordCookie, "POST",
|
||||
"/api/executive-meetings/requests",
|
||||
{ requestType: "note", requestDetails: { note: "post-restore-fanout" } });
|
||||
const submit = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "إخطار-إعادة-الافتراضات",
|
||||
titleEn: "Restore-defaults fan-out",
|
||||
meetingDate: today,
|
||||
attendees: [],
|
||||
});
|
||||
assert.equal(submit.status, 201);
|
||||
created.requestIds.push((await submit.json()).id);
|
||||
const submittedMeeting = await submit.json();
|
||||
created.meetingIds.push(submittedMeeting.id);
|
||||
// Give the fan-out a moment to land before counting.
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
const afterCount = await pool.query(
|
||||
`SELECT COUNT(*)::int AS n
|
||||
FROM executive_meeting_notifications
|
||||
WHERE user_id = $1
|
||||
AND notification_type = 'request_submitted'`,
|
||||
AND notification_type = 'meeting_created'`,
|
||||
[fresh.id],
|
||||
);
|
||||
assert.ok(
|
||||
afterCount.rows[0].n > beforeCount.rows[0].n,
|
||||
"user with no pref rows (post-DELETE) must receive new request_submitted rows",
|
||||
"user with no pref rows (post-DELETE) must receive new meeting_created rows",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user