Task #108 — Executive Meetings Phase 2 (RBAC + audit + Zod + i18n)
Schedule UI / shell:
- Centered cells, locked RTL column order # | الاجتماع | الحضور | الوقت,
attendees column widest, tighter padding, navy/white/gray + red highlight
only. Header label "م" → "#" (ar.json).
Schema (lib/db/src/schema/executive-meetings.ts):
- executive_meeting_requests.meetingId is now nullable so create-suggestion
requests work without an existing meeting. db push applied.
Backend (artifacts/api-server/src/routes/executive-meetings.ts) — full
rewrite:
- ALL routes (including /me) gated by requireExecutiveAccess plus fine-grained
requireMutate / requireApprove / requireRequest / requireAdminAudit on the
appropriate routes.
- Zod schemas validate every mutating body via parseBody().
- All mutations write to executiveMeetingAuditLogsTable via logAudit().
- New nested route POST /:id/requests in addition to top-level requests POST.
- Status-driven approvals: PATCH /requests/:id accepts
{status: 'approved'|'rejected'|'needs_edit', reviewNotes?} or
{action:'withdraw'} for the requester.
- PUT /font-settings (canonical) plus PATCH alias — both wired DIRECTLY to a
single shared upsertFontSettingsHandler (no Express method-rewrite hack).
- GET /tasks supports date and assigneeId filters.
- GET /notifications now accepts ?date=YYYY-MM-DD and joins through the
meetings table to scope notifications to the selected day; returns []
immediately when no meetings exist on that date.
- Removed all `as never` casts via $inferInsert/Partial typing.
Auth middleware (artifacts/api-server/src/middlewares/auth.ts):
- Added requireExecutiveAccess gating EXECUTIVE_READ_ROLES (admin +
executive_*).
Frontend (artifacts/tx-os/src/pages/executive-meetings.tsx):
- Tab-level RBAC: SECTIONS.filter(isSectionVisible(key, me)) so unauthorized
users never see Manage / Approvals / Audit / Tasks tabs (matrix in the
in-file isSectionVisible helper).
- Approvals: review() sends {status, reviewNotes}; new "Send back for edits"
(needs_edit) button beside Approve / Reject.
- Requests default scope is "mine" for non-approvers and "all" for approvers
(no over-exposure to plain requesters).
- Notifications visibility uses canRead AND the query is scoped by the same
selected `date` as the schedule (queryKey + ?date=… included).
- PdfSection: primary "Print + Archive" button (testid em-print) does archive
then print in one click; em-print-only and em-archive remain as separate
buttons.
- AuditSection: 6th column renders the new in-file AuditDiffSummary component
(compact "field: old → new" diff with 6-row truncation and create/remove
fallback).
- New tWithFallback(t, key, fallback) helper used in audit + notifications
cells (avoids strict TFunction overload errors); apiJson rewritten to
extract body/headers separately and stringify rawBody, eliminating the
"Record<string, unknown> not assignable to BodyInit" TS errors. tsc
--noEmit on executive-meetings.tsx is now clean.
- Print page (executive-meetings-print.tsx) intentionally keeps its inline
bilingual T table — it loads in a standalone print window before the i18n
provider mounts, so an inline dictionary is the right pattern.
i18n (en.json + ar.json):
- New keys executiveMeetings.approvals.{needsEdit, needsEditDone},
.audit.col.changes, .audit.{created, removed, moreChanges},
.audit.entity.pdf_archive,
.audit.action.{approved, rejected, needs_edit, withdraw, apply, duplicate,
replace_attendees, done},
.pdf.{print = "Print + archive" / "طباعة وأرشفة", printOnly,
archivedAndPrinted}.
Validation:
- runTest e2e passed: login, schedule loads with "#" header, Manage tab
creates "اجتماع تجريبي E2E", Approvals/PDF/Audit tabs render with
expected testids and the new Changes column.
- curl smoke: login 200, /me 200, dates 200, create meeting 201, nested
POST /:id/requests 201, PATCH /requests/:id status approval 200,
audit-logs 200, DELETE meeting 204, PUT and PATCH /font-settings both
200 with persisted data, /notifications?date=YYYY-MM-DD filters
correctly.
- tsc --noEmit on artifacts/tx-os/src/pages/executive-meetings.tsx is
clean (pre-existing admin.tsx errors are unrelated to this task).
- Architect review issued VERDICT: APPROVED on the previous round; only
the date wiring on NotificationsSection had to be added afterwards
(now done — NotificationsSection({date}) and queryKey/url include
date).
Validation-round-6 fixes (this commit):
- Bilingual title is now mandatory across the API + UI. Backend Zod
schema (meetingBaseFields.titleEn) requires .min(1); the manage form's
client-side `save()` validator now rejects empty titleEn alongside
empty titleAr (single i18n error key reused).
- Tasks: full reassign + edit flow. Coordinator-lead/admin actions now
expose a per-row "Reassign / edit" button (em-task-reassign-{id})
opening a dialog with assignedTo + notes inputs that PATCHes
/executive-meetings/tasks/{id}. New i18n keys
executiveMeetings.tasks.{reassign,reassigned} (AR + EN).
- Audit: filter row now includes From + To date pickers
(em-audit-date-from / -to), an actor ID filter (em-audit-actor) and
an action filter (em-audit-action) on top of the existing entity
filter. Backend already accepts dateFrom/dateTo/action/actorId so
only the UI needed wiring + i18n keys
executiveMeetings.audit.filter.{from,to,actorId} (AR + EN).
- PDF archives: each archive row in the snapshots list now has an
"Open" button (em-archive-open-{id}) that opens the print view for
that snapshot's archiveDate + version in a new tab. New i18n key
executiveMeetings.pdf.openArchive (AR + EN).
- Requests: status filter now exposes the full enum the backend
accepts: new, needs_edit, approved, rejected, withdrawn, done
(status labels were already translated).
- AI-slop cleanup: trimmed verbose explanatory comments in
artifacts/api-server/tests/executive-meetings.test.mjs and the
artifacts/tx-os/tests/executive-meetings-manage-create.spec.mjs.
- Test coverage extended: API test count went from 8 → 14, adding
bilingual-title 400 path, attendees PUT replace, /duplicate roundtrip,
full review-and-apply pipeline (highlight request applied), task
reassign PATCH, audit filter combination (dateFrom/dateTo/action/
actorId/entityType), and pdf-archive POST→GET. All 14 pass against
the live API. The Manage create E2E (1 spec) still passes.
Validation-round-5 fixes:
- Coordinator task scoping (server-side, blocking RBAC fix). Added
TASK_BROAD_VIEW_ROLES (admin + office_manager + coord_lead). Plain
coordinators GET /executive-meetings/tasks now ALWAYS receive
assignedTo=currentUserId — submitted ?mine=0 / ?assigneeId=other are
ignored. /me also exposes a new canViewAllTasks flag for the UI.
- UI mirror: TasksSection receives canViewAllTasks; coordinators see a
small read-only "My tasks only" badge next to the Tasks heading
(em-tasks-mine-badge) explaining the scope. New i18n keys
executiveMeetings.tasks.{myTasksOnly,myTasksOnlyHelp} (AR + EN).
- Seed: scripts/src/seed.ts now imports
executiveMeetingNotificationsTable and inserts 3 sample notification
rules per fresh seed (two pending reminders for meeting #1 + one
sent meeting_invite for meeting #2). Idempotent — only runs the day
the executive_meetings sample data is inserted.
- Tests added (none existed for this surface):
* artifacts/api-server/tests/executive-meetings.test.mjs — 8 happy-path
+ RBAC tests covering /me capability flags (incl canViewAllTasks),
meetings POST/GET/DELETE, requests POST + admin GET, tasks
coordinator scoping (mine=0&assigneeId=lead override is ignored),
audit-logs (200 admin, 403 coordinator), notifications ?date=
filtering, font-settings (200 valid combo, 400 medium weight, 400
size 30, 400 unknown family), and pdf-archives ?date=. All 8 pass.
* artifacts/tx-os/tests/executive-meetings-manage-create.spec.mjs —
Playwright UI test that logs in as admin, opens the Manage tab via
em-nav-manage, fills the create dialog (titleAr + titleEn + date),
saves, asserts POST /api/executive-meetings returns 201, and
verifies the row landed in the DB with the expected title and date.
Validation-round-4 fixes:
- Manage attendee editor: added per-row title input alongside name +
deterministic ChevronUp/ChevronDown reorder controls (sort order is
recomputed on save). New i18n keys
executiveMeetings.manage.attendees.{moveUp,moveDown}.
- Manage tab: per-row Copy button opens a new "Duplicate to date" dialog
that calls POST /executive-meetings/:id/duplicate with a target date
picker; on success it switches the day view to the chosen date. New
i18n keys executiveMeetings.manage.{duplicate, duplicateToDate,
duplicated, duplicateFailed}.
- Print page (executive-meetings-print.tsx) now uses react-i18next with
new locale block executiveMeetings.print.* (AR + EN parity), forces
i18n.changeLanguage(lang) so ?lang=en always prints English. Attendees
cell in the print table is now textAlign: center (matches the Step 1
schedule).
- Font settings tightened to spec: families = system/Cairo/Tajawal/
Noto Naskh Arabic/Amiri (dropped Inter, monospace), weights =
regular/bold (dropped medium, semibold), alignment = start/center
(dropped end), size range = 12–22 (was 10–32). Enforced server-side
in fontSettingsSchema (Zod) and mirrored in the UI selects/range.
Verified: PATCH font-settings returns 200 for valid combo, 400 for
fontWeight="medium", 400 for fontSize=30.
Validation-round-3 fixes:
- Tasks RBAC tightened end-to-end: new TASK_VIEW_ROLES = admin +
office_manager + coord_lead + coordinator. GET /tasks and
PATCH /tasks/:id now require requireTaskView. /me exposes new
canViewTasks flag. Frontend isSectionVisible("tasks") gated on
canViewTasks (so CEO/viewer never sees the Tasks tab or hits the
endpoint). Verified: admin /me returns canViewTasks=true.
- GET /requests gained dateFrom + dateTo (createdAt range) and a
requester filter that is honored only for approver/audit roles
(non-approvers stay locked to their own requests). Verified curl
with ?dateFrom=...&dateTo=... returns 200.
- Task dueAt schema accepts BOTH YYYY-MM-DD (from <input type="date">)
and full ISO datetime; date-only is normalized to start-of-day UTC,
empty string clears the field. Verified curl POST /tasks with
{"dueAt":"2026-12-31"} returns 201 with stored
dueAt = 2026-12-31T00:00:00.000Z.
Validation-round-2 fixes:
- GET /executive-meetings/requests: added server-side least-privilege so
only APPROVE_ROLES + ADMIN_AUDIT_ROLES see other users' requests; every
other executive role is force-scoped to requestedBy = self regardless
of the client's `mine` flag (closes the RBAC overexposure flag).
- isSectionVisible("tasks") now also returns true for canSubmitRequest,
so executive_coordinator sees the Tasks tab (coordinators execute the
follow-ups generated from approved requests).
- Requests "new request" dropdown now exposes ALL twelve backend request
types: create, edit, delete, reschedule, add_attendee, remove_attendee,
change_location, cancel_meeting, note, highlight, unhighlight, other.
- i18n parity: added en.json + ar.json keys for the five missing request
types (add_attendee, remove_attendee, change_location, cancel_meeting,
note) and the two missing statuses (needs_edit, done) under
executiveMeetings.requests.{type,status}.
- Fixed wrong key reference: reschedule form's date row was using
manage.field.date (does not exist) — switched to manage.field.meetingDate.
Drift / out of scope (explicitly deferred to follow-ups #110/#111/#112):
- Real notifications delivery, server-side PDF rendering, attendee
reorder UI (drag/arrows), duplicate-to-date UI, OpenAPI/api-spec sync,
expanded seed data including executive_meeting_notifications sample
rows.
This commit is contained in:
@@ -184,7 +184,7 @@ const attendeeSchema = z.object({
|
||||
|
||||
const meetingBaseFields = {
|
||||
titleAr: z.string().trim().min(1).max(300),
|
||||
titleEn: z.string().trim().max(300).nullable().optional(),
|
||||
titleEn: z.string().trim().min(1).max(300),
|
||||
meetingDate: dateSchema,
|
||||
dailyNumber: positiveIntSchema.max(1000).nullable().optional(),
|
||||
startTime: timeSchema.nullable().optional(),
|
||||
|
||||
@@ -36,8 +36,7 @@ async function createUser(prefix, roleName) {
|
||||
);
|
||||
const id = rows[0].id;
|
||||
created.userIds.push(id);
|
||||
const roles = ["user", roleName];
|
||||
for (const r of roles) {
|
||||
for (const r of ["user", roleName]) {
|
||||
await pool.query(
|
||||
`INSERT INTO user_roles (user_id, role_id)
|
||||
SELECT $1, id FROM roles WHERE name = $2
|
||||
@@ -89,15 +88,11 @@ let leadCookie = null;
|
||||
let leadUserId = null;
|
||||
|
||||
before(async () => {
|
||||
// Reuse the seeded admin (admin/admin123) for the broad-view path.
|
||||
adminCookie = await login("admin", "admin123");
|
||||
const meRes = await api(adminCookie, "GET", "/api/auth/me");
|
||||
const me = await meRes.json();
|
||||
adminUserId = me.id ?? me.userId;
|
||||
|
||||
// Create a fresh plain coordinator and a coord_lead so we can check the
|
||||
// server-side scoping behavior of GET /tasks (coordinator must only ever
|
||||
// see their own tasks even with mine=0/assigneeId=other).
|
||||
const coord = await createUser("em_coord", "executive_coordinator");
|
||||
coordUserId = coord.id;
|
||||
coordCookie = await login(coord.username, TEST_PASSWORD);
|
||||
@@ -108,7 +103,6 @@ before(async () => {
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
// Best-effort cleanup; per-test rows may have already been deleted.
|
||||
if (created.taskIds.length > 0) {
|
||||
await pool.query(`DELETE FROM executive_meeting_tasks WHERE id = ANY($1::int[])`, [
|
||||
created.taskIds,
|
||||
@@ -152,7 +146,7 @@ const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
|
||||
test("GET /executive-meetings/me exposes the full capability flag set including canViewAllTasks", async () => {
|
||||
test("GET /me exposes capability flags including canViewAllTasks", async () => {
|
||||
const res = await api(adminCookie, "GET", "/api/executive-meetings/me");
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
@@ -174,14 +168,25 @@ test("GET /executive-meetings/me exposes the full capability flag set including
|
||||
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,
|
||||
"plain coordinators must not get cross-user task visibility",
|
||||
);
|
||||
assert.equal(coordMe.canViewAllTasks, false);
|
||||
});
|
||||
|
||||
test("Meetings: POST creates → GET day returns it → DELETE removes it", async () => {
|
||||
test("Meetings: bilingual title required (titleEn cannot be empty)", async () => {
|
||||
const missingEn = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "اجتماع",
|
||||
meetingDate: today,
|
||||
});
|
||||
assert.equal(missingEn.status, 400);
|
||||
|
||||
const emptyEn = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "اجتماع",
|
||||
titleEn: "",
|
||||
meetingDate: today,
|
||||
});
|
||||
assert.equal(emptyEn.status, 400);
|
||||
});
|
||||
|
||||
test("Meetings: POST → GET day → DELETE roundtrip", async () => {
|
||||
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "اجتماع اختبار",
|
||||
titleEn: "Test meeting",
|
||||
@@ -218,7 +223,72 @@ test("Meetings: POST creates → GET day returns it → DELETE removes it", asyn
|
||||
assert.ok(del.status === 200 || del.status === 204);
|
||||
});
|
||||
|
||||
test("Requests: POST creates → admin GET sees it; coord scope filter respects requester", async () => {
|
||||
test("Meetings: PUT /attendees replaces the attendee list", async () => {
|
||||
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "ا",
|
||||
titleEn: "A",
|
||||
meetingDate: today,
|
||||
attendees: [{ name: "Old One", attendanceType: "internal", sortOrder: 0 }],
|
||||
});
|
||||
assert.equal(create.status, 201);
|
||||
const meeting = await create.json();
|
||||
created.meetingIds.push(meeting.id);
|
||||
|
||||
const replace = await api(
|
||||
adminCookie,
|
||||
"PUT",
|
||||
`/api/executive-meetings/${meeting.id}/attendees`,
|
||||
{
|
||||
attendees: [
|
||||
{ name: "New One", title: "Director", attendanceType: "internal", sortOrder: 0 },
|
||||
{ name: "New Two", title: "Manager", attendanceType: "virtual", sortOrder: 1 },
|
||||
],
|
||||
},
|
||||
);
|
||||
assert.equal(replace.status, 200);
|
||||
const fetched = await api(
|
||||
adminCookie,
|
||||
"GET",
|
||||
`/api/executive-meetings/${meeting.id}`,
|
||||
);
|
||||
const body = await fetched.json();
|
||||
assert.equal(body.attendees.length, 2);
|
||||
assert.ok(body.attendees.find((a) => a.name === "New One"));
|
||||
assert.ok(!body.attendees.find((a) => a.name === "Old One"));
|
||||
});
|
||||
|
||||
test("Meetings: POST /duplicate clones a meeting onto another date", async () => {
|
||||
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "ب",
|
||||
titleEn: "B",
|
||||
meetingDate: today,
|
||||
attendees: [{ name: "Dup Att", attendanceType: "internal", sortOrder: 0 }],
|
||||
});
|
||||
assert.equal(create.status, 201);
|
||||
const original = await create.json();
|
||||
created.meetingIds.push(original.id);
|
||||
|
||||
const dup = await api(
|
||||
adminCookie,
|
||||
"POST",
|
||||
`/api/executive-meetings/${original.id}/duplicate`,
|
||||
{ targetDate: tomorrow },
|
||||
);
|
||||
assert.equal(dup.status, 201);
|
||||
const newRow = await dup.json();
|
||||
assert.notEqual(newRow.id, original.id);
|
||||
created.meetingIds.push(newRow.id);
|
||||
|
||||
const day = await api(
|
||||
adminCookie,
|
||||
"GET",
|
||||
`/api/executive-meetings?date=${tomorrow}`,
|
||||
);
|
||||
const dayBody = await day.json();
|
||||
assert.ok(dayBody.meetings.some((m) => m.id === newRow.id));
|
||||
});
|
||||
|
||||
test("Requests: POST → admin can list", async () => {
|
||||
const create = await api(
|
||||
adminCookie,
|
||||
"POST",
|
||||
@@ -240,10 +310,45 @@ test("Requests: POST creates → admin GET sees it; coord scope filter respects
|
||||
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 () => {
|
||||
// Admin creates a task assigned to the coordinator and another assigned to
|
||||
// the lead. The coordinator must only see the first one even when they pass
|
||||
// mine=0 / assigneeId=lead.
|
||||
const t1 = await api(adminCookie, "POST", "/api/executive-meetings/tasks", {
|
||||
taskType: "follow_up",
|
||||
assignedTo: coordUserId,
|
||||
@@ -262,8 +367,6 @@ test("Tasks: server-side scoping forces coordinators to assignedTo=self", async
|
||||
const task2 = await t2.json();
|
||||
created.taskIds.push(task2.id);
|
||||
|
||||
// Plain coordinator: mine=0 + assigneeId=leadUserId attempts override; the
|
||||
// server must ignore both and force assignedTo=coordUserId.
|
||||
const coordList = await api(
|
||||
coordCookie,
|
||||
"GET",
|
||||
@@ -272,23 +375,12 @@ test("Tasks: server-side scoping forces coordinators to assignedTo=self", async
|
||||
assert.equal(coordList.status, 200);
|
||||
const coordBody = await coordList.json();
|
||||
const coordIds = coordBody.tasks.map((t) => t.id);
|
||||
assert.ok(
|
||||
coordIds.includes(task1.id),
|
||||
"coordinator should see their own task",
|
||||
);
|
||||
assert.ok(
|
||||
!coordIds.includes(task2.id),
|
||||
"coordinator must NOT see another user's task even with assigneeId override",
|
||||
);
|
||||
assert.ok(coordIds.includes(task1.id));
|
||||
assert.ok(!coordIds.includes(task2.id));
|
||||
for (const t of coordBody.tasks) {
|
||||
assert.equal(
|
||||
t.assignedTo,
|
||||
coordUserId,
|
||||
"every task returned to a coordinator must be assigned to them",
|
||||
);
|
||||
assert.equal(t.assignedTo, coordUserId);
|
||||
}
|
||||
|
||||
// Lead: broad view honors assigneeId.
|
||||
const leadList = await api(
|
||||
leadCookie,
|
||||
"GET",
|
||||
@@ -299,6 +391,28 @@ test("Tasks: server-side scoping forces coordinators to assignedTo=self", async
|
||||
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");
|
||||
});
|
||||
|
||||
test("Audit logs: admin can list, plain coordinator gets 403", async () => {
|
||||
const ok = await api(
|
||||
adminCookie,
|
||||
@@ -317,6 +431,32 @@ test("Audit logs: admin can list, plain coordinator gets 403", async () => {
|
||||
assert.equal(denied.status, 403);
|
||||
});
|
||||
|
||||
test("Audit logs: dateFrom/dateTo, action, and actorId filters all narrow results", async () => {
|
||||
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "س",
|
||||
titleEn: "S",
|
||||
meetingDate: today,
|
||||
});
|
||||
const meeting = await create.json();
|
||||
created.meetingIds.push(meeting.id);
|
||||
|
||||
const filtered = await api(
|
||||
adminCookie,
|
||||
"GET",
|
||||
`/api/executive-meetings/audit-logs?dateFrom=${today}&dateTo=${today}&action=create&actorId=${adminUserId}&entityType=meeting`,
|
||||
);
|
||||
assert.equal(filtered.status, 200);
|
||||
const body = await filtered.json();
|
||||
const entries = body.entries ?? body.logs ?? [];
|
||||
assert.ok(Array.isArray(entries));
|
||||
for (const e of entries) {
|
||||
assert.equal(e.action, "create");
|
||||
assert.equal(e.entityType, "meeting");
|
||||
assert.equal(e.performedBy, adminUserId);
|
||||
}
|
||||
assert.ok(entries.some((e) => e.entityId === meeting.id));
|
||||
});
|
||||
|
||||
test("Notifications: GET filters by ?date= and tolerates empty days", async () => {
|
||||
const empty = await api(
|
||||
adminCookie,
|
||||
@@ -335,7 +475,7 @@ test("Notifications: GET filters by ?date= and tolerates empty days", async () =
|
||||
assert.equal(todayList.status, 200);
|
||||
});
|
||||
|
||||
test("Font settings: valid combo returns 200; invalid weight or out-of-range size returns 400", async () => {
|
||||
test("Font settings: valid combo 200; invalid weight/size/family 400", async () => {
|
||||
const ok = await api(
|
||||
adminCookie,
|
||||
"PATCH",
|
||||
@@ -375,13 +515,26 @@ test("Font settings: valid combo returns 200; invalid weight or out-of-range siz
|
||||
assert.equal(badFamily.status, 400);
|
||||
});
|
||||
|
||||
test("PDF archives: GET returns an array and supports ?date=", async () => {
|
||||
const res = await api(
|
||||
test("PDF archives: POST creates a snapshot and GET returns it", async () => {
|
||||
const post = await api(
|
||||
adminCookie,
|
||||
"POST",
|
||||
"/api/executive-meetings/pdf-archives",
|
||||
{ archiveDate: today },
|
||||
);
|
||||
assert.equal(post.status, 201);
|
||||
const archive = await post.json();
|
||||
assert.ok(archive.id);
|
||||
assert.ok(archive.version >= 1);
|
||||
|
||||
const list = await api(
|
||||
adminCookie,
|
||||
"GET",
|
||||
`/api/executive-meetings/pdf-archives?date=${today}`,
|
||||
);
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.ok(Array.isArray(body.archives ?? body.items ?? body.pdfArchives ?? []));
|
||||
assert.equal(list.status, 200);
|
||||
const body = await list.json();
|
||||
const archives = body.archives ?? body.items ?? body.pdfArchives ?? [];
|
||||
assert.ok(Array.isArray(archives));
|
||||
assert.ok(archives.some((a) => a.id === archive.id));
|
||||
});
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -792,6 +792,8 @@
|
||||
"deleteConfirm": "حذف هذه المهمة؟",
|
||||
"saved": "تم حفظ المهمة",
|
||||
"deleted": "تم حذف المهمة",
|
||||
"reassign": "إعادة إسناد / تعديل",
|
||||
"reassigned": "تم تحديث المهمة",
|
||||
"myTasksOnly": "مهامي فقط",
|
||||
"myTasksOnlyHelp": "ينظر المنسقون إلى المهام المسندة إليهم فقط، بينما يرى قائد التنسيق ومدير المكتب والمسؤول جميع المهام."
|
||||
},
|
||||
@@ -800,6 +802,11 @@
|
||||
"empty": "لا توجد قيود في السجل.",
|
||||
"filterDate": "تصفية بالتاريخ",
|
||||
"filterEntity": "النوع",
|
||||
"filter": {
|
||||
"from": "من",
|
||||
"to": "إلى",
|
||||
"actorId": "معرّف المنفّذ"
|
||||
},
|
||||
"all": "الكل",
|
||||
"col": {
|
||||
"when": "الوقت",
|
||||
@@ -861,7 +868,8 @@
|
||||
"archived": "تمت أرشفة النسخة",
|
||||
"archivedAndPrinted": "تمت أرشفة النسخة وفتح نافذة الطباعة",
|
||||
"archivesHeading": "النسخ المؤرشفة",
|
||||
"noArchives": "لا توجد نسخ مؤرشفة لهذا التاريخ بعد."
|
||||
"noArchives": "لا توجد نسخ مؤرشفة لهذا التاريخ بعد.",
|
||||
"openArchive": "فتح"
|
||||
},
|
||||
"fontSettingsPage": {
|
||||
"heading": "إعدادات الخط",
|
||||
|
||||
@@ -789,6 +789,8 @@
|
||||
"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."
|
||||
},
|
||||
@@ -797,6 +799,11 @@
|
||||
"empty": "No audit entries.",
|
||||
"filterDate": "Filter by date",
|
||||
"filterEntity": "Entity",
|
||||
"filter": {
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"actorId": "Actor ID"
|
||||
},
|
||||
"all": "All",
|
||||
"col": {
|
||||
"when": "When",
|
||||
@@ -846,7 +853,8 @@
|
||||
"archived": "Snapshot archived",
|
||||
"archivedAndPrinted": "Snapshot archived — print dialog opened",
|
||||
"archivesHeading": "Archived snapshots",
|
||||
"noArchives": "No snapshots archived for this date yet."
|
||||
"noArchives": "No snapshots archived for this date yet.",
|
||||
"openArchive": "Open"
|
||||
},
|
||||
"print": {
|
||||
"title": "Executive Meetings Schedule",
|
||||
|
||||
@@ -849,8 +849,11 @@ function ManageSection({
|
||||
|
||||
async function save() {
|
||||
if (!editing) return;
|
||||
if (!editing.titleAr.trim()) {
|
||||
toast({ title: t("executiveMeetings.manage.errors.titleRequired"), variant: "destructive" });
|
||||
if (!editing.titleAr.trim() || !editing.titleEn.trim()) {
|
||||
toast({
|
||||
title: t("executiveMeetings.manage.errors.titleRequired"),
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
@@ -1518,7 +1521,16 @@ function RequestsSection({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("executiveMeetings.common.all")}</SelectItem>
|
||||
{(["new", "approved", "rejected", "withdrawn"] as const).map((s) => (
|
||||
{(
|
||||
[
|
||||
"new",
|
||||
"needs_edit",
|
||||
"approved",
|
||||
"rejected",
|
||||
"withdrawn",
|
||||
"done",
|
||||
] as const
|
||||
).map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{t(`executiveMeetings.requests.status.${s}`)}
|
||||
</SelectItem>
|
||||
@@ -2003,6 +2015,47 @@ function TasksSection({
|
||||
}
|
||||
}
|
||||
|
||||
const [reassignTarget, setReassignTarget] = useState<TaskRow | null>(null);
|
||||
const [reassignForm, setReassignForm] = useState({ assignedTo: "", notes: "" });
|
||||
const [reassignBusy, setReassignBusy] = useState(false);
|
||||
|
||||
function openReassign(task: TaskRow) {
|
||||
setReassignTarget(task);
|
||||
setReassignForm({
|
||||
assignedTo: task.assignedTo ? String(task.assignedTo) : "",
|
||||
notes: task.notes ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
async function submitReassign() {
|
||||
if (!reassignTarget) return;
|
||||
const body: Record<string, unknown> = {};
|
||||
const newAssignee = reassignForm.assignedTo.trim();
|
||||
if (newAssignee) body.assignedTo = Number(newAssignee);
|
||||
if (reassignForm.notes !== (reassignTarget.notes ?? "")) {
|
||||
body.notes = reassignForm.notes;
|
||||
}
|
||||
if (Object.keys(body).length === 0) {
|
||||
setReassignTarget(null);
|
||||
return;
|
||||
}
|
||||
setReassignBusy(true);
|
||||
try {
|
||||
await apiJson(`/api/executive-meetings/tasks/${reassignTarget.id}`, {
|
||||
method: "PATCH",
|
||||
body,
|
||||
});
|
||||
toast({ title: t("executiveMeetings.tasks.reassigned") });
|
||||
setReassignTarget(null);
|
||||
await qc.invalidateQueries({ queryKey: ["/api/executive-meetings/tasks"] });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast({ title: msg, variant: "destructive" });
|
||||
} finally {
|
||||
setReassignBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTask(id: number) {
|
||||
if (!window.confirm(t("executiveMeetings.tasks.deleteConfirm"))) return;
|
||||
try {
|
||||
@@ -2129,13 +2182,24 @@ function TasksSection({
|
||||
</>
|
||||
)}
|
||||
{canMutate && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => deleteTask(task.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-600" />
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => openReassign(task)}
|
||||
title={t("executiveMeetings.tasks.reassign")}
|
||||
data-testid={`em-task-reassign-${task.id}`}
|
||||
>
|
||||
<Pencil className="w-4 h-4 text-blue-600" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => deleteTask(task.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-600" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
@@ -2146,6 +2210,51 @@ function TasksSection({
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={!!reassignTarget}
|
||||
onOpenChange={(v) => !v && setReassignTarget(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("executiveMeetings.tasks.reassign")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<FormRow label={t("executiveMeetings.tasks.field.assignee")}>
|
||||
<Input
|
||||
type="number"
|
||||
value={reassignForm.assignedTo}
|
||||
onChange={(e) =>
|
||||
setReassignForm({ ...reassignForm, assignedTo: e.target.value })
|
||||
}
|
||||
data-testid="em-task-reassign-assignee"
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow label={t("executiveMeetings.tasks.field.notes")}>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={reassignForm.notes}
|
||||
onChange={(e) =>
|
||||
setReassignForm({ ...reassignForm, notes: e.target.value })
|
||||
}
|
||||
data-testid="em-task-reassign-notes"
|
||||
/>
|
||||
</FormRow>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setReassignTarget(null)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submitReassign}
|
||||
disabled={reassignBusy}
|
||||
data-testid="em-task-reassign-save"
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
@@ -2327,13 +2436,26 @@ function AuditSection({
|
||||
t: (k: string) => string;
|
||||
}) {
|
||||
const [date, setDate] = useState("");
|
||||
const [dateTo, setDateTo] = useState("");
|
||||
const [entityType, setEntityType] = useState("all");
|
||||
const [actionFilter, setActionFilter] = useState("all");
|
||||
const [actorFilter, setActorFilter] = useState("");
|
||||
const { data, isLoading } = useQuery<{ entries: AuditEntry[] }>({
|
||||
queryKey: ["/api/executive-meetings/audit-logs", date, entityType],
|
||||
queryKey: [
|
||||
"/api/executive-meetings/audit-logs",
|
||||
date,
|
||||
dateTo,
|
||||
entityType,
|
||||
actionFilter,
|
||||
actorFilter,
|
||||
],
|
||||
queryFn: () => {
|
||||
const qs = new URLSearchParams();
|
||||
if (date) qs.set("date", date);
|
||||
if (date) qs.set("dateFrom", date);
|
||||
if (dateTo) qs.set("dateTo", dateTo);
|
||||
if (entityType !== "all") qs.set("entityType", entityType);
|
||||
if (actionFilter !== "all") qs.set("action", actionFilter);
|
||||
if (actorFilter.trim()) qs.set("actorId", actorFilter.trim());
|
||||
return apiJson(`/api/executive-meetings/audit-logs?${qs}`);
|
||||
},
|
||||
enabled: canView,
|
||||
@@ -2348,13 +2470,50 @@ function AuditSection({
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[#0B1E3F]">
|
||||
{t("executiveMeetings.audit.heading")}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="border border-gray-300 rounded px-2 py-1 text-sm bg-white h-9"
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<label className="text-xs text-gray-500 flex items-center gap-1">
|
||||
{t("executiveMeetings.audit.filter.from")}
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="border border-gray-300 rounded px-2 py-1 text-sm bg-white h-9"
|
||||
data-testid="em-audit-date-from"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs text-gray-500 flex items-center gap-1">
|
||||
{t("executiveMeetings.audit.filter.to")}
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="border border-gray-300 rounded px-2 py-1 text-sm bg-white h-9"
|
||||
data-testid="em-audit-date-to"
|
||||
/>
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t("executiveMeetings.audit.filter.actorId")}
|
||||
value={actorFilter}
|
||||
onChange={(e) => setActorFilter(e.target.value)}
|
||||
className="w-32 h-9"
|
||||
data-testid="em-audit-actor"
|
||||
/>
|
||||
<Select value={actionFilter} onValueChange={setActionFilter}>
|
||||
<SelectTrigger className="w-36 h-9" data-testid="em-audit-action">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("executiveMeetings.audit.all")}</SelectItem>
|
||||
{(["create", "update", "delete", "approve", "reject"] as const).map(
|
||||
(a) => (
|
||||
<SelectItem key={a} value={a}>
|
||||
{tWithFallback(t, `executiveMeetings.audit.action.${a}`, a)}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={entityType} onValueChange={setEntityType}>
|
||||
<SelectTrigger className="w-36 h-9">
|
||||
<SelectValue />
|
||||
@@ -2704,9 +2863,22 @@ function PdfSection({
|
||||
· {who}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 truncate max-w-[200px]">
|
||||
{a.filePath}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const url = `/executive-meetings/print?date=${a.archiveDate}&lang=${lang}&version=${a.version}`;
|
||||
window.open(url, "_blank", "noopener");
|
||||
}}
|
||||
data-testid={`em-archive-open-${a.id}`}
|
||||
>
|
||||
{t("executiveMeetings.pdf.openArchive")}
|
||||
</Button>
|
||||
<span className="text-xs text-gray-400 truncate max-w-[160px]">
|
||||
{a.filePath}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user