Files
TX/artifacts/api-server/src/routes/executive-meetings.ts
T
riyadhafraa de7973f35b Task #293: DB CHECK constraint for executive_meetings.row_color (defense-in-depth for #288)
Mirrors the API-side row-colour whitelist at the database layer so any
out-of-band write path (manual psql, future bulk-import jobs, restored
backups) cannot smuggle an unrenderable colour past the Zod guard
introduced in #288.

Changes:

- lib/db/src/schema/executive-meetings.ts: export new shared constant
  EXECUTIVE_MEETING_ROW_COLOR_KEYS (red/amber/green/blue/violet/gray)
  + ExecutiveMeetingRowColor union type. Add a Drizzle check()
  constraint named `executive_meetings_row_color_palette_check` that
  allows NULL or any of the six keys. The CHECK uses sql.raw to inline
  the palette as quoted SQL literals (PG CHECK definitions are DDL and
  reject parameter placeholders); safe because the keys come from a
  hardcoded compile-time constant of single-word identifiers, never
  user input. Generating the literal list from the same constant
  guarantees the API guard and the DB constraint stay in sync.

- artifacts/api-server/src/routes/executive-meetings.ts: import
  EXECUTIVE_MEETING_ROW_COLOR_KEYS from @workspace/db and rewire
  rowColorSchema to z.enum(EXECUTIVE_MEETING_ROW_COLOR_KEYS).nullable().
  Deletes the local ROW_COLOR_KEYS const so the palette has exactly
  one source of truth. No behaviour change at runtime.

- artifacts/api-server/tests/executive-meetings-row-color.test.mjs:
  append a focused defense-in-depth test that bypasses the API and
  asserts (a) NULL is allowed, (b) each of the six palette keys
  round-trips on raw UPDATE, (c) off-palette UPDATEs reject with PG
  SQLSTATE 23514 referencing the constraint by name, (d) the row
  keeps its previous colour after the rejected updates, and (e) raw
  INSERT with an off-palette value is rejected the same way.

Migration applied via pnpm --filter @workspace/db push (no destructive
prompts; existing rows are NULL or valid keys from #288 so the
constraint installs cleanly). All 7 tests in the row-color file pass.
Architect review: APPROVED, no critical findings.

The single pre-existing Reorder test failure in the wider suite is
unrelated to this change (Reorder does not touch row_color).
2026-05-01 15:41:21 +00:00

