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 { logger } from "../lib/logger";
import { renderSchedulePdf, type PdfFontPrefs } from "../lib/pdf-renderer";
import { getPdfLabels } from "../lib/pdf-labels";
import { ObjectStorageService } from "../lib/objectStorage";
import {
  recordExecutiveMeetingNotifications,
  broadcastExecutiveMeetingNotifications,
  getUserIdsForRoleNames,
  sendExecutiveMeetingEmail,
  getUserDisplay as getUserDisplayForNotify,
  EXECUTIVE_MEETING_NOTIFICATION_TYPES,
} from "../lib/executive-meeting-notify";
import {
  ExecutiveMeetingsReorderBody,
  ExecutiveMeetingsSwapTimesBody,
  ExecutiveMeetingsRotateContentBody,
} 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_ceo",
  "executive_office_manager",
  "executive_coord_lead",
] as const;
const EM_ADMIN_ROLES = ["admin", "executive_office_manager"] as const;
const ADMIN_AUDIT_ROLES = [
  "admin",
  "executive_ceo",
  "executive_office_manager",
  "executive_coord_lead",
] as const;
const READ_ROLES = [
  "admin",
  "executive_ceo",
  "executive_office_manager",
  "executive_coord_lead",
  "executive_coordinator",
  "executive_viewer",
] as const;

// ---------- Role helpers (finer-grained, layered on top of requireExecutiveAccess) ----------

async function getRoleNamesForUser(userId: number): Promise<Set<string>> {
  const ids = await getEffectiveRoleIds(userId);
  if (ids.length === 0) return new Set();
  const rows = await db
    .select({ name: rolesTable.name })
    .from(rolesTable)
    .where(inArray(rolesTable.id, ids));
  return new Set(rows.map((r) => r.name));
}

function makeRequireRoles(allowed: ReadonlyArray<string>) {
  return async function (
    req: Request,
    res: Response,
    next: NextFunction,
  ): Promise<void> {
    if (!req.session.userId) {
      res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
      return;
    }
    const names = await getRoleNamesForUser(req.session.userId);
    const ok = allowed.some((r) => names.has(r));
    if (!ok) {
      res.status(403).json({ error: "Forbidden", code: "forbidden" });
      return;
    }
    next();
  };
}

const requireMutate = makeRequireRoles(MUTATE_ROLES);
const requireAdminAudit = makeRequireRoles(ADMIN_AUDIT_ROLES);

// ---------- Zod schemas ----------

// Allowed values for the row-highlight overlay on the daily schedule.
// Mirrors the ROW_COLOR_OPTIONS palette in the frontend (
// artifacts/tx-os/src/pages/executive-meetings.tsx). NULL/omitted on the
// wire = "default" (no tint). The whitelist itself is exported from
// the schema package (#293) so the API guard, the Drizzle CHECK
// constraint, and any future caller share one source of truth — change
// the palette in one place and both layers stay in sync.
const rowColorSchema = z.enum(EXECUTIVE_MEETING_ROW_COLOR_KEYS).nullable();

const dateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "expected YYYY-MM-DD");

const DATE_RE_LOCAL = /^\d{4}-\d{2}-\d{2}$/;
const timeSchema = z
  .string()
  .regex(/^\d{2}:\d{2}(:\d{2})?$/, "expected HH:MM");
const positiveIntSchema = z.number().int().positive();

// titleAr/En and attendee.name accept sanitized rich-text HTML (Tiptap
// output). The byte count grows quickly with inline <span style=...> wrappers,
// so we allow up to ~10k bytes for titles and ~5k for attendee names. The
// sanitizer strips disallowed tags/attrs before persistence.
const attendeeSchema = z
  .object({
    // For persons this is sanitized rich-text HTML (Tiptap output).
    // For subheadings this is the user's free-text label, also passed
    // through sanitizeRichText to strip any sneaky markup. Both kinds
    // require at least one non-whitespace character; an empty string is
    // a delete-row gesture in the inline editor and must never reach the DB.
    name: z.string().trim().min(1).max(5000),
    title: z.string().trim().max(200).nullable().optional(),
    attendanceType: z.enum(ATTENDANCE_TYPES).default("internal"),
    sortOrder: z.number().int().min(0).optional(),
    kind: z.enum(ATTENDEE_KINDS).default("person"),
  })
  // `.strict()` so any client-only field (e.g. the React `_sid` row id used
  // by the drag-and-drop editor) that accidentally leaks into the wire
  // payload is rejected loudly with 400 instead of being silently stripped.
  // The browser always projects to the documented wire shape; this is the
  // belt-and-suspenders guard against a future refactor regression.
  .strict();

const meetingBaseFields = {
  titleAr: z.string().trim().min(1).max(10000),
  titleEn: z.string().trim().min(1).max(10000),
  meetingDate: dateSchema,
  dailyNumber: positiveIntSchema.max(1000).nullable().optional(),
  startTime: timeSchema.nullable().optional(),
  endTime: timeSchema.nullable().optional(),
  location: z.string().trim().max(500).nullable().optional(),
  meetingUrl: z.string().trim().max(2000).nullable().optional(),
  platform: z.enum(PLATFORMS).optional(),
  status: z.enum(STATUSES).optional(),
  isHighlighted: z
    .union([z.boolean(), z.number()])
    .transform((v) => (v ? 1 : 0))
    .optional(),
  notes: z.string().trim().max(5000).nullable().optional(),
  // #635: external-meeting flag. Coerce truthy/falsy → boolean so older
  // clients can keep sending 0/1 without surprises.
  isExternal: z
    .union([z.boolean(), z.number()])
    .transform((v) => Boolean(v))
    .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,
    isExternal: meetingBaseFields.isExternal,
    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;

// MR-H2: this manual archive endpoint is the "I just printed this in
// the browser" snapshot. The body must NOT carry a filePath; the
// server derives a synthetic `print:<archiveDate>` token from the
// only field that matters here. Real `/objects/<id>` archive rows are
// produced by the server-side render path (line ~3196), which builds
// the path internally and never reads it from the request body.
// Allowing any caller-supplied path — even regex-validated — would
// keep the MR-H2 chain alive (executive_office_manager could point
// archive rows at any `/objects/<id>` they have ever observed).
const pdfArchiveCreateSchema = z
  .object({
    archiveDate: dateSchema,
  })
  .strict();

// #RRGGBB hex color.
const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
// `/objects/<id>` path; null clears the slot.
const OBJECT_PATH_RE = /^\/objects\/[A-Za-z0-9_\-./]+$/;
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"),
  fontColor: z
    .string()
    .regex(HEX_COLOR_RE, "expected #RRGGBB hex color")
    .default("#000000"),
  // null = clear, undefined = leave alone. Global scope only.
  logoObjectPath: z
    .string()
    .regex(OBJECT_PATH_RE, "expected /objects/<id> path")
    .nullable()
    .optional(),
});

function parseBody<T>(
  res: Response,
  schema: ZodType<T>,
  body: unknown,
): T | null {
  const r = schema.safeParse(body ?? {});
  if (!r.success) {
    const issue = r.error.issues[0];
    const msg = issue
      ? `${issue.path.join(".") || "body"}: ${issue.message}`
      : "validation";
    res.status(400).json({ error: msg, code: "validation" });
    return null;
  }
  return r.data;
}


type DbExecutor = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0];
type AuditInsert = typeof executiveMeetingAuditLogsTable.$inferInsert;

async function logAudit(
  executor: DbExecutor,
  opts: {
    action: string;
    entityType: string;
    entityId: number | null;
    oldValue?: unknown;
    newValue?: unknown;
    performedBy: number | null;
  },
): Promise<void> {
  const row: AuditInsert = {
    action: opts.action,
    entityType: opts.entityType,
    entityId: opts.entityId ?? null,
    oldValue: (opts.oldValue ?? null) as AuditInsert["oldValue"],
    newValue: (opts.newValue ?? null) as AuditInsert["newValue"],
    performedBy: opts.performedBy,
  };
  await executor.insert(executiveMeetingAuditLogsTable).values(row);
}

// ---------- Common helpers ----------

// Serialize concurrent transactions that mutate `executive_meetings`
// rows for the same `meeting_date`. Without this, two parallel POSTs
// for the same date can either (a) deadlock in the negate-then-renumber
// dance inside `renumberDayByStartTime`, or (b) race `nextDailyNumber`
// and trip the `(meeting_date, daily_number)` unique index. Postgres
// advisory locks scoped to the current transaction are reentrant per
// session, so callers can call this multiple times within one tx (e.g.
// once at the top of POST create and again inside renumberDayByStartTime)
// without self-deadlocking.
async function lockMeetingDate(executor: DbExecutor, date: string): Promise<void> {
  await executor.execute(
    sql`SELECT pg_advisory_xact_lock(hashtextextended(${date}, 0))`,
  );
}

async function nextDailyNumber(
  executor: DbExecutor,
  date: string,
): Promise<number> {
  const [row] = await executor
    .select({
      max: sql<number | null>`max(${executiveMeetingsTable.dailyNumber})`,
    })
    .from(executiveMeetingsTable)
    .where(eq(executiveMeetingsTable.meetingDate, date));
  return (row?.max ?? 0) + 1;
}

// #273: After a postpone/reschedule/cancel that shifts a meeting in the
// daily timeline, recompute `daily_number` for every meeting on `date`
// so that the displayed `#` matches the new chronological order.
//
// Active meetings (not cancelled) are numbered 1..N in `start_time`
// order; cancelled meetings are pushed to the tail (N+1..N+M, by id).
// The two-step "shift to negatives, then renumber" dance avoids violating
// the `(meeting_date, daily_number)` unique index mid-update.
// #312: shape returned by renumberDayByStartTime so callers (PATCH,
// POST create, DELETE, duplicate) can capture how the visible
// (non-cancelled) row order changed and enrich their audit rows. The
// `before` / `after` arrays are the visible meeting IDs in
// `daily_number` order (i.e. what the user sees top-to-bottom in the
// schedule); `orderShifted` is just the boolean comparison of those
// two arrays. Cancelled rows are excluded because they are hidden in
// the UI and their tail-position is an implementation detail.
type DayOrderShift = {
  date: string;
  orderShifted: boolean;
  before: number[];
  after: number[];
};

async function snapshotVisibleDayOrder(
  executor: DbExecutor,
  date: string,
): Promise<number[]> {
  const rows = await executor
    .select({ id: executiveMeetingsTable.id })
    .from(executiveMeetingsTable)
    .where(
      and(
        eq(executiveMeetingsTable.meetingDate, date),
        ne(executiveMeetingsTable.status, "cancelled"),
      ),
    )
    .orderBy(asc(executiveMeetingsTable.dailyNumber));
  return rows.map((r) => r.id);
}

async function renumberDayByStartTime(
  executor: DbExecutor,
  date: string,
): Promise<DayOrderShift> {
  // Serialize per-date so the negate-then-renumber dance below cannot
  // deadlock with a concurrent transaction running the same dance for
  // the same `meeting_date`. Reentrant — safe if the caller already
  // grabbed this lock.
  await lockMeetingDate(executor, date);
  // Snapshot the visible order BEFORE the renumber so callers can audit
  // a true "from -> to" diff (#312). Cheap single SELECT inside the same
  // transaction the caller passes in.
  const before = await snapshotVisibleDayOrder(executor, date);
  // Step 1: temporarily move every row to a negative number so the
  // positives are free. The negatives stay unique because the previous
  // positives were unique.
  await executor.execute(sql`
    UPDATE ${executiveMeetingsTable}
       SET daily_number = -1 * daily_number - 1
     WHERE meeting_date = ${date}
       AND daily_number > 0
  `);
  // Step 2: assign 1..N to non-cancelled meetings in start-time order.
  await executor.execute(sql`
    UPDATE ${executiveMeetingsTable} AS em
       SET daily_number = sub.new_n
      FROM (
        SELECT id,
               ROW_NUMBER() OVER (
                 ORDER BY start_time NULLS LAST, id
               ) AS new_n
          FROM ${executiveMeetingsTable}
         WHERE meeting_date = ${date}
           AND status <> 'cancelled'
      ) AS sub
     WHERE em.id = sub.id
  `);
  // Step 3: assign continuation numbers to cancelled meetings so the
  // unique index stays satisfied and they sort to the end of the list.
  await executor.execute(sql`
    UPDATE ${executiveMeetingsTable} AS em
       SET daily_number = sub.new_n
      FROM (
        SELECT em2.id,
               (
                 SELECT COUNT(*)::int
                   FROM ${executiveMeetingsTable} em3
                  WHERE em3.meeting_date = ${date}
                    AND em3.status <> 'cancelled'
               ) + ROW_NUMBER() OVER (ORDER BY em2.id) AS new_n
          FROM ${executiveMeetingsTable} em2
         WHERE em2.meeting_date = ${date}
           AND em2.status = 'cancelled'
      ) AS sub
     WHERE em.id = sub.id
  `);
  // Snapshot the visible order AFTER the renumber and compute the
  // boolean "did the user-visible chronological order actually change?".
  const after = await snapshotVisibleDayOrder(executor, date);
  const orderShifted =
    before.length !== after.length ||
    before.some((id, i) => id !== after[i]);
  return { date, orderShifted, before, after };
}

async function fetchMeetingWithAttendees(id: number) {
  const [meeting] = await db
    .select()
    .from(executiveMeetingsTable)
    .where(eq(executiveMeetingsTable.id, id));
  if (!meeting) return null;
  const attendees = await db
    .select()
    .from(executiveMeetingAttendeesTable)
    .where(eq(executiveMeetingAttendeesTable.meetingId, id))
    .orderBy(asc(executiveMeetingAttendeesTable.sortOrder));
  return { ...meeting, attendees };
}

// =====================================================================
// MEETINGS
// =====================================================================

