From d666de8a0c98a669d19c71b71010a7a184d2c75d Mon Sep 17 00:00:00 2001 From: Riyadh Date: Tue, 28 Apr 2026 07:40:33 +0000 Subject: [PATCH] =?UTF-8?q?Task=20#108=20=E2=80=94=20Executive=20Meetings?= =?UTF-8?q?=20Phase=202=20(RBAC=20+=20audit=20+=20Zod=20+=20i18n)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 ) 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. --- .../src/routes/executive-meetings.ts | 54 ++++++++++++++++++- .../tx-os/src/pages/executive-meetings.tsx | 10 ++-- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 99c4c8d4..73e46ad0 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -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 ) 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 => { 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 => { 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 => { const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) { diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index fd3a24d5..7d5acef9 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -81,6 +81,7 @@ type MeRoles = { canApprove: boolean; canSubmitRequest: boolean; canViewAudit: boolean; + canViewTasks: boolean; }; type RequestRow = { @@ -201,6 +202,7 @@ type MeCapabilities = { canApprove: boolean; canSubmitRequest: boolean; canViewAudit: boolean; + canViewTasks: boolean; }; // Decides whether a navigation tab should be visible at all for the current @@ -224,10 +226,10 @@ function isSectionVisible( case "approvals": return me.canApprove; case "tasks": - // 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; + // Tasks tab is gated by the dedicated canViewTasks server-side flag, + // which only includes admin/office_manager/coord_lead/coordinator — + // CEO and viewer roles never see it. + return me.canViewTasks; case "notifications": return me.canRead; case "audit":