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> { 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) { return async function ( req: Request, res: Response, next: NextFunction, ): Promise { 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 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( res: Response, schema: ZodType, 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[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 { 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 { const [row] = await executor .select({ max: sql`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 { 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 { // 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 => { 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(); 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 => { 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 => { 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 => { 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 => { 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 => { 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 = { 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 = { ...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[0]>[0], date: string, excludeId: number, startTime: string, endTime: string, ): Promise { 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 { 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[0]>[0], followers: CascadeFollower[], triggerMeetingId: number, deltaMinutes: number, performedBy: number, ): Promise { 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 => { 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 => { 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 => { 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 => { 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 => { 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 = { 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 => { 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 => { 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 = { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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(); 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 => { 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`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 => { // 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 => { 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 => { 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 => { 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 { 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/ path that // GET /api/storage/objects/* knows how to serve. async function uploadPdfToStorage(buf: Buffer): Promise { 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/ 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 => { 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(); 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`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 => { 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 => { 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`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 => { 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 => { 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;