router.get(
  "/executive-meetings",
  requireExecutiveAccess,
  async (req, res): Promise<void> => {
    const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : "";
    if (dateRaw && !/^\d{4}-\d{2}-\d{2}$/.test(dateRaw)) {
      res
        .status(400)
        .json({ error: "Invalid 'date' (expected YYYY-MM-DD)", code: "bad_date" });
      return;
    }
    const targetDate = dateRaw || new Date().toISOString().slice(0, 10);
    const meetings = await db
      .select()
      .from(executiveMeetingsTable)
      .where(eq(executiveMeetingsTable.meetingDate, targetDate))
      .orderBy(asc(executiveMeetingsTable.dailyNumber));
    if (meetings.length === 0) {
      res.json({ date: targetDate, meetings: [] });
      return;
    }
    const ids = meetings.map((m) => m.id);
    const attendees = await db
      .select()
      .from(executiveMeetingAttendeesTable)
      .where(inArray(executiveMeetingAttendeesTable.meetingId, ids))
      .orderBy(
        asc(executiveMeetingAttendeesTable.meetingId),
        asc(executiveMeetingAttendeesTable.sortOrder),
      );
    const byMeeting = new Map<number, typeof attendees>();
    for (const a of attendees) {
      const arr = byMeeting.get(a.meetingId) ?? [];
      arr.push(a);
      byMeeting.set(a.meetingId, arr);
    }
    res.json({
      date: targetDate,
      meetings: meetings.map((m) => ({
        ...m,
        attendees: byMeeting.get(m.id) ?? [],
      })),
    });
  },
);

router.get(
  "/executive-meetings/dates",
  requireExecutiveAccess,
  async (_req, res): Promise<void> => {
    const rows = await db
      .selectDistinct({ d: executiveMeetingsTable.meetingDate })
      .from(executiveMeetingsTable)
      .orderBy(asc(executiveMeetingsTable.meetingDate));
    res.json({ dates: rows.map((r) => r.d) });
  },
);

router.get(
  "/executive-meetings/me",
  requireExecutiveAccess,
  async (req, res): Promise<void> => {
    if (!req.session.userId) {
      res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
      return;
    }
    const names = await getRoleNamesForUser(req.session.userId);
    res.json({
      userId: req.session.userId,
      roles: Array.from(names),
      canRead: READ_ROLES.some((r) => names.has(r)),
      canMutate: MUTATE_ROLES.some((r) => names.has(r)),
      canEditGlobalFontSettings: EM_ADMIN_ROLES.some((r) => names.has(r)),
      canViewAudit: ADMIN_AUDIT_ROLES.some((r) => names.has(r)),
    });
  },
);

router.get(
  "/executive-meetings/:id",
  requireExecutiveAccess,
  async (req, res): Promise<void> => {
    const id = Number(req.params.id);
    if (!Number.isInteger(id) || id <= 0) {
      res.status(404).json({ error: "Not found", code: "not_found" });
      return;
    }
    const m = await fetchMeetingWithAttendees(id);
    if (!m) {
      res.status(404).json({ error: "Meeting not found", code: "not_found" });
      return;
    }
    res.json(m);
  },
);

router.post(
  "/executive-meetings",
  requireExecutiveAccess,
  requireMutate,
  async (req, res): Promise<void> => {
    const data = parseBody(res, meetingCreateSchema, req.body);
    if (!data) return;
    const userId = req.session.userId!;
    try {
      const inserted = await db.transaction(async (tx) => {
        // Serialize per-date so concurrent POSTs for the same date can't
        // race `nextDailyNumber` into a unique-index violation, and so
        // the renumber dance below cannot deadlock with a parallel one.
        await lockMeetingDate(tx, data.meetingDate);
        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),
          // #635: persist the external-meeting flag; default false.
          // When true, force rowColor='red' so the schedule always
          // renders the row in the agreed external-meeting tint even
          // if the client forgot to pass it.
          isExternal: Boolean(data.isExternal ?? false),
          rowColor: data.isExternal ? "red" : (data as { rowColor?: string }).rowColor ?? null,
          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;
      }
      logger.error({ err }, "executive_meetings.create failed");
      res.status(500).json({ error: "Could not create meeting", code: "create_failed" });
    }
  },
);

router.patch(
  "/executive-meetings/:id",
  requireExecutiveAccess,
  requireMutate,
  async (req, res): Promise<void> => {
    const id = Number(req.params.id);
    if (!Number.isInteger(id) || id <= 0) {
      res.status(404).json({ error: "Not found", code: "not_found" });
      return;
    }
    const existing = await fetchMeetingWithAttendees(id);
    if (!existing) {
      res.status(404).json({ error: "Meeting not found", code: "not_found" });
      return;
    }
    const data = parseBody(res, meetingPatchSchema, req.body);
    if (!data) return;
    const userId = req.session.userId!;
    try {
      await db.transaction(async (tx) => {
        // Serialize per-date for both the existing date and (if moving)
        // the destination date so concurrent PATCH/POST/duplicate flows
        // can't race the renumber + dailyNumber allocation. Acquire in
        // sorted order to prevent deadlock between opposite-direction
        // cross-day PATCHes (A->B vs B->A).
        const datesToLock = Array.from(
          new Set(
            data.meetingDate !== undefined && data.meetingDate !== existing.meetingDate
              ? [existing.meetingDate, data.meetingDate]
              : [existing.meetingDate],
          ),
        ).sort();
        for (const d of datesToLock) await lockMeetingDate(tx, d);
        const updateValues: Partial<typeof executiveMeetingsTable.$inferInsert> = {
          updatedBy: userId,
        };
        if (data.titleAr !== undefined)
          updateValues.titleAr = sanitizeRichText(data.titleAr);
        if (data.titleEn !== undefined)
          updateValues.titleEn = sanitizeRichText(data.titleEn ?? "");
        if (data.meetingDate !== undefined) updateValues.meetingDate = data.meetingDate;
        if (data.dailyNumber !== undefined && data.dailyNumber !== null)
          updateValues.dailyNumber = data.dailyNumber;
        // #312: Cross-day move via PATCH meetingDate would otherwise
        // keep the row's old daily_number, which collides with an
        // existing row on the destination date under the
        // `(meeting_date, daily_number)` unique index. Pre-allocate a
        // fresh dailyNumber on the new date (max+1) so the UPDATE
        // succeeds; renumberDayByStartTime then re-sorts the destination
        // day chronologically a few lines down. We only do this when the
        // caller did NOT pass an explicit dailyNumber — that path keeps
        // its own semantics. Safe inside the transaction because
        // nextDailyNumber reads through the same `tx` executor.
        if (
          data.meetingDate !== undefined &&
          data.meetingDate !== existing.meetingDate &&
          (data.dailyNumber === undefined || data.dailyNumber === null)
        ) {
          updateValues.dailyNumber = await nextDailyNumber(tx, data.meetingDate);
        }
        if (data.startTime !== undefined) updateValues.startTime = data.startTime;
        if (data.endTime !== undefined) updateValues.endTime = data.endTime;
        // Plain-text fields: strip any HTML at the API boundary so a
        // PATCH cannot smuggle markup that the POST path already rejects.
        // Mirrors the create handler above and the attendee.title path.
        if (data.location !== undefined)
          updateValues.location = stripTagsToPlainTextOrNull(data.location);
        if (data.meetingUrl !== undefined)
          updateValues.meetingUrl = stripTagsToPlainTextOrNull(data.meetingUrl);
        if (data.platform !== undefined) updateValues.platform = data.platform;
        if (data.status !== undefined) updateValues.status = data.status;
        if (data.isHighlighted !== undefined)
          updateValues.isHighlighted = data.isHighlighted;
        if (data.notes !== undefined)
          updateValues.notes = stripTagsToPlainTextOrNull(data.notes);
        if (data.merge !== undefined) {
          if (data.merge === null) {
            updateValues.mergeStartColumn = null;
            updateValues.mergeEndColumn = null;
            updateValues.mergeText = null;
          } else {
            updateValues.mergeStartColumn = data.merge.mergeStartColumn;
            updateValues.mergeEndColumn = data.merge.mergeEndColumn;
            updateValues.mergeText = sanitizeRichText(data.merge.mergeText);
          }
        }
        // #288: rowColor is whitelisted upstream; null = clear back to
        // default (no tint).
        if (data.rowColor !== undefined) {
          updateValues.rowColor = data.rowColor;
        }
        // #635: persist isExternal. When switching to external, also
        // force rowColor='red' (overrides any rowColor in the same
        // PATCH) so the red tint always tracks the flag.
        if (data.isExternal !== undefined) {
          updateValues.isExternal = Boolean(data.isExternal);
          if (data.isExternal) updateValues.rowColor = "red";
        }
        // #635: belt-and-suspenders — if the row is (or is becoming)
        // external and a non-red rowColor would otherwise be persisted
        // by this PATCH, force red. Prevents PDF/export paths that read
        // the raw rowColor from showing a non-red tint for externals.
        const willBeExternal =
          data.isExternal !== undefined ? Boolean(data.isExternal) : existing.isExternal;
        if (willBeExternal && updateValues.rowColor !== undefined && updateValues.rowColor !== "red") {
          updateValues.rowColor = "red";
        }

        if (Object.keys(updateValues).length > 1) {
          await tx
            .update(executiveMeetingsTable)
            .set(updateValues)
            .where(eq(executiveMeetingsTable.id, id));
        }
        let attendees = existing.attendees;
        if (data.attendees !== undefined) {
          await tx
            .delete(executiveMeetingAttendeesTable)
            .where(eq(executiveMeetingAttendeesTable.meetingId, id));
          if (data.attendees.length > 0) {
            await tx.insert(executiveMeetingAttendeesTable).values(
              data.attendees.map((a, idx) => ({
                meetingId: id,
                name: sanitizeRichText(a.name),
                title: sanitizePlainTextOrNull(a.title),
                attendanceType: a.attendanceType,
                sortOrder: a.sortOrder ?? idx,
                kind: a.kind,
              })),
            );
          }
          attendees = (await db
            .select()
            .from(executiveMeetingAttendeesTable)
            .where(eq(executiveMeetingAttendeesTable.meetingId, id))
            .orderBy(asc(executiveMeetingAttendeesTable.sortOrder)));
        }
        // #312: Auto-sort the day(s) chronologically when the PATCH
        // touched a field that affects ordering or visibility. The user's
        // bug report — typing 13:00 on a row that sits above a 12:00 row
        // — is exactly this: a startTime PATCH leaves dailyNumber
        // untouched and the row stays in its old position even though
        // the time changed. Renumbering by start_time after the UPDATE
        // makes the visible list strictly chronological without forcing
        // the user to drag the row. We trigger on:
        //   - startTime / endTime: the obvious case the user reported.
        //   - meetingDate: the row moved to a different day; both old
        //     and new days need to renumber so the row leaves one
        //     timeline cleanly and arrives in chronological order on
        //     the other.
        //   - status: cancel/uncancel changes which rows are "visible"
        //     (cancelled rows go to the tail per renumberDayByStartTime),
        //     so the visible 1..N sequence must be recomputed.
        //   - dailyNumber: a manual override; we still renumber so the
        //     final state stays chronological (auto-sort wins over a
        //     stray manual number coming from non-reorder paths).
        // We run renumber BEFORE logAudit so the audit row can carry
        // the post-renumber `dailyNumber` and an `orderShifted` flag —
        // otherwise an admin reading the audit cannot tell that editing
        // a meeting's start time also moved its row to a new position.
        const orderAffectingChange =
          data.startTime !== undefined ||
          data.endTime !== undefined ||
          data.meetingDate !== undefined ||
          data.status !== undefined ||
          data.dailyNumber !== undefined;
        let primaryShift: DayOrderShift | null = null;
        let sourceShift: DayOrderShift | null = null;
        let finalDailyNumber: number | null = null;
        if (orderAffectingChange) {
          const newDate = updateValues.meetingDate ?? existing.meetingDate;
          primaryShift = await renumberDayByStartTime(tx, newDate);
          if (newDate !== existing.meetingDate) {
            // Cross-day move: the source day also needs to close ranks
            // so its 1..N sequence stays gap-free.
            sourceShift = await renumberDayByStartTime(tx, existing.meetingDate);
          }
          // Re-read this row's daily_number after auto-sort so the
          // audit's newValue echoes the position the user actually
          // sees, not the pre-renumber draft from `updateValues`.
          const [row] = await tx
            .select({ dailyNumber: executiveMeetingsTable.dailyNumber })
            .from(executiveMeetingsTable)
            .where(eq(executiveMeetingsTable.id, id));
          finalDailyNumber = row?.dailyNumber ?? null;
        }
        // Build the audit's newValue. When auto-sort moved this row, we
        // surface (a) the row's final dailyNumber and (b) an
        // `orderShifted` flag with `dayOrder` before/after arrays so an
        // admin can read "the row jumped from #1 to #3 because the day
        // re-sorted". `dayOrder` carries the visible (non-cancelled) ID
        // sequence for the affected day(s).
        const auditNewValue: Record<string, unknown> = {
          ...existing,
          ...updateValues,
          attendees,
        };
        if (finalDailyNumber !== null) {
          auditNewValue.dailyNumber = finalDailyNumber;
        }
        const shiftsForAudit: Array<{
          date: string;
          before: number[];
          after: number[];
        }> = [];
        if (primaryShift && primaryShift.orderShifted) {
          shiftsForAudit.push({
            date: primaryShift.date,
            before: primaryShift.before,
            after: primaryShift.after,
          });
        }
        if (sourceShift && sourceShift.orderShifted) {
          shiftsForAudit.push({
            date: sourceShift.date,
            before: sourceShift.before,
            after: sourceShift.after,
          });
        }
        if (shiftsForAudit.length > 0) {
          auditNewValue.orderShifted = true;
          auditNewValue.dayOrder = shiftsForAudit;
        }
        await logAudit(tx, {
          action: "update",
          entityType: "meeting",
          entityId: id,
          oldValue: existing,
          newValue: auditNewValue,
          performedBy: userId,
        });
      });
      const updated = await fetchMeetingWithAttendees(id);
      // Realtime: when the meeting was rescheduled to a different day we
      // need to refetch BOTH days — viewers on the old day must lose the
      // row, viewers on the new day must gain it. The dedup helper is a
      // no-op when both dates match (the common in-place edit case).
      void emitExecutiveMeetingsDaysChanged([
        existing.meetingDate,
        updated?.meetingDate ?? existing.meetingDate,
      ]);
      res.json(updated);
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      if (msg.includes("executive_meetings_date_number_unique")) {
        res
          .status(409)
          .json({
            error: "Daily number already used for this date",
            code: "duplicate_number",
          });
        return;
      }
      res.status(500).json({ error: "Could not update meeting", code: "update_failed" });
    }
  },
);

// #273: ---- Upcoming-meeting alert (5-minute pre-alarm) endpoints ----

