21c935064d
Full-stack removal of the three retired sections — UI, locales, realtime
invalidations, backend routes, role lists, capability flags, schema
tables, notify lib, and tests.
Backend (artifacts/api-server)
- routes/executive-meetings.ts: deleted /requests* + /tasks* handler
block, REQUEST_ROLES / TASK_VIEW_ROLES / TASK_BROAD_VIEW_ROLES,
canSubmitRequest / canViewTasks / canViewAllTasks from /me, retired
table imports, and dead schemas (detailsByType, requestPayloadSchemas,
request*Schema, taskCreateSchema, taskPatchSchema, dueAtSchema,
dateOnly, timeHm). canApprove kept (still used by FontSettings).
- lib/executive-meeting-notify.ts: EXECUTIVE_MEETING_NOTIFICATION_TYPES
collapsed to ['meeting_created'].
Frontend (artifacts/tx-os)
- pages/executive-meetings.tsx: deleted RequestsSection /
ApprovalsSection / TasksSection / RequestListRow, pruned SECTIONS,
MeCapabilities / MeRoles types, isSectionVisible cases, icon imports.
- hooks/use-notifications-socket.ts: dropped the two retired query
invalidations.
- locales/{ar,en}.json: removed nav.{requests,approvals,tasks},
executiveMeetings.{requests,approvals,tasks} subtrees, and the 6
retired notification.type entries.
Schema + DB
- lib/db/src/schema/executive-meetings.ts: tables + relations + types
for requests/tasks removed.
- artifacts/api-server/scripts/cleanup-em-requests-tasks.sql:
idempotent BEGIN/COMMIT — deletes orphan prefs / notifications /
audit rows, then DROP TABLE … CASCADE for both retired tables.
Applied to dev DB and `db push` re-synced.
Tests
- executive-meetings.test.mjs: deleted 9 retired blocks + 2 covered
prefs duplicates, rewrote /me capability test to assert flags absent,
rewrote DELETE-wipe test to use meeting_created via POST
/api/executive-meetings, removed /requests + /tasks router.param
entries.
- executive-meetings-notifications.test.mjs: deleted 7 blocks
(request_*, task_*, cross-event-mute), updated before/after
cleanup to skip dropped tables, kept setPref/clearPref helpers
(still used by surviving meeting_created opt-out tests).
Drift / pre-existing
- 3 test failures observed under the parallel `node --test` workflow
(meeting_created fan-out count, pref opt-out daily-number conflict,
service-orders JSON-vs-HTML) are pre-existing parallel-file
pollution between executive-meetings.test.mjs and
executive-meetings-notifications.test.mjs. Verified by running
`node --test --test-concurrency=1 'tests/**/*.test.mjs'` →
226/226 pass. Out of scope for #262.
- Pre-existing tsc warnings at routes/executive-meetings.ts L509/625
(boolean/number on isHighlighted) and L1594 (font-settings scope
query) untouched.
1657 lines
58 KiB
TypeScript
1657 lines
58 KiB
TypeScript
import { Router, type IRouter } from "express";
|
|
import {
|
|
eq,
|
|
asc,
|
|
desc,
|
|
inArray,
|
|
and,
|
|
sql,
|
|
gte,
|
|
lt,
|
|
isNull,
|
|
or,
|
|
type SQL,
|
|
} from "drizzle-orm";
|
|
import { z, type ZodType } from "zod";
|
|
import { db } from "@workspace/db";
|
|
import {
|
|
executiveMeetingsTable,
|
|
executiveMeetingAttendeesTable,
|
|
// #262: executiveMeetingRequestsTable + executiveMeetingTasksTable
|
|
// removed alongside the Requests / Approvals / Tasks tabs.
|
|
executiveMeetingNotificationsTable,
|
|
executiveMeetingNotificationPrefsTable,
|
|
executiveMeetingAuditLogsTable,
|
|
executiveMeetingPdfArchivesTable,
|
|
executiveMeetingFontSettingsTable,
|
|
rolesTable,
|
|
usersTable,
|
|
type ExecutiveMeetingAttendee,
|
|
} from "@workspace/db";
|
|
import {
|
|
requireAuth,
|
|
requireExecutiveAccess,
|
|
getEffectiveRoleIds,
|
|
} from "../middlewares/auth";
|
|
import {
|
|
sanitizePlainTextOrNull,
|
|
sanitizeRichText,
|
|
stripTagsToPlainTextOrNull,
|
|
} from "../lib/sanitize";
|
|
import {
|
|
emitExecutiveMeetingsDayChanged,
|
|
emitExecutiveMeetingsDaysChanged,
|
|
} from "../lib/realtime";
|
|
import { renderSchedulePdf, type PdfFontPrefs } from "../lib/pdf-renderer";
|
|
import { ObjectStorageService } from "../lib/objectStorage";
|
|
import {
|
|
recordExecutiveMeetingNotifications,
|
|
broadcastExecutiveMeetingNotifications,
|
|
getUserIdsForRoleNames,
|
|
sendExecutiveMeetingEmail,
|
|
getUserDisplay as getUserDisplayForNotify,
|
|
EXECUTIVE_MEETING_NOTIFICATION_TYPES,
|
|
} from "../lib/executive-meeting-notify";
|
|
import { ExecutiveMeetingsReorderBody } from "@workspace/api-zod";
|
|
import type { Request, Response, NextFunction } from "express";
|
|
|
|
const router: IRouter = Router();
|
|
|
|
router.param("id", (req, res, next, value) => {
|
|
if (!/^\d+$/.test(String(value))) {
|
|
next("route");
|
|
return;
|
|
}
|
|
next();
|
|
});
|
|
|
|
// ---------- Constants ----------
|
|
|
|
const PLATFORMS = ["none", "webex", "teams", "zoom", "other"] as const;
|
|
const STATUSES = ["scheduled", "cancelled", "completed", "postponed"] as const;
|
|
const ATTENDANCE_TYPES = ["internal", "virtual", "external"] as const;
|
|
// "person" is the default for backwards compatibility — every existing row
|
|
// is backfilled to this kind. "subheading" rows are user-defined section
|
|
// labels that live in the same attendees array as persons but must be
|
|
// excluded from numbering, attendee counts, and virtual-platform-name
|
|
// extraction. They share `attendance_type` and `sort_order` with persons
|
|
// so a subheading "belongs" to exactly one attendance-type group.
|
|
const ATTENDEE_KINDS = ["person", "subheading"] as const;
|
|
// Schedule column ids that the merge-cells overlay can span. Kept in
|
|
// sync with the frontend ColumnId enum in
|
|
// artifacts/tx-os/src/pages/executive-meetings.tsx.
|
|
const MERGE_COLUMNS = ["number", "meeting", "attendees", "time"] as const;
|
|
const MERGE_COLUMN_INDEX: Record<(typeof MERGE_COLUMNS)[number], number> = {
|
|
number: 0,
|
|
meeting: 1,
|
|
attendees: 2,
|
|
time: 3,
|
|
};
|
|
// #262: REQUEST_TYPES, REQUEST_REVIEW_STATUSES, TASK_STATUSES removed
|
|
// alongside the Requests / Approvals / Tasks tabs.
|
|
const FONT_FAMILIES = [
|
|
"system",
|
|
"Cairo",
|
|
"Tajawal",
|
|
"Noto Naskh Arabic",
|
|
"Amiri",
|
|
] as const;
|
|
const FONT_WEIGHTS = ["regular", "bold"] as const;
|
|
const FONT_ALIGNMENTS = ["start", "center"] as const;
|
|
const FONT_SIZE_MIN = 12;
|
|
const FONT_SIZE_MAX = 22;
|
|
|
|
const MUTATE_ROLES = [
|
|
"admin",
|
|
"executive_office_manager",
|
|
"executive_coord_lead",
|
|
] as const;
|
|
const APPROVE_ROLES = ["admin", "executive_office_manager"] as const;
|
|
const ADMIN_AUDIT_ROLES = [
|
|
"admin",
|
|
"executive_office_manager",
|
|
"executive_coord_lead",
|
|
] as const;
|
|
// #262: REQUEST_ROLES, TASK_VIEW_ROLES, TASK_BROAD_VIEW_ROLES removed
|
|
// alongside the Requests / Approvals / Tasks tabs.
|
|
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 requireApprove = makeRequireRoles(APPROVE_ROLES);
|
|
const requireAdminAudit = makeRequireRoles(ADMIN_AUDIT_ROLES);
|
|
// #262: requireRequest + requireTaskView removed with their handlers.
|
|
|
|
// ---------- Zod schemas ----------
|
|
|
|
const dateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "expected YYYY-MM-DD");
|
|
|
|
// #262: kept after the Requests/Approvals/Tasks block was removed; the
|
|
// audit + meetings query handlers still need it to validate ?date=… and
|
|
// ?dateFrom/dateTo= inputs.
|
|
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(),
|
|
})
|
|
.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;
|
|
|
|
// #262: dateOnly + timeHm helpers removed; only the request payload
|
|
// schemas referenced them.
|
|
|
|
// #262: detailsByType, requestPayloadSchemas, requestCreateSchema,
|
|
// requestNestedCreateSchema, requestReviewSchema, dueAtSchema,
|
|
// taskCreateSchema, taskPatchSchema removed alongside the
|
|
// Requests / Approvals / Tasks handlers.
|
|
const pdfArchiveCreateSchema = z.object({
|
|
archiveDate: dateSchema,
|
|
filePath: z.string().trim().max(500).optional(),
|
|
});
|
|
|
|
const fontSettingsSchema = z.object({
|
|
scope: z.enum(["user", "global"]).default("user"),
|
|
fontFamily: z.enum(FONT_FAMILIES).default("system"),
|
|
fontSize: z.number().int().min(FONT_SIZE_MIN).max(FONT_SIZE_MAX).default(14),
|
|
fontWeight: z.enum(FONT_WEIGHTS).default("regular"),
|
|
alignment: z.enum(FONT_ALIGNMENTS).default("start"),
|
|
});
|
|
|
|
function parseBody<T>(
|
|
res: Response,
|
|
schema: ZodType<T>,
|
|
body: unknown,
|
|
): T | null {
|
|
const r = schema.safeParse(body ?? {});
|
|
if (!r.success) {
|
|
const issue = r.error.issues[0];
|
|
const msg = issue
|
|
? `${issue.path.join(".") || "body"}: ${issue.message}`
|
|
: "validation";
|
|
res.status(400).json({ error: msg, code: "validation" });
|
|
return null;
|
|
}
|
|
return r.data;
|
|
}
|
|
|
|
|
|
type DbExecutor = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0];
|
|
type AuditInsert = typeof executiveMeetingAuditLogsTable.$inferInsert;
|
|
|
|
async function logAudit(
|
|
executor: DbExecutor,
|
|
opts: {
|
|
action: string;
|
|
entityType: string;
|
|
entityId: number | null;
|
|
oldValue?: unknown;
|
|
newValue?: unknown;
|
|
performedBy: number | null;
|
|
},
|
|
): Promise<void> {
|
|
const row: AuditInsert = {
|
|
action: opts.action,
|
|
entityType: opts.entityType,
|
|
entityId: opts.entityId ?? null,
|
|
oldValue: (opts.oldValue ?? null) as AuditInsert["oldValue"],
|
|
newValue: (opts.newValue ?? null) as AuditInsert["newValue"],
|
|
performedBy: opts.performedBy,
|
|
};
|
|
await executor.insert(executiveMeetingAuditLogsTable).values(row);
|
|
}
|
|
|
|
// ---------- Common helpers ----------
|
|
|
|
async function nextDailyNumber(
|
|
executor: DbExecutor,
|
|
date: string,
|
|
): Promise<number> {
|
|
const [row] = await executor
|
|
.select({
|
|
max: sql<number | null>`max(${executiveMeetingsTable.dailyNumber})`,
|
|
})
|
|
.from(executiveMeetingsTable)
|
|
.where(eq(executiveMeetingsTable.meetingDate, date));
|
|
return (row?.max ?? 0) + 1;
|
|
}
|
|
|
|
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)),
|
|
// #262: canApprove now only gates the global font-settings write
|
|
// path; canSubmitRequest, canViewTasks, canViewAllTasks were
|
|
// removed with the Requests/Approvals/Tasks tabs.
|
|
canApprove: APPROVE_ROLES.some((r) => names.has(r)),
|
|
canViewAudit: ADMIN_AUDIT_ROLES.some((r) => names.has(r)),
|
|
});
|
|
},
|
|
);
|
|
|
|
router.get(
|
|
"/executive-meetings/:id",
|
|
requireExecutiveAccess,
|
|
async (req, res): Promise<void> => {
|
|
const id = Number(req.params.id);
|
|
if (!Number.isInteger(id) || id <= 0) {
|
|
res.status(404).json({ error: "Not found", code: "not_found" });
|
|
return;
|
|
}
|
|
const m = await fetchMeetingWithAttendees(id);
|
|
if (!m) {
|
|
res.status(404).json({ error: "Meeting not found", code: "not_found" });
|
|
return;
|
|
}
|
|
res.json(m);
|
|
},
|
|
);
|
|
|
|
router.post(
|
|
"/executive-meetings",
|
|
requireExecutiveAccess,
|
|
requireMutate,
|
|
async (req, res): Promise<void> => {
|
|
const data = parseBody(res, meetingCreateSchema, req.body);
|
|
if (!data) return;
|
|
const userId = req.session.userId!;
|
|
try {
|
|
const inserted = await db.transaction(async (tx) => {
|
|
const dailyNumber =
|
|
data.dailyNumber ?? (await nextDailyNumber(tx, data.meetingDate));
|
|
const meetingValues: typeof executiveMeetingsTable.$inferInsert = {
|
|
titleAr: sanitizeRichText(data.titleAr),
|
|
titleEn: sanitizeRichText(data.titleEn ?? ""),
|
|
meetingDate: data.meetingDate,
|
|
dailyNumber,
|
|
startTime: data.startTime ?? null,
|
|
endTime: data.endTime ?? null,
|
|
// location/meetingUrl/notes are plain-text fields in the UI but
|
|
// can receive sneaky markup from a paste; strip tags at the API
|
|
// boundary. Use stripTagsToPlainTextOrNull so URLs with `&` and
|
|
// notes with stray `<`/`>` round-trip without entity-encoding.
|
|
location: stripTagsToPlainTextOrNull(data.location),
|
|
meetingUrl: stripTagsToPlainTextOrNull(data.meetingUrl),
|
|
platform: data.platform ?? "none",
|
|
status: data.status ?? "scheduled",
|
|
isHighlighted: data.isHighlighted ?? 0,
|
|
notes: stripTagsToPlainTextOrNull(data.notes),
|
|
createdBy: userId,
|
|
updatedBy: userId,
|
|
};
|
|
const [meeting] = await tx
|
|
.insert(executiveMeetingsTable)
|
|
.values(meetingValues)
|
|
.returning();
|
|
if (!meeting) throw new Error("insert_failed");
|
|
const attendees = data.attendees ?? [];
|
|
if (attendees.length > 0) {
|
|
await tx.insert(executiveMeetingAttendeesTable).values(
|
|
attendees.map((a, idx) => ({
|
|
meetingId: meeting.id,
|
|
name: sanitizeRichText(a.name),
|
|
// title is plain text in the UI, but it gets interpolated
|
|
// into HTML by templates like the print page, so strip
|
|
// any sneaky markup at the API boundary.
|
|
title: sanitizePlainTextOrNull(a.title),
|
|
attendanceType: a.attendanceType,
|
|
sortOrder: a.sortOrder ?? idx,
|
|
kind: a.kind,
|
|
})),
|
|
);
|
|
}
|
|
await logAudit(tx, {
|
|
action: "create",
|
|
entityType: "meeting",
|
|
entityId: meeting.id,
|
|
newValue: { ...meeting, attendees },
|
|
performedBy: userId,
|
|
});
|
|
const approverIds = await getUserIdsForRoleNames(APPROVE_ROLES);
|
|
const recipients = await recordExecutiveMeetingNotifications(tx, {
|
|
recipientUserIds: approverIds,
|
|
meetingId: meeting.id,
|
|
notificationType: "meeting_created",
|
|
titleAr: "اجتماع جديد",
|
|
titleEn: "New executive meeting",
|
|
bodyAr: meeting.titleAr || meeting.titleEn || "",
|
|
bodyEn: meeting.titleEn || meeting.titleAr || "",
|
|
relatedType: "executive_meeting",
|
|
relatedId: meeting.id,
|
|
excludeUserId: userId,
|
|
});
|
|
return { meeting, recipients };
|
|
});
|
|
void broadcastExecutiveMeetingNotifications(
|
|
inserted.recipients,
|
|
"meeting_created",
|
|
inserted.meeting.id,
|
|
);
|
|
// Realtime: every open schedule tab on this day refetches so the
|
|
// newly-created row shows up without a manual refresh.
|
|
void emitExecutiveMeetingsDayChanged(inserted.meeting.meetingDate);
|
|
const full = await fetchMeetingWithAttendees(inserted.meeting.id);
|
|
res.status(201).json(full);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
if (msg.includes("executive_meetings_date_number_unique")) {
|
|
res
|
|
.status(409)
|
|
.json({
|
|
error: "Daily number already used for this date",
|
|
code: "duplicate_number",
|
|
});
|
|
return;
|
|
}
|
|
res.status(500).json({ error: "Could not create meeting", code: "create_failed" });
|
|
}
|
|
},
|
|
);
|
|
|
|
router.patch(
|
|
"/executive-meetings/:id",
|
|
requireExecutiveAccess,
|
|
requireMutate,
|
|
async (req, res): Promise<void> => {
|
|
const id = Number(req.params.id);
|
|
if (!Number.isInteger(id) || id <= 0) {
|
|
res.status(404).json({ error: "Not found", code: "not_found" });
|
|
return;
|
|
}
|
|
const existing = await fetchMeetingWithAttendees(id);
|
|
if (!existing) {
|
|
res.status(404).json({ error: "Meeting not found", code: "not_found" });
|
|
return;
|
|
}
|
|
const data = parseBody(res, meetingPatchSchema, req.body);
|
|
if (!data) return;
|
|
const userId = req.session.userId!;
|
|
try {
|
|
await db.transaction(async (tx) => {
|
|
const updateValues: Partial<typeof executiveMeetingsTable.$inferInsert> = {
|
|
updatedBy: userId,
|
|
};
|
|
if (data.titleAr !== undefined)
|
|
updateValues.titleAr = sanitizeRichText(data.titleAr);
|
|
if (data.titleEn !== undefined)
|
|
updateValues.titleEn = sanitizeRichText(data.titleEn ?? "");
|
|
if (data.meetingDate !== undefined) updateValues.meetingDate = data.meetingDate;
|
|
if (data.dailyNumber !== undefined && data.dailyNumber !== null)
|
|
updateValues.dailyNumber = data.dailyNumber;
|
|
if (data.startTime !== undefined) updateValues.startTime = data.startTime;
|
|
if (data.endTime !== undefined) updateValues.endTime = data.endTime;
|
|
// Plain-text fields: strip any HTML at the API boundary so a
|
|
// PATCH cannot smuggle markup that the POST path already rejects.
|
|
// Mirrors the create handler above and the attendee.title path.
|
|
if (data.location !== undefined)
|
|
updateValues.location = stripTagsToPlainTextOrNull(data.location);
|
|
if (data.meetingUrl !== undefined)
|
|
updateValues.meetingUrl = stripTagsToPlainTextOrNull(data.meetingUrl);
|
|
if (data.platform !== undefined) updateValues.platform = data.platform;
|
|
if (data.status !== undefined) updateValues.status = data.status;
|
|
if (data.isHighlighted !== undefined)
|
|
updateValues.isHighlighted = data.isHighlighted;
|
|
if (data.notes !== undefined)
|
|
updateValues.notes = stripTagsToPlainTextOrNull(data.notes);
|
|
if (data.merge !== undefined) {
|
|
if (data.merge === null) {
|
|
updateValues.mergeStartColumn = null;
|
|
updateValues.mergeEndColumn = null;
|
|
updateValues.mergeText = null;
|
|
} else {
|
|
updateValues.mergeStartColumn = data.merge.mergeStartColumn;
|
|
updateValues.mergeEndColumn = data.merge.mergeEndColumn;
|
|
updateValues.mergeText = sanitizeRichText(data.merge.mergeText);
|
|
}
|
|
}
|
|
|
|
if (Object.keys(updateValues).length > 1) {
|
|
await tx
|
|
.update(executiveMeetingsTable)
|
|
.set(updateValues)
|
|
.where(eq(executiveMeetingsTable.id, id));
|
|
}
|
|
let attendees = existing.attendees;
|
|
if (data.attendees !== undefined) {
|
|
await tx
|
|
.delete(executiveMeetingAttendeesTable)
|
|
.where(eq(executiveMeetingAttendeesTable.meetingId, id));
|
|
if (data.attendees.length > 0) {
|
|
await tx.insert(executiveMeetingAttendeesTable).values(
|
|
data.attendees.map((a, idx) => ({
|
|
meetingId: id,
|
|
name: sanitizeRichText(a.name),
|
|
title: sanitizePlainTextOrNull(a.title),
|
|
attendanceType: a.attendanceType,
|
|
sortOrder: a.sortOrder ?? idx,
|
|
kind: a.kind,
|
|
})),
|
|
);
|
|
}
|
|
attendees = (await db
|
|
.select()
|
|
.from(executiveMeetingAttendeesTable)
|
|
.where(eq(executiveMeetingAttendeesTable.meetingId, id))
|
|
.orderBy(asc(executiveMeetingAttendeesTable.sortOrder)));
|
|
}
|
|
await logAudit(tx, {
|
|
action: "update",
|
|
entityType: "meeting",
|
|
entityId: id,
|
|
oldValue: existing,
|
|
newValue: { ...existing, ...updateValues, attendees },
|
|
performedBy: userId,
|
|
});
|
|
});
|
|
const updated = await fetchMeetingWithAttendees(id);
|
|
// Realtime: when the meeting was rescheduled to a different day we
|
|
// need to refetch BOTH days — viewers on the old day must lose the
|
|
// row, viewers on the new day must gain it. The dedup helper is a
|
|
// no-op when both dates match (the common in-place edit case).
|
|
void emitExecutiveMeetingsDaysChanged([
|
|
existing.meetingDate,
|
|
updated?.meetingDate ?? existing.meetingDate,
|
|
]);
|
|
res.json(updated);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
if (msg.includes("executive_meetings_date_number_unique")) {
|
|
res
|
|
.status(409)
|
|
.json({
|
|
error: "Daily number already used for this date",
|
|
code: "duplicate_number",
|
|
});
|
|
return;
|
|
}
|
|
res.status(500).json({ error: "Could not update meeting", code: "update_failed" });
|
|
}
|
|
},
|
|
);
|
|
|
|
router.delete(
|
|
"/executive-meetings/:id",
|
|
requireExecutiveAccess,
|
|
requireMutate,
|
|
async (req, res): Promise<void> => {
|
|
const id = Number(req.params.id);
|
|
if (!Number.isInteger(id) || id <= 0) {
|
|
res.status(404).json({ error: "Not found", code: "not_found" });
|
|
return;
|
|
}
|
|
const existing = await fetchMeetingWithAttendees(id);
|
|
if (!existing) {
|
|
res.status(404).json({ error: "Meeting not found", code: "not_found" });
|
|
return;
|
|
}
|
|
const userId = req.session.userId!;
|
|
await db.transaction(async (tx) => {
|
|
await tx.delete(executiveMeetingsTable).where(eq(executiveMeetingsTable.id, id));
|
|
await logAudit(tx, {
|
|
action: "delete",
|
|
entityType: "meeting",
|
|
entityId: id,
|
|
oldValue: existing,
|
|
performedBy: userId,
|
|
});
|
|
});
|
|
// Realtime: every open schedule tab on the deleted meeting's day
|
|
// refetches so the row disappears without a manual refresh.
|
|
void emitExecutiveMeetingsDayChanged(existing.meetingDate);
|
|
res.status(204).end();
|
|
},
|
|
);
|
|
|
|
router.put(
|
|
"/executive-meetings/:id/attendees",
|
|
requireExecutiveAccess,
|
|
requireMutate,
|
|
async (req, res): Promise<void> => {
|
|
const id = Number(req.params.id);
|
|
if (!Number.isInteger(id) || id <= 0) {
|
|
res.status(404).json({ error: "Not found", code: "not_found" });
|
|
return;
|
|
}
|
|
const existing = await fetchMeetingWithAttendees(id);
|
|
if (!existing) {
|
|
res.status(404).json({ error: "Meeting not found", code: "not_found" });
|
|
return;
|
|
}
|
|
const data = parseBody(res, attendeesPutSchema, req.body);
|
|
if (!data) return;
|
|
const userId = req.session.userId!;
|
|
await db.transaction(async (tx) => {
|
|
await tx
|
|
.delete(executiveMeetingAttendeesTable)
|
|
.where(eq(executiveMeetingAttendeesTable.meetingId, id));
|
|
if (data.attendees.length > 0) {
|
|
await tx.insert(executiveMeetingAttendeesTable).values(
|
|
data.attendees.map((a, idx) => ({
|
|
meetingId: id,
|
|
name: sanitizeRichText(a.name),
|
|
title: sanitizePlainTextOrNull(a.title),
|
|
attendanceType: a.attendanceType,
|
|
sortOrder: a.sortOrder ?? idx,
|
|
kind: a.kind,
|
|
})),
|
|
);
|
|
}
|
|
await logAudit(tx, {
|
|
action: "replace_attendees",
|
|
entityType: "meeting",
|
|
entityId: id,
|
|
oldValue: { attendees: existing.attendees },
|
|
newValue: { attendees: data.attendees },
|
|
performedBy: userId,
|
|
});
|
|
});
|
|
const updated = await fetchMeetingWithAttendees(id);
|
|
// Realtime: replacing the attendee list is the most common
|
|
// multi-tab edit (drag-to-reorder, inline rename, group move),
|
|
// so make sure every viewer on this day refetches.
|
|
void emitExecutiveMeetingsDayChanged(
|
|
updated?.meetingDate ?? existing.meetingDate,
|
|
);
|
|
res.json(updated);
|
|
},
|
|
);
|
|
|
|
router.post(
|
|
"/executive-meetings/:id/duplicate",
|
|
requireExecutiveAccess,
|
|
requireMutate,
|
|
async (req, res): Promise<void> => {
|
|
const id = Number(req.params.id);
|
|
if (!Number.isInteger(id) || id <= 0) {
|
|
res.status(404).json({ error: "Not found", code: "not_found" });
|
|
return;
|
|
}
|
|
const data = parseBody(res, duplicateSchema, req.body);
|
|
if (!data) return;
|
|
const source = await fetchMeetingWithAttendees(id);
|
|
if (!source) {
|
|
res.status(404).json({ error: "Meeting not found", code: "not_found" });
|
|
return;
|
|
}
|
|
const userId = req.session.userId!;
|
|
try {
|
|
const inserted = await db.transaction(async (tx) => {
|
|
const nextNumber = await nextDailyNumber(tx, data.targetDate);
|
|
const meetingValues: typeof executiveMeetingsTable.$inferInsert = {
|
|
dailyNumber: nextNumber,
|
|
// Re-sanitize on duplicate so a row that was somehow stored with
|
|
// disallowed markup (e.g. raw SQL import, schema regression) does
|
|
// not get cloned into a fresh meeting unchecked.
|
|
titleAr: sanitizeRichText(source.titleAr),
|
|
titleEn: source.titleEn ? sanitizeRichText(source.titleEn) : source.titleEn,
|
|
meetingDate: data.targetDate,
|
|
startTime: source.startTime,
|
|
endTime: source.endTime,
|
|
// Re-sanitize plain-text fields on duplicate for the same
|
|
// defense-in-depth reason as the rich-text fields above.
|
|
location: stripTagsToPlainTextOrNull(source.location),
|
|
meetingUrl: stripTagsToPlainTextOrNull(source.meetingUrl),
|
|
platform: source.platform,
|
|
status: "scheduled",
|
|
isHighlighted: 0,
|
|
notes: stripTagsToPlainTextOrNull(source.notes),
|
|
attachments: source.attachments,
|
|
createdBy: userId,
|
|
updatedBy: userId,
|
|
};
|
|
const [meeting] = await tx
|
|
.insert(executiveMeetingsTable)
|
|
.values(meetingValues)
|
|
.returning();
|
|
if (!meeting) throw new Error("duplicate_failed");
|
|
if (source.attendees && source.attendees.length > 0) {
|
|
await tx.insert(executiveMeetingAttendeesTable).values(
|
|
source.attendees.map((a: ExecutiveMeetingAttendee, idx: number) => ({
|
|
meetingId: meeting.id,
|
|
// source already-stored values are sanitized at write time, but
|
|
// re-running keeps a defense-in-depth boundary if anything was
|
|
// imported via raw SQL.
|
|
name: sanitizeRichText(a.name),
|
|
title: sanitizePlainTextOrNull(a.title),
|
|
attendanceType: a.attendanceType,
|
|
sortOrder: a.sortOrder ?? idx,
|
|
// Carry custom subheadings into the duplicated meeting so the
|
|
// user does not lose their group structure on duplicate.
|
|
kind: a.kind,
|
|
})),
|
|
);
|
|
}
|
|
await logAudit(tx, {
|
|
action: "duplicate",
|
|
entityType: "meeting",
|
|
entityId: meeting.id,
|
|
oldValue: { sourceId: source.id, sourceDate: source.meetingDate },
|
|
newValue: meeting,
|
|
performedBy: userId,
|
|
});
|
|
return meeting;
|
|
});
|
|
const full = await fetchMeetingWithAttendees(inserted.id);
|
|
// Realtime: target day gained a new meeting — refetch so every
|
|
// viewer on the duplicate's destination day sees the new row.
|
|
void emitExecutiveMeetingsDayChanged(data.targetDate);
|
|
res.status(201).json(full);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
if (msg.includes("executive_meetings_date_number_unique")) {
|
|
res.status(409).json({ error: "Daily number conflict", code: "conflict" });
|
|
return;
|
|
}
|
|
res.status(500).json({ error: msg, code: "server_error" });
|
|
}
|
|
},
|
|
);
|
|
|
|
// =====================================================================
|
|
// REORDER (within a single day)
|
|
// =====================================================================
|
|
|
|
router.post(
|
|
"/executive-meetings/reorder",
|
|
requireExecutiveAccess,
|
|
requireMutate,
|
|
async (req, res): Promise<void> => {
|
|
const data = parseBody(res, reorderSchema, req.body);
|
|
if (!data) return;
|
|
const userId = req.session.userId!;
|
|
const dedup = new Set(data.orderedIds);
|
|
if (dedup.size !== data.orderedIds.length) {
|
|
res
|
|
.status(400)
|
|
.json({ error: "Duplicate ids in orderedIds", code: "duplicate_ids" });
|
|
return;
|
|
}
|
|
try {
|
|
await db.transaction(async (tx) => {
|
|
const allRows = await tx
|
|
.select()
|
|
.from(executiveMeetingsTable)
|
|
.where(eq(executiveMeetingsTable.meetingDate, data.meetingDate));
|
|
if (allRows.length !== data.orderedIds.length) {
|
|
throw Object.assign(new Error("incomplete_day_reorder"), {
|
|
httpStatus: 400,
|
|
code: "incomplete_day",
|
|
});
|
|
}
|
|
const dayIds = new Set(allRows.map((r) => r.id));
|
|
for (const id of data.orderedIds) {
|
|
if (!dayIds.has(id)) {
|
|
throw Object.assign(new Error("invalid_or_cross_day_id"), {
|
|
httpStatus: 400,
|
|
code: "invalid_ids",
|
|
});
|
|
}
|
|
}
|
|
const slots = allRows
|
|
.slice()
|
|
.sort((a, b) => {
|
|
const aHasTime = a.startTime != null;
|
|
const bHasTime = b.startTime != null;
|
|
if (aHasTime && bHasTime) {
|
|
if (a.startTime! < b.startTime!) return -1;
|
|
if (a.startTime! > b.startTime!) return 1;
|
|
return a.dailyNumber - b.dailyNumber;
|
|
}
|
|
if (aHasTime) return -1;
|
|
if (bHasTime) return 1;
|
|
return a.dailyNumber - b.dailyNumber;
|
|
})
|
|
.map((r) => ({ startTime: r.startTime, endTime: r.endTime }));
|
|
for (let i = 0; i < data.orderedIds.length; i++) {
|
|
const id = data.orderedIds[i]!;
|
|
await tx
|
|
.update(executiveMeetingsTable)
|
|
.set({ dailyNumber: -(i + 1), updatedBy: userId })
|
|
.where(eq(executiveMeetingsTable.id, id));
|
|
}
|
|
for (let i = 0; i < data.orderedIds.length; i++) {
|
|
const id = data.orderedIds[i]!;
|
|
const slot = slots[i]!;
|
|
await tx
|
|
.update(executiveMeetingsTable)
|
|
.set({
|
|
startTime: slot.startTime,
|
|
endTime: slot.endTime,
|
|
dailyNumber: i + 1,
|
|
updatedBy: userId,
|
|
})
|
|
.where(eq(executiveMeetingsTable.id, id));
|
|
}
|
|
await logAudit(tx, {
|
|
action: "executive_meeting.reorder",
|
|
entityType: "meeting",
|
|
entityId: data.orderedIds[0]!,
|
|
oldValue: {
|
|
meetingDate: data.meetingDate,
|
|
order: allRows
|
|
.slice()
|
|
.sort((a, b) => a.dailyNumber - b.dailyNumber)
|
|
.map((r) => r.id),
|
|
},
|
|
newValue: {
|
|
meetingDate: data.meetingDate,
|
|
order: data.orderedIds,
|
|
},
|
|
performedBy: userId,
|
|
});
|
|
const updated = await tx
|
|
.select()
|
|
.from(executiveMeetingsTable)
|
|
.where(eq(executiveMeetingsTable.meetingDate, data.meetingDate));
|
|
const seen = new Set<number>();
|
|
for (const u of updated) {
|
|
if (seen.has(u.dailyNumber)) {
|
|
throw new Error("duplicate_daily_number_after_reorder");
|
|
}
|
|
seen.add(u.dailyNumber);
|
|
}
|
|
});
|
|
// Return the updated meetings in the new order, with attendees.
|
|
const idToFetch = data.orderedIds;
|
|
const fullList = await Promise.all(
|
|
idToFetch.map((id) => fetchMeetingWithAttendees(id)),
|
|
);
|
|
// Realtime: every viewer on this day refetches so the new
|
|
// ordering (and any time-slot reshuffling that came with it)
|
|
// shows up without a manual refresh.
|
|
void emitExecutiveMeetingsDayChanged(data.meetingDate);
|
|
res.json({ meetings: fullList.filter(Boolean) });
|
|
} catch (err) {
|
|
const status = (err as { httpStatus?: number }).httpStatus ?? 500;
|
|
const code = (err as { code?: string }).code ?? "server_error";
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
res.status(status).json({ error: msg, code });
|
|
}
|
|
},
|
|
);
|
|
|
|
// =====================================================================
|
|
// AUDIT LOGS
|
|
// =====================================================================
|
|
|
|
router.get(
|
|
"/executive-meetings/audit-logs",
|
|
requireExecutiveAccess,
|
|
requireAdminAudit,
|
|
async (req, res): Promise<void> => {
|
|
const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : "";
|
|
const dateFromRaw =
|
|
typeof req.query.dateFrom === "string" ? req.query.dateFrom.trim() : "";
|
|
const dateToRaw =
|
|
typeof req.query.dateTo === "string" ? req.query.dateTo.trim() : "";
|
|
const entityType =
|
|
typeof req.query.entityType === "string" ? req.query.entityType.trim() : "";
|
|
const action =
|
|
typeof req.query.action === "string" ? req.query.action.trim() : "";
|
|
const actorRaw = Number(req.query.actorId);
|
|
const actorId = Number.isInteger(actorRaw) && actorRaw > 0 ? actorRaw : null;
|
|
const limitRaw = Number(req.query.limit);
|
|
const limit =
|
|
Number.isFinite(limitRaw) && limitRaw > 0
|
|
? Math.min(Math.floor(limitRaw), 200)
|
|
: 50;
|
|
const offsetRaw = Number(req.query.offset);
|
|
const offset =
|
|
Number.isFinite(offsetRaw) && offsetRaw > 0 ? Math.floor(offsetRaw) : 0;
|
|
const conds: SQL[] = [];
|
|
if (dateRaw && DATE_RE_LOCAL.test(dateRaw)) {
|
|
const start = new Date(`${dateRaw}T00:00:00.000Z`);
|
|
const end = new Date(start.getTime() + 24 * 60 * 60 * 1000);
|
|
conds.push(gte(executiveMeetingAuditLogsTable.performedAt, start));
|
|
conds.push(lt(executiveMeetingAuditLogsTable.performedAt, end));
|
|
}
|
|
if (dateFromRaw && DATE_RE_LOCAL.test(dateFromRaw)) {
|
|
conds.push(
|
|
gte(
|
|
executiveMeetingAuditLogsTable.performedAt,
|
|
new Date(`${dateFromRaw}T00:00:00.000Z`),
|
|
),
|
|
);
|
|
}
|
|
if (dateToRaw && DATE_RE_LOCAL.test(dateToRaw)) {
|
|
const end = new Date(`${dateToRaw}T00:00:00.000Z`);
|
|
end.setUTCDate(end.getUTCDate() + 1);
|
|
conds.push(lt(executiveMeetingAuditLogsTable.performedAt, end));
|
|
}
|
|
if (entityType) conds.push(eq(executiveMeetingAuditLogsTable.entityType, entityType));
|
|
if (action) conds.push(eq(executiveMeetingAuditLogsTable.action, action));
|
|
if (actorId) conds.push(eq(executiveMeetingAuditLogsTable.performedBy, actorId));
|
|
const where = conds.length > 0 ? and(...conds) : undefined;
|
|
const [{ count: totalRaw } = { count: 0 }] = await db
|
|
.select({ count: sql<number>`count(*)::int` })
|
|
.from(executiveMeetingAuditLogsTable)
|
|
.where(where);
|
|
const total = Number(totalRaw) || 0;
|
|
const rows = await db
|
|
.select({
|
|
l: executiveMeetingAuditLogsTable,
|
|
actorUsername: usersTable.username,
|
|
actorDisplayNameAr: usersTable.displayNameAr,
|
|
actorDisplayNameEn: usersTable.displayNameEn,
|
|
})
|
|
.from(executiveMeetingAuditLogsTable)
|
|
.leftJoin(usersTable, eq(usersTable.id, executiveMeetingAuditLogsTable.performedBy))
|
|
.where(where)
|
|
.orderBy(desc(executiveMeetingAuditLogsTable.performedAt))
|
|
.limit(limit)
|
|
.offset(offset);
|
|
res.json({
|
|
entries: rows.map((row) => ({
|
|
...row.l,
|
|
actor: row.actorUsername
|
|
? {
|
|
username: row.actorUsername,
|
|
displayNameAr: row.actorDisplayNameAr,
|
|
displayNameEn: row.actorDisplayNameEn,
|
|
}
|
|
: null,
|
|
})),
|
|
total,
|
|
limit,
|
|
offset,
|
|
hasMore: offset + rows.length < total,
|
|
});
|
|
},
|
|
);
|
|
|
|
// =====================================================================
|
|
// NOTIFICATIONS (placeholder list — visible to any executive read role)
|
|
// =====================================================================
|
|
|
|
router.get(
|
|
"/executive-meetings/notifications",
|
|
requireExecutiveAccess,
|
|
async (req, res): Promise<void> => {
|
|
// Optional ?date=YYYY-MM-DD filter joins through the meetings table so
|
|
// the schedule's "selected date" can scope the notifications view, as
|
|
// required by Phase 2.
|
|
const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : "";
|
|
const conds: SQL[] = [];
|
|
let meetingIdsForDate: number[] | null = null;
|
|
if (dateRaw && DATE_RE_LOCAL.test(dateRaw)) {
|
|
const meetingsOnDate = await db
|
|
.select({ id: executiveMeetingsTable.id })
|
|
.from(executiveMeetingsTable)
|
|
.where(eq(executiveMeetingsTable.meetingDate, dateRaw));
|
|
meetingIdsForDate = meetingsOnDate.map((m) => m.id);
|
|
if (meetingIdsForDate.length === 0) {
|
|
res.json({ notifications: [] });
|
|
return;
|
|
}
|
|
conds.push(
|
|
inArray(executiveMeetingNotificationsTable.meetingId, meetingIdsForDate),
|
|
);
|
|
}
|
|
const where = conds.length > 0 ? and(...conds) : undefined;
|
|
const rows = await db
|
|
.select({
|
|
n: executiveMeetingNotificationsTable,
|
|
userUsername: usersTable.username,
|
|
userDisplayNameAr: usersTable.displayNameAr,
|
|
userDisplayNameEn: usersTable.displayNameEn,
|
|
})
|
|
.from(executiveMeetingNotificationsTable)
|
|
.leftJoin(usersTable, eq(usersTable.id, executiveMeetingNotificationsTable.userId))
|
|
.where(where)
|
|
.orderBy(desc(executiveMeetingNotificationsTable.createdAt))
|
|
.limit(200);
|
|
res.json({
|
|
notifications: rows.map((row) => ({
|
|
...row.n,
|
|
user: row.userUsername
|
|
? {
|
|
username: row.userUsername,
|
|
displayNameAr: row.userDisplayNameAr,
|
|
displayNameEn: row.userDisplayNameEn,
|
|
}
|
|
: null,
|
|
})),
|
|
});
|
|
},
|
|
);
|
|
|
|
// =====================================================================
|
|
// NOTIFICATION PREFERENCES (per-user, per-event-type)
|
|
// =====================================================================
|
|
|
|
// One row per (notificationType, channel) toggle. Missing rows mean
|
|
// "default on" — see executive_meeting_notification_prefs in the schema.
|
|
const notificationPrefEntrySchema = z.object({
|
|
notificationType: z.enum(EXECUTIVE_MEETING_NOTIFICATION_TYPES),
|
|
inApp: z.boolean(),
|
|
email: z.boolean(),
|
|
});
|
|
|
|
const notificationPrefsBodySchema = z.object({
|
|
prefs: z.array(notificationPrefEntrySchema).max(64),
|
|
});
|
|
|
|
// GET returns the current user's preferences merged with defaults so the
|
|
// frontend can render every event type as a toggle without having to
|
|
// know which rows exist. `types` echoes the canonical list so the UI
|
|
// stays in sync if a new event type is added on the server.
|
|
router.get(
|
|
"/executive-meetings/notification-prefs",
|
|
requireExecutiveAccess,
|
|
async (req, res): Promise<void> => {
|
|
const userId = req.session.userId!;
|
|
const rows = await db
|
|
.select({
|
|
notificationType: executiveMeetingNotificationPrefsTable.notificationType,
|
|
inApp: executiveMeetingNotificationPrefsTable.inApp,
|
|
email: executiveMeetingNotificationPrefsTable.email,
|
|
})
|
|
.from(executiveMeetingNotificationPrefsTable)
|
|
.where(eq(executiveMeetingNotificationPrefsTable.userId, userId));
|
|
const byType = new Map(rows.map((r) => [r.notificationType, r]));
|
|
const prefs = EXECUTIVE_MEETING_NOTIFICATION_TYPES.map((type) => {
|
|
const existing = byType.get(type);
|
|
return {
|
|
notificationType: type,
|
|
inApp: existing ? existing.inApp : true,
|
|
email: existing ? existing.email : true,
|
|
};
|
|
});
|
|
res.json({
|
|
types: EXECUTIVE_MEETING_NOTIFICATION_TYPES,
|
|
prefs,
|
|
});
|
|
},
|
|
);
|
|
|
|
// PUT replaces all preferences for the current user. We upsert each
|
|
// supplied row and leave any other rows untouched (the default-on
|
|
// fallback handles unspecified types). The request only carries
|
|
// preferences the user actually toggled, so this is a partial update.
|
|
router.put(
|
|
"/executive-meetings/notification-prefs",
|
|
requireExecutiveAccess,
|
|
async (req, res): Promise<void> => {
|
|
const data = parseBody(res, notificationPrefsBodySchema, req.body);
|
|
if (!data) return;
|
|
const userId = req.session.userId!;
|
|
if (data.prefs.length === 0) {
|
|
res.json({ ok: true, count: 0 });
|
|
return;
|
|
}
|
|
await db.transaction(async (tx) => {
|
|
for (const p of data.prefs) {
|
|
await tx
|
|
.insert(executiveMeetingNotificationPrefsTable)
|
|
.values({
|
|
userId,
|
|
notificationType: p.notificationType,
|
|
inApp: p.inApp,
|
|
email: p.email,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [
|
|
executiveMeetingNotificationPrefsTable.userId,
|
|
executiveMeetingNotificationPrefsTable.notificationType,
|
|
],
|
|
set: {
|
|
inApp: p.inApp,
|
|
email: p.email,
|
|
updatedAt: new Date(),
|
|
},
|
|
});
|
|
}
|
|
});
|
|
res.json({ ok: true, count: data.prefs.length });
|
|
},
|
|
);
|
|
|
|
// #236: DELETE wipes every pref row for the current user so all toggles
|
|
// fall back to the schema default (in_app=true, email=true). Cheaper than
|
|
// asking the client to PUT N "true" rows back, and avoids the partial-
|
|
// update gotcha where a missing event type would stay opted-out.
|
|
router.delete(
|
|
"/executive-meetings/notification-prefs",
|
|
requireExecutiveAccess,
|
|
async (req, res): Promise<void> => {
|
|
const userId = req.session.userId!;
|
|
const deleted = await db
|
|
.delete(executiveMeetingNotificationPrefsTable)
|
|
.where(eq(executiveMeetingNotificationPrefsTable.userId, userId))
|
|
.returning({ id: executiveMeetingNotificationPrefsTable.id });
|
|
res.json({ ok: true, count: deleted.length });
|
|
},
|
|
);
|
|
|
|
// =====================================================================
|
|
// PDF GENERATION (server-side)
|
|
// =====================================================================
|
|
|
|
// Resolve the user's effective font preferences exactly the way the
|
|
// frontend does: user-scope row wins, otherwise global, otherwise the
|
|
// schema defaults.
|
|
async function resolveFontPrefsForUser(userId: number): Promise<PdfFontPrefs> {
|
|
const rows = await db
|
|
.select()
|
|
.from(executiveMeetingFontSettingsTable)
|
|
.where(
|
|
or(
|
|
eq(executiveMeetingFontSettingsTable.scope, "global"),
|
|
and(
|
|
eq(executiveMeetingFontSettingsTable.scope, "user"),
|
|
eq(executiveMeetingFontSettingsTable.userId, userId),
|
|
),
|
|
),
|
|
);
|
|
const userRow = rows.find((r) => r.scope === "user" && r.userId === userId);
|
|
const globalRow = rows.find((r) => r.scope === "global");
|
|
const eff = userRow ?? globalRow;
|
|
return {
|
|
fontFamily: eff?.fontFamily ?? "system",
|
|
fontSize: eff?.fontSize ?? 14,
|
|
fontWeight: (eff?.fontWeight as PdfFontPrefs["fontWeight"]) ?? "regular",
|
|
alignment: (eff?.alignment as PdfFontPrefs["alignment"]) ?? "start",
|
|
};
|
|
}
|
|
|
|
// Upload bytes to object storage by signing a PUT URL and streaming the
|
|
// buffer through `fetch`. Returns the canonical /objects/<id> path that
|
|
// GET /api/storage/objects/* knows how to serve.
|
|
async function uploadPdfToStorage(buf: Buffer): Promise<string> {
|
|
const objectStorage = new ObjectStorageService();
|
|
const uploadUrl = await objectStorage.getObjectEntityUploadURL();
|
|
const putRes = await fetch(uploadUrl, {
|
|
method: "PUT",
|
|
body: buf,
|
|
headers: {
|
|
"Content-Type": "application/pdf",
|
|
"Content-Length": String(buf.byteLength),
|
|
},
|
|
});
|
|
if (!putRes.ok) {
|
|
const body = await putRes.text().catch(() => "");
|
|
throw new Error(
|
|
`Failed to upload PDF to object storage (${putRes.status}): ${body.slice(0, 200)}`,
|
|
);
|
|
}
|
|
// Storage returns the signed URL we PUT to — normalize back to
|
|
// /objects/<id> so we can serve it via GET /api/storage/objects/*.
|
|
return objectStorage.normalizeObjectEntityPath(uploadUrl.split("?")[0]!);
|
|
}
|
|
|
|
router.get(
|
|
"/executive-meetings/pdf",
|
|
requireExecutiveAccess,
|
|
async (req, res): Promise<void> => {
|
|
const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : "";
|
|
if (!dateRaw || !DATE_RE_LOCAL.test(dateRaw)) {
|
|
res
|
|
.status(400)
|
|
.json({ error: "Invalid 'date' (expected YYYY-MM-DD)", code: "bad_date" });
|
|
return;
|
|
}
|
|
const lang: "ar" | "en" =
|
|
typeof req.query.lang === "string" && req.query.lang === "en" ? "en" : "ar";
|
|
const userId = req.session.userId!;
|
|
|
|
const meetings = await db
|
|
.select()
|
|
.from(executiveMeetingsTable)
|
|
.where(eq(executiveMeetingsTable.meetingDate, dateRaw))
|
|
.orderBy(asc(executiveMeetingsTable.dailyNumber));
|
|
let attendeesByMeeting = new Map<number, ExecutiveMeetingAttendee[]>();
|
|
if (meetings.length > 0) {
|
|
const ids = meetings.map((m) => m.id);
|
|
const attendees = await db
|
|
.select()
|
|
.from(executiveMeetingAttendeesTable)
|
|
.where(inArray(executiveMeetingAttendeesTable.meetingId, ids))
|
|
.orderBy(
|
|
asc(executiveMeetingAttendeesTable.meetingId),
|
|
asc(executiveMeetingAttendeesTable.sortOrder),
|
|
);
|
|
for (const a of attendees) {
|
|
const arr = attendeesByMeeting.get(a.meetingId) ?? [];
|
|
arr.push(a);
|
|
attendeesByMeeting.set(a.meetingId, arr);
|
|
}
|
|
}
|
|
|
|
const font = await resolveFontPrefsForUser(userId);
|
|
const labels =
|
|
lang === "ar"
|
|
? {
|
|
title: "جدول الاجتماعات التنفيذية",
|
|
no: "م",
|
|
meeting: "الاجتماع",
|
|
attendees: "الحضور",
|
|
time: "الوقت",
|
|
none: "لا توجد اجتماعات في هذا اليوم.",
|
|
}
|
|
: {
|
|
title: "Executive Meetings Schedule",
|
|
no: "#",
|
|
meeting: "Meeting",
|
|
attendees: "Attendees",
|
|
time: "Time",
|
|
none: "No meetings on this day.",
|
|
};
|
|
|
|
let pdf: Buffer;
|
|
try {
|
|
pdf = await renderSchedulePdf({
|
|
date: dateRaw,
|
|
lang,
|
|
font,
|
|
labels,
|
|
meetings: meetings.map((m) => ({
|
|
dailyNumber: m.dailyNumber,
|
|
titleAr: m.titleAr,
|
|
titleEn: m.titleEn,
|
|
startTime: m.startTime,
|
|
endTime: m.endTime,
|
|
location: m.location,
|
|
isHighlighted: m.isHighlighted ?? 0,
|
|
attendees: (attendeesByMeeting.get(m.id) ?? []).map((a) => ({
|
|
name: a.name,
|
|
title: a.title,
|
|
attendanceType: a.attendanceType,
|
|
// Forward the row kind so the renderer can render subheadings
|
|
// as labels rather than numbered attendees.
|
|
kind: a.kind,
|
|
})),
|
|
})),
|
|
});
|
|
} catch (err) {
|
|
// Fail loudly so callers see a real error instead of a corrupt PDF.
|
|
console.error("[executive-meetings] PDF render failed", err);
|
|
res.status(500).json({
|
|
error: "Failed to generate PDF",
|
|
code: "pdf_render_failed",
|
|
detail: err instanceof Error ? err.message : String(err),
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Upload + archive. If storage upload fails (e.g. sidecar offline)
|
|
// we still serve the PDF inline so the user is never blocked, but
|
|
// we record the archive with a synthetic path so the failure is
|
|
// visible in the audit trail.
|
|
let storagePath = `inline:${dateRaw}`;
|
|
try {
|
|
storagePath = await uploadPdfToStorage(pdf);
|
|
} catch (err) {
|
|
console.error("[executive-meetings] PDF upload failed; serving inline", err);
|
|
}
|
|
|
|
try {
|
|
await db.transaction(async (tx) => {
|
|
const [{ value: maxV }] = await tx
|
|
.select({
|
|
value: sql<number>`coalesce(max(${executiveMeetingPdfArchivesTable.version}), 0)`,
|
|
})
|
|
.from(executiveMeetingPdfArchivesTable)
|
|
.where(eq(executiveMeetingPdfArchivesTable.archiveDate, dateRaw));
|
|
const insertValues: typeof executiveMeetingPdfArchivesTable.$inferInsert = {
|
|
archiveDate: dateRaw,
|
|
filePath: storagePath,
|
|
version: (maxV ?? 0) + 1,
|
|
byteSize: pdf.byteLength,
|
|
generatedBy: userId,
|
|
};
|
|
const [row] = await tx
|
|
.insert(executiveMeetingPdfArchivesTable)
|
|
.values(insertValues)
|
|
.returning();
|
|
await logAudit(tx, {
|
|
action: "create",
|
|
entityType: "pdf_archive",
|
|
entityId: row!.id,
|
|
newValue: row!,
|
|
performedBy: userId,
|
|
});
|
|
});
|
|
} catch (err) {
|
|
console.error("[executive-meetings] PDF archive insert failed", err);
|
|
}
|
|
|
|
res.setHeader("Content-Type", "application/pdf");
|
|
res.setHeader(
|
|
"Content-Disposition",
|
|
`attachment; filename="executive-meetings-${dateRaw}.pdf"`,
|
|
);
|
|
res.setHeader("Content-Length", String(pdf.byteLength));
|
|
res.setHeader("Cache-Control", "no-store");
|
|
res.end(pdf);
|
|
},
|
|
);
|
|
|
|
// =====================================================================
|
|
// PDF ARCHIVES
|
|
// =====================================================================
|
|
|
|
router.get(
|
|
"/executive-meetings/pdf-archives",
|
|
requireExecutiveAccess,
|
|
async (req, res): Promise<void> => {
|
|
const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : "";
|
|
const conds: SQL[] = [];
|
|
if (dateRaw && DATE_RE_LOCAL.test(dateRaw)) {
|
|
conds.push(eq(executiveMeetingPdfArchivesTable.archiveDate, dateRaw));
|
|
}
|
|
const where = conds.length > 0 ? and(...conds) : undefined;
|
|
const rows = await db
|
|
.select({
|
|
a: executiveMeetingPdfArchivesTable,
|
|
username: usersTable.username,
|
|
displayNameAr: usersTable.displayNameAr,
|
|
displayNameEn: usersTable.displayNameEn,
|
|
})
|
|
.from(executiveMeetingPdfArchivesTable)
|
|
.leftJoin(
|
|
usersTable,
|
|
eq(usersTable.id, executiveMeetingPdfArchivesTable.generatedBy),
|
|
)
|
|
.where(where)
|
|
.orderBy(desc(executiveMeetingPdfArchivesTable.generatedAt))
|
|
.limit(200);
|
|
res.json({
|
|
archives: rows.map((row) => ({
|
|
...row.a,
|
|
generator: row.username
|
|
? {
|
|
username: row.username,
|
|
displayNameAr: row.displayNameAr,
|
|
displayNameEn: row.displayNameEn,
|
|
}
|
|
: null,
|
|
})),
|
|
});
|
|
},
|
|
);
|
|
|
|
router.post(
|
|
"/executive-meetings/pdf-archives",
|
|
requireExecutiveAccess,
|
|
async (req, res): Promise<void> => {
|
|
const data = parseBody(res, pdfArchiveCreateSchema, req.body);
|
|
if (!data) return;
|
|
const filePath = data.filePath?.trim() || `print:${data.archiveDate}`;
|
|
const userId = req.session.userId!;
|
|
const created = await db.transaction(async (tx) => {
|
|
const [{ value: maxV }] = await tx
|
|
.select({
|
|
value: sql<number>`coalesce(max(${executiveMeetingPdfArchivesTable.version}), 0)`,
|
|
})
|
|
.from(executiveMeetingPdfArchivesTable)
|
|
.where(eq(executiveMeetingPdfArchivesTable.archiveDate, data.archiveDate));
|
|
const insertValues: typeof executiveMeetingPdfArchivesTable.$inferInsert = {
|
|
archiveDate: data.archiveDate,
|
|
filePath,
|
|
version: (maxV ?? 0) + 1,
|
|
generatedBy: userId,
|
|
};
|
|
const [row] = await tx
|
|
.insert(executiveMeetingPdfArchivesTable)
|
|
.values(insertValues)
|
|
.returning();
|
|
await logAudit(tx, {
|
|
action: "create",
|
|
entityType: "pdf_archive",
|
|
entityId: row!.id,
|
|
newValue: row!,
|
|
performedBy: userId,
|
|
});
|
|
return row;
|
|
});
|
|
res.status(201).json(created);
|
|
},
|
|
);
|
|
|
|
// =====================================================================
|
|
// FONT SETTINGS
|
|
// =====================================================================
|
|
|
|
router.get(
|
|
"/executive-meetings/font-settings",
|
|
requireExecutiveAccess,
|
|
async (req, res): Promise<void> => {
|
|
const userId = req.session.userId!;
|
|
const rows = await db
|
|
.select()
|
|
.from(executiveMeetingFontSettingsTable)
|
|
.where(
|
|
or(
|
|
eq(executiveMeetingFontSettingsTable.scope, "global"),
|
|
and(
|
|
eq(executiveMeetingFontSettingsTable.scope, "user"),
|
|
eq(executiveMeetingFontSettingsTable.userId, userId),
|
|
),
|
|
),
|
|
);
|
|
const global = rows.find((r) => r.scope === "global") ?? null;
|
|
const user = rows.find((r) => r.scope === "user" && r.userId === userId) ?? null;
|
|
res.json({ global, user });
|
|
},
|
|
);
|
|
|
|
// Shared handler used by both PUT (canonical) and PATCH (alias) so we don't
|
|
// rely on Express re-dispatch tricks — both methods run the exact same logic.
|
|
const upsertFontSettingsHandler = async (
|
|
req: import("express").Request,
|
|
res: import("express").Response,
|
|
): Promise<void> => {
|
|
const userId = req.session.userId!;
|
|
const data = parseBody(res, fontSettingsSchema, req.body);
|
|
if (!data) return;
|
|
if (data.scope === "global") {
|
|
const names = await getRoleNamesForUser(userId);
|
|
const ok = APPROVE_ROLES.some((r) => names.has(r));
|
|
if (!ok) {
|
|
res.status(403).json({ error: "Forbidden", code: "forbidden" });
|
|
return;
|
|
}
|
|
}
|
|
const targetUserId = data.scope === "global" ? null : userId;
|
|
const result = await db.transaction(async (tx) => {
|
|
const [existing] = await tx
|
|
.select()
|
|
.from(executiveMeetingFontSettingsTable)
|
|
.where(
|
|
and(
|
|
eq(executiveMeetingFontSettingsTable.scope, data.scope),
|
|
targetUserId === null
|
|
? isNull(executiveMeetingFontSettingsTable.userId)
|
|
: eq(executiveMeetingFontSettingsTable.userId, targetUserId),
|
|
),
|
|
);
|
|
let row;
|
|
if (existing) {
|
|
const [updated] = await tx
|
|
.update(executiveMeetingFontSettingsTable)
|
|
.set({
|
|
fontFamily: data.fontFamily,
|
|
fontSize: data.fontSize,
|
|
fontWeight: data.fontWeight,
|
|
alignment: data.alignment,
|
|
})
|
|
.where(eq(executiveMeetingFontSettingsTable.id, existing.id))
|
|
.returning();
|
|
row = updated;
|
|
} else {
|
|
const insertValues: typeof executiveMeetingFontSettingsTable.$inferInsert = {
|
|
scope: data.scope,
|
|
userId: targetUserId,
|
|
fontFamily: data.fontFamily,
|
|
fontSize: data.fontSize,
|
|
fontWeight: data.fontWeight,
|
|
alignment: data.alignment,
|
|
};
|
|
const [inserted] = await tx
|
|
.insert(executiveMeetingFontSettingsTable)
|
|
.values(insertValues)
|
|
.returning();
|
|
row = inserted;
|
|
}
|
|
await logAudit(tx, {
|
|
action: existing ? "update" : "create",
|
|
entityType: "font_settings",
|
|
entityId: row?.id ?? null,
|
|
oldValue: existing ?? null,
|
|
newValue: row ?? null,
|
|
performedBy: userId,
|
|
});
|
|
return row;
|
|
});
|
|
res.json(result);
|
|
};
|
|
|
|
router.put(
|
|
"/executive-meetings/font-settings",
|
|
requireExecutiveAccess,
|
|
upsertFontSettingsHandler,
|
|
);
|
|
|
|
// Backwards-compatible PATCH alias — uses the same handler directly so it
|
|
// works whether the client sends PATCH or PUT.
|
|
router.patch(
|
|
"/executive-meetings/font-settings",
|
|
requireExecutiveAccess,
|
|
upsertFontSettingsHandler,
|
|
);
|
|
|
|
export { requireApprove };
|
|
export default router;
|