#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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 25 KiB |
@@ -90,12 +90,8 @@ export function useNotificationsSocket() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["/api/executive-meetings/notifications"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["/api/executive-meetings/requests"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["/api/executive-meetings/tasks"],
|
||||
});
|
||||
// #262: requests/tasks query invalidations dropped when those
|
||||
// sub-resources were removed from the API.
|
||||
});
|
||||
|
||||
socket.on(
|
||||
|
||||
@@ -378,40 +378,6 @@
|
||||
"replaceImage": "استبدال الصورة",
|
||||
"uploading": "جاري الرفع...",
|
||||
"uploadFailed": "فشل رفع الصورة",
|
||||
"users": "المستخدمين",
|
||||
"username": "اسم المستخدم",
|
||||
"email": "البريد الإلكتروني",
|
||||
"password": "كلمة المرور",
|
||||
"roles": "الصلاحيات",
|
||||
"active": "نشط",
|
||||
"issueResetLink": "إصدار رابط إعادة تعيين كلمة المرور",
|
||||
"orderReceiverRole": "مستلم الطلبات",
|
||||
"resetLinkModal": {
|
||||
"title": "رابط إعادة تعيين كلمة المرور للمستخدم {{username}}",
|
||||
"description": "شارك هذا الرابط لمرة واحدة مع المستخدم. يمكن استخدامه مرة واحدة فقط.",
|
||||
"expires": "تنتهي الصلاحية: {{time}}",
|
||||
"copy": "نسخ الرابط",
|
||||
"copied": "تم نسخ الرابط"
|
||||
},
|
||||
"siteSettings": "إعدادات الموقع",
|
||||
"siteNameAr": "اسم الموقع (عربي)",
|
||||
"siteNameEn": "اسم الموقع (إنجليزي)",
|
||||
"registrationOpen": "السماح بإنشاء حسابات",
|
||||
"registrationOpenHint": "عند الإيقاف، الأدمن فقط يقدر ينشئ مستخدمين جدد.",
|
||||
"footerTextAr": "نص التذييل (عربي)",
|
||||
"footerTextEn": "نص التذييل (إنجليزي)",
|
||||
"nav": {
|
||||
"dashboard": "الرئيسية",
|
||||
"apps": "التطبيقات",
|
||||
"services": "الخدمات",
|
||||
"users": "المستخدمين",
|
||||
"groups": "المجموعات",
|
||||
"roles": "الأدوار",
|
||||
"auditLog": "سجل التدقيق",
|
||||
"userManagement": "إدارة المستخدمين",
|
||||
"settings": "الإعدادات",
|
||||
"menu": "القائمة"
|
||||
},
|
||||
"users": {
|
||||
"searchPlaceholder": "ابحث باسم المستخدم أو البريد...",
|
||||
"allGroups": "كل المجموعات",
|
||||
@@ -476,40 +442,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"apps": {
|
||||
"counts": {
|
||||
"groups": "{{count}} مجموعة",
|
||||
"restrictions": "{{count}} قيد",
|
||||
"opens": "{{count}} فتحة"
|
||||
},
|
||||
"historyTitle": "سجل الصلاحيات",
|
||||
"historyHint": "الصلاحيات المطلوبة لاستخدام هذا التطبيق. صفِّ بحسب من غيّر أو متى.",
|
||||
"historyEmpty": "لم تُسجَّل تغييرات صلاحيات لهذا التطبيق بعد.",
|
||||
"historyEmptyFiltered": "لا توجد تغييرات صلاحيات تطابق الفلاتر الحالية.",
|
||||
"historyAdded_zero": "أُضيف ({{count}}):",
|
||||
"historyAdded_one": "أُضيف ({{count}}):",
|
||||
"historyAdded_two": "أُضيف ({{count}}):",
|
||||
"historyAdded_few": "أُضيف ({{count}}):",
|
||||
"historyAdded_many": "أُضيف ({{count}}):",
|
||||
"historyAdded_other": "أُضيف ({{count}}):",
|
||||
"historyRemoved_zero": "أُزيل ({{count}}):",
|
||||
"historyRemoved_one": "أُزيل ({{count}}):",
|
||||
"historyRemoved_two": "أُزيل ({{count}}):",
|
||||
"historyRemoved_few": "أُزيل ({{count}}):",
|
||||
"historyRemoved_many": "أُزيل ({{count}}):",
|
||||
"historyRemoved_other": "أُزيل ({{count}}):",
|
||||
"historyTotal_zero": "{{count}} صلاحية بعد التغيير",
|
||||
"historyTotal_one": "{{count}} صلاحية بعد التغيير",
|
||||
"historyTotal_two": "{{count}} صلاحيتان بعد التغيير",
|
||||
"historyTotal_few": "{{count}} صلاحيات بعد التغيير",
|
||||
"historyTotal_many": "{{count}} صلاحية بعد التغيير",
|
||||
"historyTotal_other": "{{count}} صلاحية بعد التغيير"
|
||||
},
|
||||
"services": {
|
||||
"counts": {
|
||||
"orders": "{{count}} طلب"
|
||||
}
|
||||
},
|
||||
"username": "اسم المستخدم",
|
||||
"email": "البريد الإلكتروني",
|
||||
"password": "كلمة المرور",
|
||||
"roles": {
|
||||
"searchPlaceholder": "ابحث في الأدوار...",
|
||||
"addRole": "إضافة دور",
|
||||
@@ -595,6 +530,69 @@
|
||||
"fixFiltersFirst": "صحّح عوامل التصفية أعلاه لتحميل السجل."
|
||||
}
|
||||
},
|
||||
"active": "نشط",
|
||||
"issueResetLink": "إصدار رابط إعادة تعيين كلمة المرور",
|
||||
"orderReceiverRole": "مستلم الطلبات",
|
||||
"resetLinkModal": {
|
||||
"title": "رابط إعادة تعيين كلمة المرور للمستخدم {{username}}",
|
||||
"description": "شارك هذا الرابط لمرة واحدة مع المستخدم. يمكن استخدامه مرة واحدة فقط.",
|
||||
"expires": "تنتهي الصلاحية: {{time}}",
|
||||
"copy": "نسخ الرابط",
|
||||
"copied": "تم نسخ الرابط"
|
||||
},
|
||||
"siteSettings": "إعدادات الموقع",
|
||||
"siteNameAr": "اسم الموقع (عربي)",
|
||||
"siteNameEn": "اسم الموقع (إنجليزي)",
|
||||
"registrationOpen": "السماح بإنشاء حسابات",
|
||||
"registrationOpenHint": "عند الإيقاف، الأدمن فقط يقدر ينشئ مستخدمين جدد.",
|
||||
"footerTextAr": "نص التذييل (عربي)",
|
||||
"footerTextEn": "نص التذييل (إنجليزي)",
|
||||
"nav": {
|
||||
"dashboard": "الرئيسية",
|
||||
"apps": "التطبيقات",
|
||||
"services": "الخدمات",
|
||||
"users": "المستخدمين",
|
||||
"groups": "المجموعات",
|
||||
"roles": "الأدوار",
|
||||
"auditLog": "سجل التدقيق",
|
||||
"userManagement": "إدارة المستخدمين",
|
||||
"settings": "الإعدادات",
|
||||
"menu": "القائمة"
|
||||
},
|
||||
"apps": {
|
||||
"counts": {
|
||||
"groups": "{{count}} مجموعة",
|
||||
"restrictions": "{{count}} قيد",
|
||||
"opens": "{{count}} فتحة"
|
||||
},
|
||||
"historyTitle": "سجل الصلاحيات",
|
||||
"historyHint": "الصلاحيات المطلوبة لاستخدام هذا التطبيق. صفِّ بحسب من غيّر أو متى.",
|
||||
"historyEmpty": "لم تُسجَّل تغييرات صلاحيات لهذا التطبيق بعد.",
|
||||
"historyEmptyFiltered": "لا توجد تغييرات صلاحيات تطابق الفلاتر الحالية.",
|
||||
"historyAdded_zero": "أُضيف ({{count}}):",
|
||||
"historyAdded_one": "أُضيف ({{count}}):",
|
||||
"historyAdded_two": "أُضيف ({{count}}):",
|
||||
"historyAdded_few": "أُضيف ({{count}}):",
|
||||
"historyAdded_many": "أُضيف ({{count}}):",
|
||||
"historyAdded_other": "أُضيف ({{count}}):",
|
||||
"historyRemoved_zero": "أُزيل ({{count}}):",
|
||||
"historyRemoved_one": "أُزيل ({{count}}):",
|
||||
"historyRemoved_two": "أُزيل ({{count}}):",
|
||||
"historyRemoved_few": "أُزيل ({{count}}):",
|
||||
"historyRemoved_many": "أُزيل ({{count}}):",
|
||||
"historyRemoved_other": "أُزيل ({{count}}):",
|
||||
"historyTotal_zero": "{{count}} صلاحية بعد التغيير",
|
||||
"historyTotal_one": "{{count}} صلاحية بعد التغيير",
|
||||
"historyTotal_two": "{{count}} صلاحيتان بعد التغيير",
|
||||
"historyTotal_few": "{{count}} صلاحيات بعد التغيير",
|
||||
"historyTotal_many": "{{count}} صلاحية بعد التغيير",
|
||||
"historyTotal_other": "{{count}} صلاحية بعد التغيير"
|
||||
},
|
||||
"services": {
|
||||
"counts": {
|
||||
"orders": "{{count}} طلب"
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"searchPlaceholder": "ابحث في المجموعات...",
|
||||
"addGroup": "إضافة مجموعة",
|
||||
@@ -975,7 +973,10 @@
|
||||
"newLabel": "تصنيف جديد",
|
||||
"noLabelsYet": "لا توجد تصنيفات بعد",
|
||||
"allLabels": "الكل",
|
||||
"tabs": { "active": "ملاحظات", "archived": "الأرشيف" },
|
||||
"tabs": {
|
||||
"active": "ملاحظات",
|
||||
"archived": "الأرشيف"
|
||||
},
|
||||
"pinned": "مثبّتة",
|
||||
"others": "أخرى",
|
||||
"empty": "ستظهر ملاحظاتك هنا",
|
||||
@@ -1085,9 +1086,6 @@
|
||||
"nav": {
|
||||
"schedule": "جدول الاجتماعات اليومي",
|
||||
"manage": "إدارة الاجتماعات",
|
||||
"requests": "طلبات التعديل",
|
||||
"approvals": "موافقات مدير المكتب",
|
||||
"tasks": "مهام فريق التنسيق",
|
||||
"notifications": "التنبيهات",
|
||||
"audit": "سجل التعديلات",
|
||||
"pdf": "تصدير PDF",
|
||||
@@ -1179,86 +1177,6 @@
|
||||
"saveFailed": "تعذر حفظ الاجتماع"
|
||||
}
|
||||
},
|
||||
"requests": {
|
||||
"heading": "طلبات التعديل",
|
||||
"newRequest": "طلب جديد",
|
||||
"myRequests": "طلباتي",
|
||||
"allRequests": "جميع الطلبات",
|
||||
"empty": "لا توجد طلبات.",
|
||||
"filterStatus": "تصفية حسب الحالة",
|
||||
"submitFor": "اقتراح تعديل على:",
|
||||
"noTargetMeeting": "اقتراح اجتماع جديد (بدون ربط)",
|
||||
"details": "التفاصيل",
|
||||
"detailsPlaceholder": "اشرح ما تقترح بالتفصيل...",
|
||||
"submitted": "تم إرسال الطلب",
|
||||
"submitFailed": "تعذر إرسال الطلب",
|
||||
"requestedBy": "مقدم الطلب",
|
||||
"requestedAt": "وقت الطلب",
|
||||
"type": {
|
||||
"create": "إنشاء اجتماع",
|
||||
"edit": "تعديل بيانات",
|
||||
"delete": "حذف",
|
||||
"reschedule": "إعادة جدولة",
|
||||
"add_attendee": "إضافة حاضر",
|
||||
"remove_attendee": "إزالة حاضر",
|
||||
"change_location": "تغيير المكان",
|
||||
"cancel_meeting": "إلغاء الاجتماع",
|
||||
"note": "ملاحظة",
|
||||
"highlight": "تمييز",
|
||||
"unhighlight": "إزالة التمييز",
|
||||
"other": "آخر"
|
||||
},
|
||||
"status": {
|
||||
"new": "جديد",
|
||||
"approved": "تمت الموافقة",
|
||||
"rejected": "مرفوض",
|
||||
"needs_edit": "يحتاج تعديل",
|
||||
"withdrawn": "مسحوب",
|
||||
"done": "تم"
|
||||
}
|
||||
},
|
||||
"approvals": {
|
||||
"heading": "موافقات مدير المكتب",
|
||||
"empty": "لا توجد طلبات بانتظار المراجعة.",
|
||||
"approve": "اعتماد",
|
||||
"reject": "رفض",
|
||||
"needsEdit": "إعادة للتعديل",
|
||||
"needsEditDone": "تم إرجاع الطلب لمقدّمه",
|
||||
"reviewNotes": "ملاحظات المراجعة",
|
||||
"reviewNotesPlaceholder": "اكتب ملاحظاتك هنا (اختياري)",
|
||||
"approved": "تم الاعتماد",
|
||||
"rejected": "تم الرفض",
|
||||
"actionFailed": "تعذر تنفيذ المراجعة"
|
||||
},
|
||||
"tasks": {
|
||||
"heading": "مهام فريق التنسيق",
|
||||
"addTask": "إضافة مهمة",
|
||||
"empty": "لا توجد مهام.",
|
||||
"field": {
|
||||
"taskType": "نوع المهمة",
|
||||
"assignee": "المُكلَّف",
|
||||
"assigneeIdPlaceholder": "معرّف المستخدم",
|
||||
"dueDate": "تاريخ الاستحقاق",
|
||||
"notes": "ملاحظات",
|
||||
"meeting": "الاجتماع",
|
||||
"status": "الحالة"
|
||||
},
|
||||
"status": {
|
||||
"pending": "بالانتظار",
|
||||
"in_progress": "قيد التنفيذ",
|
||||
"completed": "منجزة",
|
||||
"cancelled": "ملغاة"
|
||||
},
|
||||
"markCompleted": "إنهاء المهمة",
|
||||
"markInProgress": "بدء التنفيذ",
|
||||
"deleteConfirm": "حذف هذه المهمة؟",
|
||||
"saved": "تم حفظ المهمة",
|
||||
"deleted": "تم حذف المهمة",
|
||||
"reassign": "إعادة إسناد / تعديل",
|
||||
"reassigned": "تم تحديث المهمة",
|
||||
"myTasksOnly": "مهامي فقط",
|
||||
"myTasksOnlyHelp": "ينظر المنسقون إلى المهام المسندة إليهم فقط، بينما يرى قائد التنسيق ومدير المكتب والمسؤول جميع المهام."
|
||||
},
|
||||
"audit": {
|
||||
"heading": "سجل التعديلات",
|
||||
"empty": "لا توجد قيود في السجل.",
|
||||
|
||||
@@ -375,40 +375,6 @@
|
||||
"replaceImage": "Replace Image",
|
||||
"uploading": "Uploading...",
|
||||
"uploadFailed": "Image upload failed",
|
||||
"users": "Users",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"roles": "Roles",
|
||||
"active": "Active",
|
||||
"issueResetLink": "Issue password reset link",
|
||||
"orderReceiverRole": "Order Receiver",
|
||||
"resetLinkModal": {
|
||||
"title": "Password reset link for {{username}}",
|
||||
"description": "Share this one-time link with the user. It can only be used once.",
|
||||
"expires": "Expires: {{time}}",
|
||||
"copy": "Copy link",
|
||||
"copied": "Link copied to clipboard"
|
||||
},
|
||||
"siteSettings": "Site Settings",
|
||||
"siteNameAr": "Site Name (Arabic)",
|
||||
"siteNameEn": "Site Name (English)",
|
||||
"registrationOpen": "Allow public registration",
|
||||
"registrationOpenHint": "When off, only admins can create new users.",
|
||||
"footerTextAr": "Footer Text (Arabic)",
|
||||
"footerTextEn": "Footer Text (English)",
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"apps": "Apps",
|
||||
"services": "Services",
|
||||
"users": "Users",
|
||||
"groups": "Groups",
|
||||
"roles": "Roles",
|
||||
"auditLog": "Audit log",
|
||||
"userManagement": "User Management",
|
||||
"settings": "Settings",
|
||||
"menu": "Menu"
|
||||
},
|
||||
"users": {
|
||||
"searchPlaceholder": "Search by username or email...",
|
||||
"allGroups": "All groups",
|
||||
@@ -461,28 +427,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"apps": {
|
||||
"counts": {
|
||||
"groups": "{{count}} groups",
|
||||
"restrictions": "{{count}} restrictions",
|
||||
"opens": "{{count}} opens"
|
||||
},
|
||||
"historyTitle": "Permission history",
|
||||
"historyHint": "Permissions required to use this app. Filter by who changed it or when.",
|
||||
"historyEmpty": "No permission changes have been recorded for this app yet.",
|
||||
"historyEmptyFiltered": "No permission changes match the current filters.",
|
||||
"historyAdded_one": "Added ({{count}}):",
|
||||
"historyAdded_other": "Added ({{count}}):",
|
||||
"historyRemoved_one": "Removed ({{count}}):",
|
||||
"historyRemoved_other": "Removed ({{count}}):",
|
||||
"historyTotal_one": "{{count}} permission after change",
|
||||
"historyTotal_other": "{{count}} permissions after change"
|
||||
},
|
||||
"services": {
|
||||
"counts": {
|
||||
"orders": "{{count}} orders"
|
||||
}
|
||||
},
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"roles": {
|
||||
"searchPlaceholder": "Search roles...",
|
||||
"addRole": "Add Role",
|
||||
@@ -556,6 +503,57 @@
|
||||
"fixFiltersFirst": "Fix the filters above to load history."
|
||||
}
|
||||
},
|
||||
"active": "Active",
|
||||
"issueResetLink": "Issue password reset link",
|
||||
"orderReceiverRole": "Order Receiver",
|
||||
"resetLinkModal": {
|
||||
"title": "Password reset link for {{username}}",
|
||||
"description": "Share this one-time link with the user. It can only be used once.",
|
||||
"expires": "Expires: {{time}}",
|
||||
"copy": "Copy link",
|
||||
"copied": "Link copied to clipboard"
|
||||
},
|
||||
"siteSettings": "Site Settings",
|
||||
"siteNameAr": "Site Name (Arabic)",
|
||||
"siteNameEn": "Site Name (English)",
|
||||
"registrationOpen": "Allow public registration",
|
||||
"registrationOpenHint": "When off, only admins can create new users.",
|
||||
"footerTextAr": "Footer Text (Arabic)",
|
||||
"footerTextEn": "Footer Text (English)",
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"apps": "Apps",
|
||||
"services": "Services",
|
||||
"users": "Users",
|
||||
"groups": "Groups",
|
||||
"roles": "Roles",
|
||||
"auditLog": "Audit log",
|
||||
"userManagement": "User Management",
|
||||
"settings": "Settings",
|
||||
"menu": "Menu"
|
||||
},
|
||||
"apps": {
|
||||
"counts": {
|
||||
"groups": "{{count}} groups",
|
||||
"restrictions": "{{count}} restrictions",
|
||||
"opens": "{{count}} opens"
|
||||
},
|
||||
"historyTitle": "Permission history",
|
||||
"historyHint": "Permissions required to use this app. Filter by who changed it or when.",
|
||||
"historyEmpty": "No permission changes have been recorded for this app yet.",
|
||||
"historyEmptyFiltered": "No permission changes match the current filters.",
|
||||
"historyAdded_one": "Added ({{count}}):",
|
||||
"historyAdded_other": "Added ({{count}}):",
|
||||
"historyRemoved_one": "Removed ({{count}}):",
|
||||
"historyRemoved_other": "Removed ({{count}}):",
|
||||
"historyTotal_one": "{{count}} permission after change",
|
||||
"historyTotal_other": "{{count}} permissions after change"
|
||||
},
|
||||
"services": {
|
||||
"counts": {
|
||||
"orders": "{{count}} orders"
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"searchPlaceholder": "Search groups...",
|
||||
"addGroup": "Add Group",
|
||||
@@ -876,7 +874,10 @@
|
||||
"newLabel": "New label",
|
||||
"noLabelsYet": "No labels yet",
|
||||
"allLabels": "All",
|
||||
"tabs": { "active": "Notes", "archived": "Archived" },
|
||||
"tabs": {
|
||||
"active": "Notes",
|
||||
"archived": "Archived"
|
||||
},
|
||||
"pinned": "Pinned",
|
||||
"others": "Others",
|
||||
"empty": "Your notes will appear here",
|
||||
@@ -986,9 +987,6 @@
|
||||
"nav": {
|
||||
"schedule": "Daily Schedule",
|
||||
"manage": "Manage Meetings",
|
||||
"requests": "Change Requests",
|
||||
"approvals": "Office Manager Approvals",
|
||||
"tasks": "Coordination Tasks",
|
||||
"notifications": "Notifications",
|
||||
"audit": "Audit Log",
|
||||
"pdf": "PDF Export",
|
||||
@@ -1080,86 +1078,6 @@
|
||||
"saveFailed": "Could not save the meeting"
|
||||
}
|
||||
},
|
||||
"requests": {
|
||||
"heading": "Change Requests",
|
||||
"newRequest": "New request",
|
||||
"myRequests": "My requests",
|
||||
"allRequests": "All requests",
|
||||
"empty": "No requests yet.",
|
||||
"filterStatus": "Filter by status",
|
||||
"submitFor": "Suggest a change to:",
|
||||
"noTargetMeeting": "Propose a brand-new meeting (no link)",
|
||||
"details": "Details",
|
||||
"detailsPlaceholder": "Explain your suggestion in detail...",
|
||||
"submitted": "Request submitted",
|
||||
"submitFailed": "Could not submit the request",
|
||||
"requestedBy": "Submitted by",
|
||||
"requestedAt": "Submitted at",
|
||||
"type": {
|
||||
"create": "Create meeting",
|
||||
"edit": "Edit details",
|
||||
"delete": "Delete",
|
||||
"reschedule": "Reschedule",
|
||||
"add_attendee": "Add attendee",
|
||||
"remove_attendee": "Remove attendee",
|
||||
"change_location": "Change location",
|
||||
"cancel_meeting": "Cancel meeting",
|
||||
"note": "Note",
|
||||
"highlight": "Highlight",
|
||||
"unhighlight": "Remove highlight",
|
||||
"other": "Other"
|
||||
},
|
||||
"status": {
|
||||
"new": "New",
|
||||
"approved": "Approved",
|
||||
"rejected": "Rejected",
|
||||
"needs_edit": "Needs edit",
|
||||
"withdrawn": "Withdrawn",
|
||||
"done": "Done"
|
||||
}
|
||||
},
|
||||
"approvals": {
|
||||
"heading": "Office Manager Approvals",
|
||||
"empty": "No requests pending review.",
|
||||
"approve": "Approve",
|
||||
"reject": "Reject",
|
||||
"needsEdit": "Send back for edits",
|
||||
"needsEditDone": "Sent back to requester",
|
||||
"reviewNotes": "Review notes",
|
||||
"reviewNotesPlaceholder": "Optional notes for the requester",
|
||||
"approved": "Approved",
|
||||
"rejected": "Rejected",
|
||||
"actionFailed": "Could not complete the review"
|
||||
},
|
||||
"tasks": {
|
||||
"heading": "Coordination Tasks",
|
||||
"addTask": "Add task",
|
||||
"empty": "No tasks yet.",
|
||||
"field": {
|
||||
"taskType": "Task type",
|
||||
"assignee": "Assignee",
|
||||
"assigneeIdPlaceholder": "User ID",
|
||||
"dueDate": "Due date",
|
||||
"notes": "Notes",
|
||||
"meeting": "Meeting",
|
||||
"status": "Status"
|
||||
},
|
||||
"status": {
|
||||
"pending": "Pending",
|
||||
"in_progress": "In progress",
|
||||
"completed": "Completed",
|
||||
"cancelled": "Cancelled"
|
||||
},
|
||||
"markCompleted": "Mark completed",
|
||||
"markInProgress": "Start",
|
||||
"deleteConfirm": "Delete this task?",
|
||||
"saved": "Task saved",
|
||||
"deleted": "Task deleted",
|
||||
"reassign": "Reassign / edit",
|
||||
"reassigned": "Task updated",
|
||||
"myTasksOnly": "My tasks only",
|
||||
"myTasksOnlyHelp": "Coordinators only see tasks assigned to them. Lead/admin roles see all tasks."
|
||||
},
|
||||
"audit": {
|
||||
"heading": "Audit Log",
|
||||
"empty": "No audit entries.",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 7.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
@@ -95,58 +95,10 @@ export const executiveMeetingAttendeesTable = pgTable(
|
||||
}),
|
||||
);
|
||||
|
||||
export const executiveMeetingRequestsTable = pgTable(
|
||||
"executive_meeting_requests",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
meetingId: integer("meeting_id").references(
|
||||
() => executiveMeetingsTable.id,
|
||||
{ onDelete: "cascade" },
|
||||
),
|
||||
requestedBy: integer("requested_by").references(() => usersTable.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
requestType: varchar("request_type", { length: 64 }).notNull(),
|
||||
requestDetails: jsonb("request_details"),
|
||||
status: varchar("status", { length: 32 }).notNull().default("new"),
|
||||
reviewedBy: integer("reviewed_by").references(() => usersTable.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
reviewDecision: varchar("review_decision", { length: 32 }),
|
||||
reviewNotes: text("review_notes"),
|
||||
assignedTo: integer("assigned_to").references(() => usersTable.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
},
|
||||
);
|
||||
|
||||
export const executiveMeetingTasksTable = pgTable("executive_meeting_tasks", {
|
||||
id: serial("id").primaryKey(),
|
||||
requestId: integer("request_id").references(() => executiveMeetingRequestsTable.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
meetingId: integer("meeting_id").references(() => executiveMeetingsTable.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
assignedTo: integer("assigned_to").references(() => usersTable.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
taskType: varchar("task_type", { length: 64 }).notNull(),
|
||||
status: varchar("status", { length: 32 }).notNull().default("pending"),
|
||||
dueAt: timestamp("due_at", { withTimezone: true }),
|
||||
completedAt: timestamp("completed_at", { withTimezone: true }),
|
||||
notes: text("notes"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
});
|
||||
// #262: removed executiveMeetingRequestsTable + executiveMeetingTasksTable.
|
||||
// The Requests / Approvals / Tasks tabs were retired from the page; the
|
||||
// underlying DB tables are dropped by
|
||||
// artifacts/api-server/scripts/cleanup-em-requests-tasks.sql.
|
||||
|
||||
export const executiveMeetingNotificationsTable = pgTable(
|
||||
"executive_meeting_notifications",
|
||||
@@ -178,12 +130,10 @@ export const executiveMeetingNotificationsTable = pgTable(
|
||||
* notificationType mirrors the keys passed to
|
||||
* `recordExecutiveMeetingNotifications` / `sendExecutiveMeetingEmail`:
|
||||
* - meeting_created
|
||||
* - request_submitted
|
||||
* - request_approved
|
||||
* - request_rejected
|
||||
* - request_needs_edit
|
||||
* - task_assigned
|
||||
* - task_completed
|
||||
*
|
||||
* #262: collapsed to a single event type after Requests/Approvals/Tasks
|
||||
* were removed. Orphan rows for the old types are deleted by
|
||||
* cleanup-em-requests-tasks.sql.
|
||||
*/
|
||||
export const executiveMeetingNotificationPrefsTable = pgTable(
|
||||
"executive_meeting_notification_prefs",
|
||||
@@ -266,5 +216,3 @@ export const executiveMeetingFontSettingsTable = pgTable(
|
||||
|
||||
export type ExecutiveMeeting = typeof executiveMeetingsTable.$inferSelect;
|
||||
export type ExecutiveMeetingAttendee = typeof executiveMeetingAttendeesTable.$inferSelect;
|
||||
export type ExecutiveMeetingRequest = typeof executiveMeetingRequestsTable.$inferSelect;
|
||||
export type ExecutiveMeetingTask = typeof executiveMeetingTasksTable.$inferSelect;
|
||||
|
||||
Reference in New Issue
Block a user