// Parse "HH:MM" or "HH:MM:SS" -> minutes-of-day. Returns null on missing/invalid.
function parseTimeToMinutes(t: string | null | undefined): number | null {
  if (!t) return null;
  const m = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(t);
  if (!m) return null;
  const h = Number(m[1]);
  const mm = Number(m[2]);
  if (h < 0 || h > 23 || mm < 0 || mm > 59) return null;
  return h * 60 + mm;
}
function formatMinutesAsTime(mins: number): string {
  const safe = ((mins % (24 * 60)) + 24 * 60) % (24 * 60);
  const h = Math.floor(safe / 60);
  const m = safe % 60;
  return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:00`;
}
// #273: pass a transaction-like executor so the conflict scan reads inside
// the same DB snapshot as the UPDATE that just shifted the row.
async function detectMeetingConflicts(
  exec: typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0],
  date: string,
  excludeId: number,
  startTime: string,
  endTime: string,
): Promise<boolean> {
  const newStart = parseTimeToMinutes(startTime);
  const newEnd = parseTimeToMinutes(endTime);
  if (newStart == null || newEnd == null) return false;
  const others = await exec
    .select({
      id: executiveMeetingsTable.id,
      start: executiveMeetingsTable.startTime,
      end: executiveMeetingsTable.endTime,
      status: executiveMeetingsTable.status,
    })
    .from(executiveMeetingsTable)
    .where(eq(executiveMeetingsTable.meetingDate, date));
  for (const o of others) {
    if (o.id === excludeId) continue;
    if (o.status === "cancelled" || o.status === "completed") continue;
    const s = parseTimeToMinutes(o.start);
    const e = parseTimeToMinutes(o.end);
    if (s == null || e == null) continue;
    if (s < newEnd && e > newStart) return true;
  }
  return false;
}

// #302: Cascade-shift helpers for postpone/reschedule.
//
// Implementation note (per task plan, step 3): we expose a *separate*
// `POST /executive-meetings/:id/cascade-preview` endpoint instead of
// piggy-backing on the existing GET-by-day response. The preview is
// (a) parameterized by `deltaMinutes` (which the GET response does not
// know) and (b) only consulted by the postpone dialog at the moment
// the user picks a delta — keeping it out of the day fetch avoids
// pushing `deltaMinutes` math into every schedule render. The shape
// returned mirrors the candidate followers that the same-named cascade
// branch on postpone/reschedule will actually shift, so the dialog and
// the writer agree on the affected set.
//
// "Later" = `start_time >= original end_time` of the primary meeting,
// matching the plan's contract. Cancelled and completed meetings are
// skipped on both the preview and the writer paths so they stay in
// lockstep — a follower the user did not see in the preview will not
// be shifted by the writer.

type CascadeFollower = {
  id: number;
  titleAr: string;
  titleEn: string;
  oldStart: string;
  oldEnd: string;
  newStart: string;
  newEnd: string;
};

type CascadeResult =
  | { ok: true; followers: CascadeFollower[] }
  | {
      ok: false;
      blockedBy: {
        id: number;
        titleAr: string;
        titleEn: string;
        projectedStart: string;
        projectedEnd: string;
      };
    };

// Compute the candidate followers and their projected new times for a
// uniform shift by `deltaMinutes`. Returns either `ok: true` with the
// shifted list, or `ok: false` with the first follower (in start-time
// order) that would cross midnight — both endpoints (preview + writer)
// surface that meeting verbatim so the UI can name it in the error.
async function computeCascadeShift(
  executor: DbExecutor,
  date: string,
  primaryId: number,
  // `startTime`/`endTime` columns are nullable in the schema (an
  // unscheduled placeholder meeting can have neither). The cascade
  // contract talks about times that exist, so we just no-op when the
  // primary has no end time — the writer paths only ever pass
  // `locked.endTime` when scheduling exists, but we accept nullable
  // here so the preview endpoint doesn't have to pre-narrow.
  originalEndTime: string | null,
  deltaMinutes: number,
  // #302: when called from inside a writer transaction (postpone or
  // reschedule), set `lockRows: true` so the candidate followers are
  // selected `FOR UPDATE`. This serializes the read against any
  // concurrent mutation of those follower rows, preventing lost
  // updates and ensuring the audit `oldStart/oldEnd` always reflect
  // the locked-current values at commit time. The cascade-preview
  // endpoint runs outside a transaction and passes `lockRows: false`
  // (a read-only snapshot is fine for a preview).
  lockRows = false,
): Promise<CascadeResult> {
  if (originalEndTime == null) return { ok: true, followers: [] };
  const baselineEndMin = parseTimeToMinutes(originalEndTime);
  if (baselineEndMin == null || !Number.isFinite(deltaMinutes)) {
    return { ok: true, followers: [] };
  }
  const baseQuery = executor
    .select({
      id: executiveMeetingsTable.id,
      titleAr: executiveMeetingsTable.titleAr,
      titleEn: executiveMeetingsTable.titleEn,
      startTime: executiveMeetingsTable.startTime,
      endTime: executiveMeetingsTable.endTime,
      status: executiveMeetingsTable.status,
      isExternal: executiveMeetingsTable.isExternal,
    })
    .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")
    // #635: external meetings are excluded from auto-postpone cascades
    // by design — they're scheduled with the other party and can't be
    // unilaterally shifted.
    .filter((c) => !c.isExternal)
    .map((c) => ({
      ...c,
      _startMin: parseTimeToMinutes(c.startTime),
      _endMin: parseTimeToMinutes(c.endTime),
    }))
    .filter(
      (c): c is typeof c & { _startMin: number; _endMin: number } =>
        c._startMin != null &&
        c._endMin != null &&
        c._startMin >= baselineEndMin,
    )
    .sort((a, b) => a._startMin - b._startMin || a.id - b.id);
  for (const c of sorted) {
    const newStartMin = c._startMin + deltaMinutes;
    const newEndMin = c._endMin + deltaMinutes;
    // Same midnight rule as `postpone-minutes` on the primary meeting:
    // 24:00 (1440) is treated as crossing the day boundary, so the
    // guard is `>=`. This keeps the preview, writer, and the existing
    // primary-meeting validation aligned.
    if (newStartMin >= 24 * 60 || newEndMin >= 24 * 60) {
      return {
        ok: false,
        blockedBy: {
          id: c.id,
          titleAr: c.titleAr,
          titleEn: c.titleEn,
          projectedStart: formatMinutesAsTime(newStartMin),
          projectedEnd: formatMinutesAsTime(newEndMin),
        },
      };
    }
    followers.push({
      id: c.id,
      titleAr: c.titleAr,
      titleEn: c.titleEn,
      // `c.startTime`/`endTime` are non-null here — the filter above
      // dropped any rows whose `parseTimeToMinutes(...)` returned null,
      // which is the only way the column-level nullables survive. TS
      // can't narrow the source string from the parsed-int, so we
      // re-format the minute counters back into "HH:MM" strings, which
      // also normalizes any legacy "HH:MM:SS" values from the DB.
      oldStart: formatMinutesAsTime(c._startMin),
      oldEnd: formatMinutesAsTime(c._endMin),
      newStart: formatMinutesAsTime(newStartMin),
      newEnd: formatMinutesAsTime(newEndMin),
    });
  }
  return { ok: true, followers };
}

// Persist a previously-computed cascade. The caller has already
// verified midnight boundaries via `computeCascadeShift`. Each
// follower gets one `meeting_cascade_shift` audit row that names the
// trigger meeting + delta so the audit can be replayed later.
async function applyCascadeShift(
  tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
  followers: CascadeFollower[],
  triggerMeetingId: number,
  deltaMinutes: number,
  performedBy: number,
): Promise<void> {
  for (const f of followers) {
    await tx
      .update(executiveMeetingsTable)
      .set({
        startTime: f.newStart,
        endTime: f.newEnd,
        // #302: stamp the cascade actor so a subsequent stale_meeting
        // check on the follower attributes the change correctly.
        updatedBy: performedBy,
      })
      .where(eq(executiveMeetingsTable.id, f.id));
    await logAudit(tx, {
      action: "meeting_cascade_shift",
      entityType: "meeting",
      entityId: f.id,
      oldValue: {
        startTime: f.oldStart,
        endTime: f.oldEnd,
      },
      newValue: {
        startTime: f.newStart,
        endTime: f.newEnd,
        triggerMeetingId,
        deltaMinutes,
      },
      performedBy,
    });
  }
}

const cascadePreviewSchema = z.object({
  deltaMinutes: z.number().int().min(1).max(24 * 60),
});

const alertActionSchema = z.object({
  action: z.enum(["shown", "acknowledged", "dismissed"]),
});
const postponeMinutesSchema = z.object({
  minutes: z.number().int().min(1).max(24 * 60),
  // #283: optimistic-locking token. The client must send the
  // `updatedAt` it last observed for this meeting; if another user
  // (or the same user from another tab) mutated the meeting in
  // between, we reject with HTTP 409 + stale_meeting so the UI can
  // surface "already postponed by X" instead of double-shifting.
  // Required by design — there is no opt-out path. To "apply anyway"
  // after a conflict, the client refetches (or reuses
  // conflict.lastModifiedAt) and resubmits with the fresher token.
  expectedUpdatedAt: z.string().datetime(),
  // #302: opt-in flag for "also shift every later active meeting on
  // this day by the same delta". Defaults to false (today's behavior:
  // only the primary meeting moves) so older clients without the new
  // confirmation step keep working unchanged.
  cascadeFollowing: z.boolean().optional().default(false),
});
const rescheduleSchema = z.object({
  meetingDate: dateSchema,
  startTime: timeSchema,
  endTime: timeSchema,
  note: z.string().max(500).optional(),
  // #302: same opt-in cascade flag as postpone-minutes. Cascading is
  // only applied when the meeting stays on the same date AND the
  // delta = newStart - oldStart is positive; otherwise the flag is
  // ignored (no rejection) so existing clients keep the same shape.
  cascadeFollowing: z.boolean().optional().default(false),
});

router.get(
  "/executive-meetings/alert-state",
  requireExecutiveAccess,
  async (req, res): Promise<void> => {
    if (!req.session.userId) {
      res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
      return;
    }
    const dateRaw = String(req.query.date ?? "");
    if (!/^\d{4}-\d{2}-\d{2}$/.test(dateRaw)) {
      res.status(400).json({ error: "Invalid date", code: "invalid_date" });
      return;
    }
    // We deliberately do NOT pin `date` to the server's wall-clock
    // "today" here: the frontend computes today from the user's browser
    // local time, and the API server may be running in a different
    // timezone (typically UTC) so a server-side equality check would
    // reject perfectly valid requests near midnight in non-UTC user
    // locales. Authorization is enforced via `requireExecutiveAccess`
    // and the `userId` filter below — every returned row already
    // belongs to the calling user.
    const userId = req.session.userId;
    const rows = await db
      .select({
        meetingId: executiveMeetingAlertStateTable.meetingId,
        dismissed: executiveMeetingAlertStateTable.dismissed,
        acknowledged: executiveMeetingAlertStateTable.acknowledged,
      })
      .from(executiveMeetingAlertStateTable)
      .innerJoin(
        executiveMeetingsTable,
        eq(executiveMeetingsTable.id, executiveMeetingAlertStateTable.meetingId),
      )
      .where(
        and(
          eq(executiveMeetingAlertStateTable.userId, userId),
          eq(executiveMeetingsTable.meetingDate, dateRaw),
        ),
      );
    res.json({ states: rows });
  },
);

router.post(
  "/executive-meetings/:id/alert-state",
  requireExecutiveAccess,
  async (req, res): Promise<void> => {
    if (!req.session.userId) {
      res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
      return;
    }
    const id = Number(req.params.id);
    if (!Number.isInteger(id) || id <= 0) {
      res.status(404).json({ error: "Not found", code: "not_found" });
      return;
    }
    const body = parseBody(res, alertActionSchema, req.body);
    if (!body) return;
    const meeting = await fetchMeetingWithAttendees(id);
    if (!meeting) {
      res.status(404).json({ error: "Meeting not found", code: "not_found" });
      return;
    }
    const userId = req.session.userId;
    // #277: Track whether this request actually transitioned the per-user
    // row so we only emit a realtime event on real state changes — keeps
    // the cross-tab signal noise-free when two tabs POST the same action.
    let stateChanged = false;
    await db.transaction(async (tx) => {
      // Race-safe upsert: when two tabs surface the same alert at once they
      // both POST {action:"shown"}; using ON CONFLICT DO NOTHING means only
      // one INSERT actually creates the row, so we audit "shown" once.
      const inserted = await tx
        .insert(executiveMeetingAlertStateTable)
        .values({
          meetingId: id,
          userId,
          dismissed: body.action === "dismissed",
          acknowledged: body.action === "acknowledged",
        })
        .onConflictDoNothing({
          target: [
            executiveMeetingAlertStateTable.meetingId,
            executiveMeetingAlertStateTable.userId,
          ],
        })
        .returning({ id: executiveMeetingAlertStateTable.id });

      if (inserted.length > 0) {
        await logAudit(tx, {
          action: "meeting_alert_shown",
          entityType: "meeting",
          entityId: id,
          newValue: { userId },
          performedBy: userId,
        });
        if (body.action === "acknowledged") {
          await logAudit(tx, {
            action: "meeting_alert_acknowledged",
            entityType: "meeting",
            entityId: id,
            newValue: { userId },
            performedBy: userId,
          });
          stateChanged = true;
        } else if (body.action === "dismissed") {
          await logAudit(tx, {
            action: "meeting_alert_dismissed",
            entityType: "meeting",
            entityId: id,
            newValue: { userId },
            performedBy: userId,
          });
          stateChanged = true;
        }
        return;
      }
      // Row exists. Use a conditional UPDATE so concurrent clicks across
      // tabs only fire one audit row per real transition (false -> true).
      if (body.action === "acknowledged") {
        const upd = await tx
          .update(executiveMeetingAlertStateTable)
          .set({ acknowledged: true })
          .where(
            and(
              eq(executiveMeetingAlertStateTable.meetingId, id),
              eq(executiveMeetingAlertStateTable.userId, userId),
              eq(executiveMeetingAlertStateTable.acknowledged, false),
            ),
          )
          .returning({ id: executiveMeetingAlertStateTable.id });
        if (upd.length > 0) {
          await logAudit(tx, {
            action: "meeting_alert_acknowledged",
            entityType: "meeting",
            entityId: id,
            newValue: { userId },
            performedBy: userId,
          });
          stateChanged = true;
        }
      } else if (body.action === "dismissed") {
        const upd = await tx
          .update(executiveMeetingAlertStateTable)
          .set({ dismissed: true })
          .where(
            and(
              eq(executiveMeetingAlertStateTable.meetingId, id),
              eq(executiveMeetingAlertStateTable.userId, userId),
              eq(executiveMeetingAlertStateTable.dismissed, false),
            ),
          )
          .returning({ id: executiveMeetingAlertStateTable.id });
        if (upd.length > 0) {
          await logAudit(tx, {
            action: "meeting_alert_dismissed",
            entityType: "meeting",
            entityId: id,
            newValue: { userId },
            performedBy: userId,
          });
          stateChanged = true;
        }
      }
    });
    // #277: Push the new alert state to *only* the acting user's sockets
    // so all of their open tabs / devices clear the alert within ~1s
    // instead of waiting on the 30s poll. Other users are unaffected.
    if (stateChanged) {
      void emitExecutiveMeetingAlertStateChanged(userId, {
        meetingId: id,
        dismissed: body.action === "dismissed",
        acknowledged: body.action === "acknowledged",
      });
    }
    res.json({ ok: true });
  },
);

router.post(
  "/executive-meetings/:id/cascade-preview",
  requireExecutiveAccess,
  // #302: read-only — gated on executive access only. The dialog calls
  // this on every delta change, including for users who can view but
  // not mutate; gating on requireMutate would gray out the cascade
  // checkbox for them and silently change the prompt copy.
  async (req, res): Promise<void> => {
    const id = Number(req.params.id);
    if (!Number.isInteger(id) || id <= 0) {
      res.status(404).json({ error: "Not found", code: "not_found" });
      return;
    }
    const body = parseBody(res, cascadePreviewSchema, req.body);
    if (!body) return;
    const [primary] = await db
      .select({
        id: executiveMeetingsTable.id,
        meetingDate: executiveMeetingsTable.meetingDate,
        endTime: executiveMeetingsTable.endTime,
      })
      .from(executiveMeetingsTable)
      .where(eq(executiveMeetingsTable.id, id));
    if (!primary) {
      res.status(404).json({ error: "Meeting not found", code: "not_found" });
      return;
    }
    const cascade = await computeCascadeShift(
      db,
      primary.meetingDate,
      id,
      primary.endTime,
      body.deltaMinutes,
    );
    if (!cascade.ok) {
      // Match the writer's blocked shape so the dialog can render the
      // same error UI whether it learned about midnight at preview time
      // or at submit time (e.g. another user added a late meeting in
      // between).
      res.json({ followers: [], blockedBy: cascade.blockedBy });
      return;
    }
    res.json({ followers: cascade.followers, blockedBy: null });
  },
);

router.post(
  "/executive-meetings/:id/postpone-minutes",
  requireExecutiveAccess,
  requireMutate,
  async (req, res): Promise<void> => {
    const id = Number(req.params.id);
    if (!Number.isInteger(id) || id <= 0) {
      res.status(404).json({ error: "Not found", code: "not_found" });
      return;
    }
    const body = parseBody(res, postponeMinutesSchema, req.body);
    if (!body) return;
    const userId = req.session.userId!;
    // #273: lock-then-mutate inside a single transaction so concurrent
    // postpone/reschedule/cancel requests serialize and audit logs reflect
    // the actual committed transition.
    type StaleConflict = {
      ok: false;
      status: 409;
      error: string;
      code: "stale_meeting";
      conflict: {
        currentStartTime: string | null;
        currentEndTime: string | null;
        currentStatus: string;
        lastModifiedAt: string;
        lastActor: {
          id: number | null;
          username: string | null;
          displayNameAr: string | null;
          displayNameEn: string | null;
        } | null;
      };
    };
    // #302: success path may now also report how many followers were
    // cascade-shifted so the client can show "Postponed + shifted N
    // following meetings" without a follow-up fetch. The new failure
    // shape `cascade_crosses_midnight` carries the offending meeting
    // so the UI can name it in the error block.
    type CascadeBlock = {
      ok: false;
      status: 400;
      error: string;
      code: "cascade_crosses_midnight";
      blockedBy: {
        id: number;
        titleAr: string;
        titleEn: string;
        projectedStart: string;
        projectedEnd: string;
      };
    };
    type TxResult =
      | {
          ok: true;
          meetingDate: string;
          newStart: string;
          newEnd: string;
          conflicts: boolean;
          cascadedIds: number[];
        }
      | { ok: false; status: number; error: string; code: string }
      | StaleConflict
      | CascadeBlock;
    const txResult = await db.transaction(async (tx): Promise<TxResult> => {
      // Acquire the per-date advisory lock BEFORE the row-level FOR
      // UPDATE so we can never deadlock with a concurrent POST create
      // (which grabs the date lock first, then the row lock implicitly
      // via renumber). Pre-fetch the date with a non-locking SELECT so
      // we know which date to lock without acquiring a row lock first.
      const [meta] = await tx
        .select({ meetingDate: executiveMeetingsTable.meetingDate })
        .from(executiveMeetingsTable)
        .where(eq(executiveMeetingsTable.id, id));
      if (meta) await lockMeetingDate(tx, meta.meetingDate);
      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" };
      }
      // #635: External meetings can never be auto-postponed — they're
      // scheduled with an outside party and shifting them unilaterally
      // would put the schedule out of sync. The UI also disables the
      // Postpone button, so this is a belt-and-suspenders 409.
      if (locked.isExternal) {
        return {
          ok: false,
          status: 409,
          error: "External meetings cannot be auto-postponed",
          code: "external_meeting_no_auto_postpone",
        };
      }
      // #283: Optimistic-locking guard. The client always sends the
      // `updatedAt` it last observed; if the row has been modified
      // since, refuse to mutate and return the current state + the
      // last actor's display info so the UI can show
      // "Already postponed by Ahmed at 10:05 — apply N more minutes?"
      // We do this *inside* the FOR UPDATE lock so concurrent
      // postpone-minutes calls are serialized: the second one sees the
      // first one's committed update and returns 409. There is no
      // opt-out — to apply after a conflict, the client must resubmit
      // with the freshly-seen `lastModifiedAt`, so a third concurrent
      // mutation between conflict-display and apply-anyway will trigger
      // another 409 instead of silently stacking.
      const observedIso = new Date(body.expectedUpdatedAt).toISOString();
      const currentIso = locked.updatedAt.toISOString();
      if (observedIso !== currentIso) {
        let lastActor: StaleConflict["conflict"]["lastActor"] = null;
        if (locked.updatedBy != null) {
          const [u] = await tx
            .select({
              id: usersTable.id,
              username: usersTable.username,
              displayNameAr: usersTable.displayNameAr,
              displayNameEn: usersTable.displayNameEn,
            })
            .from(usersTable)
            .where(eq(usersTable.id, locked.updatedBy));
          lastActor = u ?? null;
        }
        return {
          ok: false,
          status: 409,
          error: "Meeting was modified by someone else",
          code: "stale_meeting",
          conflict: {
            currentStartTime: locked.startTime,
            currentEndTime: locked.endTime,
            currentStatus: locked.status,
            lastModifiedAt: currentIso,
            lastActor,
          },
        };
      }
      const startMin = parseTimeToMinutes(locked.startTime);
      const endMin = parseTimeToMinutes(locked.endTime);
      if (startMin == null || endMin == null) {
        return {
          ok: false,
          status: 400,
          error: "Meeting has no scheduled time to postpone",
          code: "no_time_window",
        };
      }
      if (
        startMin + body.minutes >= 24 * 60 ||
        // Treat 24:00 (1440) the same as 00:00 the next day — both wrap
        // the day boundary, so the end-time guard is `>=`, matching the
        // start-time guard.
        endMin + body.minutes >= 24 * 60
      ) {
        return {
          ok: false,
          status: 400,
          error: "Postponing by that many minutes would cross midnight",
          code: "crosses_midnight",
        };
      }
      const newStart = formatMinutesAsTime(startMin + body.minutes);
      const newEnd = formatMinutesAsTime(endMin + body.minutes);
      // #302: When the client opted in to cascading, compute the
      // followers *before* updating anything so we can short-circuit
      // (and roll the whole transaction back) if any of them would
      // cross midnight. Crucially we use `locked.endTime` — the
      // primary meeting's *original* end time — as the baseline so
      // the follower selection matches the preview the user just
      // confirmed in the dialog.
      let cascadedIds: number[] = [];
      if (body.cascadeFollowing) {
        const cascade = await computeCascadeShift(
          tx,
          locked.meetingDate,
          id,
          locked.endTime,
          body.minutes,
          // #302: lock follower rows FOR UPDATE inside the writer tx
          // so concurrent mutations can't sneak between read and
          // write, and so audit oldStart/oldEnd reflects the locked
          // current values at commit time.
          true,
        );
        if (!cascade.ok) {
          return {
            ok: false,
            status: 400,
            error: "Cascading would push a follower past midnight",
            code: "cascade_crosses_midnight",
            blockedBy: cascade.blockedBy,
          };
        }
        cascadedIds = cascade.followers.map((f) => f.id);
        // The primary update happens *after* the cascade preview but
        // *before* the cascade UPDATEs so that detect-conflicts and
        // renumbering see the full final state. All UPDATEs are in
        // the same FOR UPDATE-locked transaction — `locked` was
        // selected for update at the top, and the followers are
        // disjoint from the primary by id.
        await applyCascadeShift(tx, cascade.followers, id, body.minutes, userId);
      }
      await tx
        .update(executiveMeetingsTable)
        .set({
          startTime: newStart,
          endTime: newEnd,
          status: "postponed",
          // #283: stamp the actor so the next stale_meeting reply can
          // attribute "postponed by X" to a real user.
          updatedBy: userId,
        })
        .where(eq(executiveMeetingsTable.id, id));
      await logAudit(tx, {
        action: "meeting_postponed_minutes",
        entityType: "meeting",
        entityId: id,
        oldValue: {
          startTime: locked.startTime,
          endTime: locked.endTime,
          status: locked.status,
        },
        newValue: {
          startTime: newStart,
          endTime: newEnd,
          status: "postponed",
          minutes: body.minutes,
          // #302: surface the cascade decision in the primary audit
          // entry so the History view can group them together. The
          // followers also each get their own `meeting_cascade_shift`
          // row via `applyCascadeShift`.
          cascadedFollowerIds: cascadedIds,
        },
        performedBy: userId,
      });
      const conflicts = await detectMeetingConflicts(
        tx,
        locked.meetingDate,
        id,
        newStart,
        newEnd,
      );
      // #273: re-sort the day's `#` so the timeline reflects the new start.
      await renumberDayByStartTime(tx, locked.meetingDate);
      return {
        ok: true,
        meetingDate: locked.meetingDate,
        newStart,
        newEnd,
        conflicts,
        cascadedIds,
      };
    });
    if (!txResult.ok) {
      // #283: Stale-conflict responses carry an extra `conflict` payload
      // (current state + last actor display info) so the UI can render
      // a "tried to postpone but X already postponed it to 10:05" prompt
      // instead of the generic toast.
      // #302: cascade_crosses_midnight responses similarly carry a
      // `blockedBy` payload naming the offending follower so the dialog
      // can offer "back / keep their times" instead of a generic toast.
      const payload: Record<string, unknown> = {
        error: txResult.error,
        code: txResult.code,
      };
      if (txResult.code === "stale_meeting" && "conflict" in txResult) {
        payload.conflict = txResult.conflict;
      }
      if (
        txResult.code === "cascade_crosses_midnight" &&
        "blockedBy" in txResult
      ) {
        payload.blockedBy = txResult.blockedBy;
      }
      res.status(txResult.status).json(payload);
      return;
    }
    // #302: per-day realtime emit already covers every cascade-shifted
    // meeting because they all live on `txResult.meetingDate` (cascade
    // never moves a follower across days).
    void emitExecutiveMeetingsDaysChanged([txResult.meetingDate]);
    const updated = await fetchMeetingWithAttendees(id);
    res.json({
      ok: true,
      conflicts: txResult.conflicts,
      meeting: updated,
      cascadedIds: txResult.cascadedIds,
    });
  },
);

