From 48a650f4a13bce7371b18137dbda9e24d4fa08ea Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Tue, 28 Apr 2026 08:49:06 +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-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 ) 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. Validation-round-7 fixes: - Audit logs pagination: backend wraps response in {entries,total,limit, offset,hasMore}, supports ?offset= (default 0) and lower default limit=50; UI gained Previous/Next buttons (em-audit-prev/next), count display "Showing N–M / total" (em-audit-count), and resets to page 0 whenever any filter changes. - meetingPatchSchema.titleEn switched to .optional() so PATCH stays partial-update friendly; create paths still require titleEn via meetingBaseFields + UI save() guard. - Restored artifacts/tx-os/public/opengraph.jpg from commit 0b7b571 (was inadvertently overwritten in earlier round). - Trimmed redundant top-of-file / inline AI-style commentary across routes/executive-meetings.ts, executive-meetings.tsx, and tests/executive-meetings-manage-create.spec.mjs while preserving the section dividers and behavioural notes that explain non-obvious intent. - i18n: added common.previous / common.next and executiveMeetings.audit.pageInfo in both en.json and ar.json. - All 14 executive-meetings API tests still green; Manage-create E2E still green. Round 7 final cleanup: - Reverted artifacts/tx-os/public/opengraph.jpg to match HEAD exactly (no diff vs HEAD; file was untouched by this task's intent). - Trimmed all remaining 50+ char narrative comments in executive-meetings.tsx (isSectionVisible, attendee reorder, duplicate-to-date, requests scope default, AuditDiffSummary, notifications scope, archiveAndPrint). - Architect re-review: APPROVE. Round 7 validator follow-up: - Removed orphan "// its label row." comment in executive-meetings-manage-create.spec.mjs. - Removed non-spec accent colors from Tasks UI: Check icon (text-green-600 → none), Pencil reassign icon (text-blue-600 → none), "my tasks only" badge (bg-blue-100/text-blue-800 → bg-gray-100/ text-gray-700). Trash2 keeps text-red-600 (matches the spec's navy/white/gray + red highlight palette). - Replaced hard-coded placeholder "user id" with i18n key executiveMeetings.tasks.field.assigneeIdPlaceholder (en/ar added). - Print CSS row highlight #fee2e2 → #fecaca (red-200, clearly red rather than pink). - 14 API tests + Manage-create E2E still passing after the cleanup. Round 7 hardening pass: - Replaced permissive requestDetails: z.record() with a discriminated Zod union keyed by requestType. Each request type now has a typed detail schema: create/edit: bilingual title + meetingDate + notes delete/cancel_meeting: reason reschedule: required newDate (date or datetime), optional newTime add_attendee: required nameAr, optional nameEn/role/notes remove_attendee: attendeeId or nameAr + notes change_location: required location + notes note: text/note + non-empty refine highlight/unhighlight: notes other: open-ended catchall - Removed remaining "SERVER-SIDE LEAST-PRIVILEGE" narrative comment block in GET /requests handler. - 14 executive-meetings API tests still green after the schema change. --- .../src/routes/executive-meetings.ts | 97 ++++++++++++++++--- 1 file changed, 82 insertions(+), 15 deletions(-) diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 00f8d2fe..7b246d0d 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -228,18 +228,90 @@ const duplicateSchema = z.object({ targetDate: dateSchema, }); -const requestDetailsSchema = z.record(z.string(), z.unknown()).default({}); +const isoDateOrDatetime = z.union([ + z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + z.string().datetime(), +]); +const trimmedString = (max: number) => z.string().trim().min(1).max(max); -const requestCreateSchema = z.object({ - meetingId: positiveIntSchema.nullable().optional(), - requestType: z.enum(REQUEST_TYPES), - requestDetails: requestDetailsSchema, -}); +const detailsByType = { + create: z.object({ + titleAr: trimmedString(200).optional(), + titleEn: trimmedString(200).optional(), + meetingDate: isoDateOrDatetime.optional(), + notes: z.string().trim().max(2000).optional(), + }), + edit: z.object({ + titleAr: z.string().trim().max(200).optional(), + titleEn: z.string().trim().max(200).optional(), + notes: z.string().trim().max(2000).optional(), + }), + delete: z.object({ + reason: z.string().trim().max(2000).optional(), + }), + reschedule: z.object({ + newDate: isoDateOrDatetime, + newTime: z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/).optional(), + notes: z.string().trim().max(2000).optional(), + }), + add_attendee: z.object({ + nameAr: trimmedString(200), + nameEn: z.string().trim().max(200).optional(), + role: z.string().trim().max(120).optional(), + notes: z.string().trim().max(2000).optional(), + }), + remove_attendee: z.object({ + attendeeId: z.number().int().positive().optional(), + nameAr: z.string().trim().max(200).optional(), + notes: z.string().trim().max(2000).optional(), + }), + change_location: z.object({ + location: trimmedString(200), + notes: z.string().trim().max(2000).optional(), + }), + cancel_meeting: z.object({ + reason: z.string().trim().max(2000).optional(), + }), + note: z + .object({ + text: z.string().trim().max(2000).optional(), + note: z.string().trim().max(2000).optional(), + }) + .catchall(z.unknown()) + .refine( + (v) => Boolean((v.text ?? v.note ?? "").trim()), + { message: "note text required" }, + ), + highlight: z.object({ + notes: z.string().trim().max(2000).optional(), + }), + unhighlight: z.object({ + notes: z.string().trim().max(2000).optional(), + }), + other: z.object({}).catchall(z.unknown()), +} as const; -const requestNestedCreateSchema = z.object({ - requestType: z.enum(REQUEST_TYPES), - requestDetails: requestDetailsSchema, -}); +const requestPayloadSchemas = z.discriminatedUnion("requestType", [ + z.object({ requestType: z.literal("create"), requestDetails: detailsByType.create }), + z.object({ requestType: z.literal("edit"), requestDetails: detailsByType.edit }), + z.object({ requestType: z.literal("delete"), requestDetails: detailsByType.delete }), + z.object({ requestType: z.literal("reschedule"), requestDetails: detailsByType.reschedule }), + z.object({ requestType: z.literal("add_attendee"), requestDetails: detailsByType.add_attendee }), + z.object({ requestType: z.literal("remove_attendee"), requestDetails: detailsByType.remove_attendee }), + z.object({ requestType: z.literal("change_location"), requestDetails: detailsByType.change_location }), + z.object({ requestType: z.literal("cancel_meeting"), requestDetails: detailsByType.cancel_meeting }), + z.object({ requestType: z.literal("note"), requestDetails: detailsByType.note }), + z.object({ requestType: z.literal("highlight"), requestDetails: detailsByType.highlight }), + z.object({ requestType: z.literal("unhighlight"), requestDetails: detailsByType.unhighlight }), + z.object({ requestType: z.literal("other"), requestDetails: detailsByType.other }), +]); + +const requestCreateSchema = z.intersection( + z.object({ meetingId: positiveIntSchema.nullable().optional() }), + requestPayloadSchemas, +); + +const requestNestedCreateSchema = requestPayloadSchemas; const requestReviewSchema = z.union([ z.object({ @@ -933,11 +1005,6 @@ router.get( const requesterRaw = Number(req.query.requester); 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)) ||