#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:
riyadhafraa
2026-05-01 08:18:29 +00:00
parent f7ab5e8b08
commit 21c935064d
14 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",
);
});
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(
+70 -152
View File
@@ -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": "لا توجد قيود في السجل.",
+58 -140
View File
@@ -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

+8 -60
View File
@@ -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;