router.post(
  "/executive-meetings/:id/reschedule",
  requireExecutiveAccess,
  requireMutate,
  async (req, res): Promise<void> => {
    const id = Number(req.params.id);
    if (!Number.isInteger(id) || id <= 0) {
      res.status(404).json({ error: "Not found", code: "not_found" });
      return;
    }
    const body = parseBody(res, rescheduleSchema, req.body);
    if (!body) return;
    const startMin = parseTimeToMinutes(body.startTime);
    const endMin = parseTimeToMinutes(body.endTime);
    if (startMin == null || endMin == null || startMin >= endMin) {
      res.status(400).json({
        error: "End time must be after start time",
        code: "invalid_time_range",
      });
      return;
    }
    const userId = req.session.userId!;
    // #273: lock the row inside the tx so a concurrent edit cannot race us.
    // #302: cascade_crosses_midnight has the same shape as on
    // postpone-minutes so the dialog can share its handler.
    type CascadeBlock = {
      ok: false;
      status: 400;
      error: string;
      code: "cascade_crosses_midnight";
      blockedBy: {
        id: number;
        titleAr: string;
        titleEn: string;
        projectedStart: string;
        projectedEnd: string;
      };
    };
    type TxResult =
      | { ok: true; oldDate: string; conflicts: boolean; cascadedIds: number[] }
      | { ok: false; status: number; error: string; code: string }
      | CascadeBlock;
    const txResult = await db.transaction(async (tx): Promise<TxResult> => {
      // Acquire per-date advisory lock(s) BEFORE the row-level FOR
      // UPDATE. Pre-fetch the current date with a non-locking SELECT so
      // we never grab the row lock first (which would deadlock against
      // concurrent POST create on the same date).
      const [meta] = await tx
        .select({ meetingDate: executiveMeetingsTable.meetingDate })
        .from(executiveMeetingsTable)
        .where(eq(executiveMeetingsTable.id, id));
      if (meta) {
        const datesToLock = Array.from(
          new Set([meta.meetingDate, body.meetingDate].filter(Boolean) as string[]),
        ).sort();
        for (const d of datesToLock) await lockMeetingDate(tx, d);
      }
      const [locked] = await tx
        .select()
        .from(executiveMeetingsTable)
        .where(eq(executiveMeetingsTable.id, id))
        .for("update");
      if (!locked) {
        return { ok: false, status: 404, error: "Meeting not found", code: "not_found" };
      }
      // If the daily number would clash on the new date, allocate a fresh one.
      let dailyNumber = locked.dailyNumber;
      if (body.meetingDate !== locked.meetingDate) {
        dailyNumber = await nextDailyNumber(tx, body.meetingDate);
      }
      // #302: Reschedule cascade contract.
      // - Only cascades when (a) the meeting stays on the same date
      //   and (b) the delta = newStart - originalStart is positive
      //   (rescheduling earlier or only changing the end never has a
      //   meaningful "shift the next meetings" interpretation).
      // - Uses `locked.endTime` (original) as the baseline for picking
      //   followers, matching postpone-minutes.
      // - If `cascadeFollowing` is true but those guards aren't met,
      //   we silently ignore the flag — the dialog already gates the
      //   prompt on the same condition, so this is just defensive.
      let cascadedIds: number[] = [];
      if (body.cascadeFollowing && body.meetingDate === locked.meetingDate) {
        const oldStartMin = parseTimeToMinutes(locked.startTime);
        if (oldStartMin != null && startMin > oldStartMin) {
          const delta = startMin - oldStartMin;
          const cascade = await computeCascadeShift(
            tx,
            locked.meetingDate,
            id,
            locked.endTime,
            delta,
            // #302: same FOR UPDATE locking as the postpone-minutes
            // cascade — see that call site for rationale.
            true,
          );
          if (!cascade.ok) {
            return {
              ok: false,
              status: 400,
              error: "Cascading would push a follower past midnight",
              code: "cascade_crosses_midnight",
              blockedBy: cascade.blockedBy,
            };
          }
          cascadedIds = cascade.followers.map((f) => f.id);
          await applyCascadeShift(tx, cascade.followers, id, delta, userId);
        }
      }
      await tx
        .update(executiveMeetingsTable)
        .set({
          meetingDate: body.meetingDate,
          startTime: body.startTime,
          endTime: body.endTime,
          status: "postponed",
          dailyNumber,
          // #302: stamp actor for parity with postpone-minutes — the
          // existing reschedule path didn't, but it's the right thing
          // to do now that cascades attribute their followers to a
          // user, and a primary reschedule should be no different.
          updatedBy: userId,
        })
        .where(eq(executiveMeetingsTable.id, id));
      await logAudit(tx, {
        action: "meeting_rescheduled",
        entityType: "meeting",
        entityId: id,
        oldValue: {
          meetingDate: locked.meetingDate,
          startTime: locked.startTime,
          endTime: locked.endTime,
          status: locked.status,
          dailyNumber: locked.dailyNumber,
        },
        newValue: {
          meetingDate: body.meetingDate,
          startTime: body.startTime,
          endTime: body.endTime,
          status: "postponed",
          dailyNumber,
          note: body.note ?? null,
          cascadedFollowerIds: cascadedIds,
        },
        performedBy: userId,
      });
      const conflicts = await detectMeetingConflicts(
        tx,
        body.meetingDate,
        id,
        body.startTime,
        body.endTime,
      );
      // #273: re-sort `#` on both the old and new dates if the day moved,
      // so each timeline reflects the meeting leaving / arriving.
      await renumberDayByStartTime(tx, body.meetingDate);
      if (locked.meetingDate !== body.meetingDate) {
        await renumberDayByStartTime(tx, locked.meetingDate);
      }
      return {
        ok: true,
        oldDate: locked.meetingDate,
        conflicts,
        cascadedIds,
      };
    });
    if (!txResult.ok) {
      const payload: Record<string, unknown> = {
        error: txResult.error,
        code: txResult.code,
      };
      if (
        txResult.code === "cascade_crosses_midnight" &&
        "blockedBy" in txResult
      ) {
        payload.blockedBy = txResult.blockedBy;
      }
      res.status(txResult.status).json(payload);
      return;
    }
    void emitExecutiveMeetingsDaysChanged([
      txResult.oldDate,
      body.meetingDate,
    ]);
    const updated = await fetchMeetingWithAttendees(id);
    res.json({
      ok: true,
      conflicts: txResult.conflicts,
      meeting: updated,
      cascadedIds: txResult.cascadedIds,
    });
  },
);

