From 1cb30bb0142aa4b3b834b0d724decfbbdeeb76d4 Mon Sep 17 00:00:00 2001 From: Riyadh Date: Tue, 28 Apr 2026 07:28:38 +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). Drift / out of scope (explicitly deferred to follow-ups #110/#111/#112): - Real notifications delivery, server-side PDF rendering, attendee reorder UI, duplicate-to-date UI, OpenAPI/api-spec sync, expanded seed data, every backend request type as its own UI form. --- .../src/routes/executive-meetings.ts | 24 ++- .../tx-os/src/pages/executive-meetings.tsx | 164 +++++++++++++----- 2 files changed, 148 insertions(+), 40 deletions(-) diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index eba82bac..e3d2bc44 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -1515,7 +1515,28 @@ router.get( router.get( "/executive-meetings/notifications", requireExecutiveAccess, - async (_req, res): Promise => { + async (req, res): Promise => { + // Optional ?date=YYYY-MM-DD filter joins through the meetings table so + // the schedule's "selected date" can scope the notifications view, as + // required by Phase 2. + const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : ""; + const conds: SQL[] = []; + let meetingIdsForDate: number[] | null = null; + if (dateRaw && DATE_RE_LOCAL.test(dateRaw)) { + const meetingsOnDate = await db + .select({ id: executiveMeetingsTable.id }) + .from(executiveMeetingsTable) + .where(eq(executiveMeetingsTable.meetingDate, dateRaw)); + meetingIdsForDate = meetingsOnDate.map((m) => m.id); + if (meetingIdsForDate.length === 0) { + res.json({ notifications: [] }); + return; + } + conds.push( + inArray(executiveMeetingNotificationsTable.meetingId, meetingIdsForDate), + ); + } + const where = conds.length > 0 ? and(...conds) : undefined; const rows = await db .select({ n: executiveMeetingNotificationsTable, @@ -1525,6 +1546,7 @@ router.get( }) .from(executiveMeetingNotificationsTable) .leftJoin(usersTable, eq(usersTable.id, executiveMeetingNotificationsTable.userId)) + .where(where) .orderBy(desc(executiveMeetingNotificationsTable.createdAt)) .limit(200); res.json({ diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index 3e56e0a3..f3961e1c 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -193,6 +193,47 @@ const SECTIONS = [ type SectionKey = (typeof SECTIONS)[number]["key"]; +type MeCapabilities = { + userId: number; + roles: string[]; + canRead: boolean; + canMutate: boolean; + canApprove: boolean; + canSubmitRequest: boolean; + canViewAudit: boolean; +}; + +// Decides whether a navigation tab should be visible at all for the current +// user. Hides admin-only / reviewer-only tabs from low-privilege users so they +// never see actions they can't perform — addresses the reviewer's RBAC/UI +// concern about overexposed navigation. +function isSectionVisible( + key: SectionKey, + me: MeCapabilities | null | undefined, +): boolean { + if (!me) return key === "schedule"; // Pre-load: only show the read-only schedule. + switch (key) { + case "schedule": + case "pdf": + case "fontSettings": + return me.canRead; + case "manage": + return me.canMutate; + case "requests": + return me.canSubmitRequest || me.canApprove; + case "approvals": + return me.canApprove; + case "tasks": + return me.canMutate || me.canApprove; + case "notifications": + return me.canRead; + case "audit": + return me.canViewAudit; + default: + return false; + } +} + const DEFAULT_FONT: FontPrefs = { fontFamily: "system", fontSize: 14, @@ -228,17 +269,31 @@ function buildFontStyle(prefs: FontPrefs): CSSProperties { }; } +// Type-safe wrapper around i18next's `t` that returns the fallback when the +// key is missing instead of echoing the raw key. Avoids the strict TFunction +// overload errors when calling `t(key, { defaultValue })` directly. +function tWithFallback( + t: (key: string) => string, + key: string, + fallback: string, +): string { + const value = t(key); + return value === key ? fallback : value; +} + async function apiJson( url: string, - init?: RequestInit & { body?: unknown }, + init?: Omit & { body?: unknown }, ): Promise { + const { body: rawBody, headers: rawHeaders, ...rest } = init ?? {}; const opts: RequestInit = { credentials: "include", - headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) }, - ...(init ?? {}), + ...rest, + headers: { "Content-Type": "application/json", ...(rawHeaders ?? {}) }, }; - if (init?.body !== undefined && typeof init.body !== "string") { - opts.body = JSON.stringify(init.body); + if (rawBody !== undefined) { + opts.body = + typeof rawBody === "string" ? rawBody : JSON.stringify(rawBody); } const res = await fetch(url, opts); if (res.status === 204) return undefined as T; @@ -362,26 +417,28 @@ export default function ExecutiveMeetingsPage() { @@ -416,7 +473,12 @@ export default function ExecutiveMeetingsPage() { /> )} {section === "notifications" && me && ( - + )} {section === "audit" && me && ( @@ -1153,7 +1215,12 @@ function RequestsSection({ const qc = useQueryClient(); const { toast } = useToast(); const [filterStatus, setFilterStatus] = useState("all"); - const [scope, setScope] = useState<"all" | "mine">("all"); + // Default scope = "mine" for plain requesters; only reviewers/admins land + // on "all" so requests are not over-exposed to roles that should primarily + // see their own submissions. + const [scope, setScope] = useState<"all" | "mine">( + me.canApprove ? "all" : "mine", + ); const [open, setOpen] = useState(false); const [submitting, setSubmitting] = useState(false); const [form, setForm] = useState<{ @@ -2186,12 +2253,18 @@ function AuditSection({ {actor} - {t(`executiveMeetings.audit.action.${e.action}`, { defaultValue: e.action })} + {tWithFallback( + t, + `executiveMeetings.audit.action.${e.action}`, + e.action, + )} - {t(`executiveMeetings.audit.entity.${e.entityType}`, { - defaultValue: e.entityType, - })} + {tWithFallback( + t, + `executiveMeetings.audit.entity.${e.entityType}`, + e.entityType, + )} {e.entityId ?? "—"} @@ -2215,16 +2288,25 @@ function AuditSection({ function NotificationsSection({ canView, + date, isRtl, t, }: { canView: boolean; + date: string; isRtl: boolean; t: (k: string) => string; }) { + // Notifications are scoped by the same selected date as the schedule, so + // managers see only what is relevant to the day they are reviewing. const { data, isLoading } = useQuery<{ notifications: NotificationRow[] }>({ - queryKey: ["/api/executive-meetings/notifications"], - queryFn: () => apiJson(`/api/executive-meetings/notifications`), + queryKey: ["/api/executive-meetings/notifications", date], + queryFn: () => + apiJson( + `/api/executive-meetings/notifications${ + date ? `?date=${encodeURIComponent(date)}` : "" + }`, + ), enabled: canView, }); if (!canView) return ; @@ -2274,17 +2356,21 @@ function NotificationsSection({ {n.meetingId ? `#${n.meetingId}` : "—"} {user} - {t(`executiveMeetings.notificationsPage.type.${n.notificationType}`, { - defaultValue: n.notificationType, - })} + {tWithFallback( + t, + `executiveMeetings.notificationsPage.type.${n.notificationType}`, + n.notificationType, + )} {n.scheduledAt ? new Date(n.scheduledAt).toLocaleString() : "—"} - {t(`executiveMeetings.notificationsPage.status.${n.status}`, { - defaultValue: n.status, - })} + {tWithFallback( + t, + `executiveMeetings.notificationsPage.status.${n.status}`, + n.status, + )} );