2307 lines
81 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,
executiveMeetingNotificationsTable,
executiveMeetingNotificationPrefsTable,
executiveMeetingAuditLogsTable,
executiveMeetingPdfArchivesTable,
executiveMeetingFontSettingsTable,
executiveMeetingAlertStateTable,
rolesTable,
usersTable,
EXECUTIVE_MEETING_ROW_COLOR_KEYS,
type ExecutiveMeetingAttendee,
} from "@workspace/db";
import {
requireAuth,
requireExecutiveAccess,
getEffectiveRoleIds,
} from "../middlewares/auth";
import {
sanitizePlainTextOrNull,
sanitizeRichText,
stripTagsToPlainTextOrNull,
} from "../lib/sanitize";
import {
emitExecutiveMeetingsDayChanged,
emitExecutiveMeetingsDaysChanged,
emitExecutiveMeetingAlertStateChanged,
} 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,
};
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 EM_ADMIN_ROLES = ["admin", "executive_office_manager"] as const;
const ADMIN_AUDIT_ROLES = [
"admin",
"executive_office_manager",
"executive_coord_lead",
] as const;
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 requireAdminAudit = makeRequireRoles(ADMIN_AUDIT_ROLES);
// ---------- Zod schemas ----------
// Allowed values for the row-highlight overlay on the daily schedule.
// Mirrors the ROW_COLOR_OPTIONS palette in the frontend (
// artifacts/tx-os/src/pages/executive-meetings.tsx). NULL/omitted on the
// wire = "default" (no tint). The whitelist itself is exported from
// the schema package (#293) so the API guard, the Drizzle CHECK
// constraint, and any future caller share one source of truth — change
// the palette in one place and both layers stay in sync.
const rowColorSchema = z.enum(EXECUTIVE_MEETING_ROW_COLOR_KEYS).nullable();
const dateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "expected YYYY-MM-DD");
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(),
// Row-highlight colour (#288). null = clear back to default (no tint).
// Optional so this PATCH endpoint stays usable for callers that only
// want to edit other fields. Validated against the ROW_COLOR_KEYS
// whitelist above, so an unknown key returns 400 instead of writing.
rowColor: rowColorSchema.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;
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;
}
// #273: After a postpone/reschedule/cancel that shifts a meeting in the
// daily timeline, recompute `daily_number` for every meeting on `date`
// so that the displayed `#` matches the new chronological order.
//
// Active meetings (not cancelled) are numbered 1..N in `start_time`
// order; cancelled meetings are pushed to the tail (N+1..N+M, by id).
// The two-step "shift to negatives, then renumber" dance avoids violating
// the `(meeting_date, daily_number)` unique index mid-update.
async function renumberDayByStartTime(
executor: DbExecutor,
date: string,
): Promise<void> {
// Step 1: temporarily move every row to a negative number so the
// positives are free. The negatives stay unique because the previous
// positives were unique.
await executor.execute(sql`
UPDATE ${executiveMeetingsTable}
SET daily_number = -1 * daily_number - 1
WHERE meeting_date = ${date}
AND daily_number > 0
`);
// Step 2: assign 1..N to non-cancelled meetings in start-time order.
await executor.execute(sql`
UPDATE ${executiveMeetingsTable} AS em
SET daily_number = sub.new_n
FROM (
SELECT id,
ROW_NUMBER() OVER (
ORDER BY start_time NULLS LAST, id
) AS new_n
FROM ${executiveMeetingsTable}
WHERE meeting_date = ${date}
AND status <> 'cancelled'
) AS sub
WHERE em.id = sub.id
`);
// Step 3: assign continuation numbers to cancelled meetings so the
// unique index stays satisfied and they sort to the end of the list.
await executor.execute(sql`
UPDATE ${executiveMeetingsTable} AS em
SET daily_number = sub.new_n
FROM (
SELECT em2.id,
(
SELECT COUNT(*)::int
FROM ${executiveMeetingsTable} em3
WHERE em3.meeting_date = ${date}
AND em3.status <> 'cancelled'
) + ROW_NUMBER() OVER (ORDER BY em2.id) AS new_n
FROM ${executiveMeetingsTable} em2
WHERE em2.meeting_date = ${date}
AND em2.status = 'cancelled'
) AS sub
WHERE em.id = sub.id
`);
}
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)),
canEditGlobalFontSettings: EM_ADMIN_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(EM_ADMIN_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);
}
}
// #288: rowColor is whitelisted upstream; null = clear back to
// default (no tint).
if (data.rowColor !== undefined) {
updateValues.rowColor = data.rowColor;
}
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" });
}
},
);
// #273: ---- Upcoming-meeting alert (5-minute pre-alarm) endpoints ----
// Parse "HH:MM" or "HH:MM:SS" -> minutes-of-day. Returns null on missing/invalid.
function parseTimeToMinutes(t: string | null | undefined): number | null {
if (!t) return null;
const m = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(t);
if (!m) return null;
const h = Number(m[1]);
const mm = Number(m[2]);
if (h < 0 || h > 23 || mm < 0 || mm > 59) return null;
return h * 60 + mm;
}
function formatMinutesAsTime(mins: number): string {
const safe = ((mins % (24 * 60)) + 24 * 60) % (24 * 60);
const h = Math.floor(safe / 60);
const m = safe % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:00`;
}
// #273: pass a transaction-like executor so the conflict scan reads inside
// the same DB snapshot as the UPDATE that just shifted the row.
async function detectMeetingConflicts(
exec: typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0],
date: string,
excludeId: number,
startTime: string,
endTime: string,
): Promise<boolean> {
const newStart = parseTimeToMinutes(startTime);
const newEnd = parseTimeToMinutes(endTime);
if (newStart == null || newEnd == null) return false;
const others = await exec
.select({
id: executiveMeetingsTable.id,
start: executiveMeetingsTable.startTime,
end: executiveMeetingsTable.endTime,
status: executiveMeetingsTable.status,
})
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.meetingDate, date));
for (const o of others) {
if (o.id === excludeId) continue;
if (o.status === "cancelled" || o.status === "completed") continue;
const s = parseTimeToMinutes(o.start);
const e = parseTimeToMinutes(o.end);
if (s == null || e == null) continue;
if (s < newEnd && e > newStart) return true;
}
return false;
}
const alertActionSchema = z.object({
action: z.enum(["shown", "acknowledged", "dismissed"]),
});
const postponeMinutesSchema = z.object({
minutes: z.number().int().min(1).max(24 * 60),
// #283: optimistic-locking token. The client must send the
// `updatedAt` it last observed for this meeting; if another user
// (or the same user from another tab) mutated the meeting in
// between, we reject with HTTP 409 + stale_meeting so the UI can
// surface "already postponed by X" instead of double-shifting.
// Required by design — there is no opt-out path. To "apply anyway"
// after a conflict, the client refetches (or reuses
// conflict.lastModifiedAt) and resubmits with the fresher token.
expectedUpdatedAt: z.string().datetime(),
});
const rescheduleSchema = z.object({
meetingDate: dateSchema,
startTime: timeSchema,
endTime: timeSchema,
note: z.string().max(500).optional(),
});
router.get(
"/executive-meetings/alert-state",
requireExecutiveAccess,
async (req, res): Promise<void> => {
if (!req.session.userId) {
res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
return;
}
const dateRaw = String(req.query.date ?? "");
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateRaw)) {
res.status(400).json({ error: "Invalid date", code: "invalid_date" });
return;
}
// We deliberately do NOT pin `date` to the server's wall-clock
// "today" here: the frontend computes today from the user's browser
// local time, and the API server may be running in a different
// timezone (typically UTC) so a server-side equality check would
// reject perfectly valid requests near midnight in non-UTC user
// locales. Authorization is enforced via `requireExecutiveAccess`
// and the `userId` filter below — every returned row already
// belongs to the calling user.
const userId = req.session.userId;
const rows = await db
.select({
meetingId: executiveMeetingAlertStateTable.meetingId,
dismissed: executiveMeetingAlertStateTable.dismissed,
acknowledged: executiveMeetingAlertStateTable.acknowledged,
})
.from(executiveMeetingAlertStateTable)
.innerJoin(
executiveMeetingsTable,
eq(executiveMeetingsTable.id, executiveMeetingAlertStateTable.meetingId),
)
.where(
and(
eq(executiveMeetingAlertStateTable.userId, userId),
eq(executiveMeetingsTable.meetingDate, dateRaw),
),
);
res.json({ states: rows });
},
);
router.post(
"/executive-meetings/:id/alert-state",
requireExecutiveAccess,
async (req, res): Promise<void> => {
if (!req.session.userId) {
res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
return;
}
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
res.status(404).json({ error: "Not found", code: "not_found" });
return;
}
const body = parseBody(res, alertActionSchema, req.body);
if (!body) return;
const meeting = await fetchMeetingWithAttendees(id);
if (!meeting) {
res.status(404).json({ error: "Meeting not found", code: "not_found" });
return;
}
const userId = req.session.userId;
// #277: Track whether this request actually transitioned the per-user
// row so we only emit a realtime event on real state changes — keeps
// the cross-tab signal noise-free when two tabs POST the same action.
let stateChanged = false;
await db.transaction(async (tx) => {
// Race-safe upsert: when two tabs surface the same alert at once they
// both POST {action:"shown"}; using ON CONFLICT DO NOTHING means only
// one INSERT actually creates the row, so we audit "shown" once.
const inserted = await tx
.insert(executiveMeetingAlertStateTable)
.values({
meetingId: id,
userId,
dismissed: body.action === "dismissed",
acknowledged: body.action === "acknowledged",
})
.onConflictDoNothing({
target: [
executiveMeetingAlertStateTable.meetingId,
executiveMeetingAlertStateTable.userId,
],
})
.returning({ id: executiveMeetingAlertStateTable.id });
if (inserted.length > 0) {
await logAudit(tx, {
action: "meeting_alert_shown",
entityType: "meeting",
entityId: id,
newValue: { userId },
performedBy: userId,
});
if (body.action === "acknowledged") {
await logAudit(tx, {
action: "meeting_alert_acknowledged",
entityType: "meeting",
entityId: id,
newValue: { userId },
performedBy: userId,
});
stateChanged = true;
} else if (body.action === "dismissed") {
await logAudit(tx, {
action: "meeting_alert_dismissed",
entityType: "meeting",
entityId: id,
newValue: { userId },
performedBy: userId,
});
stateChanged = true;
}
return;
}
// Row exists. Use a conditional UPDATE so concurrent clicks across
// tabs only fire one audit row per real transition (false -> true).
if (body.action === "acknowledged") {
const upd = await tx
.update(executiveMeetingAlertStateTable)
.set({ acknowledged: true })
.where(
and(
eq(executiveMeetingAlertStateTable.meetingId, id),
eq(executiveMeetingAlertStateTable.userId, userId),
eq(executiveMeetingAlertStateTable.acknowledged, false),
),
)
.returning({ id: executiveMeetingAlertStateTable.id });
if (upd.length > 0) {
await logAudit(tx, {
action: "meeting_alert_acknowledged",
entityType: "meeting",
entityId: id,
newValue: { userId },
performedBy: userId,
});
stateChanged = true;
}
} else if (body.action === "dismissed") {
const upd = await tx
.update(executiveMeetingAlertStateTable)
.set({ dismissed: true })
.where(
and(
eq(executiveMeetingAlertStateTable.meetingId, id),
eq(executiveMeetingAlertStateTable.userId, userId),
eq(executiveMeetingAlertStateTable.dismissed, false),
),
)
.returning({ id: executiveMeetingAlertStateTable.id });
if (upd.length > 0) {
await logAudit(tx, {
action: "meeting_alert_dismissed",
entityType: "meeting",
entityId: id,
newValue: { userId },
performedBy: userId,
});
stateChanged = true;
}
}
});
// #277: Push the new alert state to *only* the acting user's sockets
// so all of their open tabs / devices clear the alert within ~1s
// instead of waiting on the 30s poll. Other users are unaffected.
if (stateChanged) {
void emitExecutiveMeetingAlertStateChanged(userId, {
meetingId: id,
dismissed: body.action === "dismissed",
acknowledged: body.action === "acknowledged",
});
}
res.json({ ok: true });
},
);
router.post(
"/executive-meetings/:id/postpone-minutes",
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 body = parseBody(res, postponeMinutesSchema, req.body);
if (!body) return;
const userId = req.session.userId!;
// #273: lock-then-mutate inside a single transaction so concurrent
// postpone/reschedule/cancel requests serialize and audit logs reflect
// the actual committed transition.
type StaleConflict = {
ok: false;
status: 409;
error: string;
code: "stale_meeting";
conflict: {
currentStartTime: string | null;
currentEndTime: string | null;
currentStatus: string;
lastModifiedAt: string;
lastActor: {
id: number | null;
username: string | null;
displayNameAr: string | null;
displayNameEn: string | null;
} | null;
};
};
type TxResult =
| {
ok: true;
meetingDate: string;
newStart: string;
newEnd: string;
conflicts: boolean;
}
| { ok: false; status: number; error: string; code: string }
| StaleConflict;
const txResult = await db.transaction(async (tx): Promise<TxResult> => {
const [locked] = await tx
.select()
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.id, id))
.for("update");
if (!locked) {
return { ok: false, status: 404, error: "Meeting not found", code: "not_found" };
}
// #283: Optimistic-locking guard. The client always sends the
// `updatedAt` it last observed; if the row has been modified
// since, refuse to mutate and return the current state + the
// last actor's display info so the UI can show
// "Already postponed by Ahmed at 10:05 — apply N more minutes?"
// We do this *inside* the FOR UPDATE lock so concurrent
// postpone-minutes calls are serialized: the second one sees the
// first one's committed update and returns 409. There is no
// opt-out — to apply after a conflict, the client must resubmit
// with the freshly-seen `lastModifiedAt`, so a third concurrent
// mutation between conflict-display and apply-anyway will trigger
// another 409 instead of silently stacking.
const observedIso = new Date(body.expectedUpdatedAt).toISOString();
const currentIso = locked.updatedAt.toISOString();
if (observedIso !== currentIso) {
let lastActor: StaleConflict["conflict"]["lastActor"] = null;
if (locked.updatedBy != null) {
const [u] = await tx
.select({
id: usersTable.id,
username: usersTable.username,
displayNameAr: usersTable.displayNameAr,
displayNameEn: usersTable.displayNameEn,
})
.from(usersTable)
.where(eq(usersTable.id, locked.updatedBy));
lastActor = u ?? null;
}
return {
ok: false,
status: 409,
error: "Meeting was modified by someone else",
code: "stale_meeting",
conflict: {
currentStartTime: locked.startTime,
currentEndTime: locked.endTime,
currentStatus: locked.status,
lastModifiedAt: currentIso,
lastActor,
},
};
}
const startMin = parseTimeToMinutes(locked.startTime);
const endMin = parseTimeToMinutes(locked.endTime);
if (startMin == null || endMin == null) {
return {
ok: false,
status: 400,
error: "Meeting has no scheduled time to postpone",
code: "no_time_window",
};
}
if (
startMin + body.minutes >= 24 * 60 ||
// Treat 24:00 (1440) the same as 00:00 the next day — both wrap
// the day boundary, so the end-time guard is `>=`, matching the
// start-time guard.
endMin + body.minutes >= 24 * 60
) {
return {
ok: false,
status: 400,
error: "Postponing by that many minutes would cross midnight",
code: "crosses_midnight",
};
}
const newStart = formatMinutesAsTime(startMin + body.minutes);
const newEnd = formatMinutesAsTime(endMin + body.minutes);
await tx
.update(executiveMeetingsTable)
.set({
startTime: newStart,
endTime: newEnd,
status: "postponed",
// #283: stamp the actor so the next stale_meeting reply can
// attribute "postponed by X" to a real user.
updatedBy: userId,
})
.where(eq(executiveMeetingsTable.id, id));
await logAudit(tx, {
action: "meeting_postponed_minutes",
entityType: "meeting",
entityId: id,
oldValue: {
startTime: locked.startTime,
endTime: locked.endTime,
status: locked.status,
},
newValue: {
startTime: newStart,
endTime: newEnd,
status: "postponed",
minutes: body.minutes,
},
performedBy: userId,
});
const conflicts = await detectMeetingConflicts(
tx,
locked.meetingDate,
id,
newStart,
newEnd,
);
// #273: re-sort the day's `#` so the timeline reflects the new start.
await renumberDayByStartTime(tx, locked.meetingDate);
return {
ok: true,
meetingDate: locked.meetingDate,
newStart,
newEnd,
conflicts,
};
});
if (!txResult.ok) {
// #283: Stale-conflict responses carry an extra `conflict` payload
// (current state + last actor display info) so the UI can render
// a "tried to postpone but X already postponed it to 10:05" prompt
// instead of the generic toast.
const payload: Record<string, unknown> = {
error: txResult.error,
code: txResult.code,
};
if (txResult.code === "stale_meeting" && "conflict" in txResult) {
payload.conflict = txResult.conflict;
}
res.status(txResult.status).json(payload);
return;
}
void emitExecutiveMeetingsDaysChanged([txResult.meetingDate]);
const updated = await fetchMeetingWithAttendees(id);
res.json({ ok: true, conflicts: txResult.conflicts, meeting: updated });
},
);
router.post(
"/executive-meetings/:id/reschedule",
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 body = parseBody(res, rescheduleSchema, req.body);
if (!body) return;
const startMin = parseTimeToMinutes(body.startTime);
const endMin = parseTimeToMinutes(body.endTime);
if (startMin == null || endMin == null || startMin >= endMin) {
res.status(400).json({
error: "End time must be after start time",
code: "invalid_time_range",
});
return;
}
const userId = req.session.userId!;
// #273: lock the row inside the tx so a concurrent edit cannot race us.
type TxResult =
| { ok: true; oldDate: string; conflicts: boolean }
| { ok: false; status: number; error: string; code: string };
const txResult = await db.transaction(async (tx): Promise<TxResult> => {
const [locked] = await tx
.select()
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.id, id))
.for("update");
if (!locked) {
return { ok: false, status: 404, error: "Meeting not found", code: "not_found" };
}
// If the daily number would clash on the new date, allocate a fresh one.
let dailyNumber = locked.dailyNumber;
if (body.meetingDate !== locked.meetingDate) {
dailyNumber = await nextDailyNumber(tx, body.meetingDate);
}
await tx
.update(executiveMeetingsTable)
.set({
meetingDate: body.meetingDate,
startTime: body.startTime,
endTime: body.endTime,
status: "postponed",
dailyNumber,
})
.where(eq(executiveMeetingsTable.id, id));
await logAudit(tx, {
action: "meeting_rescheduled",
entityType: "meeting",
entityId: id,
oldValue: {
meetingDate: locked.meetingDate,
startTime: locked.startTime,
endTime: locked.endTime,
status: locked.status,
dailyNumber: locked.dailyNumber,
},
newValue: {
meetingDate: body.meetingDate,
startTime: body.startTime,
endTime: body.endTime,
status: "postponed",
dailyNumber,
note: body.note ?? null,
},
performedBy: userId,
});
const conflicts = await detectMeetingConflicts(
tx,
body.meetingDate,
id,
body.startTime,
body.endTime,
);
// #273: re-sort `#` on both the old and new dates if the day moved,
// so each timeline reflects the meeting leaving / arriving.
await renumberDayByStartTime(tx, body.meetingDate);
if (locked.meetingDate !== body.meetingDate) {
await renumberDayByStartTime(tx, locked.meetingDate);
}
return { ok: true, oldDate: locked.meetingDate, conflicts };
});
if (!txResult.ok) {
res.status(txResult.status).json({ error: txResult.error, code: txResult.code });
return;
}
void emitExecutiveMeetingsDaysChanged([
txResult.oldDate,
body.meetingDate,
]);
const updated = await fetchMeetingWithAttendees(id);
res.json({ ok: true, conflicts: txResult.conflicts, meeting: updated });
},
);
router.post(
"/executive-meetings/:id/cancel",
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 userId = req.session.userId!;
// #273: lock + idempotent UPDATE — only audit on the real status change.
type TxResult =
| { ok: true; meetingDate: string; changed: boolean }
| { ok: false; status: number; error: string; code: string };
const txResult = await db.transaction(async (tx): Promise<TxResult> => {
const [locked] = await tx
.select()
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.id, id))
.for("update");
if (!locked) {
return { ok: false, status: 404, error: "Meeting not found", code: "not_found" };
}
if (locked.status === "cancelled") {
return { ok: true, meetingDate: locked.meetingDate, changed: false };
}
await tx
.update(executiveMeetingsTable)
.set({ status: "cancelled" })
.where(eq(executiveMeetingsTable.id, id));
await logAudit(tx, {
action: "meeting_cancelled",
entityType: "meeting",
entityId: id,
oldValue: { status: locked.status },
newValue: { status: "cancelled" },
performedBy: userId,
});
// #273: re-sort `#` so the cancelled meeting moves to the tail
// and the remaining active meetings get a clean 1..N sequence.
await renumberDayByStartTime(tx, locked.meetingDate);
return { ok: true, meetingDate: locked.meetingDate, changed: true };
});
if (!txResult.ok) {
res.status(txResult.status).json({ error: txResult.error, code: txResult.code });
return;
}
if (txResult.changed) {
void emitExecutiveMeetingsDaysChanged([txResult.meetingDate]);
}
const updated = await fetchMeetingWithAttendees(id);
res.json({ ok: true, meeting: updated });
},
);
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 = EM_ADMIN_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 default router;