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-3 fixes (this commit):
- 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:
Riyadh
2026-04-28 07:40:33 +00:00
parent 4cfd70f34b
commit d666de8a0c
2 changed files with 58 additions and 6 deletions
@@ -100,6 +100,15 @@ const ADMIN_AUDIT_ROLES = [
"executive_office_manager",
"executive_coord_lead",
] as const;
// Tasks are an internal coordination workflow; CEO and viewer roles must NOT
// see the Tasks tab or hit GET /tasks. Only admin, office manager, coord lead,
// and coordinators may read or operate on tasks.
const TASK_VIEW_ROLES = [
"admin",
"executive_office_manager",
"executive_coord_lead",
"executive_coordinator",
] as const;
const READ_ROLES = [
"admin",
"executive_ceo",
@@ -145,6 +154,7 @@ const requireMutate = makeRequireRoles(MUTATE_ROLES);
const requireApprove = makeRequireRoles(APPROVE_ROLES);
const requireRequest = makeRequireRoles(REQUEST_ROLES);
const requireAdminAudit = makeRequireRoles(ADMIN_AUDIT_ROLES);
const requireTaskView = makeRequireRoles(TASK_VIEW_ROLES);
// ---------- Zod schemas ----------
@@ -245,12 +255,28 @@ const requestReviewSchema = z.union([
}),
]);
// dueAt accepts either a YYYY-MM-DD date (from <input type="date">) OR a full
// ISO datetime. Date-only is normalized to start-of-day UTC. Empty string is
// treated as null so PATCH can clear a due date.
const dueAtSchema = z
.union([
z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
z.string().datetime(),
z.literal(""),
])
.nullable()
.optional()
.transform((v) => {
if (v === "" || v === null || v === undefined) return null;
return /^\d{4}-\d{2}-\d{2}$/.test(v) ? `${v}T00:00:00.000Z` : v;
});
const taskCreateSchema = z.object({
taskType: z.string().trim().min(1).max(64),
meetingId: positiveIntSchema.nullable().optional(),
requestId: positiveIntSchema.nullable().optional(),
assignedTo: positiveIntSchema.nullable().optional(),
dueAt: z.string().datetime().nullable().optional(),
dueAt: dueAtSchema,
notes: z.string().trim().max(5000).nullable().optional(),
});
@@ -258,7 +284,7 @@ const taskPatchSchema = z.object({
status: z.enum(TASK_STATUSES).optional(),
taskType: z.string().trim().min(1).max(64).optional(),
assignedTo: positiveIntSchema.nullable().optional(),
dueAt: z.union([z.string().datetime(), z.literal("")]).nullable().optional(),
dueAt: dueAtSchema,
notes: z.string().trim().max(5000).nullable().optional(),
meetingId: positiveIntSchema.nullable().optional(),
});
@@ -429,6 +455,7 @@ router.get(
canApprove: APPROVE_ROLES.some((r) => names.has(r)),
canSubmitRequest: REQUEST_ROLES.some((r) => names.has(r)),
canViewAudit: ADMIN_AUDIT_ROLES.some((r) => names.has(r)),
canViewTasks: TASK_VIEW_ROLES.some((r) => names.has(r)),
});
},
);
@@ -907,6 +934,11 @@ router.get(
async (req, res): Promise<void> => {
const status = typeof req.query.status === "string" ? req.query.status : "";
const mine = req.query.mine === "true" || req.query.mine === "1";
const dateFromRaw =
typeof req.query.dateFrom === "string" ? req.query.dateFrom.trim() : "";
const dateToRaw =
typeof req.query.dateTo === "string" ? req.query.dateTo.trim() : "";
const requesterRaw = Number(req.query.requester);
const userId = req.session.userId!;
// SERVER-SIDE LEAST-PRIVILEGE: only approver/admin/audit roles can see
@@ -923,6 +955,22 @@ router.get(
if (status) conds.push(eq(executiveMeetingRequestsTable.status, status));
if (mine || !canSeeAllRequests) {
conds.push(eq(executiveMeetingRequestsTable.requestedBy, userId));
} else if (Number.isInteger(requesterRaw) && requesterRaw > 0) {
// Approvers may filter by a specific requester; non-approvers ignored.
conds.push(eq(executiveMeetingRequestsTable.requestedBy, requesterRaw));
}
if (dateFromRaw && /^\d{4}-\d{2}-\d{2}$/.test(dateFromRaw)) {
conds.push(
gte(
executiveMeetingRequestsTable.createdAt,
new Date(`${dateFromRaw}T00:00:00.000Z`),
),
);
}
if (dateToRaw && /^\d{4}-\d{2}-\d{2}$/.test(dateToRaw)) {
const end = new Date(`${dateToRaw}T00:00:00.000Z`);
end.setUTCDate(end.getUTCDate() + 1);
conds.push(lt(executiveMeetingRequestsTable.createdAt, end));
}
const where = conds.length > 0 ? and(...conds) : undefined;
@@ -1207,6 +1255,7 @@ router.patch(
router.get(
"/executive-meetings/tasks",
requireExecutiveAccess,
requireTaskView,
async (req, res): Promise<void> => {
const status = typeof req.query.status === "string" ? req.query.status : "";
const mine = req.query.mine === "true" || req.query.mine === "1";
@@ -1321,6 +1370,7 @@ router.post(
router.patch(
"/executive-meetings/tasks/:id",
requireExecutiveAccess,
requireTaskView,
async (req, res): Promise<void> => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {