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. Fine-grained
  middleware: 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 the same shared upsertFontSettingsHandler (no method-rewrite hack).
- GET /tasks supports date and assigneeId filters.
- 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):
- Notifications visibility now uses canRead (was canViewAudit).
- Approvals: review() sends {status, reviewNotes} and a new
  "Send back for edits" (needs_edit) button is rendered alongside
  Approve/Reject.
- PdfSection: primary "Print + Archive" button (testid em-print) does
  archive then print in one click; em-print-only and em-archive remain.
- AuditSection: 6th column renders the new in-file AuditDiffSummary
  component (compact "field: old → new" diff with truncation).
- Print page (executive-meetings-print.tsx): kept 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. No hardcoded UI
  text remains in the main page.

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.
- Architect re-reviewed twice: VERDICT: APPROVED on the second pass
  after fixing /me gating and the PATCH font-settings dispatch.

Out of scope (handled in follow-ups #110/#111/#112): real notifications
delivery, server-side PDF rendering, mobile polish.

Drift: none material. Followed plan T1–T8.
This commit is contained in:
riyadhafraa
2026-04-28 07:19:07 +00:00
parent bf85d7066e
commit 0b7b571a1e
9 changed files with 983 additions and 807 deletions
+2 -1
View File
@@ -24,7 +24,8 @@
"google-auth-library": "^10.6.2",
"pino": "^9",
"pino-http": "^10",
"socket.io": "^4.8.3"
"socket.io": "^4.8.3",
"zod": "catalog:"
},
"devDependencies": {
"@types/bcryptjs": "^3.0.0",
@@ -190,3 +190,52 @@ export async function getUserRoles(userId: number): Promise<string[]> {
return Array.from(new Set(roles.map((r) => r.roleName)));
}
const EXECUTIVE_READ_ROLES: ReadonlyArray<string> = [
"admin",
"executive_ceo",
"executive_office_manager",
"executive_coord_lead",
"executive_coordinator",
"executive_viewer",
];
/**
* Gate any read access to the Executive Meetings module. Allows admin and
* any of the executive_* roles. Mutate / approve / audit-view sub-roles
* are layered on top via local helpers in the routes file.
*/
export async function requireExecutiveAccess(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
if (!req.session.userId) {
res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
return;
}
const [user] = await db
.select({ isActive: usersTable.isActive })
.from(usersTable)
.where(eq(usersTable.id, req.session.userId));
if (!user || !user.isActive) {
res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
return;
}
const ids = await getEffectiveRoleIds(req.session.userId);
if (ids.length === 0) {
res.status(403).json({ error: "Forbidden", code: "forbidden" });
return;
}
const rows = await db
.select({ name: rolesTable.name })
.from(rolesTable)
.where(inArray(rolesTable.id, ids));
const names = new Set(rows.map((r) => r.name));
const ok = EXECUTIVE_READ_ROLES.some((r) => names.has(r));
if (!ok) {
res.status(403).json({ error: "Forbidden", code: "forbidden" });
return;
}
next();
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+21 -4
View File
@@ -748,6 +748,8 @@
"empty": "لا توجد طلبات بانتظار المراجعة.",
"approve": "اعتماد",
"reject": "رفض",
"needsEdit": "إعادة للتعديل",
"needsEditDone": "تم إرجاع الطلب لمقدّمه",
"reviewNotes": "ملاحظات المراجعة",
"reviewNotesPlaceholder": "اكتب ملاحظاتك هنا (اختياري)",
"approved": "تم الاعتماد",
@@ -790,32 +792,47 @@
"action": "الإجراء",
"entity": "النوع",
"entityId": "المعرف",
"diff": "التفاصيل"
"diff": "التفاصيل",
"changes": "التغييرات"
},
"created": "تم إنشاء",
"removed": "تم حذف",
"moreChanges": "تغييرات أخرى",
"entity": {
"meeting": "اجتماع",
"attendee": "حاضر",
"request": "طلب",
"task": "مهمة",
"font_settings": "إعدادات الخط"
"font_settings": "إعدادات الخط",
"pdf_archive": "أرشيف PDF"
},
"action": {
"create": "إنشاء",
"update": "تعديل",
"delete": "حذف",
"approve": "اعتماد",
"approved": "تم الاعتماد",
"reject": "رفض",
"submit": "إرسال"
"rejected": "تم الرفض",
"needs_edit": "إعادة للتعديل",
"withdraw": "سحب",
"submit": "إرسال",
"apply": "تطبيق",
"duplicate": "تكرار",
"replace_attendees": "تحديث الحضور",
"done": "تم"
}
},
"pdf": {
"heading": "تصدير / طباعة",
"intro": "تستخدم هذه الواجهة طابعة المتصفح لإنتاج نسخة PDF من جدول اليوم. اختر التاريخ ثم اضغط طباعة.",
"print": "طباعة الجدول",
"print": "طباعة وأرشفة",
"printOnly": "طباعة فقط",
"openSchedule": "عرض الجدول",
"selectDate": "تاريخ الجدول",
"archiveSnapshot": "أرشفة نسخة",
"archived": "تمت أرشفة النسخة",
"archivedAndPrinted": "تمت أرشفة النسخة وفتح نافذة الطباعة",
"archivesHeading": "النسخ المؤرشفة",
"noArchives": "لا توجد نسخ مؤرشفة لهذا التاريخ بعد."
},
+21 -4
View File
@@ -745,6 +745,8 @@
"empty": "No requests pending review.",
"approve": "Approve",
"reject": "Reject",
"needsEdit": "Send back for edits",
"needsEditDone": "Sent back to requester",
"reviewNotes": "Review notes",
"reviewNotesPlaceholder": "Optional notes for the requester",
"approved": "Approved",
@@ -787,32 +789,47 @@
"action": "Action",
"entity": "Entity",
"entityId": "ID",
"diff": "Details"
"diff": "Details",
"changes": "Changes"
},
"created": "Created",
"removed": "Removed",
"moreChanges": "more changes",
"entity": {
"meeting": "Meeting",
"attendee": "Attendee",
"request": "Request",
"task": "Task",
"font_settings": "Font settings"
"font_settings": "Font settings",
"pdf_archive": "PDF archive"
},
"action": {
"create": "Create",
"update": "Update",
"delete": "Delete",
"approve": "Approve",
"approved": "Approved",
"reject": "Reject",
"submit": "Submit"
"rejected": "Rejected",
"needs_edit": "Needs edit",
"withdraw": "Withdrawn",
"submit": "Submit",
"apply": "Apply",
"duplicate": "Duplicate",
"replace_attendees": "Update attendees",
"done": "Done"
}
},
"pdf": {
"heading": "Print / Export",
"intro": "This view uses the browser print dialog to produce a PDF of the day's schedule. Pick a date and click Print.",
"print": "Print schedule",
"print": "Print + archive",
"printOnly": "Print only",
"openSchedule": "Open schedule",
"selectDate": "Schedule date",
"archiveSnapshot": "Archive snapshot",
"archived": "Snapshot archived",
"archivedAndPrinted": "Snapshot archived — print dialog opened",
"archivesHeading": "Archived snapshots",
"noArchives": "No snapshots archived for this date yet."
},
+187 -17
View File
@@ -416,7 +416,7 @@ export default function ExecutiveMeetingsPage() {
/>
)}
{section === "notifications" && me && (
<NotificationsSection canView={me.canViewAudit} isRtl={isRtl} t={t} />
<NotificationsSection canView={me.canRead} isRtl={isRtl} t={t} />
)}
{section === "audit" && me && (
<AuditSection canView={me.canViewAudit} isRtl={isRtl} t={t} />
@@ -1595,17 +1595,22 @@ function ApprovalsSection({
});
const requests = data?.requests ?? [];
async function review(id: number, action: "approve" | "reject") {
async function review(
id: number,
status: "approved" | "rejected" | "needs_edit",
) {
try {
await apiJson(`/api/executive-meetings/requests/${id}`, {
method: "PATCH",
body: { action, reviewNotes: notes[id] ?? "" },
body: { status, reviewNotes: notes[id] ?? "" },
});
toast({
title:
action === "approve"
status === "approved"
? t("executiveMeetings.approvals.approved")
: t("executiveMeetings.approvals.rejected"),
: status === "rejected"
? t("executiveMeetings.approvals.rejected")
: t("executiveMeetings.approvals.needsEditDone"),
});
await Promise.all([
qc.invalidateQueries({ queryKey: ["/api/executive-meetings/requests"] }),
@@ -1675,10 +1680,18 @@ function ApprovalsSection({
value={notes[r.id] ?? ""}
onChange={(e) => setNotes({ ...notes, [r.id]: e.target.value })}
/>
<div className="flex gap-2 justify-end">
<div className="flex gap-2 justify-end flex-wrap">
<Button
variant="outline"
onClick={() => review(r.id, "reject")}
onClick={() => review(r.id, "needs_edit")}
className="gap-1"
data-testid={`em-needs-edit-${r.id}`}
>
{t("executiveMeetings.approvals.needsEdit")}
</Button>
<Button
variant="outline"
onClick={() => review(r.id, "rejected")}
className="gap-1"
data-testid={`em-reject-${r.id}`}
>
@@ -1686,7 +1699,7 @@ function ApprovalsSection({
{t("executiveMeetings.approvals.reject")}
</Button>
<Button
onClick={() => review(r.id, "approve")}
onClick={() => review(r.id, "approved")}
className="bg-[#0B1E3F] gap-1"
data-testid={`em-approve-${r.id}`}
>
@@ -1963,6 +1976,116 @@ function TasksSection({
// ============ AUDIT ============
// Compact "field: old → new" summary of an audit entry's old/new JSON values.
// Used in the audit log table so reviewers can see what actually changed
// without having to expand raw JSON. Falls back to JSON dumps for unknown
// shapes so nothing is hidden.
function AuditDiffSummary({
oldValue,
newValue,
t,
}: {
oldValue: unknown;
newValue: unknown;
t: (k: string) => string;
}) {
const interesting = [
"titleAr",
"titleEn",
"meetingDate",
"dailyNumber",
"startTime",
"endTime",
"location",
"meetingUrl",
"platform",
"status",
"isHighlighted",
"notes",
"assignedTo",
"taskType",
"dueAt",
"reviewDecision",
"reviewNotes",
] as const;
const oldObj =
oldValue && typeof oldValue === "object"
? (oldValue as Record<string, unknown>)
: null;
const newObj =
newValue && typeof newValue === "object"
? (newValue as Record<string, unknown>)
: null;
const fmt = (v: unknown): string => {
if (v === null || v === undefined || v === "") return "—";
if (typeof v === "string") return v.length > 40 ? `${v.slice(0, 40)}` : v;
if (typeof v === "number" || typeof v === "boolean") return String(v);
try {
const s = JSON.stringify(v);
return s.length > 40 ? `${s.slice(0, 40)}` : s;
} catch {
return String(v);
}
};
if (!oldObj && !newObj) return <span className="text-gray-400"></span>;
if (oldObj && newObj) {
const diffs: { key: string; from: unknown; to: unknown }[] = [];
for (const k of interesting) {
const a = oldObj[k];
const b = newObj[k];
if (JSON.stringify(a) !== JSON.stringify(b)) {
diffs.push({ key: k, from: a, to: b });
}
}
if (diffs.length === 0) {
return <span className="text-gray-400"></span>;
}
return (
<ul className="space-y-0.5">
{diffs.slice(0, 6).map((d) => (
<li key={d.key} className="leading-snug">
<span className="font-semibold text-[#0B1E3F]">{d.key}</span>:{" "}
<span className="text-red-700 line-through">{fmt(d.from)}</span>
<span className="mx-1 text-gray-400"></span>
<span className="text-green-700">{fmt(d.to)}</span>
</li>
))}
{diffs.length > 6 && (
<li className="text-gray-500">
+{diffs.length - 6} {t("executiveMeetings.audit.moreChanges")}
</li>
)}
</ul>
);
}
// Pure create or pure delete — show a compact field summary
const single = (newObj ?? oldObj) as Record<string, unknown>;
const label = newObj
? t("executiveMeetings.audit.created")
: t("executiveMeetings.audit.removed");
const items = interesting
.filter((k) => single[k] !== undefined && single[k] !== null && single[k] !== "")
.slice(0, 6);
return (
<div>
<div className="font-semibold text-[#0B1E3F] mb-0.5">{label}</div>
<ul className="space-y-0.5">
{items.map((k) => (
<li key={k} className="leading-snug">
<span className="font-semibold">{k}</span>: {fmt(single[k])}
</li>
))}
</ul>
</div>
);
}
function AuditSection({
canView,
isRtl,
@@ -2028,19 +2151,20 @@ function AuditSection({
<th className="px-3 py-2 text-start w-28">{t("executiveMeetings.audit.col.action")}</th>
<th className="px-3 py-2 text-start w-28">{t("executiveMeetings.audit.col.entity")}</th>
<th className="px-3 py-2 text-start w-20">{t("executiveMeetings.audit.col.entityId")}</th>
<th className="px-3 py-2 text-start">{t("executiveMeetings.audit.col.changes")}</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={5} className="py-8 text-center text-gray-500">
<td colSpan={6} className="py-8 text-center text-gray-500">
{t("common.loading")}
</td>
</tr>
)}
{!isLoading && entries.length === 0 && (
<tr>
<td colSpan={5} className="py-8 text-center text-gray-500">
<td colSpan={6} className="py-8 text-center text-gray-500">
{t("executiveMeetings.audit.empty")}
</td>
</tr>
@@ -2052,20 +2176,31 @@ function AuditSection({
: e.actor.displayNameEn || e.actor.username
: "—";
return (
<tr key={e.id} className="border-t border-gray-100">
<td className="px-3 py-2 align-middle text-xs text-gray-600">
<tr
key={e.id}
className="border-t border-gray-100 align-top"
data-testid={`em-audit-row-${e.id}`}
>
<td className="px-3 py-2 text-xs text-gray-600 whitespace-nowrap">
{new Date(e.performedAt).toLocaleString()}
</td>
<td className="px-3 py-2 align-middle">{actor}</td>
<td className="px-3 py-2 align-middle text-xs">
<td className="px-3 py-2">{actor}</td>
<td className="px-3 py-2 text-xs">
{t(`executiveMeetings.audit.action.${e.action}`, { defaultValue: e.action })}
</td>
<td className="px-3 py-2 align-middle text-xs">
<td className="px-3 py-2 text-xs">
{t(`executiveMeetings.audit.entity.${e.entityType}`, {
defaultValue: e.entityType,
})}
</td>
<td className="px-3 py-2 align-middle text-xs">{e.entityId ?? "—"}</td>
<td className="px-3 py-2 text-xs">{e.entityId ?? "—"}</td>
<td className="px-3 py-2 text-xs">
<AuditDiffSummary
oldValue={e.oldValue}
newValue={e.newValue}
t={t}
/>
</td>
</tr>
);
})}
@@ -2208,6 +2343,30 @@ function PdfSection({
}
}
// Print + archive in one click: archive the snapshot first, then open the
// print view in a new tab so the user can hit Ctrl+P / Save as PDF.
async function archiveAndPrint() {
if (!date) return;
setBusy(true);
try {
await apiJson(`/api/executive-meetings/pdf-archives`, {
method: "POST",
body: { archiveDate: date },
});
await qc.invalidateQueries({
queryKey: ["/api/executive-meetings/pdf-archives", date],
});
const url = `/executive-meetings/print?date=${date}&lang=${lang}`;
window.open(url, "_blank", "noopener");
toast({ title: t("executiveMeetings.pdf.archivedAndPrinted") });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({ title: msg, variant: "destructive" });
} finally {
setBusy(false);
}
}
const archives = archivesQuery.data?.archives ?? [];
return (
@@ -2226,13 +2385,24 @@ function PdfSection({
</FormRow>
<div className="flex flex-wrap gap-2">
<Button
onClick={openPrint}
onClick={archiveAndPrint}
disabled={busy || !date}
className="bg-[#0B1E3F] gap-1"
data-testid="em-print"
>
<Printer className="w-4 h-4" />
{t("executiveMeetings.pdf.print")}
</Button>
<Button
onClick={openPrint}
disabled={!date}
variant="outline"
className="gap-1"
data-testid="em-print-only"
>
<Printer className="w-4 h-4" />
{t("executiveMeetings.pdf.printOnly")}
</Button>
<Button
onClick={archiveSnapshot}
disabled={busy || !date}
+4 -1
View File
@@ -61,7 +61,7 @@ catalogs:
specifier: ^7.3.2
version: 7.3.2
zod:
specifier: ^3.25.76
specifier: 3.25.76
version: 3.25.76
overrides:
@@ -123,6 +123,9 @@ importers:
socket.io:
specifier: ^4.8.3
version: 4.8.3
zod:
specifier: 'catalog:'
version: 3.25.76
devDependencies:
'@types/bcryptjs':
specifier: ^3.0.0
+1 -1
View File
@@ -27,7 +27,7 @@ catalog:
tailwindcss: ^4.1.14
tsx: ^4.21.0
vite: ^7.3.2
zod: ^3.25.76
zod: 3.25.76
minimumReleaseAge: 1440