router.post(
  "/executive-meetings/:id/cancel",
  requireExecutiveAccess,
  requireMutate,
  async (req, res): Promise<void> => {
    const id = Number(req.params.id);
    if (!Number.isInteger(id) || id <= 0) {
      res.status(404).json({ error: "Not found", code: "not_found" });
      return;
    }
    const userId = req.session.userId!;
    // #273: lock + idempotent UPDATE — only audit on the real status change.
    type TxResult =
      | { ok: true; meetingDate: string; changed: boolean }
      | { ok: false; status: number; error: string; code: string };
    const txResult = await db.transaction(async (tx): Promise<TxResult> => {
      // Per-date advisory lock BEFORE row FOR UPDATE — see comment on
      // postpone-minutes for the deadlock pattern this prevents.
      const [meta] = await tx
        .select({ meetingDate: executiveMeetingsTable.meetingDate })
        .from(executiveMeetingsTable)
        .where(eq(executiveMeetingsTable.id, id));
      if (meta) await lockMeetingDate(tx, meta.meetingDate);
      const [locked] = await tx
        .select()
        .from(executiveMeetingsTable)
        .where(eq(executiveMeetingsTable.id, id))
        .for("update");
      if (!locked) {
        return { ok: false, status: 404, error: "Meeting not found", code: "not_found" };
      }
      if (locked.status === "cancelled") {
        return { ok: true, meetingDate: locked.meetingDate, changed: false };
      }
      await tx
        .update(executiveMeetingsTable)
        .set({ status: "cancelled" })
        .where(eq(executiveMeetingsTable.id, id));
      await logAudit(tx, {
        action: "meeting_cancelled",
        entityType: "meeting",
        entityId: id,
        oldValue: { status: locked.status },
        newValue: { status: "cancelled" },
        performedBy: userId,
      });
      // #273: re-sort `#` so the cancelled meeting moves to the tail
      // and the remaining active meetings get a clean 1..N sequence.
      await renumberDayByStartTime(tx, locked.meetingDate);
      return { ok: true, meetingDate: locked.meetingDate, changed: true };
    });
    if (!txResult.ok) {
      res.status(txResult.status).json({ error: txResult.error, code: txResult.code });
      return;
    }
    if (txResult.changed) {
      void emitExecutiveMeetingsDaysChanged([txResult.meetingDate]);
    }
    const updated = await fetchMeetingWithAttendees(id);
    res.json({ ok: true, meeting: updated });
  },
);

router.delete(
  "/executive-meetings/:id",
  requireExecutiveAccess,
  requireMutate,
  async (req, res): Promise<void> => {
    const id = Number(req.params.id);
    if (!Number.isInteger(id) || id <= 0) {
      res.status(404).json({ error: "Not found", code: "not_found" });
      return;
    }
    const existing = await fetchMeetingWithAttendees(id);
    if (!existing) {
      res.status(404).json({ error: "Meeting not found", code: "not_found" });
      return;
    }
    const userId = req.session.userId!;
    await db.transaction(async (tx) => {
      // Serialize per-date so DELETE + renumber can't deadlock with a
      // concurrent POST/PATCH that grabbed the advisory lock first.
      await lockMeetingDate(tx, existing.meetingDate);
      await tx.delete(executiveMeetingsTable).where(eq(executiveMeetingsTable.id, id));
      await logAudit(tx, {
        action: "delete",
        entityType: "meeting",
        entityId: id,
        oldValue: existing,
        performedBy: userId,
      });
      // #312: After deleting a row the day's `#` sequence has a gap
      // (the deleted dailyNumber) and any cancelled rows that used to
      // occupy slots above the deleted one are now off by one. Renumber
      // 1..N by start_time so the visible list stays clean and the next
      // call to nextDailyNumber() doesn't allocate the recycled slot.
      await renumberDayByStartTime(tx, existing.meetingDate);
    });
    // Realtime: every open schedule tab on the deleted meeting's day
    // refetches so the row disappears without a manual refresh.
    void emitExecutiveMeetingsDayChanged(existing.meetingDate);
    res.status(204).end();
  },
);

router.put(
  "/executive-meetings/:id/attendees",
  requireExecutiveAccess,
  requireMutate,
  async (req, res): Promise<void> => {
    const id = Number(req.params.id);
    if (!Number.isInteger(id) || id <= 0) {
      res.status(404).json({ error: "Not found", code: "not_found" });
      return;
    }
    const existing = await fetchMeetingWithAttendees(id);
    if (!existing) {
      res.status(404).json({ error: "Meeting not found", code: "not_found" });
      return;
    }
    const data = parseBody(res, attendeesPutSchema, req.body);
    if (!data) return;
    const userId = req.session.userId!;
    await db.transaction(async (tx) => {
      await tx
        .delete(executiveMeetingAttendeesTable)
        .where(eq(executiveMeetingAttendeesTable.meetingId, id));
      if (data.attendees.length > 0) {
        await tx.insert(executiveMeetingAttendeesTable).values(
          data.attendees.map((a, idx) => ({
            meetingId: id,
            name: sanitizeRichText(a.name),
            title: sanitizePlainTextOrNull(a.title),
            attendanceType: a.attendanceType,
            sortOrder: a.sortOrder ?? idx,
            kind: a.kind,
          })),
        );
      }
      await logAudit(tx, {
        action: "replace_attendees",
        entityType: "meeting",
        entityId: id,
        oldValue: { attendees: existing.attendees },
        newValue: { attendees: data.attendees },
        performedBy: userId,
      });
    });
    const updated = await fetchMeetingWithAttendees(id);
    // Realtime: replacing the attendee list is the most common
    // multi-tab edit (drag-to-reorder, inline rename, group move),
    // so make sure every viewer on this day refetches.
    void emitExecutiveMeetingsDayChanged(
      updated?.meetingDate ?? existing.meetingDate,
    );
    res.json(updated);
  },
);

router.post(
  "/executive-meetings/:id/duplicate",
  requireExecutiveAccess,
  requireMutate,
  async (req, res): Promise<void> => {
    const id = Number(req.params.id);
    if (!Number.isInteger(id) || id <= 0) {
      res.status(404).json({ error: "Not found", code: "not_found" });
      return;
    }
    const data = parseBody(res, duplicateSchema, req.body);
    if (!data) return;
    const source = await fetchMeetingWithAttendees(id);
    if (!source) {
      res.status(404).json({ error: "Meeting not found", code: "not_found" });
      return;
    }
    const userId = req.session.userId!;
    try {
      const inserted = await db.transaction(async (tx) => {
        // Serialize per-date so the renumber + insert flow can't race
        // a concurrent POST/PATCH on the same target date.
        await lockMeetingDate(tx, data.targetDate);
        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" });
    }
  },
);

// =====================================================================
// SWAP TIMES (#486 — schedule row quick-actions Move up / Move down)
// =====================================================================
//
// Swaps the (startTime, endTime) tuple between two meetings on the
// same date so the schedule's Time column stays visually anchored
// (each row keeps its slot; the meetings rotate through the slots).
// Daily numbering is recomputed via renumberDayByStartTime so the
// `#` column matches the new chronological order. Both rows are
// FOR UPDATE-locked inside one transaction in id-order to avoid
// deadlocks when two browsers race on the same pair, and each row
// carries an optimistic-lock token (expectedUpdatedAt{A,B}) so a
// stale click returns 409 stale_meeting + a `conflict` payload that
// names the offending row + last actor — same shape the
// PostponeDialog already understands.
router.post(
  "/executive-meetings/swap-times",
  requireExecutiveAccess,
  requireMutate,
  async (req, res): Promise<void> => {
    const data = parseBody(res, ExecutiveMeetingsSwapTimesBody, req.body);
    if (!data) return;
    if (data.aId === data.bId) {
      res
        .status(400)
        .json({ error: "aId and bId must differ", code: "same_id" });
      return;
    }
    const userId = req.session.userId!;
    type StaleConflict = {
      ok: false;
      status: 409;
      error: string;
      code: "stale_meeting";
      conflict: {
        id: number;
        currentStartTime: string | null;
        currentEndTime: string | null;
        currentStatus: string;
        lastModifiedAt: string;
        lastActor: {
          id: number | null;
          username: string | null;
          displayNameAr: string | null;
          displayNameEn: string | null;
        } | null;
      };
    };
    type TxResult =
      | { ok: true; meetingDate: string }
      | { ok: false; status: number; error: string; code: string }
      | StaleConflict;
    const result = await db.transaction(async (tx): Promise<TxResult> => {
      // Always lock by ascending id to prevent deadlocks when two
      // concurrent swap-times calls touch the same pair from opposite
      // sides (A↔B vs B↔A).
      const ids = [data.aId, data.bId].slice().sort((x, y) => x - y);
      // Per-date advisory lock(s) BEFORE the row-level FOR UPDATE so a
      // concurrent POST create on the same date cannot deadlock against
      // us. Pre-fetch dates without locking, then lock distinct dates
      // in sorted order.
      const metas = await tx
        .select({
          id: executiveMeetingsTable.id,
          meetingDate: executiveMeetingsTable.meetingDate,
        })
        .from(executiveMeetingsTable)
        .where(inArray(executiveMeetingsTable.id, ids));
      const datesToLock = Array.from(new Set(metas.map((m) => m.meetingDate))).sort();
      for (const d of datesToLock) await lockMeetingDate(tx, d);
      const lockedRows = await tx
        .select()
        .from(executiveMeetingsTable)
        .where(inArray(executiveMeetingsTable.id, ids))
        .for("update");
      const aRow = lockedRows.find((r) => r.id === data.aId);
      const bRow = lockedRows.find((r) => r.id === data.bId);
      if (!aRow || !bRow) {
        return {
          ok: false,
          status: 404,
          error: "Meeting not found",
          code: "not_found",
        };
      }
      if (aRow.meetingDate !== bRow.meetingDate) {
        return {
          ok: false,
          status: 400,
          error: "Meetings are on different dates",
          code: "different_dates",
        };
      }
      if (
        aRow.startTime == null ||
        aRow.endTime == null ||
        bRow.startTime == null ||
        bRow.endTime == null
      ) {
        return {
          ok: false,
          status: 400,
          error: "Both meetings must have a scheduled time window to swap",
          code: "no_time_window",
        };
      }
      // Optimistic-lock check on each row. Same shape the postpone
      // and reschedule endpoints use (#283) so the popover can render
      // the same "X just changed this meeting" prompt.
      for (const [row, expected] of [
        [aRow, data.expectedUpdatedAtA] as const,
        [bRow, data.expectedUpdatedAtB] as const,
      ]) {
        const observedIso = new Date(expected).toISOString();
        const currentIso = row.updatedAt.toISOString();
        if (observedIso !== currentIso) {
          let lastActor: StaleConflict["conflict"]["lastActor"] = null;
          if (row.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, row.updatedBy));
            lastActor = u ?? null;
          }
          return {
            ok: false,
            status: 409,
            error: "Meeting was modified by someone else",
            code: "stale_meeting",
            conflict: {
              id: row.id,
              currentStartTime: row.startTime,
              currentEndTime: row.endTime,
              currentStatus: row.status,
              lastModifiedAt: currentIso,
              lastActor,
            },
          };
        }
      }
      // Snapshot the original windows so the swap is symmetric even
      // if Drizzle's `set` evaluates lazily.
      const aStart = aRow.startTime;
      const aEnd = aRow.endTime;
      const bStart = bRow.startTime;
      const bEnd = bRow.endTime;
      await tx
        .update(executiveMeetingsTable)
        .set({ startTime: bStart, endTime: bEnd, updatedBy: userId })
        .where(eq(executiveMeetingsTable.id, aRow.id));
      await tx
        .update(executiveMeetingsTable)
        .set({ startTime: aStart, endTime: aEnd, updatedBy: userId })
        .where(eq(executiveMeetingsTable.id, bRow.id));
      await logAudit(tx, {
        action: "meeting_swap_times",
        entityType: "meeting",
        entityId: aRow.id,
        oldValue: { startTime: aStart, endTime: aEnd },
        newValue: {
          startTime: bStart,
          endTime: bEnd,
          swappedWith: bRow.id,
        },
        performedBy: userId,
      });
      await logAudit(tx, {
        action: "meeting_swap_times",
        entityType: "meeting",
        entityId: bRow.id,
        oldValue: { startTime: bStart, endTime: bEnd },
        newValue: {
          startTime: aStart,
          endTime: aEnd,
          swappedWith: aRow.id,
        },
        performedBy: userId,
      });
      // Re-sort daily_number by start time so the schedule's `#`
      // column matches the new chronological order.
      await renumberDayByStartTime(tx, aRow.meetingDate);
      return { ok: true, meetingDate: aRow.meetingDate };
    });
    if (!result.ok) {
      const payload: Record<string, unknown> = {
        error: result.error,
        code: result.code,
      };
      if (result.code === "stale_meeting" && "conflict" in result) {
        payload.conflict = result.conflict;
      }
      res.status(result.status).json(payload);
      return;
    }
    void emitExecutiveMeetingsDayChanged(result.meetingDate);
    const [updatedA, updatedB] = await Promise.all([
      fetchMeetingWithAttendees(data.aId),
      fetchMeetingWithAttendees(data.bId),
    ]);
    res.json({
      ok: true,
      meetings: [updatedA, updatedB].filter((m) => m != null),
    });
  },
);

