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).

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.
This commit is contained in:
Riyadh
2026-04-28 07:28:38 +00:00
parent 2f10ae6dc4
commit 1cb30bb014
2 changed files with 148 additions and 40 deletions
@@ -1515,7 +1515,28 @@ router.get(
router.get(
"/executive-meetings/notifications",
requireExecutiveAccess,
async (_req, res): Promise<void> => {
async (req, res): Promise<void> => {
// 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({
+125 -39
View File
@@ -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<T>(
url: string,
init?: RequestInit & { body?: unknown },
init?: Omit<RequestInit, "body"> & { body?: unknown },
): Promise<T> {
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() {
<nav className="border-t border-gray-100 bg-white overflow-x-auto">
<div className="max-w-screen-2xl mx-auto px-2 sm:px-4 flex gap-1 sm:gap-2 min-w-max">
{SECTIONS.map(({ key, icon: Icon }) => {
const active = section === key;
return (
<button
key={key}
type="button"
onClick={() => setSection(key)}
className={
"flex items-center gap-1.5 px-3 sm:px-4 py-2.5 text-xs sm:text-sm whitespace-nowrap border-b-2 transition-colors " +
(active
? "border-[#0B1E3F] text-[#0B1E3F] font-semibold"
: "border-transparent text-muted-foreground hover:text-foreground")
}
data-testid={`em-nav-${key}`}
>
<Icon className="w-4 h-4" />
{t(`executiveMeetings.nav.${key}`)}
</button>
);
})}
{SECTIONS.filter(({ key }) => isSectionVisible(key, me)).map(
({ key, icon: Icon }) => {
const active = section === key;
return (
<button
key={key}
type="button"
onClick={() => setSection(key)}
className={
"flex items-center gap-1.5 px-3 sm:px-4 py-2.5 text-xs sm:text-sm whitespace-nowrap border-b-2 transition-colors " +
(active
? "border-[#0B1E3F] text-[#0B1E3F] font-semibold"
: "border-transparent text-muted-foreground hover:text-foreground")
}
data-testid={`em-nav-${key}`}
>
<Icon className="w-4 h-4" />
{t(`executiveMeetings.nav.${key}`)}
</button>
);
},
)}
</div>
</nav>
</header>
@@ -416,7 +473,12 @@ export default function ExecutiveMeetingsPage() {
/>
)}
{section === "notifications" && me && (
<NotificationsSection canView={me.canRead} isRtl={isRtl} t={t} />
<NotificationsSection
canView={me.canRead}
date={date}
isRtl={isRtl}
t={t}
/>
)}
{section === "audit" && me && (
<AuditSection canView={me.canViewAudit} isRtl={isRtl} t={t} />
@@ -1153,7 +1215,12 @@ function RequestsSection({
const qc = useQueryClient();
const { toast } = useToast();
const [filterStatus, setFilterStatus] = useState<string>("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({
</td>
<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 })}
{tWithFallback(
t,
`executiveMeetings.audit.action.${e.action}`,
e.action,
)}
</td>
<td className="px-3 py-2 text-xs">
{t(`executiveMeetings.audit.entity.${e.entityType}`, {
defaultValue: e.entityType,
})}
{tWithFallback(
t,
`executiveMeetings.audit.entity.${e.entityType}`,
e.entityType,
)}
</td>
<td className="px-3 py-2 text-xs">{e.entityId ?? "—"}</td>
<td className="px-3 py-2 text-xs">
@@ -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 <NoPermission t={t} />;
@@ -2274,17 +2356,21 @@ function NotificationsSection({
<td className="px-3 py-2">{n.meetingId ? `#${n.meetingId}` : "—"}</td>
<td className="px-3 py-2">{user}</td>
<td className="px-3 py-2 text-xs">
{t(`executiveMeetings.notificationsPage.type.${n.notificationType}`, {
defaultValue: n.notificationType,
})}
{tWithFallback(
t,
`executiveMeetings.notificationsPage.type.${n.notificationType}`,
n.notificationType,
)}
</td>
<td className="px-3 py-2 text-xs">
{n.scheduledAt ? new Date(n.scheduledAt).toLocaleString() : "—"}
</td>
<td className="px-3 py-2 text-xs">
{t(`executiveMeetings.notificationsPage.status.${n.status}`, {
defaultValue: n.status,
})}
{tWithFallback(
t,
`executiveMeetings.notificationsPage.status.${n.status}`,
n.status,
)}
</td>
</tr>
);