a89d05501b
Scope: MR-H1, MR-H2, MR-M7 from .local/security/manual-review.md.
Changes
-------
- New lib/objectAuthz.ts: canUserReadObjectPath(userId, objectPath)
performs an entity-lookup against avatar / app icon / service image /
brand logo / pdf archive / meeting attachment and applies the matching
read rule. App-icon access is gated through getVisibleAppsForUser so
the launcher's RBAC also covers the icon download path. Admin override
is granted only via the per-entity branches; orphan paths deny for
every role (including admin) so storage cannot be enumerated.
- routes/storage.ts: GET /api/storage/objects/* now calls
canUserReadObjectPath BEFORE getObjectEntityFile and returns 404 on
deny so existence is not leaked (MR-H1 fix).
- routes/apps.ts: getVisibleAppsForUser exported for the authz lib.
- routes/executive-meetings.ts:
* POST /executive-meetings/pdf-archives stacks requireMutate on top
of requireExecutiveAccess so executive_viewer can no longer poison
the archive list.
* pdfArchiveCreateSchema is now z.object({ archiveDate }).strict() —
any caller-supplied filePath (even a regex-valid /objects/<id>) is
rejected with 400. The handler always derives filePath server-side
as `print:<archiveDate>`. Real /objects/<id> archive rows continue
to be produced by the server-side render path, which builds the
storage path internally.
- lib/objectAcl.ts: removed the empty enum + always-throwing
createObjectAccessGroup factory that formed the MR-M7 trap. Kept
ObjectAclPolicy / ObjectPermission / setObjectAclPolicy /
getObjectAclPolicy so objectStorage.ts compiles. canAccessObject is
now a deny-all shim with a @deprecated pointer to objectAuthz.ts.
Tests added (artifacts/api-server/tests/storage-object-authz.test.mjs)
---------------------------------------------------------------------
A. Unauthenticated GET /api/storage/objects/* -> 401
B. Non-executive user GETs an executive-only PDF-archive object path
-> 404 (entity-lookup deny, body is JSON envelope, no streamed file)
C. Owner uploads via presign + PUT, sets users.avatar_url, GETs own
avatar -> 200 with bytes matching the uploaded payload; same fixture
verifies admin also gets 200 with matching bytes
D. Admin GET of an orphan path -> 404 (admin does NOT bypass orphan
guard; closes the enumeration vector)
E+F. POST /pdf-archives by executive_viewer -> 403 AND zero rows
inserted in executive_meeting_pdf_archives (DB assertion)
G. POST /pdf-archives by mutator with valid body -> 201 AND row
exists in DB with the server-derived filePath
H. Authed user GET of an orphan path -> 404 (regression)
I. POST /pdf-archives with a caller-supplied filePath -> 400
J. GET /pdf-archives by executive_viewer -> 200 (regression)
K. Brand-logo path: real upload + presign, wired to font_settings.
logo_object_path; on the SAME existing object the executive_viewer
streams 200 + bytes while the order_receiver gets 404 — proves the
divergence is from authz, not from missing-file behavior
Test results
------------
All 10 new tests pass. Full api-server suite: 319/324 pass. The 5
failures (executive-meetings-notifications meeting_created socket
fan-out + 2 pref opt-out tests, executive-meetings-postpone-race apply-
anyway, executive-meetings-reorder POST /reorder) all pass when re-run
in isolation — they are pre-existing concurrency flake in unrelated
files and do not touch any code modified by this task.
Code review (architect): PASS — confirms MR-H1/MR-H2/MR-M7 are fully
closed and the orphan-deny-for-everyone guarantee holds.
Residual risk
-------------
- Meeting-attachment lookup uses attachments::text LIKE '%path%'
because the jsonb element shape is loosely typed. Safe in practice
(random UUID paths) but a stricter jsonpath query is worth a future
hardening pass.
- Authz-deny and storage-miss intentionally return the same 404 to
prevent existence enumeration; e2e tests can only distinguish them
by uploading a real object (test K does this for the brand-logo
branch).
Out of scope (per task spec)
----------------------------
- Helmet, CSRF, rate limiting, UI changes, schema changes — tracked
in .local/security/manual-review.md and proposed as follow-up
Task #525 (auth rate limiting, MR-H3).
3540 lines
130 KiB
TypeScript
3540 lines
130 KiB
TypeScript
import { Router, type IRouter } from "express";
|
|
import {
|
|
eq,
|
|
ne,
|
|
asc,
|
|
desc,
|
|
inArray,
|
|
and,
|
|
sql,
|
|
gte,
|
|
lt,
|
|
isNull,
|
|
or,
|
|
type SQL,
|
|
} from "drizzle-orm";
|
|
import { z, type ZodType } from "zod";
|
|
import { db } from "@workspace/db";
|
|
import {
|
|
executiveMeetingsTable,
|
|
executiveMeetingAttendeesTable,
|
|
executiveMeetingNotificationsTable,
|
|
executiveMeetingNotificationPrefsTable,
|
|
executiveMeetingAuditLogsTable,
|
|
executiveMeetingPdfArchivesTable,
|
|
executiveMeetingFontSettingsTable,
|
|
executiveMeetingAlertStateTable,
|
|
rolesTable,
|
|
usersTable,
|
|
EXECUTIVE_MEETING_ROW_COLOR_KEYS,
|
|
type ExecutiveMeetingAttendee,
|
|
} from "@workspace/db";
|
|
import {
|
|
requireAuth,
|
|
requireExecutiveAccess,
|
|
getEffectiveRoleIds,
|
|
} from "../middlewares/auth";
|
|
import {
|
|
sanitizePlainTextOrNull,
|
|
sanitizeRichText,
|
|
stripTagsToPlainTextOrNull,
|
|
} from "../lib/sanitize";
|
|
import {
|
|
emitExecutiveMeetingsDayChanged,
|
|
emitExecutiveMeetingsDaysChanged,
|
|
emitExecutiveMeetingAlertStateChanged,
|
|
} from "../lib/realtime";
|
|
import { renderSchedulePdf, type PdfFontPrefs } from "../lib/pdf-renderer";
|
|
import { 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_office_manager",
|
|
"executive_coord_lead",
|
|
] as const;
|
|
const EM_ADMIN_ROLES = ["admin", "executive_office_manager"] as const;
|
|
const ADMIN_AUDIT_ROLES = [
|
|
"admin",
|
|
"executive_office_manager",
|
|
"executive_coord_lead",
|
|
] as const;
|
|
const READ_ROLES = [
|
|
"admin",
|
|
"executive_ceo",
|
|
"executive_office_manager",
|
|
"executive_coord_lead",
|
|
"executive_coordinator",
|
|
"executive_viewer",
|
|
] as const;
|
|
|
|
// ---------- Role helpers (finer-grained, layered on top of requireExecutiveAccess) ----------
|
|
|
|
async function getRoleNamesForUser(userId: number): Promise<Set<string>> {
|
|
const ids = await getEffectiveRoleIds(userId);
|
|
if (ids.length === 0) return new Set();
|
|
const rows = await db
|
|
.select({ name: rolesTable.name })
|
|
.from(rolesTable)
|
|
.where(inArray(rolesTable.id, ids));
|
|
return new Set(rows.map((r) => r.name));
|
|
}
|
|
|
|
function makeRequireRoles(allowed: ReadonlyArray<string>) {
|
|
return async function (
|
|
req: Request,
|
|
res: Response,
|
|
next: NextFunction,
|
|
): Promise<void> {
|
|
if (!req.session.userId) {
|
|
res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
|
|
return;
|
|
}
|
|
const names = await getRoleNamesForUser(req.session.userId);
|
|
const ok = allowed.some((r) => names.has(r));
|
|
if (!ok) {
|
|
res.status(403).json({ error: "Forbidden", code: "forbidden" });
|
|
return;
|
|
}
|
|
next();
|
|
};
|
|
}
|
|
|
|
const requireMutate = makeRequireRoles(MUTATE_ROLES);
|
|
const requireAdminAudit = makeRequireRoles(ADMIN_AUDIT_ROLES);
|
|
|
|
// ---------- Zod schemas ----------
|
|
|
|
// Allowed values for the row-highlight overlay on the daily schedule.
|
|
// Mirrors the ROW_COLOR_OPTIONS palette in the frontend (
|
|
// artifacts/tx-os/src/pages/executive-meetings.tsx). NULL/omitted on the
|
|
// wire = "default" (no tint). The whitelist itself is exported from
|
|
// the schema package (#293) so the API guard, the Drizzle CHECK
|
|
// constraint, and any future caller share one source of truth — change
|
|
// the palette in one place and both layers stay in sync.
|
|
const rowColorSchema = z.enum(EXECUTIVE_MEETING_ROW_COLOR_KEYS).nullable();
|
|
|
|
const dateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "expected YYYY-MM-DD");
|
|
|
|
const DATE_RE_LOCAL = /^\d{4}-\d{2}-\d{2}$/;
|
|
const timeSchema = z
|
|
.string()
|
|
.regex(/^\d{2}:\d{2}(:\d{2})?$/, "expected HH:MM");
|
|
const positiveIntSchema = z.number().int().positive();
|
|
|
|
// titleAr/En and attendee.name accept sanitized rich-text HTML (Tiptap
|
|
// output). The byte count grows quickly with inline <span style=...> wrappers,
|
|
// so we allow up to ~10k bytes for titles and ~5k for attendee names. The
|
|
// sanitizer strips disallowed tags/attrs before persistence.
|
|
const attendeeSchema = z
|
|
.object({
|
|
// For persons this is sanitized rich-text HTML (Tiptap output).
|
|
// For subheadings this is the user's free-text label, also passed
|
|
// through sanitizeRichText to strip any sneaky markup. Both kinds
|
|
// require at least one non-whitespace character; an empty string is
|
|
// a delete-row gesture in the inline editor and must never reach the DB.
|
|
name: z.string().trim().min(1).max(5000),
|
|
title: z.string().trim().max(200).nullable().optional(),
|
|
attendanceType: z.enum(ATTENDANCE_TYPES).default("internal"),
|
|
sortOrder: z.number().int().min(0).optional(),
|
|
kind: z.enum(ATTENDEE_KINDS).default("person"),
|
|
})
|
|
// `.strict()` so any client-only field (e.g. the React `_sid` row id used
|
|
// by the drag-and-drop editor) that accidentally leaks into the wire
|
|
// payload is rejected loudly with 400 instead of being silently stripped.
|
|
// The browser always projects to the documented wire shape; this is the
|
|
// belt-and-suspenders guard against a future refactor regression.
|
|
.strict();
|
|
|
|
const meetingBaseFields = {
|
|
titleAr: z.string().trim().min(1).max(10000),
|
|
titleEn: z.string().trim().min(1).max(10000),
|
|
meetingDate: dateSchema,
|
|
dailyNumber: positiveIntSchema.max(1000).nullable().optional(),
|
|
startTime: timeSchema.nullable().optional(),
|
|
endTime: timeSchema.nullable().optional(),
|
|
location: z.string().trim().max(500).nullable().optional(),
|
|
meetingUrl: z.string().trim().max(2000).nullable().optional(),
|
|
platform: z.enum(PLATFORMS).optional(),
|
|
status: z.enum(STATUSES).optional(),
|
|
isHighlighted: z
|
|
.union([z.boolean(), z.number()])
|
|
.transform((v) => (v ? 1 : 0))
|
|
.optional(),
|
|
notes: z.string().trim().max(5000).nullable().optional(),
|
|
} as const;
|
|
|
|
const meetingCreateSchema = z
|
|
.object({
|
|
...meetingBaseFields,
|
|
attendees: z.array(attendeeSchema).optional(),
|
|
})
|
|
.refine(
|
|
(v) => !v.startTime || !v.endTime || v.startTime <= v.endTime,
|
|
{ message: "startTime must be <= endTime", path: ["endTime"] },
|
|
);
|
|
|
|
// Cell-merge overlay payload accepted by PATCH. The whole `merge` key is
|
|
// optional; when present it must either be `null` (clear) or a fully
|
|
// specified object describing the column range and the free-text label.
|
|
// `mergeText` accepts sanitized rich-text HTML up to ~10k bytes (same
|
|
// budget as titleAr/titleEn).
|
|
const meetingMergeSchema = z
|
|
.union([
|
|
z.null(),
|
|
z.object({
|
|
mergeStartColumn: z.enum(MERGE_COLUMNS),
|
|
mergeEndColumn: z.enum(MERGE_COLUMNS),
|
|
mergeText: z.string().trim().max(10000),
|
|
}),
|
|
])
|
|
.refine(
|
|
(v) =>
|
|
v === null ||
|
|
MERGE_COLUMN_INDEX[v.mergeStartColumn] <=
|
|
MERGE_COLUMN_INDEX[v.mergeEndColumn],
|
|
{
|
|
message: "mergeStartColumn must come before mergeEndColumn",
|
|
path: ["mergeEndColumn"],
|
|
},
|
|
);
|
|
|
|
const meetingPatchSchema = z
|
|
.object({
|
|
titleAr: meetingBaseFields.titleAr.optional(),
|
|
titleEn: meetingBaseFields.titleEn.optional(),
|
|
meetingDate: meetingBaseFields.meetingDate.optional(),
|
|
dailyNumber: meetingBaseFields.dailyNumber,
|
|
startTime: meetingBaseFields.startTime,
|
|
endTime: meetingBaseFields.endTime,
|
|
location: meetingBaseFields.location,
|
|
meetingUrl: meetingBaseFields.meetingUrl,
|
|
platform: meetingBaseFields.platform,
|
|
status: meetingBaseFields.status,
|
|
isHighlighted: meetingBaseFields.isHighlighted,
|
|
notes: meetingBaseFields.notes,
|
|
attendees: z.array(attendeeSchema).optional(),
|
|
merge: meetingMergeSchema.optional(),
|
|
// Row-highlight colour (#288). null = clear back to default (no tint).
|
|
// Optional so this PATCH endpoint stays usable for callers that only
|
|
// want to edit other fields. Validated against the ROW_COLOR_KEYS
|
|
// whitelist above, so an unknown key returns 400 instead of writing.
|
|
rowColor: rowColorSchema.optional(),
|
|
})
|
|
.refine(
|
|
(v) => !v.startTime || !v.endTime || v.startTime <= v.endTime,
|
|
{ message: "startTime must be <= endTime", path: ["endTime"] },
|
|
);
|
|
|
|
const attendeesPutSchema = z.object({
|
|
attendees: z.array(attendeeSchema),
|
|
});
|
|
|
|
const duplicateSchema = z.object({
|
|
targetDate: dateSchema,
|
|
});
|
|
|
|
// Reorder schema lives in @workspace/api-zod (lib/api-zod/src/manual.ts) so
|
|
// frontend and server share the same contract for POST /reorder.
|
|
const reorderSchema = ExecutiveMeetingsReorderBody;
|
|
|
|
// 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 ----------
|
|
|
|
async function nextDailyNumber(
|
|
executor: DbExecutor,
|
|
date: string,
|
|
): Promise<number> {
|
|
const [row] = await executor
|
|
.select({
|
|
max: sql<number | null>`max(${executiveMeetingsTable.dailyNumber})`,
|
|
})
|
|
.from(executiveMeetingsTable)
|
|
.where(eq(executiveMeetingsTable.meetingDate, date));
|
|
return (row?.max ?? 0) + 1;
|
|
}
|
|
|
|
// #273: After a postpone/reschedule/cancel that shifts a meeting in the
|
|
// daily timeline, recompute `daily_number` for every meeting on `date`
|
|
// so that the displayed `#` matches the new chronological order.
|
|
//
|
|
// Active meetings (not cancelled) are numbered 1..N in `start_time`
|
|
// order; cancelled meetings are pushed to the tail (N+1..N+M, by id).
|
|
// The two-step "shift to negatives, then renumber" dance avoids violating
|
|
// the `(meeting_date, daily_number)` unique index mid-update.
|
|
// #312: shape returned by renumberDayByStartTime so callers (PATCH,
|
|
// POST create, DELETE, duplicate) can capture how the visible
|
|
// (non-cancelled) row order changed and enrich their audit rows. The
|
|
// `before` / `after` arrays are the visible meeting IDs in
|
|
// `daily_number` order (i.e. what the user sees top-to-bottom in the
|
|
// schedule); `orderShifted` is just the boolean comparison of those
|
|
// two arrays. Cancelled rows are excluded because they are hidden in
|
|
// the UI and their tail-position is an implementation detail.
|
|
type DayOrderShift = {
|
|
date: string;
|
|
orderShifted: boolean;
|
|
before: number[];
|
|
after: number[];
|
|
};
|
|
|
|
async function snapshotVisibleDayOrder(
|
|
executor: DbExecutor,
|
|
date: string,
|
|
): Promise<number[]> {
|
|
const rows = await executor
|
|
.select({ id: executiveMeetingsTable.id })
|
|
.from(executiveMeetingsTable)
|
|
.where(
|
|
and(
|
|
eq(executiveMeetingsTable.meetingDate, date),
|
|
ne(executiveMeetingsTable.status, "cancelled"),
|
|
),
|
|
)
|
|
.orderBy(asc(executiveMeetingsTable.dailyNumber));
|
|
return rows.map((r) => r.id);
|
|
}
|
|
|
|
async function renumberDayByStartTime(
|
|
executor: DbExecutor,
|
|
date: string,
|
|
): Promise<DayOrderShift> {
|
|
// Snapshot the visible order BEFORE the renumber so callers can audit
|
|
// a true "from -> to" diff (#312). Cheap single SELECT inside the same
|
|
// transaction the caller passes in.
|
|
const before = await snapshotVisibleDayOrder(executor, date);
|
|
// Step 1: temporarily move every row to a negative number so the
|
|
// positives are free. The negatives stay unique because the previous
|
|
// positives were unique.
|
|
await executor.execute(sql`
|
|
UPDATE ${executiveMeetingsTable}
|
|
SET daily_number = -1 * daily_number - 1
|
|
WHERE meeting_date = ${date}
|
|
AND daily_number > 0
|
|
`);
|
|
// Step 2: assign 1..N to non-cancelled meetings in start-time order.
|
|
await executor.execute(sql`
|
|
UPDATE ${executiveMeetingsTable} AS em
|
|
SET daily_number = sub.new_n
|
|
FROM (
|
|
SELECT id,
|
|
ROW_NUMBER() OVER (
|
|
ORDER BY start_time NULLS LAST, id
|
|
) AS new_n
|
|
FROM ${executiveMeetingsTable}
|
|
WHERE meeting_date = ${date}
|
|
AND status <> 'cancelled'
|
|
) AS sub
|
|
WHERE em.id = sub.id
|
|
`);
|
|
// Step 3: assign continuation numbers to cancelled meetings so the
|
|
// unique index stays satisfied and they sort to the end of the list.
|
|
await executor.execute(sql`
|
|
UPDATE ${executiveMeetingsTable} AS em
|
|
SET daily_number = sub.new_n
|
|
FROM (
|
|
SELECT em2.id,
|
|
(
|
|
SELECT COUNT(*)::int
|
|
FROM ${executiveMeetingsTable} em3
|
|
WHERE em3.meeting_date = ${date}
|
|
AND em3.status <> 'cancelled'
|
|
) + ROW_NUMBER() OVER (ORDER BY em2.id) AS new_n
|
|
FROM ${executiveMeetingsTable} em2
|
|
WHERE em2.meeting_date = ${date}
|
|
AND em2.status = 'cancelled'
|
|
) AS sub
|
|
WHERE em.id = sub.id
|
|
`);
|
|
// Snapshot the visible order AFTER the renumber and compute the
|
|
// boolean "did the user-visible chronological order actually change?".
|
|
const after = await snapshotVisibleDayOrder(executor, date);
|
|
const orderShifted =
|
|
before.length !== after.length ||
|
|
before.some((id, i) => id !== after[i]);
|
|
return { date, orderShifted, before, after };
|
|
}
|
|
|
|
async function fetchMeetingWithAttendees(id: number) {
|
|
const [meeting] = await db
|
|
.select()
|
|
.from(executiveMeetingsTable)
|
|
.where(eq(executiveMeetingsTable.id, id));
|
|
if (!meeting) return null;
|
|
const attendees = await db
|
|
.select()
|
|
.from(executiveMeetingAttendeesTable)
|
|
.where(eq(executiveMeetingAttendeesTable.meetingId, id))
|
|
.orderBy(asc(executiveMeetingAttendeesTable.sortOrder));
|
|
return { ...meeting, attendees };
|
|
}
|
|
|
|
// =====================================================================
|
|
// MEETINGS
|
|
// =====================================================================
|
|
|
|
router.get(
|
|
"/executive-meetings",
|
|
requireExecutiveAccess,
|
|
async (req, res): Promise<void> => {
|
|
const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : "";
|
|
if (dateRaw && !/^\d{4}-\d{2}-\d{2}$/.test(dateRaw)) {
|
|
res
|
|
.status(400)
|
|
.json({ error: "Invalid 'date' (expected YYYY-MM-DD)", code: "bad_date" });
|
|
return;
|
|
}
|
|
const targetDate = dateRaw || new Date().toISOString().slice(0, 10);
|
|
const meetings = await db
|
|
.select()
|
|
.from(executiveMeetingsTable)
|
|
.where(eq(executiveMeetingsTable.meetingDate, targetDate))
|
|
.orderBy(asc(executiveMeetingsTable.dailyNumber));
|
|
if (meetings.length === 0) {
|
|
res.json({ date: targetDate, meetings: [] });
|
|
return;
|
|
}
|
|
const ids = meetings.map((m) => m.id);
|
|
const attendees = await db
|
|
.select()
|
|
.from(executiveMeetingAttendeesTable)
|
|
.where(inArray(executiveMeetingAttendeesTable.meetingId, ids))
|
|
.orderBy(
|
|
asc(executiveMeetingAttendeesTable.meetingId),
|
|
asc(executiveMeetingAttendeesTable.sortOrder),
|
|
);
|
|
const byMeeting = new Map<number, typeof attendees>();
|
|
for (const a of attendees) {
|
|
const arr = byMeeting.get(a.meetingId) ?? [];
|
|
arr.push(a);
|
|
byMeeting.set(a.meetingId, arr);
|
|
}
|
|
res.json({
|
|
date: targetDate,
|
|
meetings: meetings.map((m) => ({
|
|
...m,
|
|
attendees: byMeeting.get(m.id) ?? [],
|
|
})),
|
|
});
|
|
},
|
|
);
|
|
|
|
router.get(
|
|
"/executive-meetings/dates",
|
|
requireExecutiveAccess,
|
|
async (_req, res): Promise<void> => {
|
|
const rows = await db
|
|
.selectDistinct({ d: executiveMeetingsTable.meetingDate })
|
|
.from(executiveMeetingsTable)
|
|
.orderBy(asc(executiveMeetingsTable.meetingDate));
|
|
res.json({ dates: rows.map((r) => r.d) });
|
|
},
|
|
);
|
|
|
|
router.get(
|
|
"/executive-meetings/me",
|
|
requireExecutiveAccess,
|
|
async (req, res): Promise<void> => {
|
|
if (!req.session.userId) {
|
|
res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
|
|
return;
|
|
}
|
|
const names = await getRoleNamesForUser(req.session.userId);
|
|
res.json({
|
|
userId: req.session.userId,
|
|
roles: Array.from(names),
|
|
canRead: READ_ROLES.some((r) => names.has(r)),
|
|
canMutate: MUTATE_ROLES.some((r) => names.has(r)),
|
|
canEditGlobalFontSettings: EM_ADMIN_ROLES.some((r) => names.has(r)),
|
|
canViewAudit: ADMIN_AUDIT_ROLES.some((r) => names.has(r)),
|
|
});
|
|
},
|
|
);
|
|
|
|
router.get(
|
|
"/executive-meetings/:id",
|
|
requireExecutiveAccess,
|
|
async (req, res): Promise<void> => {
|
|
const id = Number(req.params.id);
|
|
if (!Number.isInteger(id) || id <= 0) {
|
|
res.status(404).json({ error: "Not found", code: "not_found" });
|
|
return;
|
|
}
|
|
const m = await fetchMeetingWithAttendees(id);
|
|
if (!m) {
|
|
res.status(404).json({ error: "Meeting not found", code: "not_found" });
|
|
return;
|
|
}
|
|
res.json(m);
|
|
},
|
|
);
|
|
|
|
router.post(
|
|
"/executive-meetings",
|
|
requireExecutiveAccess,
|
|
requireMutate,
|
|
async (req, res): Promise<void> => {
|
|
const data = parseBody(res, meetingCreateSchema, req.body);
|
|
if (!data) return;
|
|
const userId = req.session.userId!;
|
|
try {
|
|
const inserted = await db.transaction(async (tx) => {
|
|
const dailyNumber =
|
|
data.dailyNumber ?? (await nextDailyNumber(tx, data.meetingDate));
|
|
const meetingValues: typeof executiveMeetingsTable.$inferInsert = {
|
|
titleAr: sanitizeRichText(data.titleAr),
|
|
titleEn: sanitizeRichText(data.titleEn ?? ""),
|
|
meetingDate: data.meetingDate,
|
|
dailyNumber,
|
|
startTime: data.startTime ?? null,
|
|
endTime: data.endTime ?? null,
|
|
// location/meetingUrl/notes are plain-text fields in the UI but
|
|
// can receive sneaky markup from a paste; strip tags at the API
|
|
// boundary. Use stripTagsToPlainTextOrNull so URLs with `&` and
|
|
// notes with stray `<`/`>` round-trip without entity-encoding.
|
|
location: stripTagsToPlainTextOrNull(data.location),
|
|
meetingUrl: stripTagsToPlainTextOrNull(data.meetingUrl),
|
|
platform: data.platform ?? "none",
|
|
status: data.status ?? "scheduled",
|
|
isHighlighted: data.isHighlighted ?? 0,
|
|
notes: stripTagsToPlainTextOrNull(data.notes),
|
|
createdBy: userId,
|
|
updatedBy: userId,
|
|
};
|
|
const [meeting] = await tx
|
|
.insert(executiveMeetingsTable)
|
|
.values(meetingValues)
|
|
.returning();
|
|
if (!meeting) throw new Error("insert_failed");
|
|
const attendees = data.attendees ?? [];
|
|
if (attendees.length > 0) {
|
|
await tx.insert(executiveMeetingAttendeesTable).values(
|
|
attendees.map((a, idx) => ({
|
|
meetingId: meeting.id,
|
|
name: sanitizeRichText(a.name),
|
|
// title is plain text in the UI, but it gets interpolated
|
|
// into HTML by templates like the print page, so strip
|
|
// any sneaky markup at the API boundary.
|
|
title: sanitizePlainTextOrNull(a.title),
|
|
attendanceType: a.attendanceType,
|
|
sortOrder: a.sortOrder ?? idx,
|
|
kind: a.kind,
|
|
})),
|
|
);
|
|
}
|
|
await logAudit(tx, {
|
|
action: "create",
|
|
entityType: "meeting",
|
|
entityId: meeting.id,
|
|
newValue: { ...meeting, attendees },
|
|
performedBy: userId,
|
|
});
|
|
// #312: A new meeting with an explicit start_time arrives at the
|
|
// tail (nextDailyNumber appends), so the visible list is no
|
|
// longer chronological. Renumber 1..N by start_time so the row
|
|
// appears at its correct slot without requiring a manual drag.
|
|
// Cancelled rows go to the tail (renumberDayByStartTime handles
|
|
// that) so the per-day unique index stays satisfied. The newly
|
|
// INSERTed row has its own dailyNumber from `nextDailyNumber`,
|
|
// so the temporary positives-to-negatives sweep in step 1 picks
|
|
// it up too.
|
|
await renumberDayByStartTime(tx, data.meetingDate);
|
|
const approverIds = await getUserIdsForRoleNames(EM_ADMIN_ROLES);
|
|
const recipients = await recordExecutiveMeetingNotifications(tx, {
|
|
recipientUserIds: approverIds,
|
|
meetingId: meeting.id,
|
|
notificationType: "meeting_created",
|
|
titleAr: "اجتماع جديد",
|
|
titleEn: "New executive meeting",
|
|
bodyAr: meeting.titleAr || meeting.titleEn || "",
|
|
bodyEn: meeting.titleEn || meeting.titleAr || "",
|
|
relatedType: "executive_meeting",
|
|
relatedId: meeting.id,
|
|
excludeUserId: userId,
|
|
});
|
|
return { meeting, recipients };
|
|
});
|
|
void broadcastExecutiveMeetingNotifications(
|
|
inserted.recipients,
|
|
"meeting_created",
|
|
inserted.meeting.id,
|
|
);
|
|
// Realtime: every open schedule tab on this day refetches so the
|
|
// newly-created row shows up without a manual refresh.
|
|
void emitExecutiveMeetingsDayChanged(inserted.meeting.meetingDate);
|
|
const full = await fetchMeetingWithAttendees(inserted.meeting.id);
|
|
res.status(201).json(full);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
if (msg.includes("executive_meetings_date_number_unique")) {
|
|
res
|
|
.status(409)
|
|
.json({
|
|
error: "Daily number already used for this date",
|
|
code: "duplicate_number",
|
|
});
|
|
return;
|
|
}
|
|
res.status(500).json({ error: "Could not create meeting", code: "create_failed" });
|
|
}
|
|
},
|
|
);
|
|
|
|
router.patch(
|
|
"/executive-meetings/:id",
|
|
requireExecutiveAccess,
|
|
requireMutate,
|
|
async (req, res): Promise<void> => {
|
|
const id = Number(req.params.id);
|
|
if (!Number.isInteger(id) || id <= 0) {
|
|
res.status(404).json({ error: "Not found", code: "not_found" });
|
|
return;
|
|
}
|
|
const existing = await fetchMeetingWithAttendees(id);
|
|
if (!existing) {
|
|
res.status(404).json({ error: "Meeting not found", code: "not_found" });
|
|
return;
|
|
}
|
|
const data = parseBody(res, meetingPatchSchema, req.body);
|
|
if (!data) return;
|
|
const userId = req.session.userId!;
|
|
try {
|
|
await db.transaction(async (tx) => {
|
|
const updateValues: Partial<typeof executiveMeetingsTable.$inferInsert> = {
|
|
updatedBy: userId,
|
|
};
|
|
if (data.titleAr !== undefined)
|
|
updateValues.titleAr = sanitizeRichText(data.titleAr);
|
|
if (data.titleEn !== undefined)
|
|
updateValues.titleEn = sanitizeRichText(data.titleEn ?? "");
|
|
if (data.meetingDate !== undefined) updateValues.meetingDate = data.meetingDate;
|
|
if (data.dailyNumber !== undefined && data.dailyNumber !== null)
|
|
updateValues.dailyNumber = data.dailyNumber;
|
|
// #312: Cross-day move via PATCH meetingDate would otherwise
|
|
// keep the row's old daily_number, which collides with an
|
|
// existing row on the destination date under the
|
|
// `(meeting_date, daily_number)` unique index. Pre-allocate a
|
|
// fresh dailyNumber on the new date (max+1) so the UPDATE
|
|
// succeeds; renumberDayByStartTime then re-sorts the destination
|
|
// day chronologically a few lines down. We only do this when the
|
|
// caller did NOT pass an explicit dailyNumber — that path keeps
|
|
// its own semantics. Safe inside the transaction because
|
|
// nextDailyNumber reads through the same `tx` executor.
|
|
if (
|
|
data.meetingDate !== undefined &&
|
|
data.meetingDate !== existing.meetingDate &&
|
|
(data.dailyNumber === undefined || data.dailyNumber === null)
|
|
) {
|
|
updateValues.dailyNumber = await nextDailyNumber(tx, data.meetingDate);
|
|
}
|
|
if (data.startTime !== undefined) updateValues.startTime = data.startTime;
|
|
if (data.endTime !== undefined) updateValues.endTime = data.endTime;
|
|
// Plain-text fields: strip any HTML at the API boundary so a
|
|
// PATCH cannot smuggle markup that the POST path already rejects.
|
|
// Mirrors the create handler above and the attendee.title path.
|
|
if (data.location !== undefined)
|
|
updateValues.location = stripTagsToPlainTextOrNull(data.location);
|
|
if (data.meetingUrl !== undefined)
|
|
updateValues.meetingUrl = stripTagsToPlainTextOrNull(data.meetingUrl);
|
|
if (data.platform !== undefined) updateValues.platform = data.platform;
|
|
if (data.status !== undefined) updateValues.status = data.status;
|
|
if (data.isHighlighted !== undefined)
|
|
updateValues.isHighlighted = data.isHighlighted;
|
|
if (data.notes !== undefined)
|
|
updateValues.notes = stripTagsToPlainTextOrNull(data.notes);
|
|
if (data.merge !== undefined) {
|
|
if (data.merge === null) {
|
|
updateValues.mergeStartColumn = null;
|
|
updateValues.mergeEndColumn = null;
|
|
updateValues.mergeText = null;
|
|
} else {
|
|
updateValues.mergeStartColumn = data.merge.mergeStartColumn;
|
|
updateValues.mergeEndColumn = data.merge.mergeEndColumn;
|
|
updateValues.mergeText = sanitizeRichText(data.merge.mergeText);
|
|
}
|
|
}
|
|
// #288: rowColor is whitelisted upstream; null = clear back to
|
|
// default (no tint).
|
|
if (data.rowColor !== undefined) {
|
|
updateValues.rowColor = data.rowColor;
|
|
}
|
|
|
|
if (Object.keys(updateValues).length > 1) {
|
|
await tx
|
|
.update(executiveMeetingsTable)
|
|
.set(updateValues)
|
|
.where(eq(executiveMeetingsTable.id, id));
|
|
}
|
|
let attendees = existing.attendees;
|
|
if (data.attendees !== undefined) {
|
|
await tx
|
|
.delete(executiveMeetingAttendeesTable)
|
|
.where(eq(executiveMeetingAttendeesTable.meetingId, id));
|
|
if (data.attendees.length > 0) {
|
|
await tx.insert(executiveMeetingAttendeesTable).values(
|
|
data.attendees.map((a, idx) => ({
|
|
meetingId: id,
|
|
name: sanitizeRichText(a.name),
|
|
title: sanitizePlainTextOrNull(a.title),
|
|
attendanceType: a.attendanceType,
|
|
sortOrder: a.sortOrder ?? idx,
|
|
kind: a.kind,
|
|
})),
|
|
);
|
|
}
|
|
attendees = (await db
|
|
.select()
|
|
.from(executiveMeetingAttendeesTable)
|
|
.where(eq(executiveMeetingAttendeesTable.meetingId, id))
|
|
.orderBy(asc(executiveMeetingAttendeesTable.sortOrder)));
|
|
}
|
|
// #312: Auto-sort the day(s) chronologically when the PATCH
|
|
// touched a field that affects ordering or visibility. The user's
|
|
// bug report — typing 13:00 on a row that sits above a 12:00 row
|
|
// — is exactly this: a startTime PATCH leaves dailyNumber
|
|
// untouched and the row stays in its old position even though
|
|
// the time changed. Renumbering by start_time after the UPDATE
|
|
// makes the visible list strictly chronological without forcing
|
|
// the user to drag the row. We trigger on:
|
|
// - startTime / endTime: the obvious case the user reported.
|
|
// - meetingDate: the row moved to a different day; both old
|
|
// and new days need to renumber so the row leaves one
|
|
// timeline cleanly and arrives in chronological order on
|
|
// the other.
|
|
// - status: cancel/uncancel changes which rows are "visible"
|
|
// (cancelled rows go to the tail per renumberDayByStartTime),
|
|
// so the visible 1..N sequence must be recomputed.
|
|
// - dailyNumber: a manual override; we still renumber so the
|
|
// final state stays chronological (auto-sort wins over a
|
|
// stray manual number coming from non-reorder paths).
|
|
// We run renumber BEFORE logAudit so the audit row can carry
|
|
// the post-renumber `dailyNumber` and an `orderShifted` flag —
|
|
// otherwise an admin reading the audit cannot tell that editing
|
|
// a meeting's start time also moved its row to a new position.
|
|
const orderAffectingChange =
|
|
data.startTime !== undefined ||
|
|
data.endTime !== undefined ||
|
|
data.meetingDate !== undefined ||
|
|
data.status !== undefined ||
|
|
data.dailyNumber !== undefined;
|
|
let primaryShift: DayOrderShift | null = null;
|
|
let sourceShift: DayOrderShift | null = null;
|
|
let finalDailyNumber: number | null = null;
|
|
if (orderAffectingChange) {
|
|
const newDate = updateValues.meetingDate ?? existing.meetingDate;
|
|
primaryShift = await renumberDayByStartTime(tx, newDate);
|
|
if (newDate !== existing.meetingDate) {
|
|
// Cross-day move: the source day also needs to close ranks
|
|
// so its 1..N sequence stays gap-free.
|
|
sourceShift = await renumberDayByStartTime(tx, existing.meetingDate);
|
|
}
|
|
// Re-read this row's daily_number after auto-sort so the
|
|
// audit's newValue echoes the position the user actually
|
|
// sees, not the pre-renumber draft from `updateValues`.
|
|
const [row] = await tx
|
|
.select({ dailyNumber: executiveMeetingsTable.dailyNumber })
|
|
.from(executiveMeetingsTable)
|
|
.where(eq(executiveMeetingsTable.id, id));
|
|
finalDailyNumber = row?.dailyNumber ?? null;
|
|
}
|
|
// Build the audit's newValue. When auto-sort moved this row, we
|
|
// surface (a) the row's final dailyNumber and (b) an
|
|
// `orderShifted` flag with `dayOrder` before/after arrays so an
|
|
// admin can read "the row jumped from #1 to #3 because the day
|
|
// re-sorted". `dayOrder` carries the visible (non-cancelled) ID
|
|
// sequence for the affected day(s).
|
|
const auditNewValue: Record<string, unknown> = {
|
|
...existing,
|
|
...updateValues,
|
|
attendees,
|
|
};
|
|
if (finalDailyNumber !== null) {
|
|
auditNewValue.dailyNumber = finalDailyNumber;
|
|
}
|
|
const shiftsForAudit: Array<{
|
|
date: string;
|
|
before: number[];
|
|
after: number[];
|
|
}> = [];
|
|
if (primaryShift && primaryShift.orderShifted) {
|
|
shiftsForAudit.push({
|
|
date: primaryShift.date,
|
|
before: primaryShift.before,
|
|
after: primaryShift.after,
|
|
});
|
|
}
|
|
if (sourceShift && sourceShift.orderShifted) {
|
|
shiftsForAudit.push({
|
|
date: sourceShift.date,
|
|
before: sourceShift.before,
|
|
after: sourceShift.after,
|
|
});
|
|
}
|
|
if (shiftsForAudit.length > 0) {
|
|
auditNewValue.orderShifted = true;
|
|
auditNewValue.dayOrder = shiftsForAudit;
|
|
}
|
|
await logAudit(tx, {
|
|
action: "update",
|
|
entityType: "meeting",
|
|
entityId: id,
|
|
oldValue: existing,
|
|
newValue: auditNewValue,
|
|
performedBy: userId,
|
|
});
|
|
});
|
|
const updated = await fetchMeetingWithAttendees(id);
|
|
// Realtime: when the meeting was rescheduled to a different day we
|
|
// need to refetch BOTH days — viewers on the old day must lose the
|
|
// row, viewers on the new day must gain it. The dedup helper is a
|
|
// no-op when both dates match (the common in-place edit case).
|
|
void emitExecutiveMeetingsDaysChanged([
|
|
existing.meetingDate,
|
|
updated?.meetingDate ?? existing.meetingDate,
|
|
]);
|
|
res.json(updated);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
if (msg.includes("executive_meetings_date_number_unique")) {
|
|
res
|
|
.status(409)
|
|
.json({
|
|
error: "Daily number already used for this date",
|
|
code: "duplicate_number",
|
|
});
|
|
return;
|
|
}
|
|
res.status(500).json({ error: "Could not update meeting", code: "update_failed" });
|
|
}
|
|
},
|
|
);
|
|
|
|
// #273: ---- Upcoming-meeting alert (5-minute pre-alarm) endpoints ----
|
|
|
|
// Parse "HH:MM" or "HH:MM:SS" -> minutes-of-day. Returns null on missing/invalid.
|
|
function parseTimeToMinutes(t: string | null | undefined): number | null {
|
|
if (!t) return null;
|
|
const m = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(t);
|
|
if (!m) return null;
|
|
const h = Number(m[1]);
|
|
const mm = Number(m[2]);
|
|
if (h < 0 || h > 23 || mm < 0 || mm > 59) return null;
|
|
return h * 60 + mm;
|
|
}
|
|
function formatMinutesAsTime(mins: number): string {
|
|
const safe = ((mins % (24 * 60)) + 24 * 60) % (24 * 60);
|
|
const h = Math.floor(safe / 60);
|
|
const m = safe % 60;
|
|
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:00`;
|
|
}
|
|
// #273: pass a transaction-like executor so the conflict scan reads inside
|
|
// the same DB snapshot as the UPDATE that just shifted the row.
|
|
async function detectMeetingConflicts(
|
|
exec: typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0],
|
|
date: string,
|
|
excludeId: number,
|
|
startTime: string,
|
|
endTime: string,
|
|
): Promise<boolean> {
|
|
const newStart = parseTimeToMinutes(startTime);
|
|
const newEnd = parseTimeToMinutes(endTime);
|
|
if (newStart == null || newEnd == null) return false;
|
|
const others = await exec
|
|
.select({
|
|
id: executiveMeetingsTable.id,
|
|
start: executiveMeetingsTable.startTime,
|
|
end: executiveMeetingsTable.endTime,
|
|
status: executiveMeetingsTable.status,
|
|
})
|
|
.from(executiveMeetingsTable)
|
|
.where(eq(executiveMeetingsTable.meetingDate, date));
|
|
for (const o of others) {
|
|
if (o.id === excludeId) continue;
|
|
if (o.status === "cancelled" || o.status === "completed") continue;
|
|
const s = parseTimeToMinutes(o.start);
|
|
const e = parseTimeToMinutes(o.end);
|
|
if (s == null || e == null) continue;
|
|
if (s < newEnd && e > newStart) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// #302: Cascade-shift helpers for postpone/reschedule.
|
|
//
|
|
// Implementation note (per task plan, step 3): we expose a *separate*
|
|
// `POST /executive-meetings/:id/cascade-preview` endpoint instead of
|
|
// piggy-backing on the existing GET-by-day response. The preview is
|
|
// (a) parameterized by `deltaMinutes` (which the GET response does not
|
|
// know) and (b) only consulted by the postpone dialog at the moment
|
|
// the user picks a delta — keeping it out of the day fetch avoids
|
|
// pushing `deltaMinutes` math into every schedule render. The shape
|
|
// returned mirrors the candidate followers that the same-named cascade
|
|
// branch on postpone/reschedule will actually shift, so the dialog and
|
|
// the writer agree on the affected set.
|
|
//
|
|
// "Later" = `start_time >= original end_time` of the primary meeting,
|
|
// matching the plan's contract. Cancelled and completed meetings are
|
|
// skipped on both the preview and the writer paths so they stay in
|
|
// lockstep — a follower the user did not see in the preview will not
|
|
// be shifted by the writer.
|
|
|
|
type CascadeFollower = {
|
|
id: number;
|
|
titleAr: string;
|
|
titleEn: string;
|
|
oldStart: string;
|
|
oldEnd: string;
|
|
newStart: string;
|
|
newEnd: string;
|
|
};
|
|
|
|
type CascadeResult =
|
|
| { ok: true; followers: CascadeFollower[] }
|
|
| {
|
|
ok: false;
|
|
blockedBy: {
|
|
id: number;
|
|
titleAr: string;
|
|
titleEn: string;
|
|
projectedStart: string;
|
|
projectedEnd: string;
|
|
};
|
|
};
|
|
|
|
// Compute the candidate followers and their projected new times for a
|
|
// uniform shift by `deltaMinutes`. Returns either `ok: true` with the
|
|
// shifted list, or `ok: false` with the first follower (in start-time
|
|
// order) that would cross midnight — both endpoints (preview + writer)
|
|
// surface that meeting verbatim so the UI can name it in the error.
|
|
async function computeCascadeShift(
|
|
executor: DbExecutor,
|
|
date: string,
|
|
primaryId: number,
|
|
// `startTime`/`endTime` columns are nullable in the schema (an
|
|
// unscheduled placeholder meeting can have neither). The cascade
|
|
// contract talks about times that exist, so we just no-op when the
|
|
// primary has no end time — the writer paths only ever pass
|
|
// `locked.endTime` when scheduling exists, but we accept nullable
|
|
// here so the preview endpoint doesn't have to pre-narrow.
|
|
originalEndTime: string | null,
|
|
deltaMinutes: number,
|
|
// #302: when called from inside a writer transaction (postpone or
|
|
// reschedule), set `lockRows: true` so the candidate followers are
|
|
// selected `FOR UPDATE`. This serializes the read against any
|
|
// concurrent mutation of those follower rows, preventing lost
|
|
// updates and ensuring the audit `oldStart/oldEnd` always reflect
|
|
// the locked-current values at commit time. The cascade-preview
|
|
// endpoint runs outside a transaction and passes `lockRows: false`
|
|
// (a read-only snapshot is fine for a preview).
|
|
lockRows = false,
|
|
): Promise<CascadeResult> {
|
|
if (originalEndTime == null) return { ok: true, followers: [] };
|
|
const baselineEndMin = parseTimeToMinutes(originalEndTime);
|
|
if (baselineEndMin == null || !Number.isFinite(deltaMinutes)) {
|
|
return { ok: true, followers: [] };
|
|
}
|
|
const baseQuery = executor
|
|
.select({
|
|
id: executiveMeetingsTable.id,
|
|
titleAr: executiveMeetingsTable.titleAr,
|
|
titleEn: executiveMeetingsTable.titleEn,
|
|
startTime: executiveMeetingsTable.startTime,
|
|
endTime: executiveMeetingsTable.endTime,
|
|
status: executiveMeetingsTable.status,
|
|
})
|
|
.from(executiveMeetingsTable)
|
|
.where(eq(executiveMeetingsTable.meetingDate, date));
|
|
const candidates = lockRows
|
|
? await baseQuery.for("update")
|
|
: await baseQuery;
|
|
// Sort in start-time order so the "first follower that crosses
|
|
// midnight" naming is stable and matches what the user sees.
|
|
const followers: CascadeFollower[] = [];
|
|
const sorted = candidates
|
|
.filter((c) => c.id !== primaryId)
|
|
.filter((c) => c.status !== "cancelled" && c.status !== "completed")
|
|
.map((c) => ({
|
|
...c,
|
|
_startMin: parseTimeToMinutes(c.startTime),
|
|
_endMin: parseTimeToMinutes(c.endTime),
|
|
}))
|
|
.filter(
|
|
(c): c is typeof c & { _startMin: number; _endMin: number } =>
|
|
c._startMin != null &&
|
|
c._endMin != null &&
|
|
c._startMin >= baselineEndMin,
|
|
)
|
|
.sort((a, b) => a._startMin - b._startMin || a.id - b.id);
|
|
for (const c of sorted) {
|
|
const newStartMin = c._startMin + deltaMinutes;
|
|
const newEndMin = c._endMin + deltaMinutes;
|
|
// Same midnight rule as `postpone-minutes` on the primary meeting:
|
|
// 24:00 (1440) is treated as crossing the day boundary, so the
|
|
// guard is `>=`. This keeps the preview, writer, and the existing
|
|
// primary-meeting validation aligned.
|
|
if (newStartMin >= 24 * 60 || newEndMin >= 24 * 60) {
|
|
return {
|
|
ok: false,
|
|
blockedBy: {
|
|
id: c.id,
|
|
titleAr: c.titleAr,
|
|
titleEn: c.titleEn,
|
|
projectedStart: formatMinutesAsTime(newStartMin),
|
|
projectedEnd: formatMinutesAsTime(newEndMin),
|
|
},
|
|
};
|
|
}
|
|
followers.push({
|
|
id: c.id,
|
|
titleAr: c.titleAr,
|
|
titleEn: c.titleEn,
|
|
// `c.startTime`/`endTime` are non-null here — the filter above
|
|
// dropped any rows whose `parseTimeToMinutes(...)` returned null,
|
|
// which is the only way the column-level nullables survive. TS
|
|
// can't narrow the source string from the parsed-int, so we
|
|
// re-format the minute counters back into "HH:MM" strings, which
|
|
// also normalizes any legacy "HH:MM:SS" values from the DB.
|
|
oldStart: formatMinutesAsTime(c._startMin),
|
|
oldEnd: formatMinutesAsTime(c._endMin),
|
|
newStart: formatMinutesAsTime(newStartMin),
|
|
newEnd: formatMinutesAsTime(newEndMin),
|
|
});
|
|
}
|
|
return { ok: true, followers };
|
|
}
|
|
|
|
// Persist a previously-computed cascade. The caller has already
|
|
// verified midnight boundaries via `computeCascadeShift`. Each
|
|
// follower gets one `meeting_cascade_shift` audit row that names the
|
|
// trigger meeting + delta so the audit can be replayed later.
|
|
async function applyCascadeShift(
|
|
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
|
|
followers: CascadeFollower[],
|
|
triggerMeetingId: number,
|
|
deltaMinutes: number,
|
|
performedBy: number,
|
|
): Promise<void> {
|
|
for (const f of followers) {
|
|
await tx
|
|
.update(executiveMeetingsTable)
|
|
.set({
|
|
startTime: f.newStart,
|
|
endTime: f.newEnd,
|
|
// #302: stamp the cascade actor so a subsequent stale_meeting
|
|
// check on the follower attributes the change correctly.
|
|
updatedBy: performedBy,
|
|
})
|
|
.where(eq(executiveMeetingsTable.id, f.id));
|
|
await logAudit(tx, {
|
|
action: "meeting_cascade_shift",
|
|
entityType: "meeting",
|
|
entityId: f.id,
|
|
oldValue: {
|
|
startTime: f.oldStart,
|
|
endTime: f.oldEnd,
|
|
},
|
|
newValue: {
|
|
startTime: f.newStart,
|
|
endTime: f.newEnd,
|
|
triggerMeetingId,
|
|
deltaMinutes,
|
|
},
|
|
performedBy,
|
|
});
|
|
}
|
|
}
|
|
|
|
const cascadePreviewSchema = z.object({
|
|
deltaMinutes: z.number().int().min(1).max(24 * 60),
|
|
});
|
|
|
|
const alertActionSchema = z.object({
|
|
action: z.enum(["shown", "acknowledged", "dismissed"]),
|
|
});
|
|
const postponeMinutesSchema = z.object({
|
|
minutes: z.number().int().min(1).max(24 * 60),
|
|
// #283: optimistic-locking token. The client must send the
|
|
// `updatedAt` it last observed for this meeting; if another user
|
|
// (or the same user from another tab) mutated the meeting in
|
|
// between, we reject with HTTP 409 + stale_meeting so the UI can
|
|
// surface "already postponed by X" instead of double-shifting.
|
|
// Required by design — there is no opt-out path. To "apply anyway"
|
|
// after a conflict, the client refetches (or reuses
|
|
// conflict.lastModifiedAt) and resubmits with the fresher token.
|
|
expectedUpdatedAt: z.string().datetime(),
|
|
// #302: opt-in flag for "also shift every later active meeting on
|
|
// this day by the same delta". Defaults to false (today's behavior:
|
|
// only the primary meeting moves) so older clients without the new
|
|
// confirmation step keep working unchanged.
|
|
cascadeFollowing: z.boolean().optional().default(false),
|
|
});
|
|
const rescheduleSchema = z.object({
|
|
meetingDate: dateSchema,
|
|
startTime: timeSchema,
|
|
endTime: timeSchema,
|
|
note: z.string().max(500).optional(),
|
|
// #302: same opt-in cascade flag as postpone-minutes. Cascading is
|
|
// only applied when the meeting stays on the same date AND the
|
|
// delta = newStart - oldStart is positive; otherwise the flag is
|
|
// ignored (no rejection) so existing clients keep the same shape.
|
|
cascadeFollowing: z.boolean().optional().default(false),
|
|
});
|
|
|
|
router.get(
|
|
"/executive-meetings/alert-state",
|
|
requireExecutiveAccess,
|
|
async (req, res): Promise<void> => {
|
|
if (!req.session.userId) {
|
|
res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
|
|
return;
|
|
}
|
|
const dateRaw = String(req.query.date ?? "");
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateRaw)) {
|
|
res.status(400).json({ error: "Invalid date", code: "invalid_date" });
|
|
return;
|
|
}
|
|
// We deliberately do NOT pin `date` to the server's wall-clock
|
|
// "today" here: the frontend computes today from the user's browser
|
|
// local time, and the API server may be running in a different
|
|
// timezone (typically UTC) so a server-side equality check would
|
|
// reject perfectly valid requests near midnight in non-UTC user
|
|
// locales. Authorization is enforced via `requireExecutiveAccess`
|
|
// and the `userId` filter below — every returned row already
|
|
// belongs to the calling user.
|
|
const userId = req.session.userId;
|
|
const rows = await db
|
|
.select({
|
|
meetingId: executiveMeetingAlertStateTable.meetingId,
|
|
dismissed: executiveMeetingAlertStateTable.dismissed,
|
|
acknowledged: executiveMeetingAlertStateTable.acknowledged,
|
|
})
|
|
.from(executiveMeetingAlertStateTable)
|
|
.innerJoin(
|
|
executiveMeetingsTable,
|
|
eq(executiveMeetingsTable.id, executiveMeetingAlertStateTable.meetingId),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(executiveMeetingAlertStateTable.userId, userId),
|
|
eq(executiveMeetingsTable.meetingDate, dateRaw),
|
|
),
|
|
);
|
|
res.json({ states: rows });
|
|
},
|
|
);
|
|
|
|
router.post(
|
|
"/executive-meetings/:id/alert-state",
|
|
requireExecutiveAccess,
|
|
async (req, res): Promise<void> => {
|
|
if (!req.session.userId) {
|
|
res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
|
|
return;
|
|
}
|
|
const id = Number(req.params.id);
|
|
if (!Number.isInteger(id) || id <= 0) {
|
|
res.status(404).json({ error: "Not found", code: "not_found" });
|
|
return;
|
|
}
|
|
const body = parseBody(res, alertActionSchema, req.body);
|
|
if (!body) return;
|
|
const meeting = await fetchMeetingWithAttendees(id);
|
|
if (!meeting) {
|
|
res.status(404).json({ error: "Meeting not found", code: "not_found" });
|
|
return;
|
|
}
|
|
const userId = req.session.userId;
|
|
// #277: Track whether this request actually transitioned the per-user
|
|
// row so we only emit a realtime event on real state changes — keeps
|
|
// the cross-tab signal noise-free when two tabs POST the same action.
|
|
let stateChanged = false;
|
|
await db.transaction(async (tx) => {
|
|
// Race-safe upsert: when two tabs surface the same alert at once they
|
|
// both POST {action:"shown"}; using ON CONFLICT DO NOTHING means only
|
|
// one INSERT actually creates the row, so we audit "shown" once.
|
|
const inserted = await tx
|
|
.insert(executiveMeetingAlertStateTable)
|
|
.values({
|
|
meetingId: id,
|
|
userId,
|
|
dismissed: body.action === "dismissed",
|
|
acknowledged: body.action === "acknowledged",
|
|
})
|
|
.onConflictDoNothing({
|
|
target: [
|
|
executiveMeetingAlertStateTable.meetingId,
|
|
executiveMeetingAlertStateTable.userId,
|
|
],
|
|
})
|
|
.returning({ id: executiveMeetingAlertStateTable.id });
|
|
|
|
if (inserted.length > 0) {
|
|
await logAudit(tx, {
|
|
action: "meeting_alert_shown",
|
|
entityType: "meeting",
|
|
entityId: id,
|
|
newValue: { userId },
|
|
performedBy: userId,
|
|
});
|
|
if (body.action === "acknowledged") {
|
|
await logAudit(tx, {
|
|
action: "meeting_alert_acknowledged",
|
|
entityType: "meeting",
|
|
entityId: id,
|
|
newValue: { userId },
|
|
performedBy: userId,
|
|
});
|
|
stateChanged = true;
|
|
} else if (body.action === "dismissed") {
|
|
await logAudit(tx, {
|
|
action: "meeting_alert_dismissed",
|
|
entityType: "meeting",
|
|
entityId: id,
|
|
newValue: { userId },
|
|
performedBy: userId,
|
|
});
|
|
stateChanged = true;
|
|
}
|
|
return;
|
|
}
|
|
// Row exists. Use a conditional UPDATE so concurrent clicks across
|
|
// tabs only fire one audit row per real transition (false -> true).
|
|
if (body.action === "acknowledged") {
|
|
const upd = await tx
|
|
.update(executiveMeetingAlertStateTable)
|
|
.set({ acknowledged: true })
|
|
.where(
|
|
and(
|
|
eq(executiveMeetingAlertStateTable.meetingId, id),
|
|
eq(executiveMeetingAlertStateTable.userId, userId),
|
|
eq(executiveMeetingAlertStateTable.acknowledged, false),
|
|
),
|
|
)
|
|
.returning({ id: executiveMeetingAlertStateTable.id });
|
|
if (upd.length > 0) {
|
|
await logAudit(tx, {
|
|
action: "meeting_alert_acknowledged",
|
|
entityType: "meeting",
|
|
entityId: id,
|
|
newValue: { userId },
|
|
performedBy: userId,
|
|
});
|
|
stateChanged = true;
|
|
}
|
|
} else if (body.action === "dismissed") {
|
|
const upd = await tx
|
|
.update(executiveMeetingAlertStateTable)
|
|
.set({ dismissed: true })
|
|
.where(
|
|
and(
|
|
eq(executiveMeetingAlertStateTable.meetingId, id),
|
|
eq(executiveMeetingAlertStateTable.userId, userId),
|
|
eq(executiveMeetingAlertStateTable.dismissed, false),
|
|
),
|
|
)
|
|
.returning({ id: executiveMeetingAlertStateTable.id });
|
|
if (upd.length > 0) {
|
|
await logAudit(tx, {
|
|
action: "meeting_alert_dismissed",
|
|
entityType: "meeting",
|
|
entityId: id,
|
|
newValue: { userId },
|
|
performedBy: userId,
|
|
});
|
|
stateChanged = true;
|
|
}
|
|
}
|
|
});
|
|
// #277: Push the new alert state to *only* the acting user's sockets
|
|
// so all of their open tabs / devices clear the alert within ~1s
|
|
// instead of waiting on the 30s poll. Other users are unaffected.
|
|
if (stateChanged) {
|
|
void emitExecutiveMeetingAlertStateChanged(userId, {
|
|
meetingId: id,
|
|
dismissed: body.action === "dismissed",
|
|
acknowledged: body.action === "acknowledged",
|
|
});
|
|
}
|
|
res.json({ ok: true });
|
|
},
|
|
);
|
|
|
|
router.post(
|
|
"/executive-meetings/:id/cascade-preview",
|
|
requireExecutiveAccess,
|
|
// #302: read-only — gated on executive access only. The dialog calls
|
|
// this on every delta change, including for users who can view but
|
|
// not mutate; gating on requireMutate would gray out the cascade
|
|
// checkbox for them and silently change the prompt copy.
|
|
async (req, res): Promise<void> => {
|
|
const id = Number(req.params.id);
|
|
if (!Number.isInteger(id) || id <= 0) {
|
|
res.status(404).json({ error: "Not found", code: "not_found" });
|
|
return;
|
|
}
|
|
const body = parseBody(res, cascadePreviewSchema, req.body);
|
|
if (!body) return;
|
|
const [primary] = await db
|
|
.select({
|
|
id: executiveMeetingsTable.id,
|
|
meetingDate: executiveMeetingsTable.meetingDate,
|
|
endTime: executiveMeetingsTable.endTime,
|
|
})
|
|
.from(executiveMeetingsTable)
|
|
.where(eq(executiveMeetingsTable.id, id));
|
|
if (!primary) {
|
|
res.status(404).json({ error: "Meeting not found", code: "not_found" });
|
|
return;
|
|
}
|
|
const cascade = await computeCascadeShift(
|
|
db,
|
|
primary.meetingDate,
|
|
id,
|
|
primary.endTime,
|
|
body.deltaMinutes,
|
|
);
|
|
if (!cascade.ok) {
|
|
// Match the writer's blocked shape so the dialog can render the
|
|
// same error UI whether it learned about midnight at preview time
|
|
// or at submit time (e.g. another user added a late meeting in
|
|
// between).
|
|
res.json({ followers: [], blockedBy: cascade.blockedBy });
|
|
return;
|
|
}
|
|
res.json({ followers: cascade.followers, blockedBy: null });
|
|
},
|
|
);
|
|
|
|
router.post(
|
|
"/executive-meetings/:id/postpone-minutes",
|
|
requireExecutiveAccess,
|
|
requireMutate,
|
|
async (req, res): Promise<void> => {
|
|
const id = Number(req.params.id);
|
|
if (!Number.isInteger(id) || id <= 0) {
|
|
res.status(404).json({ error: "Not found", code: "not_found" });
|
|
return;
|
|
}
|
|
const body = parseBody(res, postponeMinutesSchema, req.body);
|
|
if (!body) return;
|
|
const userId = req.session.userId!;
|
|
// #273: lock-then-mutate inside a single transaction so concurrent
|
|
// postpone/reschedule/cancel requests serialize and audit logs reflect
|
|
// the actual committed transition.
|
|
type StaleConflict = {
|
|
ok: false;
|
|
status: 409;
|
|
error: string;
|
|
code: "stale_meeting";
|
|
conflict: {
|
|
currentStartTime: string | null;
|
|
currentEndTime: string | null;
|
|
currentStatus: string;
|
|
lastModifiedAt: string;
|
|
lastActor: {
|
|
id: number | null;
|
|
username: string | null;
|
|
displayNameAr: string | null;
|
|
displayNameEn: string | null;
|
|
} | null;
|
|
};
|
|
};
|
|
// #302: success path may now also report how many followers were
|
|
// cascade-shifted so the client can show "Postponed + shifted N
|
|
// following meetings" without a follow-up fetch. The new failure
|
|
// shape `cascade_crosses_midnight` carries the offending meeting
|
|
// so the UI can name it in the error block.
|
|
type CascadeBlock = {
|
|
ok: false;
|
|
status: 400;
|
|
error: string;
|
|
code: "cascade_crosses_midnight";
|
|
blockedBy: {
|
|
id: number;
|
|
titleAr: string;
|
|
titleEn: string;
|
|
projectedStart: string;
|
|
projectedEnd: string;
|
|
};
|
|
};
|
|
type TxResult =
|
|
| {
|
|
ok: true;
|
|
meetingDate: string;
|
|
newStart: string;
|
|
newEnd: string;
|
|
conflicts: boolean;
|
|
cascadedIds: number[];
|
|
}
|
|
| { ok: false; status: number; error: string; code: string }
|
|
| StaleConflict
|
|
| CascadeBlock;
|
|
const txResult = await db.transaction(async (tx): Promise<TxResult> => {
|
|
const [locked] = await tx
|
|
.select()
|
|
.from(executiveMeetingsTable)
|
|
.where(eq(executiveMeetingsTable.id, id))
|
|
.for("update");
|
|
if (!locked) {
|
|
return { ok: false, status: 404, error: "Meeting not found", code: "not_found" };
|
|
}
|
|
// #283: Optimistic-locking guard. The client always sends the
|
|
// `updatedAt` it last observed; if the row has been modified
|
|
// since, refuse to mutate and return the current state + the
|
|
// last actor's display info so the UI can show
|
|
// "Already postponed by Ahmed at 10:05 — apply N more minutes?"
|
|
// We do this *inside* the FOR UPDATE lock so concurrent
|
|
// postpone-minutes calls are serialized: the second one sees the
|
|
// first one's committed update and returns 409. There is no
|
|
// opt-out — to apply after a conflict, the client must resubmit
|
|
// with the freshly-seen `lastModifiedAt`, so a third concurrent
|
|
// mutation between conflict-display and apply-anyway will trigger
|
|
// another 409 instead of silently stacking.
|
|
const observedIso = new Date(body.expectedUpdatedAt).toISOString();
|
|
const currentIso = locked.updatedAt.toISOString();
|
|
if (observedIso !== currentIso) {
|
|
let lastActor: StaleConflict["conflict"]["lastActor"] = null;
|
|
if (locked.updatedBy != null) {
|
|
const [u] = await tx
|
|
.select({
|
|
id: usersTable.id,
|
|
username: usersTable.username,
|
|
displayNameAr: usersTable.displayNameAr,
|
|
displayNameEn: usersTable.displayNameEn,
|
|
})
|
|
.from(usersTable)
|
|
.where(eq(usersTable.id, locked.updatedBy));
|
|
lastActor = u ?? null;
|
|
}
|
|
return {
|
|
ok: false,
|
|
status: 409,
|
|
error: "Meeting was modified by someone else",
|
|
code: "stale_meeting",
|
|
conflict: {
|
|
currentStartTime: locked.startTime,
|
|
currentEndTime: locked.endTime,
|
|
currentStatus: locked.status,
|
|
lastModifiedAt: currentIso,
|
|
lastActor,
|
|
},
|
|
};
|
|
}
|
|
const startMin = parseTimeToMinutes(locked.startTime);
|
|
const endMin = parseTimeToMinutes(locked.endTime);
|
|
if (startMin == null || endMin == null) {
|
|
return {
|
|
ok: false,
|
|
status: 400,
|
|
error: "Meeting has no scheduled time to postpone",
|
|
code: "no_time_window",
|
|
};
|
|
}
|
|
if (
|
|
startMin + body.minutes >= 24 * 60 ||
|
|
// Treat 24:00 (1440) the same as 00:00 the next day — both wrap
|
|
// the day boundary, so the end-time guard is `>=`, matching the
|
|
// start-time guard.
|
|
endMin + body.minutes >= 24 * 60
|
|
) {
|
|
return {
|
|
ok: false,
|
|
status: 400,
|
|
error: "Postponing by that many minutes would cross midnight",
|
|
code: "crosses_midnight",
|
|
};
|
|
}
|
|
const newStart = formatMinutesAsTime(startMin + body.minutes);
|
|
const newEnd = formatMinutesAsTime(endMin + body.minutes);
|
|
// #302: When the client opted in to cascading, compute the
|
|
// followers *before* updating anything so we can short-circuit
|
|
// (and roll the whole transaction back) if any of them would
|
|
// cross midnight. Crucially we use `locked.endTime` — the
|
|
// primary meeting's *original* end time — as the baseline so
|
|
// the follower selection matches the preview the user just
|
|
// confirmed in the dialog.
|
|
let cascadedIds: number[] = [];
|
|
if (body.cascadeFollowing) {
|
|
const cascade = await computeCascadeShift(
|
|
tx,
|
|
locked.meetingDate,
|
|
id,
|
|
locked.endTime,
|
|
body.minutes,
|
|
// #302: lock follower rows FOR UPDATE inside the writer tx
|
|
// so concurrent mutations can't sneak between read and
|
|
// write, and so audit oldStart/oldEnd reflects the locked
|
|
// current values at commit time.
|
|
true,
|
|
);
|
|
if (!cascade.ok) {
|
|
return {
|
|
ok: false,
|
|
status: 400,
|
|
error: "Cascading would push a follower past midnight",
|
|
code: "cascade_crosses_midnight",
|
|
blockedBy: cascade.blockedBy,
|
|
};
|
|
}
|
|
cascadedIds = cascade.followers.map((f) => f.id);
|
|
// The primary update happens *after* the cascade preview but
|
|
// *before* the cascade UPDATEs so that detect-conflicts and
|
|
// renumbering see the full final state. All UPDATEs are in
|
|
// the same FOR UPDATE-locked transaction — `locked` was
|
|
// selected for update at the top, and the followers are
|
|
// disjoint from the primary by id.
|
|
await applyCascadeShift(tx, cascade.followers, id, body.minutes, userId);
|
|
}
|
|
await tx
|
|
.update(executiveMeetingsTable)
|
|
.set({
|
|
startTime: newStart,
|
|
endTime: newEnd,
|
|
status: "postponed",
|
|
// #283: stamp the actor so the next stale_meeting reply can
|
|
// attribute "postponed by X" to a real user.
|
|
updatedBy: userId,
|
|
})
|
|
.where(eq(executiveMeetingsTable.id, id));
|
|
await logAudit(tx, {
|
|
action: "meeting_postponed_minutes",
|
|
entityType: "meeting",
|
|
entityId: id,
|
|
oldValue: {
|
|
startTime: locked.startTime,
|
|
endTime: locked.endTime,
|
|
status: locked.status,
|
|
},
|
|
newValue: {
|
|
startTime: newStart,
|
|
endTime: newEnd,
|
|
status: "postponed",
|
|
minutes: body.minutes,
|
|
// #302: surface the cascade decision in the primary audit
|
|
// entry so the History view can group them together. The
|
|
// followers also each get their own `meeting_cascade_shift`
|
|
// row via `applyCascadeShift`.
|
|
cascadedFollowerIds: cascadedIds,
|
|
},
|
|
performedBy: userId,
|
|
});
|
|
const conflicts = await detectMeetingConflicts(
|
|
tx,
|
|
locked.meetingDate,
|
|
id,
|
|
newStart,
|
|
newEnd,
|
|
);
|
|
// #273: re-sort the day's `#` so the timeline reflects the new start.
|
|
await renumberDayByStartTime(tx, locked.meetingDate);
|
|
return {
|
|
ok: true,
|
|
meetingDate: locked.meetingDate,
|
|
newStart,
|
|
newEnd,
|
|
conflicts,
|
|
cascadedIds,
|
|
};
|
|
});
|
|
if (!txResult.ok) {
|
|
// #283: Stale-conflict responses carry an extra `conflict` payload
|
|
// (current state + last actor display info) so the UI can render
|
|
// a "tried to postpone but X already postponed it to 10:05" prompt
|
|
// instead of the generic toast.
|
|
// #302: cascade_crosses_midnight responses similarly carry a
|
|
// `blockedBy` payload naming the offending follower so the dialog
|
|
// can offer "back / keep their times" instead of a generic toast.
|
|
const payload: Record<string, unknown> = {
|
|
error: txResult.error,
|
|
code: txResult.code,
|
|
};
|
|
if (txResult.code === "stale_meeting" && "conflict" in txResult) {
|
|
payload.conflict = txResult.conflict;
|
|
}
|
|
if (
|
|
txResult.code === "cascade_crosses_midnight" &&
|
|
"blockedBy" in txResult
|
|
) {
|
|
payload.blockedBy = txResult.blockedBy;
|
|
}
|
|
res.status(txResult.status).json(payload);
|
|
return;
|
|
}
|
|
// #302: per-day realtime emit already covers every cascade-shifted
|
|
// meeting because they all live on `txResult.meetingDate` (cascade
|
|
// never moves a follower across days).
|
|
void emitExecutiveMeetingsDaysChanged([txResult.meetingDate]);
|
|
const updated = await fetchMeetingWithAttendees(id);
|
|
res.json({
|
|
ok: true,
|
|
conflicts: txResult.conflicts,
|
|
meeting: updated,
|
|
cascadedIds: txResult.cascadedIds,
|
|
});
|
|
},
|
|
);
|
|
|
|
router.post(
|
|
"/executive-meetings/:id/reschedule",
|
|
requireExecutiveAccess,
|
|
requireMutate,
|
|
async (req, res): Promise<void> => {
|
|
const id = Number(req.params.id);
|
|
if (!Number.isInteger(id) || id <= 0) {
|
|
res.status(404).json({ error: "Not found", code: "not_found" });
|
|
return;
|
|
}
|
|
const body = parseBody(res, rescheduleSchema, req.body);
|
|
if (!body) return;
|
|
const startMin = parseTimeToMinutes(body.startTime);
|
|
const endMin = parseTimeToMinutes(body.endTime);
|
|
if (startMin == null || endMin == null || startMin >= endMin) {
|
|
res.status(400).json({
|
|
error: "End time must be after start time",
|
|
code: "invalid_time_range",
|
|
});
|
|
return;
|
|
}
|
|
const userId = req.session.userId!;
|
|
// #273: lock the row inside the tx so a concurrent edit cannot race us.
|
|
// #302: cascade_crosses_midnight has the same shape as on
|
|
// postpone-minutes so the dialog can share its handler.
|
|
type CascadeBlock = {
|
|
ok: false;
|
|
status: 400;
|
|
error: string;
|
|
code: "cascade_crosses_midnight";
|
|
blockedBy: {
|
|
id: number;
|
|
titleAr: string;
|
|
titleEn: string;
|
|
projectedStart: string;
|
|
projectedEnd: string;
|
|
};
|
|
};
|
|
type TxResult =
|
|
| { ok: true; oldDate: string; conflicts: boolean; cascadedIds: number[] }
|
|
| { ok: false; status: number; error: string; code: string }
|
|
| CascadeBlock;
|
|
const txResult = await db.transaction(async (tx): Promise<TxResult> => {
|
|
const [locked] = await tx
|
|
.select()
|
|
.from(executiveMeetingsTable)
|
|
.where(eq(executiveMeetingsTable.id, id))
|
|
.for("update");
|
|
if (!locked) {
|
|
return { ok: false, status: 404, error: "Meeting not found", code: "not_found" };
|
|
}
|
|
// If the daily number would clash on the new date, allocate a fresh one.
|
|
let dailyNumber = locked.dailyNumber;
|
|
if (body.meetingDate !== locked.meetingDate) {
|
|
dailyNumber = await nextDailyNumber(tx, body.meetingDate);
|
|
}
|
|
// #302: Reschedule cascade contract.
|
|
// - Only cascades when (a) the meeting stays on the same date
|
|
// and (b) the delta = newStart - originalStart is positive
|
|
// (rescheduling earlier or only changing the end never has a
|
|
// meaningful "shift the next meetings" interpretation).
|
|
// - Uses `locked.endTime` (original) as the baseline for picking
|
|
// followers, matching postpone-minutes.
|
|
// - If `cascadeFollowing` is true but those guards aren't met,
|
|
// we silently ignore the flag — the dialog already gates the
|
|
// prompt on the same condition, so this is just defensive.
|
|
let cascadedIds: number[] = [];
|
|
if (body.cascadeFollowing && body.meetingDate === locked.meetingDate) {
|
|
const oldStartMin = parseTimeToMinutes(locked.startTime);
|
|
if (oldStartMin != null && startMin > oldStartMin) {
|
|
const delta = startMin - oldStartMin;
|
|
const cascade = await computeCascadeShift(
|
|
tx,
|
|
locked.meetingDate,
|
|
id,
|
|
locked.endTime,
|
|
delta,
|
|
// #302: same FOR UPDATE locking as the postpone-minutes
|
|
// cascade — see that call site for rationale.
|
|
true,
|
|
);
|
|
if (!cascade.ok) {
|
|
return {
|
|
ok: false,
|
|
status: 400,
|
|
error: "Cascading would push a follower past midnight",
|
|
code: "cascade_crosses_midnight",
|
|
blockedBy: cascade.blockedBy,
|
|
};
|
|
}
|
|
cascadedIds = cascade.followers.map((f) => f.id);
|
|
await applyCascadeShift(tx, cascade.followers, id, delta, userId);
|
|
}
|
|
}
|
|
await tx
|
|
.update(executiveMeetingsTable)
|
|
.set({
|
|
meetingDate: body.meetingDate,
|
|
startTime: body.startTime,
|
|
endTime: body.endTime,
|
|
status: "postponed",
|
|
dailyNumber,
|
|
// #302: stamp actor for parity with postpone-minutes — the
|
|
// existing reschedule path didn't, but it's the right thing
|
|
// to do now that cascades attribute their followers to a
|
|
// user, and a primary reschedule should be no different.
|
|
updatedBy: userId,
|
|
})
|
|
.where(eq(executiveMeetingsTable.id, id));
|
|
await logAudit(tx, {
|
|
action: "meeting_rescheduled",
|
|
entityType: "meeting",
|
|
entityId: id,
|
|
oldValue: {
|
|
meetingDate: locked.meetingDate,
|
|
startTime: locked.startTime,
|
|
endTime: locked.endTime,
|
|
status: locked.status,
|
|
dailyNumber: locked.dailyNumber,
|
|
},
|
|
newValue: {
|
|
meetingDate: body.meetingDate,
|
|
startTime: body.startTime,
|
|
endTime: body.endTime,
|
|
status: "postponed",
|
|
dailyNumber,
|
|
note: body.note ?? null,
|
|
cascadedFollowerIds: cascadedIds,
|
|
},
|
|
performedBy: userId,
|
|
});
|
|
const conflicts = await detectMeetingConflicts(
|
|
tx,
|
|
body.meetingDate,
|
|
id,
|
|
body.startTime,
|
|
body.endTime,
|
|
);
|
|
// #273: re-sort `#` on both the old and new dates if the day moved,
|
|
// so each timeline reflects the meeting leaving / arriving.
|
|
await renumberDayByStartTime(tx, body.meetingDate);
|
|
if (locked.meetingDate !== body.meetingDate) {
|
|
await renumberDayByStartTime(tx, locked.meetingDate);
|
|
}
|
|
return {
|
|
ok: true,
|
|
oldDate: locked.meetingDate,
|
|
conflicts,
|
|
cascadedIds,
|
|
};
|
|
});
|
|
if (!txResult.ok) {
|
|
const payload: Record<string, unknown> = {
|
|
error: txResult.error,
|
|
code: txResult.code,
|
|
};
|
|
if (
|
|
txResult.code === "cascade_crosses_midnight" &&
|
|
"blockedBy" in txResult
|
|
) {
|
|
payload.blockedBy = txResult.blockedBy;
|
|
}
|
|
res.status(txResult.status).json(payload);
|
|
return;
|
|
}
|
|
void emitExecutiveMeetingsDaysChanged([
|
|
txResult.oldDate,
|
|
body.meetingDate,
|
|
]);
|
|
const updated = await fetchMeetingWithAttendees(id);
|
|
res.json({
|
|
ok: true,
|
|
conflicts: txResult.conflicts,
|
|
meeting: updated,
|
|
cascadedIds: txResult.cascadedIds,
|
|
});
|
|
},
|
|
);
|
|
|
|
router.post(
|
|
"/executive-meetings/:id/cancel",
|
|
requireExecutiveAccess,
|
|
requireMutate,
|
|
async (req, res): Promise<void> => {
|
|
const id = Number(req.params.id);
|
|
if (!Number.isInteger(id) || id <= 0) {
|
|
res.status(404).json({ error: "Not found", code: "not_found" });
|
|
return;
|
|
}
|
|
const userId = req.session.userId!;
|
|
// #273: lock + idempotent UPDATE — only audit on the real status change.
|
|
type TxResult =
|
|
| { ok: true; meetingDate: string; changed: boolean }
|
|
| { ok: false; status: number; error: string; code: string };
|
|
const txResult = await db.transaction(async (tx): Promise<TxResult> => {
|
|
const [locked] = await tx
|
|
.select()
|
|
.from(executiveMeetingsTable)
|
|
.where(eq(executiveMeetingsTable.id, id))
|
|
.for("update");
|
|
if (!locked) {
|
|
return { ok: false, status: 404, error: "Meeting not found", code: "not_found" };
|
|
}
|
|
if (locked.status === "cancelled") {
|
|
return { ok: true, meetingDate: locked.meetingDate, changed: false };
|
|
}
|
|
await tx
|
|
.update(executiveMeetingsTable)
|
|
.set({ status: "cancelled" })
|
|
.where(eq(executiveMeetingsTable.id, id));
|
|
await logAudit(tx, {
|
|
action: "meeting_cancelled",
|
|
entityType: "meeting",
|
|
entityId: id,
|
|
oldValue: { status: locked.status },
|
|
newValue: { status: "cancelled" },
|
|
performedBy: userId,
|
|
});
|
|
// #273: re-sort `#` so the cancelled meeting moves to the tail
|
|
// and the remaining active meetings get a clean 1..N sequence.
|
|
await renumberDayByStartTime(tx, locked.meetingDate);
|
|
return { ok: true, meetingDate: locked.meetingDate, changed: true };
|
|
});
|
|
if (!txResult.ok) {
|
|
res.status(txResult.status).json({ error: txResult.error, code: txResult.code });
|
|
return;
|
|
}
|
|
if (txResult.changed) {
|
|
void emitExecutiveMeetingsDaysChanged([txResult.meetingDate]);
|
|
}
|
|
const updated = await fetchMeetingWithAttendees(id);
|
|
res.json({ ok: true, meeting: updated });
|
|
},
|
|
);
|
|
|
|
router.delete(
|
|
"/executive-meetings/:id",
|
|
requireExecutiveAccess,
|
|
requireMutate,
|
|
async (req, res): Promise<void> => {
|
|
const id = Number(req.params.id);
|
|
if (!Number.isInteger(id) || id <= 0) {
|
|
res.status(404).json({ error: "Not found", code: "not_found" });
|
|
return;
|
|
}
|
|
const existing = await fetchMeetingWithAttendees(id);
|
|
if (!existing) {
|
|
res.status(404).json({ error: "Meeting not found", code: "not_found" });
|
|
return;
|
|
}
|
|
const userId = req.session.userId!;
|
|
await db.transaction(async (tx) => {
|
|
await tx.delete(executiveMeetingsTable).where(eq(executiveMeetingsTable.id, id));
|
|
await logAudit(tx, {
|
|
action: "delete",
|
|
entityType: "meeting",
|
|
entityId: id,
|
|
oldValue: existing,
|
|
performedBy: userId,
|
|
});
|
|
// #312: After deleting a row the day's `#` sequence has a gap
|
|
// (the deleted dailyNumber) and any cancelled rows that used to
|
|
// occupy slots above the deleted one are now off by one. Renumber
|
|
// 1..N by start_time so the visible list stays clean and the next
|
|
// call to nextDailyNumber() doesn't allocate the recycled slot.
|
|
await renumberDayByStartTime(tx, existing.meetingDate);
|
|
});
|
|
// Realtime: every open schedule tab on the deleted meeting's day
|
|
// refetches so the row disappears without a manual refresh.
|
|
void emitExecutiveMeetingsDayChanged(existing.meetingDate);
|
|
res.status(204).end();
|
|
},
|
|
);
|
|
|
|
router.put(
|
|
"/executive-meetings/:id/attendees",
|
|
requireExecutiveAccess,
|
|
requireMutate,
|
|
async (req, res): Promise<void> => {
|
|
const id = Number(req.params.id);
|
|
if (!Number.isInteger(id) || id <= 0) {
|
|
res.status(404).json({ error: "Not found", code: "not_found" });
|
|
return;
|
|
}
|
|
const existing = await fetchMeetingWithAttendees(id);
|
|
if (!existing) {
|
|
res.status(404).json({ error: "Meeting not found", code: "not_found" });
|
|
return;
|
|
}
|
|
const data = parseBody(res, attendeesPutSchema, req.body);
|
|
if (!data) return;
|
|
const userId = req.session.userId!;
|
|
await db.transaction(async (tx) => {
|
|
await tx
|
|
.delete(executiveMeetingAttendeesTable)
|
|
.where(eq(executiveMeetingAttendeesTable.meetingId, id));
|
|
if (data.attendees.length > 0) {
|
|
await tx.insert(executiveMeetingAttendeesTable).values(
|
|
data.attendees.map((a, idx) => ({
|
|
meetingId: id,
|
|
name: sanitizeRichText(a.name),
|
|
title: sanitizePlainTextOrNull(a.title),
|
|
attendanceType: a.attendanceType,
|
|
sortOrder: a.sortOrder ?? idx,
|
|
kind: a.kind,
|
|
})),
|
|
);
|
|
}
|
|
await logAudit(tx, {
|
|
action: "replace_attendees",
|
|
entityType: "meeting",
|
|
entityId: id,
|
|
oldValue: { attendees: existing.attendees },
|
|
newValue: { attendees: data.attendees },
|
|
performedBy: userId,
|
|
});
|
|
});
|
|
const updated = await fetchMeetingWithAttendees(id);
|
|
// Realtime: replacing the attendee list is the most common
|
|
// multi-tab edit (drag-to-reorder, inline rename, group move),
|
|
// so make sure every viewer on this day refetches.
|
|
void emitExecutiveMeetingsDayChanged(
|
|
updated?.meetingDate ?? existing.meetingDate,
|
|
);
|
|
res.json(updated);
|
|
},
|
|
);
|
|
|
|
router.post(
|
|
"/executive-meetings/:id/duplicate",
|
|
requireExecutiveAccess,
|
|
requireMutate,
|
|
async (req, res): Promise<void> => {
|
|
const id = Number(req.params.id);
|
|
if (!Number.isInteger(id) || id <= 0) {
|
|
res.status(404).json({ error: "Not found", code: "not_found" });
|
|
return;
|
|
}
|
|
const data = parseBody(res, duplicateSchema, req.body);
|
|
if (!data) return;
|
|
const source = await fetchMeetingWithAttendees(id);
|
|
if (!source) {
|
|
res.status(404).json({ error: "Meeting not found", code: "not_found" });
|
|
return;
|
|
}
|
|
const userId = req.session.userId!;
|
|
try {
|
|
const inserted = await db.transaction(async (tx) => {
|
|
const nextNumber = await nextDailyNumber(tx, data.targetDate);
|
|
const meetingValues: typeof executiveMeetingsTable.$inferInsert = {
|
|
dailyNumber: nextNumber,
|
|
// Re-sanitize on duplicate so a row that was somehow stored with
|
|
// disallowed markup (e.g. raw SQL import, schema regression) does
|
|
// not get cloned into a fresh meeting unchecked.
|
|
titleAr: sanitizeRichText(source.titleAr),
|
|
titleEn: source.titleEn ? sanitizeRichText(source.titleEn) : source.titleEn,
|
|
meetingDate: data.targetDate,
|
|
startTime: source.startTime,
|
|
endTime: source.endTime,
|
|
// Re-sanitize plain-text fields on duplicate for the same
|
|
// defense-in-depth reason as the rich-text fields above.
|
|
location: stripTagsToPlainTextOrNull(source.location),
|
|
meetingUrl: stripTagsToPlainTextOrNull(source.meetingUrl),
|
|
platform: source.platform,
|
|
status: "scheduled",
|
|
isHighlighted: 0,
|
|
notes: stripTagsToPlainTextOrNull(source.notes),
|
|
attachments: source.attachments,
|
|
createdBy: userId,
|
|
updatedBy: userId,
|
|
};
|
|
const [meeting] = await tx
|
|
.insert(executiveMeetingsTable)
|
|
.values(meetingValues)
|
|
.returning();
|
|
if (!meeting) throw new Error("duplicate_failed");
|
|
if (source.attendees && source.attendees.length > 0) {
|
|
await tx.insert(executiveMeetingAttendeesTable).values(
|
|
source.attendees.map((a: ExecutiveMeetingAttendee, idx: number) => ({
|
|
meetingId: meeting.id,
|
|
// source already-stored values are sanitized at write time, but
|
|
// re-running keeps a defense-in-depth boundary if anything was
|
|
// imported via raw SQL.
|
|
name: sanitizeRichText(a.name),
|
|
title: sanitizePlainTextOrNull(a.title),
|
|
attendanceType: a.attendanceType,
|
|
sortOrder: a.sortOrder ?? idx,
|
|
// Carry custom subheadings into the duplicated meeting so the
|
|
// user does not lose their group structure on duplicate.
|
|
kind: a.kind,
|
|
})),
|
|
);
|
|
}
|
|
await logAudit(tx, {
|
|
action: "duplicate",
|
|
entityType: "meeting",
|
|
entityId: meeting.id,
|
|
oldValue: { sourceId: source.id, sourceDate: source.meetingDate },
|
|
newValue: meeting,
|
|
performedBy: userId,
|
|
});
|
|
// #312: A duplicate copies the source's start_time but is
|
|
// appended at the tail (nextDailyNumber). Renumber the target
|
|
// day so the duplicate slots into its correct chronological
|
|
// position — same reason POST /executive-meetings does it.
|
|
await renumberDayByStartTime(tx, data.targetDate);
|
|
return meeting;
|
|
});
|
|
const full = await fetchMeetingWithAttendees(inserted.id);
|
|
// Realtime: target day gained a new meeting — refetch so every
|
|
// viewer on the duplicate's destination day sees the new row.
|
|
void emitExecutiveMeetingsDayChanged(data.targetDate);
|
|
res.status(201).json(full);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
if (msg.includes("executive_meetings_date_number_unique")) {
|
|
res.status(409).json({ error: "Daily number conflict", code: "conflict" });
|
|
return;
|
|
}
|
|
res.status(500).json({ error: msg, code: "server_error" });
|
|
}
|
|
},
|
|
);
|
|
|
|
// =====================================================================
|
|
// 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);
|
|
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);
|
|
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) => {
|
|
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;
|