// =====================================================================
// ROTATE CONTENT (#489: drag whole row to a new position)
// =====================================================================
//
// The schedule lets a `canMutate` user drag any meeting from anywhere
// on its row to a new position. The visible time column AND the daily
// numbers are anchored to physical rows: only the meeting CONTENT
// (title, attendees, notes, color, status, merge) rotates through the
// fixed slots.
//
// Implementation: the (startTime, endTime, dailyNumber) tuple of each
// row in chronological order is the "slot". Given new content order
// `orderedIds`, we assign slot[i] to the meeting at orderedIds[i]. So
// every slot keeps its original tuple, and the meeting that ends up
// in physical row i carries away the time tuple of whatever meeting
// originally sat there. From the user's perspective the time column
// and `#` column are unchanged; the meeting content rotated.
//
// Concurrency: the entire day's visible rows are FOR UPDATE-locked
// inside one transaction (id-ordered to avoid deadlocks). Each
// meeting carries an optimistic-lock token via
// `expectedUpdatedAt[id]`, mirroring `swap-times` and the postpone
// route — a stale token returns 409 `stale_meeting` with a `conflict`
// payload naming the offending row + last actor.
router.post(
  "/executive-meetings/rotate-content",
  requireExecutiveAccess,
  requireMutate,
  async (req, res): Promise<void> => {
    const data = parseBody(res, ExecutiveMeetingsRotateContentBody, req.body);
    if (!data) return;
    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;
    }
    const userId = req.session.userId!;
    type StaleConflict = {
      ok: false;
      status: 409;
      error: string;
      code: "stale_meeting";
      conflict: {
        id: number;
        currentStartTime: string | null;
        currentEndTime: string | null;
        currentStatus: string;
        lastModifiedAt: string;
        lastActor: {
          id: number | null;
          username: string | null;
          displayNameAr: string | null;
          displayNameEn: string | null;
        } | null;
      };
    };
    type TxResult =
      | { ok: true; meetingDate: string; changedIds: number[] }
      | { ok: false; status: number; error: string; code: string }
      | StaleConflict;
    const result = await db.transaction(async (tx): Promise<TxResult> => {
      // Lock by ascending id for deadlock safety. Pulling EVERY visible
      // row on the date (not just ordered ids) so we can validate the
      // payload covers exactly the visible set — partial rotations
      // would silently leave stale time tuples on whatever rows the
      // client omitted.
      const ids = data.orderedIds.slice().sort((a, b) => a - b);
      // Per-date advisory lock BEFORE row FOR UPDATE so we don't
      // deadlock with a concurrent POST create on the same date.
      await lockMeetingDate(tx, data.meetingDate);
      const lockedRows = await tx
        .select()
        .from(executiveMeetingsTable)
        .where(inArray(executiveMeetingsTable.id, ids))
        .for("update");
      if (lockedRows.length !== data.orderedIds.length) {
        return {
          ok: false,
          status: 404,
          error: "One or more meetings not found",
          code: "not_found",
        };
      }
      // Cross-date guard: every meeting must live on the requested date.
      for (const r of lockedRows) {
        if (r.meetingDate !== data.meetingDate) {
          return {
            ok: false,
            status: 400,
            error: "Meetings are on different dates",
            code: "different_dates",
          };
        }
      }
      // Guard: every visible (non-cancelled) row on the date must be in
      // orderedIds. Otherwise we would renumber an incomplete subset
      // and the cancelled-row tail tracking would drift.
      const allDayRows = await tx
        .select()
        .from(executiveMeetingsTable)
        .where(eq(executiveMeetingsTable.meetingDate, data.meetingDate));
      const orderedSet = new Set(data.orderedIds);
      const missingVisible = allDayRows.filter(
        (r) => r.status !== "cancelled" && !orderedSet.has(r.id),
      );
      if (missingVisible.length > 0) {
        return {
          ok: false,
          status: 400,
          error: "orderedIds must include every visible meeting on the day",
          code: "incomplete_day",
        };
      }
      const cancelledInOrder = data.orderedIds.filter((id) => {
        const r = lockedRows.find((x) => x.id === id);
        return r != null && r.status === "cancelled";
      });
      if (cancelledInOrder.length > 0) {
        return {
          ok: false,
          status: 400,
          error: "orderedIds may not include cancelled meetings",
          code: "cancelled_in_rotate",
        };
      }
      // #497: the previous `no_time_window` guard was removed —
      // meetings without a (startTime, endTime) tuple are a perfectly
      // normal state and the rotation algorithm below handles `null`
      // tuples just like real ones (a `null/null` slot rotates to its
      // new physical row alongside the timed slots).
      // Optimistic-lock check on every meeting in the rotation. Same
      // shape as swap-times so the client can render the same conflict
      // toast.
      for (const r of lockedRows) {
        const expected = data.expectedUpdatedAt[String(r.id)];
        if (!expected) {
          return {
            ok: false,
            status: 400,
            error: `Missing expectedUpdatedAt for meeting ${r.id}`,
            code: "missing_token",
          };
        }
        const observedIso = new Date(expected).toISOString();
        const currentIso = r.updatedAt.toISOString();
        if (observedIso !== currentIso) {
          let lastActor: StaleConflict["conflict"]["lastActor"] = null;
          if (r.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, r.updatedBy));
            lastActor = u ?? null;
          }
          return {
            ok: false,
            status: 409,
            error: "Meeting was modified by someone else",
            code: "stale_meeting",
            conflict: {
              id: r.id,
              currentStartTime: r.startTime,
              currentEndTime: r.endTime,
              currentStatus: r.status,
              lastModifiedAt: currentIso,
              lastActor,
            },
          };
        }
      }
      // Build the canonical chronological slot order. Sort by
      // (startTime, then id) so the slot[i] tuple is deterministic
      // even when two rows share a start time. #497: null start times
      // sort LAST (after every timed row) and tie-break by id so the
      // ordering is stable across runs. This keeps the visual
      // chronological order matching what the client renders (timed
      // rows first in time order, untimed rows trailing by insertion).
      const chrono = lockedRows.slice().sort((a, b) => {
        const aNull = a.startTime == null;
        const bNull = b.startTime == null;
        if (aNull && !bNull) return 1;
        if (!aNull && bNull) return -1;
        if (!aNull && !bNull) {
          if (a.startTime! < b.startTime!) return -1;
          if (a.startTime! > b.startTime!) return 1;
        }
        return a.id - b.id;
      });
      const slots = chrono.map((r) => ({
        startTime: r.startTime,
        endTime: r.endTime,
      }));
      // Two-phase write so we don't transiently violate the per-day
      // unique index on dailyNumber: park every row on a temporary
      // negative number, then assign the new tuple. We only update
      // (startTime, endTime) here — renumberDayByStartTime below
      // recomputes daily_number from the new start times so the # 
      // column stays anchored to its physical row.
      const changedIds: number[] = [];
      for (let i = 0; i < data.orderedIds.length; i++) {
        const id = data.orderedIds[i]!;
        const slot = slots[i]!;
        const original = lockedRows.find((r) => r.id === id)!;
        const isChanged =
          original.startTime !== slot.startTime ||
          original.endTime !== slot.endTime;
        await tx
          .update(executiveMeetingsTable)
          .set({
            startTime: slot.startTime,
            endTime: slot.endTime,
            updatedBy: userId,
          })
          .where(eq(executiveMeetingsTable.id, id));
        if (isChanged) {
          changedIds.push(id);
          await logAudit(tx, {
            action: "meeting_rotate_content",
            entityType: "meeting",
            entityId: id,
            oldValue: {
              startTime: original.startTime,
              endTime: original.endTime,
            },
            newValue: {
              startTime: slot.startTime,
              endTime: slot.endTime,
              orderedIds: data.orderedIds,
            },
            performedBy: userId,
          });
        }
      }
      // Renumber by the new start times so the # column matches the
      // anchored physical-row order.
      await renumberDayByStartTime(tx, data.meetingDate);
      return { ok: true, meetingDate: data.meetingDate, changedIds };
    });
    if (!result.ok) {
      const payload: Record<string, unknown> = {
        error: result.error,
        code: result.code,
      };
      if (result.code === "stale_meeting" && "conflict" in result) {
        payload.conflict = result.conflict;
      }
      res.status(result.status).json(payload);
      return;
    }
    void emitExecutiveMeetingsDayChanged(result.meetingDate);
    const fullList = await Promise.all(
      data.orderedIds.map((id) => fetchMeetingWithAttendees(id)),
    );
    res.json({
      ok: true,
      meetings: fullList.filter((m) => m != null),
      changedIds: result.changedIds,
    });
  },
);

// =====================================================================
// REORDER (within a single day)
// =====================================================================

