#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:
Riyadh
2026-05-01 08:18:29 +00:00
parent c282db045b
commit cd6cb317fc
11 changed files with 288 additions and 3308 deletions
@@ -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",
);
});