Files
TX/executive-meetings.ts.old
T
Riyadh b228b7d652 Task #349: Executive Meetings PDF improvements
- Schema: add `font_color` (hex, default #000000) and `logo_object_path`
  to `executive_meeting_font_settings`. Pushed via drizzle-kit.

- PDF renderer:
  - Add `fontColor` to PdfFontPrefs (body cells only; header chrome
    and logo intentionally ignore it to preserve branding).
  - Add `rowColor` to PdfMeeting and a ROW_COLOR_FILL palette kept in
    lockstep with the on-screen swatches; deliberately stop painting
    the legacy `isHighlighted` overlay so the archived PDF reflects
    editorial state instead of the viewer's transient cursor.
  - Add optional `logo: Buffer`. Header now reserves a left-anchored
    logo box and centers the title in the remaining width; bad image
    bytes log + fall back to the no-logo layout instead of crashing.

- API route:
  - Extend fontSettingsSchema with strict #RRGGBB regex and
    /^/objects/<id>$/ regex for logoObjectPath.
  - resolveFontPrefsForUser now returns { font, logoObjectPath }.
  - loadLogoBytes downloads the brand asset via ObjectStorageService.
  - Logo only writable on the global-scope row.
  - PDF labels switched to "قائمة بأسماء حضور الاجتماعات" /
    "Meeting Attendance List" per the user's printed sample.

- Frontend (executive-meetings.tsx):
  - FontPrefs gains fontColor; FontSettingsResponse.global gains
    logoObjectPath; DEFAULT_FONT and effectiveFont updated.
  - buildFontStyle applies fontColor to on-screen rows.
  - FontSettingsSection: native color picker + hex text input;
    logo upload (PNG/JPEG) via @workspace/object-storage-web's
    useUpload, visible only at the global scope for admins.

- Locales: AR/EN keys for fontColor + logo.{label,upload,replace,
  remove,uploading,uploadFailed,globalOnly}.

- Tests: existing font-settings roundtrip extended with fontColor;
  new test rejects malformed fontColor and non-/objects logo paths.

Type-check clean for api-server and tx-os; new font-settings tests
pass. Other test failures in the suite pre-date this change.
2026-05-03 14:34:29 +00:00

2938 lines
108 KiB
Plaintext

import { Router, type IRouter } from "express";
import {
eq,
ne,
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,
};
// Allowed values for the user/global font-settings PATCH endpoint and
// the rich-text font-family CSS allowlist. Keep in lockstep with the
// `FontSettingsSection` dropdown in `tx-os/src/pages/executive-meetings.tsx`,
// the @font-face declarations in `tx-os/src/custom-fonts.css`, and the
// FAMILY_MAP in `pdf-renderer.ts`. The previous Cairo / Noto Naskh
// Arabic / Amiri entries were dropped because they were never actually
// loaded with @font-face on the client, so picking them was a no-op.
const FONT_FAMILIES = [
"system",
"DIN Next LT Arabic",
"Tajawal",
"Helvetica Neue LT Arabic",
"Helvetica Neue",
"Majalla",
] 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.
// #312: shape returned by renumberDayByStartTime so callers (PATCH,
// POST create, DELETE, duplicate) can capture how the visible
// (non-cancelled) row order changed and enrich their audit rows. The
// `before` / `after` arrays are the visible meeting IDs in
// `daily_number` order (i.e. what the user sees top-to-bottom in the
// schedule); `orderShifted` is just the boolean comparison of those
// two arrays. Cancelled rows are excluded because they are hidden in
// the UI and their tail-position is an implementation detail.
type DayOrderShift = {
date: string;
orderShifted: boolean;
before: number[];
after: number[];
};
async function snapshotVisibleDayOrder(
executor: DbExecutor,
date: string,
): Promise<number[]> {
const rows = await executor
.select({ id: executiveMeetingsTable.id })
.from(executiveMeetingsTable)
.where(
and(
eq(executiveMeetingsTable.meetingDate, date),
ne(executiveMeetingsTable.status, "cancelled"),
),
)
.orderBy(asc(executiveMeetingsTable.dailyNumber));
return rows.map((r) => r.id);
}
async function renumberDayByStartTime(
executor: DbExecutor,
date: string,
): Promise<DayOrderShift> {
// Snapshot the visible order BEFORE the renumber so callers can audit
// a true "from -> to" diff (#312). Cheap single SELECT inside the same
// transaction the caller passes in.
const before = await snapshotVisibleDayOrder(executor, date);
// 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
`);
// Snapshot the visible order AFTER the renumber and compute the
// boolean "did the user-visible chronological order actually change?".
const after = await snapshotVisibleDayOrder(executor, date);
const orderShifted =
before.length !== after.length ||
before.some((id, i) => id !== after[i]);
return { date, orderShifted, before, after };
}
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,
});
// #312: A new meeting with an explicit start_time arrives at the
// tail (nextDailyNumber appends), so the visible list is no
// longer chronological. Renumber 1..N by start_time so the row
// appears at its correct slot without requiring a manual drag.
// Cancelled rows go to the tail (renumberDayByStartTime handles
// that) so the per-day unique index stays satisfied. The newly
// INSERTed row has its own dailyNumber from `nextDailyNumber`,
// so the temporary positives-to-negatives sweep in step 1 picks
// it up too.
await renumberDayByStartTime(tx, data.meetingDate);
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;
// #312: Cross-day move via PATCH meetingDate would otherwise
// keep the row's old daily_number, which collides with an
// existing row on the destination date under the
// `(meeting_date, daily_number)` unique index. Pre-allocate a
// fresh dailyNumber on the new date (max+1) so the UPDATE
// succeeds; renumberDayByStartTime then re-sorts the destination
// day chronologically a few lines down. We only do this when the
// caller did NOT pass an explicit dailyNumber — that path keeps
// its own semantics. Safe inside the transaction because
// nextDailyNumber reads through the same `tx` executor.
if (
data.meetingDate !== undefined &&
data.meetingDate !== existing.meetingDate &&
(data.dailyNumber === undefined || data.dailyNumber === null)
) {
updateValues.dailyNumber = await nextDailyNumber(tx, data.meetingDate);
}
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)));
}
// #312: Auto-sort the day(s) chronologically when the PATCH
// touched a field that affects ordering or visibility. The user's
// bug report — typing 13:00 on a row that sits above a 12:00 row
// — is exactly this: a startTime PATCH leaves dailyNumber
// untouched and the row stays in its old position even though
// the time changed. Renumbering by start_time after the UPDATE
// makes the visible list strictly chronological without forcing
// the user to drag the row. We trigger on:
// - startTime / endTime: the obvious case the user reported.
// - meetingDate: the row moved to a different day; both old
// and new days need to renumber so the row leaves one
// timeline cleanly and arrives in chronological order on
// the other.
// - status: cancel/uncancel changes which rows are "visible"
// (cancelled rows go to the tail per renumberDayByStartTime),
// so the visible 1..N sequence must be recomputed.
// - dailyNumber: a manual override; we still renumber so the
// final state stays chronological (auto-sort wins over a
// stray manual number coming from non-reorder paths).
// We run renumber BEFORE logAudit so the audit row can carry
// the post-renumber `dailyNumber` and an `orderShifted` flag —
// otherwise an admin reading the audit cannot tell that editing
// a meeting's start time also moved its row to a new position.
const orderAffectingChange =
data.startTime !== undefined ||
data.endTime !== undefined ||
data.meetingDate !== undefined ||
data.status !== undefined ||
data.dailyNumber !== undefined;
let primaryShift: DayOrderShift | null = null;
let sourceShift: DayOrderShift | null = null;
let finalDailyNumber: number | null = null;
if (orderAffectingChange) {
const newDate = updateValues.meetingDate ?? existing.meetingDate;
primaryShift = await renumberDayByStartTime(tx, newDate);
if (newDate !== existing.meetingDate) {
// Cross-day move: the source day also needs to close ranks
// so its 1..N sequence stays gap-free.
sourceShift = await renumberDayByStartTime(tx, existing.meetingDate);
}
// Re-read this row's daily_number after auto-sort so the
// audit's newValue echoes the position the user actually
// sees, not the pre-renumber draft from `updateValues`.
const [row] = await tx
.select({ dailyNumber: executiveMeetingsTable.dailyNumber })
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.id, id));
finalDailyNumber = row?.dailyNumber ?? null;
}
// Build the audit's newValue. When auto-sort moved this row, we
// surface (a) the row's final dailyNumber and (b) an
// `orderShifted` flag with `dayOrder` before/after arrays so an
// admin can read "the row jumped from #1 to #3 because the day
// re-sorted". `dayOrder` carries the visible (non-cancelled) ID
// sequence for the affected day(s).
const auditNewValue: Record<string, unknown> = {
...existing,
...updateValues,
attendees,
};
if (finalDailyNumber !== null) {
auditNewValue.dailyNumber = finalDailyNumber;
}
const shiftsForAudit: Array<{
date: string;
before: number[];
after: number[];
}> = [];
if (primaryShift && primaryShift.orderShifted) {
shiftsForAudit.push({
date: primaryShift.date,
before: primaryShift.before,
after: primaryShift.after,
});
}
if (sourceShift && sourceShift.orderShifted) {
shiftsForAudit.push({
date: sourceShift.date,
before: sourceShift.before,
after: sourceShift.after,
});
}
if (shiftsForAudit.length > 0) {
auditNewValue.orderShifted = true;
auditNewValue.dayOrder = shiftsForAudit;
}
await logAudit(tx, {
action: "update",
entityType: "meeting",
entityId: id,
oldValue: existing,
newValue: auditNewValue,
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;
}
// #302: Cascade-shift helpers for postpone/reschedule.
//
// Implementation note (per task plan, step 3): we expose a *separate*
// `POST /executive-meetings/:id/cascade-preview` endpoint instead of
// piggy-backing on the existing GET-by-day response. The preview is
// (a) parameterized by `deltaMinutes` (which the GET response does not
// know) and (b) only consulted by the postpone dialog at the moment
// the user picks a delta — keeping it out of the day fetch avoids
// pushing `deltaMinutes` math into every schedule render. The shape
// returned mirrors the candidate followers that the same-named cascade
// branch on postpone/reschedule will actually shift, so the dialog and
// the writer agree on the affected set.
//
// "Later" = `start_time >= original end_time` of the primary meeting,
// matching the plan's contract. Cancelled and completed meetings are
// skipped on both the preview and the writer paths so they stay in
// lockstep — a follower the user did not see in the preview will not
// be shifted by the writer.
type CascadeFollower = {
id: number;
titleAr: string;
titleEn: string;
oldStart: string;
oldEnd: string;
newStart: string;
newEnd: string;
};
type CascadeResult =
| { ok: true; followers: CascadeFollower[] }
| {
ok: false;
blockedBy: {
id: number;
titleAr: string;
titleEn: string;
projectedStart: string;
projectedEnd: string;
};
};
// Compute the candidate followers and their projected new times for a
// uniform shift by `deltaMinutes`. Returns either `ok: true` with the
// shifted list, or `ok: false` with the first follower (in start-time
// order) that would cross midnight — both endpoints (preview + writer)
// surface that meeting verbatim so the UI can name it in the error.
async function computeCascadeShift(
executor: DbExecutor,
date: string,
primaryId: number,
// `startTime`/`endTime` columns are nullable in the schema (an
// unscheduled placeholder meeting can have neither). The cascade
// contract talks about times that exist, so we just no-op when the
// primary has no end time — the writer paths only ever pass
// `locked.endTime` when scheduling exists, but we accept nullable
// here so the preview endpoint doesn't have to pre-narrow.
originalEndTime: string | null,
deltaMinutes: number,
// #302: when called from inside a writer transaction (postpone or
// reschedule), set `lockRows: true` so the candidate followers are
// selected `FOR UPDATE`. This serializes the read against any
// concurrent mutation of those follower rows, preventing lost
// updates and ensuring the audit `oldStart/oldEnd` always reflect
// the locked-current values at commit time. The cascade-preview
// endpoint runs outside a transaction and passes `lockRows: false`
// (a read-only snapshot is fine for a preview).
lockRows = false,
): Promise<CascadeResult> {
if (originalEndTime == null) return { ok: true, followers: [] };
const baselineEndMin = parseTimeToMinutes(originalEndTime);
if (baselineEndMin == null || !Number.isFinite(deltaMinutes)) {
return { ok: true, followers: [] };
}
const baseQuery = executor
.select({
id: executiveMeetingsTable.id,
titleAr: executiveMeetingsTable.titleAr,
titleEn: executiveMeetingsTable.titleEn,
startTime: executiveMeetingsTable.startTime,
endTime: executiveMeetingsTable.endTime,
status: executiveMeetingsTable.status,
})
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.meetingDate, date));
const candidates = lockRows
? await baseQuery.for("update")
: await baseQuery;
// Sort in start-time order so the "first follower that crosses
// midnight" naming is stable and matches what the user sees.
const followers: CascadeFollower[] = [];
const sorted = candidates
.filter((c) => c.id !== primaryId)
.filter((c) => c.status !== "cancelled" && c.status !== "completed")
.map((c) => ({
...c,
_startMin: parseTimeToMinutes(c.startTime),
_endMin: parseTimeToMinutes(c.endTime),
}))
.filter(
(c): c is typeof c & { _startMin: number; _endMin: number } =>
c._startMin != null &&
c._endMin != null &&
c._startMin >= baselineEndMin,
)
.sort((a, b) => a._startMin - b._startMin || a.id - b.id);
for (const c of sorted) {
const newStartMin = c._startMin + deltaMinutes;
const newEndMin = c._endMin + deltaMinutes;
// Same midnight rule as `postpone-minutes` on the primary meeting:
// 24:00 (1440) is treated as crossing the day boundary, so the
// guard is `>=`. This keeps the preview, writer, and the existing
// primary-meeting validation aligned.
if (newStartMin >= 24 * 60 || newEndMin >= 24 * 60) {
return {
ok: false,
blockedBy: {
id: c.id,
titleAr: c.titleAr,
titleEn: c.titleEn,
projectedStart: formatMinutesAsTime(newStartMin),
projectedEnd: formatMinutesAsTime(newEndMin),
},
};
}
followers.push({
id: c.id,
titleAr: c.titleAr,
titleEn: c.titleEn,
// `c.startTime`/`endTime` are non-null here — the filter above
// dropped any rows whose `parseTimeToMinutes(...)` returned null,
// which is the only way the column-level nullables survive. TS
// can't narrow the source string from the parsed-int, so we
// re-format the minute counters back into "HH:MM" strings, which
// also normalizes any legacy "HH:MM:SS" values from the DB.
oldStart: formatMinutesAsTime(c._startMin),
oldEnd: formatMinutesAsTime(c._endMin),
newStart: formatMinutesAsTime(newStartMin),
newEnd: formatMinutesAsTime(newEndMin),
});
}
return { ok: true, followers };
}
// Persist a previously-computed cascade. The caller has already
// verified midnight boundaries via `computeCascadeShift`. Each
// follower gets one `meeting_cascade_shift` audit row that names the
// trigger meeting + delta so the audit can be replayed later.
async function applyCascadeShift(
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
followers: CascadeFollower[],
triggerMeetingId: number,
deltaMinutes: number,
performedBy: number,
): Promise<void> {
for (const f of followers) {
await tx
.update(executiveMeetingsTable)
.set({
startTime: f.newStart,
endTime: f.newEnd,
// #302: stamp the cascade actor so a subsequent stale_meeting
// check on the follower attributes the change correctly.
updatedBy: performedBy,
})
.where(eq(executiveMeetingsTable.id, f.id));
await logAudit(tx, {
action: "meeting_cascade_shift",
entityType: "meeting",
entityId: f.id,
oldValue: {
startTime: f.oldStart,
endTime: f.oldEnd,
},
newValue: {
startTime: f.newStart,
endTime: f.newEnd,
triggerMeetingId,
deltaMinutes,
},
performedBy,
});
}
}
const cascadePreviewSchema = z.object({
deltaMinutes: z.number().int().min(1).max(24 * 60),
});
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(),
// #302: opt-in flag for "also shift every later active meeting on
// this day by the same delta". Defaults to false (today's behavior:
// only the primary meeting moves) so older clients without the new
// confirmation step keep working unchanged.
cascadeFollowing: z.boolean().optional().default(false),
});
const rescheduleSchema = z.object({
meetingDate: dateSchema,
startTime: timeSchema,
endTime: timeSchema,
note: z.string().max(500).optional(),
// #302: same opt-in cascade flag as postpone-minutes. Cascading is
// only applied when the meeting stays on the same date AND the
// delta = newStart - oldStart is positive; otherwise the flag is
// ignored (no rejection) so existing clients keep the same shape.
cascadeFollowing: z.boolean().optional().default(false),
});
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/cascade-preview",
requireExecutiveAccess,
// #302: read-only — gated on executive access only. The dialog calls
// this on every delta change, including for users who can view but
// not mutate; gating on requireMutate would gray out the cascade
// checkbox for them and silently change the prompt copy.
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, cascadePreviewSchema, req.body);
if (!body) return;
const [primary] = await db
.select({
id: executiveMeetingsTable.id,
meetingDate: executiveMeetingsTable.meetingDate,
endTime: executiveMeetingsTable.endTime,
})
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.id, id));
if (!primary) {
res.status(404).json({ error: "Meeting not found", code: "not_found" });
return;
}
const cascade = await computeCascadeShift(
db,
primary.meetingDate,
id,
primary.endTime,
body.deltaMinutes,
);
if (!cascade.ok) {
// Match the writer's blocked shape so the dialog can render the
// same error UI whether it learned about midnight at preview time
// or at submit time (e.g. another user added a late meeting in
// between).
res.json({ followers: [], blockedBy: cascade.blockedBy });
return;
}
res.json({ followers: cascade.followers, blockedBy: null });
},
);
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;
};
};
// #302: success path may now also report how many followers were
// cascade-shifted so the client can show "Postponed + shifted N
// following meetings" without a follow-up fetch. The new failure
// shape `cascade_crosses_midnight` carries the offending meeting
// so the UI can name it in the error block.
type CascadeBlock = {
ok: false;
status: 400;
error: string;
code: "cascade_crosses_midnight";
blockedBy: {
id: number;
titleAr: string;
titleEn: string;
projectedStart: string;
projectedEnd: string;
};
};
type TxResult =
| {
ok: true;
meetingDate: string;
newStart: string;
newEnd: string;
conflicts: boolean;
cascadedIds: number[];
}
| { ok: false; status: number; error: string; code: string }
| StaleConflict
| CascadeBlock;
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);
// #302: When the client opted in to cascading, compute the
// followers *before* updating anything so we can short-circuit
// (and roll the whole transaction back) if any of them would
// cross midnight. Crucially we use `locked.endTime` — the
// primary meeting's *original* end time — as the baseline so
// the follower selection matches the preview the user just
// confirmed in the dialog.
let cascadedIds: number[] = [];
if (body.cascadeFollowing) {
const cascade = await computeCascadeShift(
tx,
locked.meetingDate,
id,
locked.endTime,
body.minutes,
// #302: lock follower rows FOR UPDATE inside the writer tx
// so concurrent mutations can't sneak between read and
// write, and so audit oldStart/oldEnd reflects the locked
// current values at commit time.
true,
);
if (!cascade.ok) {
return {
ok: false,
status: 400,
error: "Cascading would push a follower past midnight",
code: "cascade_crosses_midnight",
blockedBy: cascade.blockedBy,
};
}
cascadedIds = cascade.followers.map((f) => f.id);
// The primary update happens *after* the cascade preview but
// *before* the cascade UPDATEs so that detect-conflicts and
// renumbering see the full final state. All UPDATEs are in
// the same FOR UPDATE-locked transaction — `locked` was
// selected for update at the top, and the followers are
// disjoint from the primary by id.
await applyCascadeShift(tx, cascade.followers, id, body.minutes, userId);
}
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,
// #302: surface the cascade decision in the primary audit
// entry so the History view can group them together. The
// followers also each get their own `meeting_cascade_shift`
// row via `applyCascadeShift`.
cascadedFollowerIds: cascadedIds,
},
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,
cascadedIds,
};
});
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.
// #302: cascade_crosses_midnight responses similarly carry a
// `blockedBy` payload naming the offending follower so the dialog
// can offer "back / keep their times" instead of a 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;
}
if (
txResult.code === "cascade_crosses_midnight" &&
"blockedBy" in txResult
) {
payload.blockedBy = txResult.blockedBy;
}
res.status(txResult.status).json(payload);
return;
}
// #302: per-day realtime emit already covers every cascade-shifted
// meeting because they all live on `txResult.meetingDate` (cascade
// never moves a follower across days).
void emitExecutiveMeetingsDaysChanged([txResult.meetingDate]);
const updated = await fetchMeetingWithAttendees(id);
res.json({
ok: true,
conflicts: txResult.conflicts,
meeting: updated,
cascadedIds: txResult.cascadedIds,
});
},
);
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.
// #302: cascade_crosses_midnight has the same shape as on
// postpone-minutes so the dialog can share its handler.
type CascadeBlock = {
ok: false;
status: 400;
error: string;
code: "cascade_crosses_midnight";
blockedBy: {
id: number;
titleAr: string;
titleEn: string;
projectedStart: string;
projectedEnd: string;
};
};
type TxResult =
| { ok: true; oldDate: string; conflicts: boolean; cascadedIds: number[] }
| { ok: false; status: number; error: string; code: string }
| CascadeBlock;
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);
}
// #302: Reschedule cascade contract.
// - Only cascades when (a) the meeting stays on the same date
// and (b) the delta = newStart - originalStart is positive
// (rescheduling earlier or only changing the end never has a
// meaningful "shift the next meetings" interpretation).
// - Uses `locked.endTime` (original) as the baseline for picking
// followers, matching postpone-minutes.
// - If `cascadeFollowing` is true but those guards aren't met,
// we silently ignore the flag — the dialog already gates the
// prompt on the same condition, so this is just defensive.
let cascadedIds: number[] = [];
if (body.cascadeFollowing && body.meetingDate === locked.meetingDate) {
const oldStartMin = parseTimeToMinutes(locked.startTime);
if (oldStartMin != null && startMin > oldStartMin) {
const delta = startMin - oldStartMin;
const cascade = await computeCascadeShift(
tx,
locked.meetingDate,
id,
locked.endTime,
delta,
// #302: same FOR UPDATE locking as the postpone-minutes
// cascade — see that call site for rationale.
true,
);
if (!cascade.ok) {
return {
ok: false,
status: 400,
error: "Cascading would push a follower past midnight",
code: "cascade_crosses_midnight",
blockedBy: cascade.blockedBy,
};
}
cascadedIds = cascade.followers.map((f) => f.id);
await applyCascadeShift(tx, cascade.followers, id, delta, userId);
}
}
await tx
.update(executiveMeetingsTable)
.set({
meetingDate: body.meetingDate,
startTime: body.startTime,
endTime: body.endTime,
status: "postponed",
dailyNumber,
// #302: stamp actor for parity with postpone-minutes — the
// existing reschedule path didn't, but it's the right thing
// to do now that cascades attribute their followers to a
// user, and a primary reschedule should be no different.
updatedBy: userId,
})
.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,
cascadedFollowerIds: cascadedIds,
},
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,
cascadedIds,
};
});
if (!txResult.ok) {
const payload: Record<string, unknown> = {
error: txResult.error,
code: txResult.code,
};
if (
txResult.code === "cascade_crosses_midnight" &&
"blockedBy" in txResult
) {
payload.blockedBy = txResult.blockedBy;
}
res.status(txResult.status).json(payload);
return;
}
void emitExecutiveMeetingsDaysChanged([
txResult.oldDate,
body.meetingDate,
]);
const updated = await fetchMeetingWithAttendees(id);
res.json({
ok: true,
conflicts: txResult.conflicts,
meeting: updated,
cascadedIds: txResult.cascadedIds,
});
},
);
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,
});
// #312: After deleting a row the day's `#` sequence has a gap
// (the deleted dailyNumber) and any cancelled rows that used to
// occupy slots above the deleted one are now off by one. Renumber
// 1..N by start_time so the visible list stays clean and the next
// call to nextDailyNumber() doesn't allocate the recycled slot.
await renumberDayByStartTime(tx, existing.meetingDate);
});
// 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,
});
// #312: A duplicate copies the source's start_time but is
// appended at the tail (nextDailyNumber). Renumber the target
// day so the duplicate slots into its correct chronological
// position — same reason POST /executive-meetings does it.
await renumberDayByStartTime(tx, data.targetDate);
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));
// The schedule view hides cancelled meetings (#273), so dnd-kit
// can only ever produce drags between visible (non-cancelled)
// rows. The reorder contract therefore operates on the
// "in-scope" subset = meetings whose status !== 'cancelled'.
// Cancelled meetings keep their startTime, endTime, and
// dailyNumber unchanged so they do not steal slots from the
// visible list (which previously made the visible rows look
// out of order after a drag).
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 orderedSet = new Set(data.orderedIds);
// 1) orderedIds must not include any cancelled meetings — those
// are not draggable in the UI, so a payload containing one
// means the client and server have drifted out of sync.
const cancelledInOrder = data.orderedIds.filter((id) =>
allRows.some((r) => r.id === id && r.status === "cancelled"),
);
if (cancelledInOrder.length > 0) {
throw Object.assign(new Error("cancelled_in_reorder"), {
httpStatus: 400,
code: "cancelled_in_reorder",
});
}
// 2) Every non-cancelled meeting on the day must appear in
// orderedIds. Omitting a visible meeting would leave it
// un-renumbered and produce duplicate dailyNumbers, so reject
// with the same incomplete_day code we used before.
const missingNonCancelled = allRows.filter(
(r) => r.status !== "cancelled" && !orderedSet.has(r.id),
);
if (missingNonCancelled.length > 0) {
throw Object.assign(new Error("incomplete_day_reorder"), {
httpStatus: 400,
code: "incomplete_day",
});
}
// Slot-swap operates on in-scope (visible) meetings only.
// A "slot" is the (startTime, endTime, dailyNumber) tuple of
// an in-scope meeting sorted chronologically by start time.
// Assigning slot[i] to orderedIds[i] permutes the visible
// dailyNumbers among the visible meetings — cancelled rows
// keep their existing dailyNumbers, so no unique-constraint
// conflicts arise.
const inScope = allRows.filter((r) => orderedSet.has(r.id));
const slots = inScope
.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,
dailyNumber: r.dailyNumber,
}));
// Phase 1: park in-scope rows on negative dailyNumbers so
// phase 2 can write the (permuted) positive numbers without
// tripping the per-day unique constraint.
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));
}
// Phase 2: assign each meeting in orderedIds the slot tuple
// (start, end, dailyNumber) for its new visible position.
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: slot.dailyNumber,
updatedBy: userId,
})
.where(eq(executiveMeetingsTable.id, id));
}
await logAudit(tx, {
action: "executive_meeting.reorder",
entityType: "meeting",
entityId: data.orderedIds[0]!,
oldValue: {
meetingDate: data.meetingDate,
// Audit the OLD visible order (cancelled excluded) so the
// before/after pair reflects what the user actually saw
// on screen rather than the raw row order.
order: inScope
.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;