router.post(
  "/executive-meetings/reorder",
  requireExecutiveAccess,
  requireMutate,
  async (req, res): Promise<void> => {
    const data = parseBody(res, reorderSchema, req.body);
    if (!data) return;
    const userId = req.session.userId!;
    const dedup = new Set(data.orderedIds);
    if (dedup.size !== data.orderedIds.length) {
      res
        .status(400)
        .json({ error: "Duplicate ids in orderedIds", code: "duplicate_ids" });
      return;
    }
    try {
      await db.transaction(async (tx) => {
        // Serialize per-date so concurrent reorder/POST/PATCH on the
        // same day cannot deadlock with each other's renumber dance.
        await lockMeetingDate(tx, data.meetingDate);
        const allRows = await tx
          .select()
          .from(executiveMeetingsTable)
          .where(eq(executiveMeetingsTable.meetingDate, data.meetingDate));
        // The schedule view hides cancelled meetings (#273), so dnd-kit
        // can only ever produce drags between visible (non-cancelled)
        // rows. The reorder contract therefore operates on the
        // "in-scope" subset = meetings whose status !== 'cancelled'.
        // Cancelled meetings keep their startTime, endTime, and
        // dailyNumber unchanged so they do not steal slots from the
        // visible list (which previously made the visible rows look
        // out of order after a drag).
        const dayIds = new Set(allRows.map((r) => r.id));
        for (const id of data.orderedIds) {
          if (!dayIds.has(id)) {
            throw Object.assign(new Error("invalid_or_cross_day_id"), {
              httpStatus: 400,
              code: "invalid_ids",
            });
          }
        }
        const orderedSet = new Set(data.orderedIds);
        // 1) orderedIds must not include any cancelled meetings — those
        // are not draggable in the UI, so a payload containing one
        // means the client and server have drifted out of sync.
        const cancelledInOrder = data.orderedIds.filter((id) =>
          allRows.some((r) => r.id === id && r.status === "cancelled"),
        );
        if (cancelledInOrder.length > 0) {
          throw Object.assign(new Error("cancelled_in_reorder"), {
            httpStatus: 400,
            code: "cancelled_in_reorder",
          });
        }
        // 2) Every non-cancelled meeting on the day must appear in
        // orderedIds. Omitting a visible meeting would leave it
        // un-renumbered and produce duplicate dailyNumbers, so reject
        // with the same incomplete_day code we used before.
        const missingNonCancelled = allRows.filter(
          (r) => r.status !== "cancelled" && !orderedSet.has(r.id),
        );
        if (missingNonCancelled.length > 0) {
          throw Object.assign(new Error("incomplete_day_reorder"), {
            httpStatus: 400,
            code: "incomplete_day",
          });
        }
        // Slot-swap operates on in-scope (visible) meetings only.
        // A "slot" is the (startTime, endTime, dailyNumber) tuple of
        // an in-scope meeting sorted chronologically by start time.
        // Assigning slot[i] to orderedIds[i] permutes the visible
        // dailyNumbers among the visible meetings — cancelled rows
        // keep their existing dailyNumbers, so no unique-constraint
        // conflicts arise.
        const inScope = allRows.filter((r) => orderedSet.has(r.id));
        const slots = inScope
          .slice()
          .sort((a, b) => {
            const aHasTime = a.startTime != null;
            const bHasTime = b.startTime != null;
            if (aHasTime && bHasTime) {
              if (a.startTime! < b.startTime!) return -1;
              if (a.startTime! > b.startTime!) return 1;
              return a.dailyNumber - b.dailyNumber;
            }
            if (aHasTime) return -1;
            if (bHasTime) return 1;
            return a.dailyNumber - b.dailyNumber;
          })
          .map((r) => ({
            startTime: r.startTime,
            endTime: r.endTime,
            dailyNumber: r.dailyNumber,
          }));
        // Phase 1: park in-scope rows on negative dailyNumbers so
        // phase 2 can write the (permuted) positive numbers without
        // tripping the per-day unique constraint.
        for (let i = 0; i < data.orderedIds.length; i++) {
          const id = data.orderedIds[i]!;
          await tx
            .update(executiveMeetingsTable)
            .set({ dailyNumber: -(i + 1), updatedBy: userId })
            .where(eq(executiveMeetingsTable.id, id));
        }
        // Phase 2: assign each meeting in orderedIds the slot tuple
        // (start, end, dailyNumber) for its new visible position.
        for (let i = 0; i < data.orderedIds.length; i++) {
          const id = data.orderedIds[i]!;
          const slot = slots[i]!;
          await tx
            .update(executiveMeetingsTable)
            .set({
              startTime: slot.startTime,
              endTime: slot.endTime,
              dailyNumber: slot.dailyNumber,
              updatedBy: userId,
            })
            .where(eq(executiveMeetingsTable.id, id));
        }
        await logAudit(tx, {
          action: "executive_meeting.reorder",
          entityType: "meeting",
          entityId: data.orderedIds[0]!,
          oldValue: {
            meetingDate: data.meetingDate,
            // Audit the OLD visible order (cancelled excluded) so the
            // before/after pair reflects what the user actually saw
            // on screen rather than the raw row order.
            order: inScope
              .slice()
              .sort((a, b) => a.dailyNumber - b.dailyNumber)
              .map((r) => r.id),
          },
          newValue: {
            meetingDate: data.meetingDate,
            order: data.orderedIds,
          },
          performedBy: userId,
        });
        const updated = await tx
          .select()
          .from(executiveMeetingsTable)
          .where(eq(executiveMeetingsTable.meetingDate, data.meetingDate));
        const seen = new Set<number>();
        for (const u of updated) {
          if (seen.has(u.dailyNumber)) {
            throw new Error("duplicate_daily_number_after_reorder");
          }
          seen.add(u.dailyNumber);
        }
      });
      // Return the updated meetings in the new order, with attendees.
      const idToFetch = data.orderedIds;
      const fullList = await Promise.all(
        idToFetch.map((id) => fetchMeetingWithAttendees(id)),
      );
      // Realtime: every viewer on this day refetches so the new
      // ordering (and any time-slot reshuffling that came with it)
      // shows up without a manual refresh.
      void emitExecutiveMeetingsDayChanged(data.meetingDate);
      res.json({ meetings: fullList.filter(Boolean) });
    } catch (err) {
      const status = (err as { httpStatus?: number }).httpStatus ?? 500;
      const code = (err as { code?: string }).code ?? "server_error";
      const msg = err instanceof Error ? err.message : String(err);
      res.status(status).json({ error: msg, code });
    }
  },
);

// =====================================================================
// AUDIT LOGS
// =====================================================================

router.get(
  "/executive-meetings/audit-logs",
  requireExecutiveAccess,
  requireAdminAudit,
  async (req, res): Promise<void> => {
    const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : "";
    const dateFromRaw =
      typeof req.query.dateFrom === "string" ? req.query.dateFrom.trim() : "";
    const dateToRaw =
      typeof req.query.dateTo === "string" ? req.query.dateTo.trim() : "";
    const entityType =
      typeof req.query.entityType === "string" ? req.query.entityType.trim() : "";
    const action =
      typeof req.query.action === "string" ? req.query.action.trim() : "";
    const actorRaw = Number(req.query.actorId);
    const actorId = Number.isInteger(actorRaw) && actorRaw > 0 ? actorRaw : null;
    const limitRaw = Number(req.query.limit);
    const limit =
      Number.isFinite(limitRaw) && limitRaw > 0
        ? Math.min(Math.floor(limitRaw), 200)
        : 50;
    const offsetRaw = Number(req.query.offset);
    const offset =
      Number.isFinite(offsetRaw) && offsetRaw > 0 ? Math.floor(offsetRaw) : 0;
    const conds: SQL[] = [];
    if (dateRaw && DATE_RE_LOCAL.test(dateRaw)) {
      const start = new Date(`${dateRaw}T00:00:00.000Z`);
      const end = new Date(start.getTime() + 24 * 60 * 60 * 1000);
      conds.push(gte(executiveMeetingAuditLogsTable.performedAt, start));
      conds.push(lt(executiveMeetingAuditLogsTable.performedAt, end));
    }
    if (dateFromRaw && DATE_RE_LOCAL.test(dateFromRaw)) {
      conds.push(
        gte(
          executiveMeetingAuditLogsTable.performedAt,
          new Date(`${dateFromRaw}T00:00:00.000Z`),
        ),
      );
    }
    if (dateToRaw && DATE_RE_LOCAL.test(dateToRaw)) {
      const end = new Date(`${dateToRaw}T00:00:00.000Z`);
      end.setUTCDate(end.getUTCDate() + 1);
      conds.push(lt(executiveMeetingAuditLogsTable.performedAt, end));
    }
    if (entityType) conds.push(eq(executiveMeetingAuditLogsTable.entityType, entityType));
    if (action) conds.push(eq(executiveMeetingAuditLogsTable.action, action));
    if (actorId) conds.push(eq(executiveMeetingAuditLogsTable.performedBy, actorId));
    const where = conds.length > 0 ? and(...conds) : undefined;
    const [{ count: totalRaw } = { count: 0 }] = await db
      .select({ count: sql<number>`count(*)::int` })
      .from(executiveMeetingAuditLogsTable)
      .where(where);
    const total = Number(totalRaw) || 0;
    const rows = await db
      .select({
        l: executiveMeetingAuditLogsTable,
        actorUsername: usersTable.username,
        actorDisplayNameAr: usersTable.displayNameAr,
        actorDisplayNameEn: usersTable.displayNameEn,
      })
      .from(executiveMeetingAuditLogsTable)
      .leftJoin(usersTable, eq(usersTable.id, executiveMeetingAuditLogsTable.performedBy))
      .where(where)
      .orderBy(desc(executiveMeetingAuditLogsTable.performedAt))
      .limit(limit)
      .offset(offset);
    res.json({
      entries: rows.map((row) => ({
        ...row.l,
        actor: row.actorUsername
          ? {
              username: row.actorUsername,
              displayNameAr: row.actorDisplayNameAr,
              displayNameEn: row.actorDisplayNameEn,
            }
          : null,
      })),
      total,
      limit,
      offset,
      hasMore: offset + rows.length < total,
    });
  },
);

// =====================================================================
// NOTIFICATIONS (placeholder list — visible to any executive read role)
// =====================================================================

router.get(
  "/executive-meetings/notifications",
  requireExecutiveAccess,
  async (req, res): Promise<void> => {
    // Optional ?date=YYYY-MM-DD filter joins through the meetings table so
    // the schedule's "selected date" can scope the notifications view, as
    // required by Phase 2.
    const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : "";
    const conds: SQL[] = [];
    let meetingIdsForDate: number[] | null = null;
    if (dateRaw && DATE_RE_LOCAL.test(dateRaw)) {
      const meetingsOnDate = await db
        .select({ id: executiveMeetingsTable.id })
        .from(executiveMeetingsTable)
        .where(eq(executiveMeetingsTable.meetingDate, dateRaw));
      meetingIdsForDate = meetingsOnDate.map((m) => m.id);
      if (meetingIdsForDate.length === 0) {
        res.json({ notifications: [] });
        return;
      }
      conds.push(
        inArray(executiveMeetingNotificationsTable.meetingId, meetingIdsForDate),
      );
    }
    const where = conds.length > 0 ? and(...conds) : undefined;
    const rows = await db
      .select({
        n: executiveMeetingNotificationsTable,
        userUsername: usersTable.username,
        userDisplayNameAr: usersTable.displayNameAr,
        userDisplayNameEn: usersTable.displayNameEn,
      })
      .from(executiveMeetingNotificationsTable)
      .leftJoin(usersTable, eq(usersTable.id, executiveMeetingNotificationsTable.userId))
      .where(where)
      .orderBy(desc(executiveMeetingNotificationsTable.createdAt))
      .limit(200);
    res.json({
      notifications: rows.map((row) => ({
        ...row.n,
        user: row.userUsername
          ? {
              username: row.userUsername,
              displayNameAr: row.userDisplayNameAr,
              displayNameEn: row.userDisplayNameEn,
            }
          : null,
      })),
    });
  },
);

// =====================================================================
// NOTIFICATION PREFERENCES (per-user, per-event-type)
// =====================================================================

// One row per (notificationType, channel) toggle. Missing rows mean
// "default on" — see executive_meeting_notification_prefs in the schema.
const notificationPrefEntrySchema = z.object({
  notificationType: z.enum(EXECUTIVE_MEETING_NOTIFICATION_TYPES),
  inApp: z.boolean(),
  email: z.boolean(),
});

const notificationPrefsBodySchema = z.object({
  prefs: z.array(notificationPrefEntrySchema).max(64),
});

// GET returns the current user's preferences merged with defaults so the
// frontend can render every event type as a toggle without having to
// know which rows exist. `types` echoes the canonical list so the UI
// stays in sync if a new event type is added on the server.
router.get(
  "/executive-meetings/notification-prefs",
  requireExecutiveAccess,
  async (req, res): Promise<void> => {
    const userId = req.session.userId!;
    const rows = await db
      .select({
        notificationType: executiveMeetingNotificationPrefsTable.notificationType,
        inApp: executiveMeetingNotificationPrefsTable.inApp,
        email: executiveMeetingNotificationPrefsTable.email,
      })
      .from(executiveMeetingNotificationPrefsTable)
      .where(eq(executiveMeetingNotificationPrefsTable.userId, userId));
    const byType = new Map(rows.map((r) => [r.notificationType, r]));
    const prefs = EXECUTIVE_MEETING_NOTIFICATION_TYPES.map((type) => {
      const existing = byType.get(type);
      return {
        notificationType: type,
        inApp: existing ? existing.inApp : true,
        email: existing ? existing.email : true,
      };
    });
    res.json({
      types: EXECUTIVE_MEETING_NOTIFICATION_TYPES,
      prefs,
    });
  },
);

// PUT replaces all preferences for the current user. We upsert each
// supplied row and leave any other rows untouched (the default-on
// fallback handles unspecified types). The request only carries
// preferences the user actually toggled, so this is a partial update.
router.put(
  "/executive-meetings/notification-prefs",
  requireExecutiveAccess,
  async (req, res): Promise<void> => {
    const data = parseBody(res, notificationPrefsBodySchema, req.body);
    if (!data) return;
    const userId = req.session.userId!;
    if (data.prefs.length === 0) {
      res.json({ ok: true, count: 0 });
      return;
    }
    await db.transaction(async (tx) => {
      for (const p of data.prefs) {
        await tx
          .insert(executiveMeetingNotificationPrefsTable)
          .values({
            userId,
            notificationType: p.notificationType,
            inApp: p.inApp,
            email: p.email,
          })
          .onConflictDoUpdate({
            target: [
              executiveMeetingNotificationPrefsTable.userId,
              executiveMeetingNotificationPrefsTable.notificationType,
            ],
            set: {
              inApp: p.inApp,
              email: p.email,
              updatedAt: new Date(),
            },
          });
      }
    });
    res.json({ ok: true, count: data.prefs.length });
  },
);

// #236: DELETE wipes every pref row for the current user so all toggles
// fall back to the schema default (in_app=true, email=true). Cheaper than
// asking the client to PUT N "true" rows back, and avoids the partial-
// update gotcha where a missing event type would stay opted-out.
router.delete(
  "/executive-meetings/notification-prefs",
  requireExecutiveAccess,
  async (req, res): Promise<void> => {
    const userId = req.session.userId!;
    const deleted = await db
      .delete(executiveMeetingNotificationPrefsTable)
      .where(eq(executiveMeetingNotificationPrefsTable.userId, userId))
      .returning({ id: executiveMeetingNotificationPrefsTable.id });
    res.json({ ok: true, count: deleted.length });
  },
);

// =====================================================================
// PDF GENERATION (server-side)
// =====================================================================

// Resolve the user's effective font preferences via per-field merge:
// user-scope value (if non-null) → global value (if non-null) → schema default.
// User-scope rows store NULL for fields that inherit from global.
async function resolveFontPrefsForUser(
  userId: number,
): Promise<{ font: PdfFontPrefs; logoObjectPath: string | null }> {
  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");
  return {
    font: {
      fontFamily: (userRow?.fontFamily ?? globalRow?.fontFamily ?? "system") as string,
      fontSize: userRow?.fontSize ?? globalRow?.fontSize ?? 14,
      fontWeight: (userRow?.fontWeight ?? globalRow?.fontWeight ?? "regular") as PdfFontPrefs["fontWeight"],
      alignment: (userRow?.alignment ?? globalRow?.alignment ?? "start") as PdfFontPrefs["alignment"],
      fontColor: userRow?.fontColor ?? globalRow?.fontColor ?? "#000000",
    },
    logoObjectPath: globalRow?.logoObjectPath ?? null,
  };
}

// Download `/objects/<id>` as a Buffer for PDFKit. Null on failure.
async function loadLogoBytes(objectPath: string | null): Promise<Buffer | null> {
  if (!objectPath) return null;
  try {
    const objectStorage = new ObjectStorageService();
    const file = await objectStorage.getObjectEntityFile(objectPath);
    const [buf] = await file.download();
    return buf;
  } catch (err) {
    console.warn("[executive-meetings] failed to load brand logo", err);
    return null;
  }
}

