Files
TX/artifacts/api-server/src/routes/executive-meetings.ts
T
riyadhafraa 21c935064d #262: remove Requests / Approvals / Tasks tabs from Executive Meetings
Full-stack removal of the three retired sections — UI, locales, realtime
invalidations, backend routes, role lists, capability flags, schema
tables, notify lib, and tests.

Backend (artifacts/api-server)
- routes/executive-meetings.ts: deleted /requests* + /tasks* handler
  block, REQUEST_ROLES / TASK_VIEW_ROLES / TASK_BROAD_VIEW_ROLES,
  canSubmitRequest / canViewTasks / canViewAllTasks from /me, retired
  table imports, and dead schemas (detailsByType, requestPayloadSchemas,
  request*Schema, taskCreateSchema, taskPatchSchema, dueAtSchema,
  dateOnly, timeHm). canApprove kept (still used by FontSettings).
- lib/executive-meeting-notify.ts: EXECUTIVE_MEETING_NOTIFICATION_TYPES
  collapsed to ['meeting_created'].

Frontend (artifacts/tx-os)
- pages/executive-meetings.tsx: deleted RequestsSection /
  ApprovalsSection / TasksSection / RequestListRow, pruned SECTIONS,
  MeCapabilities / MeRoles types, isSectionVisible cases, icon imports.
- hooks/use-notifications-socket.ts: dropped the two retired query
  invalidations.
- locales/{ar,en}.json: removed nav.{requests,approvals,tasks},
  executiveMeetings.{requests,approvals,tasks} subtrees, and the 6
  retired notification.type entries.

Schema + DB
- lib/db/src/schema/executive-meetings.ts: tables + relations + types
  for requests/tasks removed.
- artifacts/api-server/scripts/cleanup-em-requests-tasks.sql:
  idempotent BEGIN/COMMIT — deletes orphan prefs / notifications /
  audit rows, then DROP TABLE … CASCADE for both retired tables.
  Applied to dev DB and `db push` re-synced.

Tests
- executive-meetings.test.mjs: deleted 9 retired blocks + 2 covered
  prefs duplicates, rewrote /me capability test to assert flags absent,
  rewrote DELETE-wipe test to use meeting_created via POST
  /api/executive-meetings, removed /requests + /tasks router.param
  entries.
- executive-meetings-notifications.test.mjs: deleted 7 blocks
  (request_*, task_*, cross-event-mute), updated before/after
  cleanup to skip dropped tables, kept setPref/clearPref helpers
  (still used by surviving meeting_created opt-out tests).

Drift / pre-existing
- 3 test failures observed under the parallel `node --test` workflow
  (meeting_created fan-out count, pref opt-out daily-number conflict,
  service-orders JSON-vs-HTML) are pre-existing parallel-file
  pollution between executive-meetings.test.mjs and
  executive-meetings-notifications.test.mjs. Verified by running
  `node --test --test-concurrency=1 'tests/**/*.test.mjs'` →
  226/226 pass. Out of scope for #262.
- Pre-existing tsc warnings at routes/executive-meetings.ts L509/625
  (boolean/number on isHighlighted) and L1594 (font-settings scope
  query) untouched.
2026-05-01 08:18:29 +00:00

1657 lines
58 KiB
TypeScript

