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.
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.
Round 7 contract realignment (validator round 4 fix):
- Aligned discriminated request schemas to the EXACT field names that
the UI form builder sends and that applyApprovedRequest reads:
reschedule: meetingDate / startTime / endTime (not new*)
add_attendee: name / title / attendanceType (not name*Ar/role)
remove_attendee: required attendeeId
change_location: location / meetingUrl / platform (uses PLATFORMS)
note: accepts both `note` and `text` fields
All schemas use .catchall(z.unknown()) for forward-compat extra
fields and .refine() to require at least one mutating field where
applicable (reschedule, change_location, note).
- All 14 executive-meetings API tests still pass after the rewrite.
This commit is contained in:
@@ -228,66 +228,96 @@ const duplicateSchema = z.object({
|
|||||||
targetDate: dateSchema,
|
targetDate: dateSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isoDateOrDatetime = z.union([
|
const dateOnly = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
const timeHm = z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/);
|
||||||
z.string().datetime(),
|
|
||||||
]);
|
|
||||||
const trimmedString = (max: number) => z.string().trim().min(1).max(max);
|
|
||||||
|
|
||||||
const detailsByType = {
|
const detailsByType = {
|
||||||
create: z.object({
|
create: z
|
||||||
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({
|
.object({
|
||||||
text: z.string().trim().max(2000).optional(),
|
titleAr: z.string().trim().max(300).optional(),
|
||||||
note: z.string().trim().max(2000).optional(),
|
titleEn: z.string().trim().max(300).optional(),
|
||||||
|
meetingDate: dateOnly.optional(),
|
||||||
|
startTime: timeHm.optional(),
|
||||||
|
endTime: timeHm.optional(),
|
||||||
|
location: z.string().trim().max(300).optional(),
|
||||||
|
notes: z.string().trim().max(5000).optional(),
|
||||||
|
})
|
||||||
|
.catchall(z.unknown()),
|
||||||
|
edit: z
|
||||||
|
.object({
|
||||||
|
titleAr: z.string().trim().max(300).optional(),
|
||||||
|
titleEn: z.string().trim().max(300).optional(),
|
||||||
|
notes: z.string().trim().max(5000).optional(),
|
||||||
|
})
|
||||||
|
.catchall(z.unknown()),
|
||||||
|
delete: z
|
||||||
|
.object({
|
||||||
|
reason: z.string().trim().max(5000).optional(),
|
||||||
|
})
|
||||||
|
.catchall(z.unknown()),
|
||||||
|
reschedule: z
|
||||||
|
.object({
|
||||||
|
meetingDate: dateOnly.optional(),
|
||||||
|
startTime: timeHm.optional(),
|
||||||
|
endTime: timeHm.optional(),
|
||||||
|
notes: z.string().trim().max(5000).optional(),
|
||||||
})
|
})
|
||||||
.catchall(z.unknown())
|
.catchall(z.unknown())
|
||||||
.refine(
|
.refine(
|
||||||
(v) => Boolean((v.text ?? v.note ?? "").trim()),
|
(v) => Boolean(v.meetingDate || v.startTime || v.endTime),
|
||||||
|
{ message: "reschedule requires meetingDate, startTime, or endTime" },
|
||||||
|
),
|
||||||
|
add_attendee: z
|
||||||
|
.object({
|
||||||
|
name: z.string().trim().min(1).max(200),
|
||||||
|
title: z.string().trim().max(200).optional(),
|
||||||
|
attendanceType: z.enum(ATTENDANCE_TYPES).optional(),
|
||||||
|
notes: z.string().trim().max(5000).optional(),
|
||||||
|
})
|
||||||
|
.catchall(z.unknown()),
|
||||||
|
remove_attendee: z
|
||||||
|
.object({
|
||||||
|
attendeeId: z.number().int().positive(),
|
||||||
|
notes: z.string().trim().max(5000).optional(),
|
||||||
|
})
|
||||||
|
.catchall(z.unknown()),
|
||||||
|
change_location: z
|
||||||
|
.object({
|
||||||
|
location: z.string().trim().max(300).optional(),
|
||||||
|
meetingUrl: z.string().trim().max(1000).optional(),
|
||||||
|
platform: z.enum(PLATFORMS).optional(),
|
||||||
|
notes: z.string().trim().max(5000).optional(),
|
||||||
|
})
|
||||||
|
.catchall(z.unknown())
|
||||||
|
.refine(
|
||||||
|
(v) => Boolean(v.location || v.meetingUrl || v.platform),
|
||||||
|
{ message: "change_location requires location, meetingUrl, or platform" },
|
||||||
|
),
|
||||||
|
cancel_meeting: z
|
||||||
|
.object({
|
||||||
|
reason: z.string().trim().max(5000).optional(),
|
||||||
|
})
|
||||||
|
.catchall(z.unknown()),
|
||||||
|
note: z
|
||||||
|
.object({
|
||||||
|
note: z.string().trim().max(5000).optional(),
|
||||||
|
text: z.string().trim().max(5000).optional(),
|
||||||
|
})
|
||||||
|
.catchall(z.unknown())
|
||||||
|
.refine(
|
||||||
|
(v) => Boolean((v.note ?? v.text ?? "").trim()),
|
||||||
{ message: "note text required" },
|
{ message: "note text required" },
|
||||||
),
|
),
|
||||||
highlight: z.object({
|
highlight: z
|
||||||
notes: z.string().trim().max(2000).optional(),
|
.object({
|
||||||
}),
|
notes: z.string().trim().max(5000).optional(),
|
||||||
unhighlight: z.object({
|
})
|
||||||
notes: z.string().trim().max(2000).optional(),
|
.catchall(z.unknown()),
|
||||||
}),
|
unhighlight: z
|
||||||
|
.object({
|
||||||
|
notes: z.string().trim().max(5000).optional(),
|
||||||
|
})
|
||||||
|
.catchall(z.unknown()),
|
||||||
other: z.object({}).catchall(z.unknown()),
|
other: z.object({}).catchall(z.unknown()),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user