// Upload bytes to object storage by signing a PUT URL and streaming the
// buffer through `fetch`. Returns the canonical /objects/<id> path that
// GET /api/storage/objects/* knows how to serve.
async function uploadPdfToStorage(buf: Buffer): Promise<string> {
  const objectStorage = new ObjectStorageService();
  const uploadUrl = await objectStorage.getObjectEntityUploadURL();
  const putRes = await fetch(uploadUrl, {
    method: "PUT",
    body: buf,
    headers: {
      "Content-Type": "application/pdf",
      "Content-Length": String(buf.byteLength),
    },
  });
  if (!putRes.ok) {
    const body = await putRes.text().catch(() => "");
    throw new Error(
      `Failed to upload PDF to object storage (${putRes.status}): ${body.slice(0, 200)}`,
    );
  }
  // Storage returns the signed URL we PUT to — normalize back to
  // /objects/<id> so we can serve it via GET /api/storage/objects/*.
  return objectStorage.normalizeObjectEntityPath(uploadUrl.split("?")[0]!);
}

router.get(
  "/executive-meetings/pdf",
  requireExecutiveAccess,
  async (req, res): Promise<void> => {
    const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : "";
    if (!dateRaw || !DATE_RE_LOCAL.test(dateRaw)) {
      res
        .status(400)
        .json({ error: "Invalid 'date' (expected YYYY-MM-DD)", code: "bad_date" });
      return;
    }
    const lang: "ar" | "en" =
      typeof req.query.lang === "string" && req.query.lang === "en" ? "en" : "ar";
    const userId = req.session.userId!;

    const meetings = await db
      .select()
      .from(executiveMeetingsTable)
      .where(eq(executiveMeetingsTable.meetingDate, dateRaw))
      .orderBy(asc(executiveMeetingsTable.dailyNumber));
    let attendeesByMeeting = new Map<number, ExecutiveMeetingAttendee[]>();
    if (meetings.length > 0) {
      const ids = meetings.map((m) => m.id);
      const attendees = await db
        .select()
        .from(executiveMeetingAttendeesTable)
        .where(inArray(executiveMeetingAttendeesTable.meetingId, ids))
        .orderBy(
          asc(executiveMeetingAttendeesTable.meetingId),
          asc(executiveMeetingAttendeesTable.sortOrder),
        );
      for (const a of attendees) {
        const arr = attendeesByMeeting.get(a.meetingId) ?? [];
        arr.push(a);
        attendeesByMeeting.set(a.meetingId, arr);
      }
    }

    const { font, logoObjectPath } = await resolveFontPrefsForUser(userId);
    const logo = await loadLogoBytes(logoObjectPath);
    const labels = getPdfLabels(lang);

    let pdf: Buffer;
    try {
      pdf = await renderSchedulePdf({
        date: dateRaw,
        lang,
        font,
        labels,
        logo,
        meetings: meetings.map((m) => ({
          dailyNumber: m.dailyNumber,
          titleAr: m.titleAr,
          titleEn: m.titleEn,
          startTime: m.startTime,
          endTime: m.endTime,
          location: m.location,
          rowColor: m.rowColor,
          mergeStartColumn: m.mergeStartColumn,
          mergeEndColumn: m.mergeEndColumn,
          mergeText: m.mergeText,
          attendees: (attendeesByMeeting.get(m.id) ?? []).map((a) => ({
            name: a.name,
            title: a.title,
            attendanceType: a.attendanceType,
            kind: a.kind,
          })),
        })),
      });
    } catch (err) {
      // Fail loudly so callers see a real error instead of a corrupt PDF.
      console.error("[executive-meetings] PDF render failed", err);
      res.status(500).json({
        error: "Failed to generate PDF",
        code: "pdf_render_failed",
        detail: err instanceof Error ? err.message : String(err),
      });
      return;
    }

    // Upload + archive. If storage upload fails (e.g. sidecar offline)
    // we still serve the PDF inline so the user is never blocked, but
    // we record the archive with a synthetic path so the failure is
    // visible in the audit trail.
    let storagePath = `inline:${dateRaw}`;
    try {
      storagePath = await uploadPdfToStorage(pdf);
    } catch (err) {
      console.error("[executive-meetings] PDF upload failed; serving inline", err);
    }

    try {
      await db.transaction(async (tx) => {
        const [{ value: maxV }] = await tx
          .select({
            value: sql<number>`coalesce(max(${executiveMeetingPdfArchivesTable.version}), 0)`,
          })
          .from(executiveMeetingPdfArchivesTable)
          .where(eq(executiveMeetingPdfArchivesTable.archiveDate, dateRaw));
        const insertValues: typeof executiveMeetingPdfArchivesTable.$inferInsert = {
          archiveDate: dateRaw,
          filePath: storagePath,
          version: (maxV ?? 0) + 1,
          byteSize: pdf.byteLength,
          generatedBy: userId,
        };
        const [row] = await tx
          .insert(executiveMeetingPdfArchivesTable)
          .values(insertValues)
          .returning();
        await logAudit(tx, {
          action: "create",
          entityType: "pdf_archive",
          entityId: row!.id,
          newValue: row!,
          performedBy: userId,
        });
      });
    } catch (err) {
      console.error("[executive-meetings] PDF archive insert failed", err);
    }

    // Two-part filename per RFC 5987:
    //   - `filename=` is a stable ASCII slug (`executive-meetings-YYYY-MM-DD.pdf`)
    //     so older clients, curl, and tests get a predictable, parseable name.
    //   - `filename*=UTF-8''…` is the localized human-readable name (Arabic
    //     "قائمة الاجتماعات يوم …" / English "Meetings - …") that modern
    //     browsers prefer when offering the download.
    const dayName = new Intl.DateTimeFormat(
      lang === "ar" ? "ar-SA" : "en-US",
      { weekday: "long" },
    ).format(new Date(dateRaw + "T00:00:00"));
    const asciiFileName = `executive-meetings-${dateRaw}.pdf`;
    const friendlyFileName =
      lang === "ar"
        ? `قائمة الاجتماعات يوم ${dayName}.pdf`
        : `Meetings - ${dayName}.pdf`;
    res.setHeader("Content-Type", "application/pdf");
    res.setHeader(
      "Content-Disposition",
      `attachment; filename="${asciiFileName}"; filename*=UTF-8''${encodeURIComponent(friendlyFileName)}`,
    );
    res.setHeader("Content-Length", String(pdf.byteLength));
    res.setHeader("Cache-Control", "no-store");
    res.end(pdf);
  },
);

// =====================================================================
// PDF ARCHIVES
// =====================================================================

router.get(
  "/executive-meetings/pdf-archives",
  requireExecutiveAccess,
  async (req, res): Promise<void> => {
    const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : "";
    const conds: SQL[] = [];
    if (dateRaw && DATE_RE_LOCAL.test(dateRaw)) {
      conds.push(eq(executiveMeetingPdfArchivesTable.archiveDate, dateRaw));
    }
    const where = conds.length > 0 ? and(...conds) : undefined;
    const rows = await db
      .select({
        a: executiveMeetingPdfArchivesTable,
        username: usersTable.username,
        displayNameAr: usersTable.displayNameAr,
        displayNameEn: usersTable.displayNameEn,
      })
      .from(executiveMeetingPdfArchivesTable)
      .leftJoin(
        usersTable,
        eq(usersTable.id, executiveMeetingPdfArchivesTable.generatedBy),
      )
      .where(where)
      .orderBy(desc(executiveMeetingPdfArchivesTable.generatedAt))
      .limit(200);
    res.json({
      archives: rows.map((row) => ({
        ...row.a,
        generator: row.username
          ? {
              username: row.username,
              displayNameAr: row.displayNameAr,
              displayNameEn: row.displayNameEn,
            }
          : null,
      })),
    });
  },
);

router.post(
  "/executive-meetings/pdf-archives",
  requireExecutiveAccess,
  // MR-H2: writing an archive row is a mutation; the read-only
  // `executive_viewer` role must NOT be able to create archives or
  // poison the archive list. Mirrors the gating on every other write
  // endpoint in this module (see e.g. line 609).
  requireMutate,
  async (req, res): Promise<void> => {
    const data = parseBody(res, pdfArchiveCreateSchema, req.body);
    if (!data) return;
    // Server-derived; never read from the body. See pdfArchiveCreateSchema.
    const filePath = `print:${data.archiveDate}`;
    const userId = req.session.userId!;
    const created = await db.transaction(async (tx) => {
      const [{ value: maxV }] = await tx
        .select({
          value: sql<number>`coalesce(max(${executiveMeetingPdfArchivesTable.version}), 0)`,
        })
        .from(executiveMeetingPdfArchivesTable)
        .where(eq(executiveMeetingPdfArchivesTable.archiveDate, data.archiveDate));
      const insertValues: typeof executiveMeetingPdfArchivesTable.$inferInsert = {
        archiveDate: data.archiveDate,
        filePath,
        version: (maxV ?? 0) + 1,
        generatedBy: userId,
      };
      const [row] = await tx
        .insert(executiveMeetingPdfArchivesTable)
        .values(insertValues)
        .returning();
      await logAudit(tx, {
        action: "create",
        entityType: "pdf_archive",
        entityId: row!.id,
        newValue: row!,
        performedBy: userId,
      });
      return row;
    });
    res.status(201).json(created);
  },
);

// =====================================================================
// FONT SETTINGS
// =====================================================================

router.get(
  "/executive-meetings/font-settings",
  requireExecutiveAccess,
  async (req, res): Promise<void> => {
    const userId = req.session.userId!;
    const rows = await db
      .select()
      .from(executiveMeetingFontSettingsTable)
      .where(
        or(
          eq(executiveMeetingFontSettingsTable.scope, "global"),
          and(
            eq(executiveMeetingFontSettingsTable.scope, "user"),
            eq(executiveMeetingFontSettingsTable.userId, userId),
          ),
        ),
      );
    const global = rows.find((r) => r.scope === "global") ?? null;
    const user = rows.find((r) => r.scope === "user" && r.userId === userId) ?? null;
    res.json({ global, user });
  },
);

// Shared handler used by both PUT (canonical) and PATCH (alias) so we don't
// rely on Express re-dispatch tricks — both methods run the exact same logic.
const upsertFontSettingsHandler = async (
  req: import("express").Request,
  res: import("express").Response,
): Promise<void> => {
  const userId = req.session.userId!;
  const data = parseBody(res, fontSettingsSchema, req.body);
  if (!data) return;
  const userRoleNames = await getRoleNamesForUser(userId);
  const isAdmin = EM_ADMIN_ROLES.some((r) => userRoleNames.has(r));
  if (data.scope === "global" && !isAdmin) {
    res.status(403).json({ error: "Forbidden", code: "forbidden" });
    return;
  }
  // Logo lives on the GLOBAL row only — a user-scope row is the per-user
  // overlay (font/color/etc.) and never owns a logo. `null` is allowed
  // (it just means "no override here", which is already the default for
  // user rows). Anything else on a user-scope payload is rejected so
  // we don't silently drop the field on the floor.
  if (
    data.scope === "user" &&
    typeof data.logoObjectPath === "string"
  ) {
    res.status(400).json({
      error: "logoObjectPath is only allowed on global font settings",
      code: "logo_user_scope_forbidden",
    });
    return;
  }
  const targetUserId = data.scope === "global" ? null : userId;
  const logoSentByAdmin =
    isAdmin && data.logoObjectPath !== undefined;
  const logoForFontRow =
    data.scope === "global" && logoSentByAdmin
      ? data.logoObjectPath
      : undefined;
  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 updateSet: Partial<typeof executiveMeetingFontSettingsTable.$inferInsert> = {
        fontFamily: data.fontFamily,
        fontSize: data.fontSize,
        fontWeight: data.fontWeight,
        alignment: data.alignment,
        fontColor: data.fontColor,
      };
      if (logoForFontRow !== undefined) {
        updateSet.logoObjectPath = logoForFontRow;
      }
      const [updated] = await tx
        .update(executiveMeetingFontSettingsTable)
        .set(updateSet)
        .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,
        fontColor: data.fontColor,
        logoObjectPath: logoForFontRow ?? null,
      };
      const [inserted] = await tx
        .insert(executiveMeetingFontSettingsTable)
        .values(insertValues)
        .returning();
      row = inserted;
    }
    if (logoSentByAdmin && data.scope !== "global") {
      const [globalRow] = await tx
        .select()
        .from(executiveMeetingFontSettingsTable)
        .where(
          and(
            eq(executiveMeetingFontSettingsTable.scope, "global"),
            isNull(executiveMeetingFontSettingsTable.userId),
          ),
        );
      if (globalRow) {
        await tx
          .update(executiveMeetingFontSettingsTable)
          .set({ logoObjectPath: data.logoObjectPath })
          .where(eq(executiveMeetingFontSettingsTable.id, globalRow.id));
      } else {
        await tx
          .insert(executiveMeetingFontSettingsTable)
          .values({
            scope: "global",
            userId: null,
            fontFamily: "system",
            fontSize: 14,
            fontWeight: "regular",
            alignment: "start",
            fontColor: "#000000",
            logoObjectPath: data.logoObjectPath ?? null,
          });
      }
    }
    if (data.scope === "user") {
      const [curGlobal] = await tx
        .select()
        .from(executiveMeetingFontSettingsTable)
        .where(
          and(
            eq(executiveMeetingFontSettingsTable.scope, "global"),
            isNull(executiveMeetingFontSettingsTable.userId),
          ),
        );
      if (curGlobal && row) {
        const nullIfSame = <T>(userVal: T, globalVal: T): T | null =>
          userVal === globalVal ? null : userVal;
        const nullified = {
          fontFamily: nullIfSame(row.fontFamily, curGlobal.fontFamily),
          fontSize: nullIfSame(row.fontSize, curGlobal.fontSize),
          fontWeight: nullIfSame(row.fontWeight, curGlobal.fontWeight),
          alignment: nullIfSame(row.alignment, curGlobal.alignment),
          fontColor: nullIfSame(row.fontColor, curGlobal.fontColor),
        };
        const [updated] = await tx
          .update(executiveMeetingFontSettingsTable)
          .set(nullified)
          .where(eq(executiveMeetingFontSettingsTable.id, row.id))
          .returning();
        row = updated;
      }
    }
    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;
