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-2 fixes (this commit):
- 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:
Riyadh
2026-04-28 07:35:26 +00:00
parent 1cb30bb014
commit 4cfd70f34b
4 changed files with 39 additions and 5 deletions
@@ -909,9 +909,21 @@ router.get(
const mine = req.query.mine === "true" || req.query.mine === "1";
const userId = req.session.userId!;
// SERVER-SIDE LEAST-PRIVILEGE: only approver/admin/audit roles can see
// requests submitted by other users. Everyone else (CEO, coordinators,
// viewers) is silently scoped to their own submissions regardless of
// what the client asked for. The client may still pass mine=1 to
// narrow further, but it cannot escape the requester filter.
const names = await getRoleNamesForUser(userId);
const canSeeAllRequests =
APPROVE_ROLES.some((r) => names.has(r)) ||
ADMIN_AUDIT_ROLES.some((r) => names.has(r));
const conds: SQL[] = [];
if (status) conds.push(eq(executiveMeetingRequestsTable.status, status));
if (mine) conds.push(eq(executiveMeetingRequestsTable.requestedBy, userId));
if (mine || !canSeeAllRequests) {
conds.push(eq(executiveMeetingRequestsTable.requestedBy, userId));
}
const where = conds.length > 0 ? and(...conds) : undefined;
const rows = await db
+8 -1
View File
@@ -732,6 +732,11 @@
"edit": "تعديل بيانات",
"delete": "حذف",
"reschedule": "إعادة جدولة",
"add_attendee": "إضافة حاضر",
"remove_attendee": "إزالة حاضر",
"change_location": "تغيير المكان",
"cancel_meeting": "إلغاء الاجتماع",
"note": "ملاحظة",
"highlight": "تمييز",
"unhighlight": "إزالة التمييز",
"other": "آخر"
@@ -740,7 +745,9 @@
"new": "جديد",
"approved": "تمت الموافقة",
"rejected": "مرفوض",
"withdrawn": "مسحوب"
"needs_edit": "يحتاج تعديل",
"withdrawn": "مسحوب",
"done": "تم"
}
},
"approvals": {
+8 -1
View File
@@ -729,6 +729,11 @@
"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"
@@ -737,7 +742,9 @@
"new": "New",
"approved": "Approved",
"rejected": "Rejected",
"withdrawn": "Withdrawn"
"needs_edit": "Needs edit",
"withdrawn": "Withdrawn",
"done": "Done"
}
},
"approvals": {
@@ -224,7 +224,10 @@ function isSectionVisible(
case "approvals":
return me.canApprove;
case "tasks":
return me.canMutate || me.canApprove;
// Coordination team (executive_coordinator) needs the Tasks tab even
// though they don't have full mutation rights — they execute follow-ups
// generated from approved requests. canSubmitRequest covers them.
return me.canMutate || me.canApprove || me.canSubmitRequest;
case "notifications":
return me.canRead;
case "audit":
@@ -1452,6 +1455,11 @@ function RequestsSection({
"edit",
"delete",
"reschedule",
"add_attendee",
"remove_attendee",
"change_location",
"cancel_meeting",
"note",
"highlight",
"unhighlight",
"other",
@@ -1474,7 +1482,7 @@ function RequestsSection({
</FormRow>
{form.requestType === "reschedule" && (
<>
<FormRow label={t("executiveMeetings.manage.field.date")}>
<FormRow label={t("executiveMeetings.manage.field.meetingDate")}>
<Input
type="date"
value={form.meetingDate}