import { Router, type IRouter } from "express";
import {
eq,
asc,
desc,
inArray,
and,
sql,
gte,
lt,
isNull,
or,
type SQL,
} from "drizzle-orm";
import { z, type ZodType } from "zod";
import { db } from "@workspace/db";
import {
executiveMeetingsTable,
executiveMeetingAttendeesTable,
// #262: executiveMeetingRequestsTable + executiveMeetingTasksTable
// removed alongside the Requests / Approvals / Tasks tabs.
executiveMeetingNotificationsTable,
executiveMeetingNotificationPrefsTable,
executiveMeetingAuditLogsTable,
executiveMeetingPdfArchivesTable,
executiveMeetingFontSettingsTable,
rolesTable,
usersTable,
type ExecutiveMeetingAttendee,
} from "@workspace/db";
import {
requireAuth,
requireExecutiveAccess,
getEffectiveRoleIds,
} from "../middlewares/auth";
import {
sanitizePlainTextOrNull,
sanitizeRichText,
stripTagsToPlainTextOrNull,
} from "../lib/sanitize";
import {
emitExecutiveMeetingsDayChanged,
emitExecutiveMeetingsDaysChanged,
} from "../lib/realtime";
import { renderSchedulePdf, type PdfFontPrefs } from "../lib/pdf-renderer";
import { ObjectStorageService } from "../lib/objectStorage";
import {
recordExecutiveMeetingNotifications,
broadcastExecutiveMeetingNotifications,
getUserIdsForRoleNames,
sendExecutiveMeetingEmail,
getUserDisplay as getUserDisplayForNotify,
EXECUTIVE_MEETING_NOTIFICATION_TYPES,
} from "../lib/executive-meeting-notify";
import { ExecutiveMeetingsReorderBody } from "@workspace/api-zod";
import type { Request, Response, NextFunction } from "express";
const router: IRouter = Router();
router.param("id", (req, res, next, value) => {
if (!/^\d+$/.test(String(value))) {
next("route");
return;
}
next();
});
// ---------- Constants ----------
const PLATFORMS = ["none", "webex", "teams", "zoom", "other"] as const;
const STATUSES = ["scheduled", "cancelled", "completed", "postponed"] as const;
const ATTENDANCE_TYPES = ["internal", "virtual", "external"] as const;
// "person" is the default for backwards compatibility — every existing row
// is backfilled to this kind. "subheading" rows are user-defined section
// labels that live in the same attendees array as persons but must be
// excluded from numbering, attendee counts, and virtual-platform-name
// extraction. They share `attendance_type` and `sort_order` with persons
// so a subheading "belongs" to exactly one attendance-type group.
const ATTENDEE_KINDS = ["person", "subheading"] as const;
// Schedule column ids that the merge-cells overlay can span. Kept in
// sync with the frontend ColumnId enum in
// artifacts/tx-os/src/pages/executive-meetings.tsx.
const MERGE_COLUMNS = ["number", "meeting", "attendees", "time"] as const;
const MERGE_COLUMN_INDEX: Record<(typeof MERGE_COLUMNS)[number], number> = {
number: 0,
meeting: 1,
attendees: 2,
time: 3,
};
// #262: REQUEST_TYPES, REQUEST_REVIEW_STATUSES, TASK_STATUSES removed
// alongside the Requests / Approvals / Tasks tabs.
const FONT_FAMILIES = [
"system",
"Cairo",
"Tajawal",
"Noto Naskh Arabic",
"Amiri",
] as const;
const FONT_WEIGHTS = ["regular", "bold"] as const;
const FONT_ALIGNMENTS = ["start", "center"] as const;
const FONT_SIZE_MIN = 12;
const FONT_SIZE_MAX = 22;
const MUTATE_ROLES = [
"admin",
"executive_office_manager",
"executive_coord_lead",
] as const;
const APPROVE_ROLES = ["admin", "executive_office_manager"] as const;
const ADMIN_AUDIT_ROLES = [
"admin",
"executive_office_manager",
"executive_coord_lead",
] as const;
// #262: REQUEST_ROLES, TASK_VIEW_ROLES, TASK_BROAD_VIEW_ROLES removed
// alongside the Requests / Approvals / Tasks tabs.
const READ_ROLES = [
"admin",
"executive_ceo",
"executive_office_manager",
"executive_coord_lead",
"executive_coordinator",
"executive_viewer",
] as const;
// ---------- Role helpers (finer-grained, layered on top of requireExecutiveAccess) ----------
async function getRoleNamesForUser(userId: number): Promise<Set<string>> {
const ids = await getEffectiveRoleIds(userId);
if (ids.length === 0) return new Set();
const rows = await db
.select({ name: rolesTable.name })
.from(rolesTable)
.where(inArray(rolesTable.id, ids));
return new Set(rows.map((r) => r.name));
}
function makeRequireRoles(allowed: ReadonlyArray<string>) {
return async function (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
if (!req.session.userId) {
res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
return;
}
const names = await getRoleNamesForUser(req.session.userId);
const ok = allowed.some((r) => names.has(r));
if (!ok) {
res.status(403).json({ error: "Forbidden", code: "forbidden" });
return;
}
next();
};
}
const requireMutate = makeRequireRoles(MUTATE_ROLES);
const requireApprove = makeRequireRoles(APPROVE_ROLES);
const requireAdminAudit = makeRequireRoles(ADMIN_AUDIT_ROLES);
// #262: requireRequest + requireTaskView removed with their handlers.
// ---------- Zod schemas ----------
const dateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "expected YYYY-MM-DD");
// #262: kept after the Requests/Approvals/Tasks block was removed; the
// audit + meetings query handlers still need it to validate ?date=… and
// ?dateFrom/dateTo= inputs.
const DATE_RE_LOCAL = /^\d{4}-\d{2}-\d{2}$/;
const timeSchema = z
.string()
.regex(/^\d{2}:\d{2}(:\d{2})?$/, "expected HH:MM");
const positiveIntSchema = z.number().int().positive();
// titleAr/En and attendee.name accept sanitized rich-text HTML (Tiptap
// output). The byte count grows quickly with inline <span style=...> wrappers,
// so we allow up to ~10k bytes for titles and ~5k for attendee names. The
// sanitizer strips disallowed tags/attrs before persistence.
const attendeeSchema = z
.object({
// For persons this is sanitized rich-text HTML (Tiptap output).
// For subheadings this is the user's free-text label, also passed
// through sanitizeRichText to strip any sneaky markup. Both kinds
// require at least one non-whitespace character; an empty string is
// a delete-row gesture in the inline editor and must never reach the DB.
name: z.string().trim().min(1).max(5000),
title: z.string().trim().max(200).nullable().optional(),
attendanceType: z.enum(ATTENDANCE_TYPES).default("internal"),
sortOrder: z.number().int().min(0).optional(),
kind: z.enum(ATTENDEE_KINDS).default("person"),
})
// `.strict()` so any client-only field (e.g. the React `_sid` row id used
// by the drag-and-drop editor) that accidentally leaks into the wire
// payload is rejected loudly with 400 instead of being silently stripped.
// The browser always projects to the documented wire shape; this is the
// belt-and-suspenders guard against a future refactor regression.
.strict();
const meetingBaseFields = {
titleAr: z.string().trim().min(1).max(10000),
titleEn: z.string().trim().min(1).max(10000),
meetingDate: dateSchema,
dailyNumber: positiveIntSchema.max(1000).nullable().optional(),
startTime: timeSchema.nullable().optional(),
endTime: timeSchema.nullable().optional(),
location: z.string().trim().max(500).nullable().optional(),
meetingUrl: z.string().trim().max(2000).nullable().optional(),
platform: z.enum(PLATFORMS).optional(),
status: z.enum(STATUSES).optional(),
isHighlighted: z
.union([z.boolean(), z.number()])
.transform((v) => (v ? 1 : 0))
.optional(),
notes: z.string().trim().max(5000).nullable().optional(),
} as const;
const meetingCreateSchema = z
.object({
...meetingBaseFields,
attendees: z.array(attendeeSchema).optional(),
})
.refine(
(v) => !v.startTime || !v.endTime || v.startTime <= v.endTime,
{ message: "startTime must be <= endTime", path: ["endTime"] },
);
// Cell-merge overlay payload accepted by PATCH. The whole `merge` key is
// optional; when present it must either be `null` (clear) or a fully
// specified object describing the column range and the free-text label.
// `mergeText` accepts sanitized rich-text HTML up to ~10k bytes (same
// budget as titleAr/titleEn).
const meetingMergeSchema = z
.union([
z.null(),
z.object({
mergeStartColumn: z.enum(MERGE_COLUMNS),
mergeEndColumn: z.enum(MERGE_COLUMNS),
mergeText: z.string().trim().max(10000),
}),
])
.refine(
(v) =>
v === null ||
MERGE_COLUMN_INDEX[v.mergeStartColumn] <=
MERGE_COLUMN_INDEX[v.mergeEndColumn],
{
message: "mergeStartColumn must come before mergeEndColumn",
path: ["mergeEndColumn"],
},
);
const meetingPatchSchema = z
.object({
titleAr: meetingBaseFields.titleAr.optional(),
titleEn: meetingBaseFields.titleEn.optional(),
meetingDate: meetingBaseFields.meetingDate.optional(),
dailyNumber: meetingBaseFields.dailyNumber,
startTime: meetingBaseFields.startTime,
endTime: meetingBaseFields.endTime,
location: meetingBaseFields.location,
meetingUrl: meetingBaseFields.meetingUrl,
platform: meetingBaseFields.platform,
status: meetingBaseFields.status,
isHighlighted: meetingBaseFields.isHighlighted,
notes: meetingBaseFields.notes,
attendees: z.array(attendeeSchema).optional(),
merge: meetingMergeSchema.optional(),
})
.refine(
(v) => !v.startTime || !v.endTime || v.startTime <= v.endTime,
{ message: "startTime must be <= endTime", path: ["endTime"] },
);
const attendeesPutSchema = z.object({
attendees: z.array(attendeeSchema),
});
const duplicateSchema = z.object({
targetDate: dateSchema,
});
// Reorder schema lives in @workspace/api-zod (lib/api-zod/src/manual.ts) so
// frontend and server share the same contract for POST /reorder.
const reorderSchema = ExecutiveMeetingsReorderBody;
// #262: dateOnly + timeHm helpers removed; only the request payload
// schemas referenced them.
// #262: detailsByType, requestPayloadSchemas, requestCreateSchema,
// requestNestedCreateSchema, requestReviewSchema, dueAtSchema,
// taskCreateSchema, taskPatchSchema removed alongside the
// Requests / Approvals / Tasks handlers.
const pdfArchiveCreateSchema = z.object({
archiveDate: dateSchema,
filePath: z.string().trim().max(500).optional(),
});
const fontSettingsSchema = z.object({
scope: z.enum(["user", "global"]).default("user"),
fontFamily: z.enum(FONT_FAMILIES).default("system"),
fontSize: z.number().int().min(FONT_SIZE_MIN).max(FONT_SIZE_MAX).default(14),
fontWeight: z.enum(FONT_WEIGHTS).default("regular"),
alignment: z.enum(FONT_ALIGNMENTS).default("start"),
});
function parseBody<T>(
res: Response,
schema: ZodType<T>,
body: unknown,
): T | null {
const r = schema.safeParse(body ?? {});
if (!r.success) {
const issue = r.error.issues[0];
const msg = issue
? `${issue.path.join(".") || "body"}: ${issue.message}`
: "validation";
res.status(400).json({ error: msg, code: "validation" });
return null;
}
return r.data;
}
type DbExecutor = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0];
type AuditInsert = typeof executiveMeetingAuditLogsTable.$inferInsert;
async function logAudit(
executor: DbExecutor,
opts: {
action: string;
entityType: string;
entityId: number | null;
oldValue?: unknown;
newValue?: unknown;
performedBy: number | null;
},
): Promise<void> {
const row: AuditInsert = {
action: opts.action,
entityType: opts.entityType,
entityId: opts.entityId ?? null,
oldValue: (opts.oldValue ?? null) as AuditInsert["oldValue"],
newValue: (opts.newValue ?? null) as AuditInsert["newValue"],
performedBy: opts.performedBy,
};
await executor.insert(executiveMeetingAuditLogsTable).values(row);
}
// ---------- Common helpers ----------
async function nextDailyNumber(
executor: DbExecutor,
date: string,
): Promise<number> {
const [row] = await executor
.select({
max: sql<number | null>`max(${executiveMeetingsTable.dailyNumber})`,
})
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.meetingDate, date));
return (row?.max ?? 0) + 1;
}
async function fetchMeetingWithAttendees(id: number) {
const [meeting] = await db
.select()
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.id, id));
if (!meeting) return null;
const attendees = await db
.select()
.from(executiveMeetingAttendeesTable)
.where(eq(executiveMeetingAttendeesTable.meetingId, id))
.orderBy(asc(executiveMeetingAttendeesTable.sortOrder));
return { ...meeting, attendees };
}
// =====================================================================
// MEETINGS
// =====================================================================
router.get(
"/executive-meetings",
requireExecutiveAccess,
async (req, res): Promise<void> => {
const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : "";
if (dateRaw && !/^\d{4}-\d{2}-\d{2}$/.test(dateRaw)) {
res
.status(400)
.json({ error: "Invalid 'date' (expected YYYY-MM-DD)", code: "bad_date" });
return;
}
const targetDate = dateRaw || new Date().toISOString().slice(0, 10);
const meetings = await db
.select()
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.meetingDate, targetDate))
.orderBy(asc(executiveMeetingsTable.dailyNumber));
if (meetings.length === 0) {
res.json({ date: targetDate, meetings: [] });
return;
}
const ids = meetings.map((m) => m.id);
const attendees = await db
.select()
.from(executiveMeetingAttendeesTable)
.where(inArray(executiveMeetingAttendeesTable.meetingId, ids))
.orderBy(
asc(executiveMeetingAttendeesTable.meetingId),
asc(executiveMeetingAttendeesTable.sortOrder),
);
const byMeeting = new Map<number, typeof attendees>();
for (const a of attendees) {
const arr = byMeeting.get(a.meetingId) ?? [];
arr.push(a);
byMeeting.set(a.meetingId, arr);
}
res.json({
date: targetDate,
meetings: meetings.map((m) => ({
...m,
attendees: byMeeting.get(m.id) ?? [],
})),
});
},
);
router.get(
"/executive-meetings/dates",
requireExecutiveAccess,
async (_req, res): Promise<void> => {
const rows = await db
.selectDistinct({ d: executiveMeetingsTable.meetingDate })
.from(executiveMeetingsTable)
.orderBy(asc(executiveMeetingsTable.meetingDate));
res.json({ dates: rows.map((r) => r.d) });
},
);
router.get(
"/executive-meetings/me",
requireExecutiveAccess,
async (req, res): Promise<void> => {
if (!req.session.userId) {
res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
return;
}
const names = await getRoleNamesForUser(req.session.userId);
res.json({
userId: req.session.userId,
roles: Array.from(names),
canRead: READ_ROLES.some((r) => names.has(r)),
canMutate: MUTATE_ROLES.some((r) => names.has(r)),
// #262: canApprove now only gates the global font-settings write
// path; canSubmitRequest, canViewTasks, canViewAllTasks were
// removed with the Requests/Approvals/Tasks tabs.
canApprove: APPROVE_ROLES.some((r) => names.has(r)),
canViewAudit: ADMIN_AUDIT_ROLES.some((r) => names.has(r)),
});
},
);
router.get(
"/executive-meetings/:id",
requireExecutiveAccess,
async (req, res): Promise<void> => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
res.status(404).json({ error: "Not found", code: "not_found" });
return;
}
const m = await fetchMeetingWithAttendees(id);
if (!m) {
res.status(404).json({ error: "Meeting not found", code: "not_found" });
return;
}
res.json(m);
},
);
router.post(
"/executive-meetings",
requireExecutiveAccess,
requireMutate,
async (req, res): Promise<void> => {
const data = parseBody(res, meetingCreateSchema, req.body);
if (!data) return;
const userId = req.session.userId!;
try {
const inserted = await db.transaction(async (tx) => {
const dailyNumber =
data.dailyNumber ?? (await nextDailyNumber(tx, data.meetingDate));
const meetingValues: typeof executiveMeetingsTable.$inferInsert = {
titleAr: sanitizeRichText(data.titleAr),
titleEn: sanitizeRichText(data.titleEn ?? ""),
meetingDate: data.meetingDate,
dailyNumber,
startTime: data.startTime ?? null,
endTime: data.endTime ?? null,
// location/meetingUrl/notes are plain-text fields in the UI but
// can receive sneaky markup from a paste; strip tags at the API
// boundary. Use stripTagsToPlainTextOrNull so URLs with `&` and
// notes with stray `<`/`>` round-trip without entity-encoding.
location: stripTagsToPlainTextOrNull(data.location),
meetingUrl: stripTagsToPlainTextOrNull(data.meetingUrl),
platform: data.platform ?? "none",
status: data.status ?? "scheduled",
isHighlighted: data.isHighlighted ?? 0,
notes: stripTagsToPlainTextOrNull(data.notes),
createdBy: userId,
updatedBy: userId,
};
const [meeting] = await tx
.insert(executiveMeetingsTable)
.values(meetingValues)
.returning();
if (!meeting) throw new Error("insert_failed");
const attendees = data.attendees ?? [];
if (attendees.length > 0) {
await tx.insert(executiveMeetingAttendeesTable).values(
attendees.map((a, idx) => ({
meetingId: meeting.id,
name: sanitizeRichText(a.name),
// title is plain text in the UI, but it gets interpolated
// into HTML by templates like the print page, so strip
// any sneaky markup at the API boundary.
title: sanitizePlainTextOrNull(a.title),
attendanceType: a.attendanceType,
sortOrder: a.sortOrder ?? idx,
kind: a.kind,
})),
);
}
await logAudit(tx, {
action: "create",
entityType: "meeting",
entityId: meeting.id,
newValue: { ...meeting, attendees },
performedBy: userId,
});
const approverIds = await getUserIdsForRoleNames(APPROVE_ROLES);
const recipients = await recordExecutiveMeetingNotifications(tx, {
recipientUserIds: approverIds,
meetingId: meeting.id,
notificationType: "meeting_created",
titleAr: "اجتماع جديد",
titleEn: "New executive meeting",
bodyAr: meeting.titleAr || meeting.titleEn || "",
bodyEn: meeting.titleEn || meeting.titleAr || "",
relatedType: "executive_meeting",
relatedId: meeting.id,
excludeUserId: userId,
});
return { meeting, recipients };
});
void broadcastExecutiveMeetingNotifications(
inserted.recipients,
"meeting_created",
inserted.meeting.id,
);
// Realtime: every open schedule tab on this day refetches so the
// newly-created row shows up without a manual refresh.
void emitExecutiveMeetingsDayChanged(inserted.meeting.meetingDate);
const full = await fetchMeetingWithAttendees(inserted.meeting.id);
res.status(201).json(full);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("executive_meetings_date_number_unique")) {
res
.status(409)
.json({
error: "Daily number already used for this date",
code: "duplicate_number",
});
return;
}
res.status(500).json({ error: "Could not create meeting", code: "create_failed" });
}
},
);
router.patch(
"/executive-meetings/:id",
requireExecutiveAccess,
requireMutate,
async (req, res): Promise<void> => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
res.status(404).json({ error: "Not found", code: "not_found" });
return;
}
const existing = await fetchMeetingWithAttendees(id);
if (!existing) {
res.status(404).json({ error: "Meeting not found", code: "not_found" });
return;
}
const data = parseBody(res, meetingPatchSchema, req.body);
if (!data) return;
const userId = req.session.userId!;
try {
await db.transaction(async (tx) => {
const updateValues: Partial<typeof executiveMeetingsTable.$inferInsert> = {
updatedBy: userId,
};
if (data.titleAr !== undefined)
updateValues.titleAr = sanitizeRichText(data.titleAr);
if (data.titleEn !== undefined)
updateValues.titleEn = sanitizeRichText(data.titleEn ?? "");
if (data.meetingDate !== undefined) updateValues.meetingDate = data.meetingDate;
if (data.dailyNumber !== undefined && data.dailyNumber !== null)
updateValues.dailyNumber = data.dailyNumber;
if (data.startTime !== undefined) updateValues.startTime = data.startTime;
if (data.endTime !== undefined) updateValues.endTime = data.endTime;
// Plain-text fields: strip any HTML at the API boundary so a
// PATCH cannot smuggle markup that the POST path already rejects.
// Mirrors the create handler above and the attendee.title path.
if (data.location !== undefined)
updateValues.location = stripTagsToPlainTextOrNull(data.location);
if (data.meetingUrl !== undefined)
updateValues.meetingUrl = stripTagsToPlainTextOrNull(data.meetingUrl);
if (data.platform !== undefined) updateValues.platform = data.platform;
if (data.status !== undefined) updateValues.status = data.status;
if (data.isHighlighted !== undefined)
updateValues.isHighlighted = data.isHighlighted;
if (data.notes !== undefined)
updateValues.notes = stripTagsToPlainTextOrNull(data.notes);
if (data.merge !== undefined) {
if (data.merge === null) {
updateValues.mergeStartColumn = null;
updateValues.mergeEndColumn = null;
updateValues.mergeText = null;
} else {
updateValues.mergeStartColumn = data.merge.mergeStartColumn;
updateValues.mergeEndColumn = data.merge.mergeEndColumn;
updateValues.mergeText = sanitizeRichText(data.merge.mergeText);
}
}
if (Object.keys(updateValues).length > 1) {
await tx
.update(executiveMeetingsTable)
.set(updateValues)
.where(eq(executiveMeetingsTable.id, id));
}
let attendees = existing.attendees;
if (data.attendees !== undefined) {
await tx
.delete(executiveMeetingAttendeesTable)
.where(eq(executiveMeetingAttendeesTable.meetingId, id));
if (data.attendees.length > 0) {
await tx.insert(executiveMeetingAttendeesTable).values(
data.attendees.map((a, idx) => ({
meetingId: id,
name: sanitizeRichText(a.name),
title: sanitizePlainTextOrNull(a.title),
attendanceType: a.attendanceType,
sortOrder: a.sortOrder ?? idx,
kind: a.kind,
})),
);
}
attendees = (await db
.select()
.from(executiveMeetingAttendeesTable)
.where(eq(executiveMeetingAttendeesTable.meetingId, id))
.orderBy(asc(executiveMeetingAttendeesTable.sortOrder)));
}
await logAudit(tx, {
action: "update",
entityType: "meeting",
entityId: id,
oldValue: existing,
newValue: { ...existing, ...updateValues, attendees },
performedBy: userId,
});
});
const updated = await fetchMeetingWithAttendees(id);
// Realtime: when the meeting was rescheduled to a different day we
// need to refetch BOTH days — viewers on the old day must lose the
// row, viewers on the new day must gain it. The dedup helper is a
// no-op when both dates match (the common in-place edit case).
void emitExecutiveMeetingsDaysChanged([
existing.meetingDate,
updated?.meetingDate ?? existing.meetingDate,
]);
res.json(updated);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("executive_meetings_date_number_unique")) {
res
.status(409)
.json({
error: "Daily number already used for this date",
code: "duplicate_number",
});
return;
}
res.status(500).json({ error: "Could not update meeting", code: "update_failed" });
}
},
);
router.delete(
"/executive-meetings/:id",
requireExecutiveAccess,
requireMutate,
async (req, res): Promise<void> => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
res.status(404).json({ error: "Not found", code: "not_found" });
return;
}
const existing = await fetchMeetingWithAttendees(id);
if (!existing) {
res.status(404).json({ error: "Meeting not found", code: "not_found" });
return;
}
const userId = req.session.userId!;
await db.transaction(async (tx) => {
await tx.delete(executiveMeetingsTable).where(eq(executiveMeetingsTable.id, id));
await logAudit(tx, {
action: "delete",
entityType: "meeting",
entityId: id,
oldValue: existing,
performedBy: userId,
});
});
// Realtime: every open schedule tab on the deleted meeting's day
// refetches so the row disappears without a manual refresh.
void emitExecutiveMeetingsDayChanged(existing.meetingDate);
res.status(204).end();
},
);
router.put(
"/executive-meetings/:id/attendees",
requireExecutiveAccess,
requireMutate,
async (req, res): Promise<void> => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
res.status(404).json({ error: "Not found", code: "not_found" });
return;
}
const existing = await fetchMeetingWithAttendees(id);
if (!existing) {
res.status(404).json({ error: "Meeting not found", code: "not_found" });
return;
}
const data = parseBody(res, attendeesPutSchema, req.body);
if (!data) return;
const userId = req.session.userId!;
await db.transaction(async (tx) => {
await tx
.delete(executiveMeetingAttendeesTable)
.where(eq(executiveMeetingAttendeesTable.meetingId, id));
if (data.attendees.length > 0) {
await tx.insert(executiveMeetingAttendeesTable).values(
data.attendees.map((a, idx) => ({
meetingId: id,
name: sanitizeRichText(a.name),
title: sanitizePlainTextOrNull(a.title),
attendanceType: a.attendanceType,
sortOrder: a.sortOrder ?? idx,
kind: a.kind,
})),
);
}
await logAudit(tx, {
action: "replace_attendees",
entityType: "meeting",
entityId: id,
oldValue: { attendees: existing.attendees },
newValue: { attendees: data.attendees },
performedBy: userId,
});
});
const updated = await fetchMeetingWithAttendees(id);
// Realtime: replacing the attendee list is the most common
// multi-tab edit (drag-to-reorder, inline rename, group move),
// so make sure every viewer on this day refetches.
void emitExecutiveMeetingsDayChanged(
updated?.meetingDate ?? existing.meetingDate,
);
res.json(updated);
},
);
router.post(
"/executive-meetings/:id/duplicate",
requireExecutiveAccess,
requireMutate,
async (req, res): Promise<void> => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
res.status(404).json({ error: "Not found", code: "not_found" });
return;
}
const data = parseBody(res, duplicateSchema, req.body);
if (!data) return;
const source = await fetchMeetingWithAttendees(id);
if (!source) {
res.status(404).json({ error: "Meeting not found", code: "not_found" });
return;
}
const userId = req.session.userId!;
try {
const inserted = await db.transaction(async (tx) => {
const nextNumber = await nextDailyNumber(tx, data.targetDate);
const meetingValues: typeof executiveMeetingsTable.$inferInsert = {
dailyNumber: nextNumber,
// Re-sanitize on duplicate so a row that was somehow stored with
// disallowed markup (e.g. raw SQL import, schema regression) does
// not get cloned into a fresh meeting unchecked.
titleAr: sanitizeRichText(source.titleAr),
titleEn: source.titleEn ? sanitizeRichText(source.titleEn) : source.titleEn,
meetingDate: data.targetDate,
startTime: source.startTime,
endTime: source.endTime,
// Re-sanitize plain-text fields on duplicate for the same
// defense-in-depth reason as the rich-text fields above.
location: stripTagsToPlainTextOrNull(source.location),
meetingUrl: stripTagsToPlainTextOrNull(source.meetingUrl),
platform: source.platform,
status: "scheduled",
isHighlighted: 0,
notes: stripTagsToPlainTextOrNull(source.notes),
attachments: source.attachments,
createdBy: userId,
updatedBy: userId,
};
const [meeting] = await tx
.insert(executiveMeetingsTable)
.values(meetingValues)
.returning();
if (!meeting) throw new Error("duplicate_failed");
if (source.attendees && source.attendees.length > 0) {
await tx.insert(executiveMeetingAttendeesTable).values(
source.attendees.map((a: ExecutiveMeetingAttendee, idx: number) => ({
meetingId: meeting.id,
// source already-stored values are sanitized at write time, but
// re-running keeps a defense-in-depth boundary if anything was
// imported via raw SQL.
name: sanitizeRichText(a.name),
title: sanitizePlainTextOrNull(a.title),
attendanceType: a.attendanceType,
sortOrder: a.sortOrder ?? idx,
// Carry custom subheadings into the duplicated meeting so the
// user does not lose their group structure on duplicate.
kind: a.kind,
})),
);
}
await logAudit(tx, {
action: "duplicate",
entityType: "meeting",
entityId: meeting.id,
oldValue: { sourceId: source.id, sourceDate: source.meetingDate },
newValue: meeting,
performedBy: userId,
});
return meeting;
});
const full = await fetchMeetingWithAttendees(inserted.id);
// Realtime: target day gained a new meeting — refetch so every
// viewer on the duplicate's destination day sees the new row.
void emitExecutiveMeetingsDayChanged(data.targetDate);
res.status(201).json(full);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("executive_meetings_date_number_unique")) {
res.status(409).json({ error: "Daily number conflict", code: "conflict" });
return;
}
res.status(500).json({ error: msg, code: "server_error" });
}
},
);
// =====================================================================
// REORDER (within a single day)
// =====================================================================
router.post(
"/executive-meetings/reorder",
requireExecutiveAccess,
requireMutate,
async (req, res): Promise<void> => {
const data = parseBody(res, reorderSchema, req.body);
if (!data) return;
const userId = req.session.userId!;
const dedup = new Set(data.orderedIds);
if (dedup.size !== data.orderedIds.length) {
res
.status(400)
.json({ error: "Duplicate ids in orderedIds", code: "duplicate_ids" });
return;
}
try {
await db.transaction(async (tx) => {
const allRows = await tx
.select()
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.meetingDate, data.meetingDate));
if (allRows.length !== data.orderedIds.length) {
throw Object.assign(new Error("incomplete_day_reorder"), {
httpStatus: 400,
code: "incomplete_day",
});
}
const dayIds = new Set(allRows.map((r) => r.id));
for (const id of data.orderedIds) {
if (!dayIds.has(id)) {
throw Object.assign(new Error("invalid_or_cross_day_id"), {
httpStatus: 400,
code: "invalid_ids",
});
}
}
const slots = allRows
.slice()
.sort((a, b) => {
const aHasTime = a.startTime != null;
const bHasTime = b.startTime != null;
if (aHasTime && bHasTime) {
if (a.startTime! < b.startTime!) return -1;
if (a.startTime! > b.startTime!) return 1;
return a.dailyNumber - b.dailyNumber;
}
if (aHasTime) return -1;
if (bHasTime) return 1;
return a.dailyNumber - b.dailyNumber;
})
.map((r) => ({ startTime: r.startTime, endTime: r.endTime }));
for (let i = 0; i < data.orderedIds.length; i++) {
const id = data.orderedIds[i]!;
await tx
.update(executiveMeetingsTable)
.set({ dailyNumber: -(i + 1), updatedBy: userId })
.where(eq(executiveMeetingsTable.id, id));
}
for (let i = 0; i < data.orderedIds.length; i++) {
const id = data.orderedIds[i]!;
const slot = slots[i]!;
await tx
.update(executiveMeetingsTable)
.set({
startTime: slot.startTime,
endTime: slot.endTime,
dailyNumber: i + 1,
updatedBy: userId,
})
.where(eq(executiveMeetingsTable.id, id));
}
await logAudit(tx, {
action: "executive_meeting.reorder",
entityType: "meeting",
entityId: data.orderedIds[0]!,
oldValue: {
meetingDate: data.meetingDate,
order: allRows
.slice()
.sort((a, b) => a.dailyNumber - b.dailyNumber)
.map((r) => r.id),
},
newValue: {
meetingDate: data.meetingDate,
order: data.orderedIds,
},
performedBy: userId,
});
const updated = await tx
.select()
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.meetingDate, data.meetingDate));
const seen = new Set<number>();
for (const u of updated) {
if (seen.has(u.dailyNumber)) {
throw new Error("duplicate_daily_number_after_reorder");
}
seen.add(u.dailyNumber);
}
});
// Return the updated meetings in the new order, with attendees.
const idToFetch = data.orderedIds;
const fullList = await Promise.all(
idToFetch.map((id) => fetchMeetingWithAttendees(id)),
);
// Realtime: every viewer on this day refetches so the new
// ordering (and any time-slot reshuffling that came with it)
// shows up without a manual refresh.
void emitExecutiveMeetingsDayChanged(data.meetingDate);
res.json({ meetings: fullList.filter(Boolean) });
} catch (err) {
const status = (err as { httpStatus?: number }).httpStatus ?? 500;
const code = (err as { code?: string }).code ?? "server_error";
const msg = err instanceof Error ? err.message : String(err);
res.status(status).json({ error: msg, code });
}
},
);
// =====================================================================
// AUDIT LOGS
// =====================================================================
router.get(
"/executive-meetings/audit-logs",
requireExecutiveAccess,
requireAdminAudit,
async (req, res): Promise<void> => {
const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : "";
const dateFromRaw =
typeof req.query.dateFrom === "string" ? req.query.dateFrom.trim() : "";
const dateToRaw =
typeof req.query.dateTo === "string" ? req.query.dateTo.trim() : "";
const entityType =
typeof req.query.entityType === "string" ? req.query.entityType.trim() : "";
const action =
typeof req.query.action === "string" ? req.query.action.trim() : "";
const actorRaw = Number(req.query.actorId);
const actorId = Number.isInteger(actorRaw) && actorRaw > 0 ? actorRaw : null;
const limitRaw = Number(req.query.limit);
const limit =
Number.isFinite(limitRaw) && limitRaw > 0
? Math.min(Math.floor(limitRaw), 200)
: 50;
const offsetRaw = Number(req.query.offset);
const offset =
Number.isFinite(offsetRaw) && offsetRaw > 0 ? Math.floor(offsetRaw) : 0;
const conds: SQL[] = [];
if (dateRaw && DATE_RE_LOCAL.test(dateRaw)) {
const start = new Date(`${dateRaw}T00:00:00.000Z`);
const end = new Date(start.getTime() + 24 * 60 * 60 * 1000);
conds.push(gte(executiveMeetingAuditLogsTable.performedAt, start));
conds.push(lt(executiveMeetingAuditLogsTable.performedAt, end));
}
if (dateFromRaw && DATE_RE_LOCAL.test(dateFromRaw)) {
conds.push(
gte(
executiveMeetingAuditLogsTable.performedAt,
new Date(`${dateFromRaw}T00:00:00.000Z`),
),
);
}
if (dateToRaw && DATE_RE_LOCAL.test(dateToRaw)) {
const end = new Date(`${dateToRaw}T00:00:00.000Z`);
end.setUTCDate(end.getUTCDate() + 1);
conds.push(lt(executiveMeetingAuditLogsTable.performedAt, end));
}
if (entityType) conds.push(eq(executiveMeetingAuditLogsTable.entityType, entityType));
if (action) conds.push(eq(executiveMeetingAuditLogsTable.action, action));
if (actorId) conds.push(eq(executiveMeetingAuditLogsTable.performedBy, actorId));
const where = conds.length > 0 ? and(...conds) : undefined;
const [{ count: totalRaw } = { count: 0 }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(executiveMeetingAuditLogsTable)
.where(where);
const total = Number(totalRaw) || 0;
const rows = await db
.select({
l: executiveMeetingAuditLogsTable,
actorUsername: usersTable.username,
actorDisplayNameAr: usersTable.displayNameAr,
actorDisplayNameEn: usersTable.displayNameEn,
})
.from(executiveMeetingAuditLogsTable)
.leftJoin(usersTable, eq(usersTable.id, executiveMeetingAuditLogsTable.performedBy))
.where(where)
.orderBy(desc(executiveMeetingAuditLogsTable.performedAt))
.limit(limit)
.offset(offset);
res.json({
entries: rows.map((row) => ({
...row.l,
actor: row.actorUsername
? {
username: row.actorUsername,
displayNameAr: row.actorDisplayNameAr,
displayNameEn: row.actorDisplayNameEn,
}
: null,
})),
total,
limit,
offset,
hasMore: offset + rows.length < total,
});
},
);
// =====================================================================
// NOTIFICATIONS (placeholder list — visible to any executive read role)
// =====================================================================
router.get(
"/executive-meetings/notifications",
requireExecutiveAccess,
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,
userUsername: usersTable.username,
userDisplayNameAr: usersTable.displayNameAr,
userDisplayNameEn: usersTable.displayNameEn,
})
.from(executiveMeetingNotificationsTable)
.leftJoin(usersTable, eq(usersTable.id, executiveMeetingNotificationsTable.userId))
.where(where)
.orderBy(desc(executiveMeetingNotificationsTable.createdAt))
.limit(200);
res.json({
notifications: rows.map((row) => ({
...row.n,
user: row.userUsername
? {
username: row.userUsername,
displayNameAr: row.userDisplayNameAr,
displayNameEn: row.userDisplayNameEn,
}
: null,
})),
});
},
);
// =====================================================================
// NOTIFICATION PREFERENCES (per-user, per-event-type)
// =====================================================================
// One row per (notificationType, channel) toggle. Missing rows mean
// "default on" — see executive_meeting_notification_prefs in the schema.
const notificationPrefEntrySchema = z.object({
notificationType: z.enum(EXECUTIVE_MEETING_NOTIFICATION_TYPES),
inApp: z.boolean(),
email: z.boolean(),
});
const notificationPrefsBodySchema = z.object({
prefs: z.array(notificationPrefEntrySchema).max(64),
});
// GET returns the current user's preferences merged with defaults so the
// frontend can render every event type as a toggle without having to
// know which rows exist. `types` echoes the canonical list so the UI
// stays in sync if a new event type is added on the server.
router.get(
"/executive-meetings/notification-prefs",
requireExecutiveAccess,
async (req, res): Promise<void> => {
const userId = req.session.userId!;
const rows = await db
.select({
notificationType: executiveMeetingNotificationPrefsTable.notificationType,
inApp: executiveMeetingNotificationPrefsTable.inApp,
email: executiveMeetingNotificationPrefsTable.email,
})
.from(executiveMeetingNotificationPrefsTable)
.where(eq(executiveMeetingNotificationPrefsTable.userId, userId));
const byType = new Map(rows.map((r) => [r.notificationType, r]));
const prefs = EXECUTIVE_MEETING_NOTIFICATION_TYPES.map((type) => {
const existing = byType.get(type);
return {
notificationType: type,
inApp: existing ? existing.inApp : true,
email: existing ? existing.email : true,
};
});
res.json({
types: EXECUTIVE_MEETING_NOTIFICATION_TYPES,
prefs,
});
},
);
// PUT replaces all preferences for the current user. We upsert each
// supplied row and leave any other rows untouched (the default-on
// fallback handles unspecified types). The request only carries
// preferences the user actually toggled, so this is a partial update.
router.put(
"/executive-meetings/notification-prefs",
requireExecutiveAccess,
async (req, res): Promise<void> => {
const data = parseBody(res, notificationPrefsBodySchema, req.body);
if (!data) return;
const userId = req.session.userId!;
if (data.prefs.length === 0) {
res.json({ ok: true, count: 0 });
return;
}
await db.transaction(async (tx) => {
for (const p of data.prefs) {
await tx
.insert(executiveMeetingNotificationPrefsTable)
.values({
userId,
notificationType: p.notificationType,
inApp: p.inApp,
email: p.email,
})
.onConflictDoUpdate({
target: [
executiveMeetingNotificationPrefsTable.userId,
executiveMeetingNotificationPrefsTable.notificationType,
],
set: {
inApp: p.inApp,
email: p.email,
updatedAt: new Date(),
},
});
}
});
res.json({ ok: true, count: data.prefs.length });
},
);
// #236: DELETE wipes every pref row for the current user so all toggles
// fall back to the schema default (in_app=true, email=true). Cheaper than
// asking the client to PUT N "true" rows back, and avoids the partial-
// update gotcha where a missing event type would stay opted-out.
router.delete(
"/executive-meetings/notification-prefs",
requireExecutiveAccess,
async (req, res): Promise<void> => {
const userId = req.session.userId!;
const deleted = await db
.delete(executiveMeetingNotificationPrefsTable)
.where(eq(executiveMeetingNotificationPrefsTable.userId, userId))
.returning({ id: executiveMeetingNotificationPrefsTable.id });
res.json({ ok: true, count: deleted.length });
},
);
// =====================================================================
// PDF GENERATION (server-side)
// =====================================================================
// Resolve the user's effective font preferences exactly the way the
// frontend does: user-scope row wins, otherwise global, otherwise the
// schema defaults.
async function resolveFontPrefsForUser(userId: number): Promise<PdfFontPrefs> {
const rows = await db
.select()
.from(executiveMeetingFontSettingsTable)
.where(
or(
eq(executiveMeetingFontSettingsTable.scope, "global"),
and(
eq(executiveMeetingFontSettingsTable.scope, "user"),
eq(executiveMeetingFontSettingsTable.userId, userId),
),
),
);
const userRow = rows.find((r) => r.scope === "user" && r.userId === userId);
const globalRow = rows.find((r) => r.scope === "global");
const eff = userRow ?? globalRow;
return {
fontFamily: eff?.fontFamily ?? "system",
fontSize: eff?.fontSize ?? 14,
fontWeight: (eff?.fontWeight as PdfFontPrefs["fontWeight"]) ?? "regular",
alignment: (eff?.alignment as PdfFontPrefs["alignment"]) ?? "start",
};
}
// Upload bytes to object storage by signing a PUT URL and streaming the
// buffer through `fetch`. Returns the canonical /objects/<id> path that
// GET /api/storage/objects/* knows how to serve.
async function uploadPdfToStorage(buf: Buffer): Promise<string> {
const objectStorage = new ObjectStorageService();
const uploadUrl = await objectStorage.getObjectEntityUploadURL();
const putRes = await fetch(uploadUrl, {
method: "PUT",
body: buf,
headers: {
"Content-Type": "application/pdf",
"Content-Length": String(buf.byteLength),
},
});
if (!putRes.ok) {
const body = await putRes.text().catch(() => "");
throw new Error(
`Failed to upload PDF to object storage (${putRes.status}): ${body.slice(0, 200)}`,
);
}
// Storage returns the signed URL we PUT to — normalize back to
// /objects/<id> so we can serve it via GET /api/storage/objects/*.
return objectStorage.normalizeObjectEntityPath(uploadUrl.split("?")[0]!);
}
router.get(
"/executive-meetings/pdf",
requireExecutiveAccess,
async (req, res): Promise<void> => {
const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : "";
if (!dateRaw || !DATE_RE_LOCAL.test(dateRaw)) {
res
.status(400)
.json({ error: "Invalid 'date' (expected YYYY-MM-DD)", code: "bad_date" });
return;
}
const lang: "ar" | "en" =
typeof req.query.lang === "string" && req.query.lang === "en" ? "en" : "ar";
const userId = req.session.userId!;
const meetings = await db
.select()
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.meetingDate, dateRaw))
.orderBy(asc(executiveMeetingsTable.dailyNumber));
let attendeesByMeeting = new Map<number, ExecutiveMeetingAttendee[]>();
if (meetings.length > 0) {
const ids = meetings.map((m) => m.id);
const attendees = await db
.select()
.from(executiveMeetingAttendeesTable)
.where(inArray(executiveMeetingAttendeesTable.meetingId, ids))
.orderBy(
asc(executiveMeetingAttendeesTable.meetingId),
asc(executiveMeetingAttendeesTable.sortOrder),
);
for (const a of attendees) {
const arr = attendeesByMeeting.get(a.meetingId) ?? [];
arr.push(a);
attendeesByMeeting.set(a.meetingId, arr);
}
}
const font = await resolveFontPrefsForUser(userId);
const labels =
lang === "ar"
? {
title: "جدول الاجتماعات التنفيذية",
no: "م",
meeting: "الاجتماع",
attendees: "الحضور",
time: "الوقت",
none: "لا توجد اجتماعات في هذا اليوم.",
}
: {
title: "Executive Meetings Schedule",
no: "#",
meeting: "Meeting",
attendees: "Attendees",
time: "Time",
none: "No meetings on this day.",
};
let pdf: Buffer;
try {
pdf = await renderSchedulePdf({
date: dateRaw,
lang,
font,
labels,
meetings: meetings.map((m) => ({
dailyNumber: m.dailyNumber,
titleAr: m.titleAr,
titleEn: m.titleEn,
startTime: m.startTime,
endTime: m.endTime,
location: m.location,
isHighlighted: m.isHighlighted ?? 0,
attendees: (attendeesByMeeting.get(m.id) ?? []).map((a) => ({
name: a.name,
title: a.title,
attendanceType: a.attendanceType,
// Forward the row kind so the renderer can render subheadings
// as labels rather than numbered attendees.
kind: a.kind,
})),
})),
});
} catch (err) {
// Fail loudly so callers see a real error instead of a corrupt PDF.
console.error("[executive-meetings] PDF render failed", err);
res.status(500).json({
error: "Failed to generate PDF",
code: "pdf_render_failed",
detail: err instanceof Error ? err.message : String(err),
});
return;
}
// Upload + archive. If storage upload fails (e.g. sidecar offline)
// we still serve the PDF inline so the user is never blocked, but
// we record the archive with a synthetic path so the failure is
// visible in the audit trail.
let storagePath = `inline:${dateRaw}`;
try {
storagePath = await uploadPdfToStorage(pdf);
} catch (err) {
console.error("[executive-meetings] PDF upload failed; serving inline", err);
}
try {
await db.transaction(async (tx) => {
const [{ value: maxV }] = await tx
.select({
value: sql<number>`coalesce(max(${executiveMeetingPdfArchivesTable.version}), 0)`,
})
.from(executiveMeetingPdfArchivesTable)
.where(eq(executiveMeetingPdfArchivesTable.archiveDate, dateRaw));
const insertValues: typeof executiveMeetingPdfArchivesTable.$inferInsert = {
archiveDate: dateRaw,
filePath: storagePath,
version: (maxV ?? 0) + 1,
byteSize: pdf.byteLength,
generatedBy: userId,
};
const [row] = await tx
.insert(executiveMeetingPdfArchivesTable)
.values(insertValues)
.returning();
await logAudit(tx, {
action: "create",
entityType: "pdf_archive",
entityId: row!.id,
newValue: row!,
performedBy: userId,
});
});
} catch (err) {
console.error("[executive-meetings] PDF archive insert failed", err);
}
res.setHeader("Content-Type", "application/pdf");
res.setHeader(
"Content-Disposition",
`attachment; filename="executive-meetings-${dateRaw}.pdf"`,
);
res.setHeader("Content-Length", String(pdf.byteLength));
res.setHeader("Cache-Control", "no-store");
res.end(pdf);
},
);
// =====================================================================
// PDF ARCHIVES
// =====================================================================
router.get(
"/executive-meetings/pdf-archives",
requireExecutiveAccess,
async (req, res): Promise<void> => {
const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : "";
const conds: SQL[] = [];
if (dateRaw && DATE_RE_LOCAL.test(dateRaw)) {
conds.push(eq(executiveMeetingPdfArchivesTable.archiveDate, dateRaw));
}
const where = conds.length > 0 ? and(...conds) : undefined;
const rows = await db
.select({
a: executiveMeetingPdfArchivesTable,
username: usersTable.username,
displayNameAr: usersTable.displayNameAr,
displayNameEn: usersTable.displayNameEn,
})
.from(executiveMeetingPdfArchivesTable)
.leftJoin(
usersTable,
eq(usersTable.id, executiveMeetingPdfArchivesTable.generatedBy),
)
.where(where)
.orderBy(desc(executiveMeetingPdfArchivesTable.generatedAt))
.limit(200);
res.json({
archives: rows.map((row) => ({
...row.a,
generator: row.username
? {
username: row.username,
displayNameAr: row.displayNameAr,
displayNameEn: row.displayNameEn,
}
: null,
})),
});
},
);
router.post(
"/executive-meetings/pdf-archives",
requireExecutiveAccess,
async (req, res): Promise<void> => {
const data = parseBody(res, pdfArchiveCreateSchema, req.body);
if (!data) return;
const filePath = data.filePath?.trim() || `print:${data.archiveDate}`;
const userId = req.session.userId!;
const created = await db.transaction(async (tx) => {
const [{ value: maxV }] = await tx
.select({
value: sql<number>`coalesce(max(${executiveMeetingPdfArchivesTable.version}), 0)`,
})
.from(executiveMeetingPdfArchivesTable)
.where(eq(executiveMeetingPdfArchivesTable.archiveDate, data.archiveDate));
const insertValues: typeof executiveMeetingPdfArchivesTable.$inferInsert = {
archiveDate: data.archiveDate,
filePath,
version: (maxV ?? 0) + 1,
generatedBy: userId,
};
const [row] = await tx
.insert(executiveMeetingPdfArchivesTable)
.values(insertValues)
.returning();
await logAudit(tx, {
action: "create",
entityType: "pdf_archive",
entityId: row!.id,
newValue: row!,
performedBy: userId,
});
return row;
});
res.status(201).json(created);
},
);
// =====================================================================
// FONT SETTINGS
// =====================================================================
router.get(
"/executive-meetings/font-settings",
requireExecutiveAccess,
async (req, res): Promise<void> => {
const userId = req.session.userId!;
const rows = await db
.select()
.from(executiveMeetingFontSettingsTable)
.where(
or(
eq(executiveMeetingFontSettingsTable.scope, "global"),
and(
eq(executiveMeetingFontSettingsTable.scope, "user"),
eq(executiveMeetingFontSettingsTable.userId, userId),
),
),
);
const global = rows.find((r) => r.scope === "global") ?? null;
const user = rows.find((r) => r.scope === "user" && r.userId === userId) ?? null;
res.json({ global, user });
},
);
// Shared handler used by both PUT (canonical) and PATCH (alias) so we don't
// rely on Express re-dispatch tricks — both methods run the exact same logic.
const upsertFontSettingsHandler = async (
req: import("express").Request,
res: import("express").Response,
): Promise<void> => {
const userId = req.session.userId!;
const data = parseBody(res, fontSettingsSchema, req.body);
if (!data) return;
if (data.scope === "global") {
const names = await getRoleNamesForUser(userId);
const ok = APPROVE_ROLES.some((r) => names.has(r));
if (!ok) {
res.status(403).json({ error: "Forbidden", code: "forbidden" });
return;
}
}
const targetUserId = data.scope === "global" ? null : userId;
const result = await db.transaction(async (tx) => {
const [existing] = await tx
.select()
.from(executiveMeetingFontSettingsTable)
.where(
and(
eq(executiveMeetingFontSettingsTable.scope, data.scope),
targetUserId === null
? isNull(executiveMeetingFontSettingsTable.userId)
: eq(executiveMeetingFontSettingsTable.userId, targetUserId),
),
);
let row;
if (existing) {
const [updated] = await tx
.update(executiveMeetingFontSettingsTable)
.set({
fontFamily: data.fontFamily,
fontSize: data.fontSize,
fontWeight: data.fontWeight,
alignment: data.alignment,
})
.where(eq(executiveMeetingFontSettingsTable.id, existing.id))
.returning();
row = updated;
} else {
const insertValues: typeof executiveMeetingFontSettingsTable.$inferInsert = {
scope: data.scope,
userId: targetUserId,
fontFamily: data.fontFamily,
fontSize: data.fontSize,
fontWeight: data.fontWeight,
alignment: data.alignment,
};
const [inserted] = await tx
.insert(executiveMeetingFontSettingsTable)
.values(insertValues)
.returning();
row = inserted;
}
await logAudit(tx, {
action: existing ? "update" : "create",
entityType: "font_settings",
entityId: row?.id ?? null,
oldValue: existing ?? null,
newValue: row ?? null,
performedBy: userId,
});
return row;
});
res.json(result);
};
router.put(
"/executive-meetings/font-settings",
requireExecutiveAccess,
upsertFontSettingsHandler,
);
// Backwards-compatible PATCH alias — uses the same handler directly so it
// works whether the client sends PATCH or PUT.
router.patch(
"/executive-meetings/font-settings",
requireExecutiveAccess,
upsertFontSettingsHandler,
);
export { requireApprove };
export default router;