diff --git a/artifacts/api-server/package.json b/artifacts/api-server/package.json index d8cb3d3b..9494042e 100644 --- a/artifacts/api-server/package.json +++ b/artifacts/api-server/package.json @@ -24,7 +24,8 @@ "google-auth-library": "^10.6.2", "pino": "^9", "pino-http": "^10", - "socket.io": "^4.8.3" + "socket.io": "^4.8.3", + "zod": "catalog:" }, "devDependencies": { "@types/bcryptjs": "^3.0.0", diff --git a/artifacts/api-server/src/middlewares/auth.ts b/artifacts/api-server/src/middlewares/auth.ts index 1b169ce2..38a079d0 100644 --- a/artifacts/api-server/src/middlewares/auth.ts +++ b/artifacts/api-server/src/middlewares/auth.ts @@ -190,3 +190,52 @@ export async function getUserRoles(userId: number): Promise { return Array.from(new Set(roles.map((r) => r.roleName))); } +const EXECUTIVE_READ_ROLES: ReadonlyArray = [ + "admin", + "executive_ceo", + "executive_office_manager", + "executive_coord_lead", + "executive_coordinator", + "executive_viewer", +]; + +/** + * Gate any read access to the Executive Meetings module. Allows admin and + * any of the executive_* roles. Mutate / approve / audit-view sub-roles + * are layered on top via local helpers in the routes file. + */ +export async function requireExecutiveAccess( + req: Request, + res: Response, + next: NextFunction, +): Promise { + if (!req.session.userId) { + res.status(401).json({ error: "Unauthorized", code: "unauthorized" }); + return; + } + const [user] = await db + .select({ isActive: usersTable.isActive }) + .from(usersTable) + .where(eq(usersTable.id, req.session.userId)); + if (!user || !user.isActive) { + res.status(401).json({ error: "Unauthorized", code: "unauthorized" }); + return; + } + const ids = await getEffectiveRoleIds(req.session.userId); + if (ids.length === 0) { + res.status(403).json({ error: "Forbidden", code: "forbidden" }); + return; + } + const rows = await db + .select({ name: rolesTable.name }) + .from(rolesTable) + .where(inArray(rolesTable.id, ids)); + const names = new Set(rows.map((r) => r.name)); + const ok = EXECUTIVE_READ_ROLES.some((r) => names.has(r)); + if (!ok) { + res.status(403).json({ error: "Forbidden", code: "forbidden" }); + return; + } + next(); +} + diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 918df1bd..eba82bac 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -1,5 +1,18 @@ import { Router, type IRouter } from "express"; -import { eq, asc, desc, inArray, and, sql, gte, lt, isNull, or } from "drizzle-orm"; +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, @@ -13,14 +26,17 @@ import { rolesTable, usersTable, } from "@workspace/db"; -import { requireAuth, getEffectiveRoleIds } from "../middlewares/auth"; +import { + requireAuth, + requireExecutiveAccess, + getEffectiveRoleIds, +} from "../middlewares/auth"; import type { Request, Response, NextFunction } from "express"; const router: IRouter = Router(); -// Constrain the :id placeholder to numeric values; otherwise fall through -// to subsequent routes. Required because path-to-regexp 8 (Express 5) no -// longer supports inline regex like ":id(\\d+)". +// Constrain :id to numeric values; otherwise fall through. Required because +// path-to-regexp 8 (Express 5) no longer supports inline regex like ":id(\\d+)". router.param("id", (req, res, next, value) => { if (!/^\d+$/.test(String(value))) { next("route"); @@ -29,9 +45,61 @@ router.param("id", (req, res, next, value) => { next(); }); -const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; -const TIME_RE = /^\d{2}:\d{2}(:\d{2})?$/; +// ---------- 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; +const REQUEST_TYPES = [ + "create", + "edit", + "delete", + "reschedule", + "add_attendee", + "remove_attendee", + "change_location", + "cancel_meeting", + "note", + "highlight", + "unhighlight", + "other", +] as const; +const REQUEST_REVIEW_STATUSES = [ + "approved", + "rejected", + "needs_edit", +] as const; +const TASK_STATUSES = ["pending", "in_progress", "completed", "cancelled"] as const; +const FONT_FAMILIES = [ + "system", + "Cairo", + "Tajawal", + "Noto Naskh Arabic", + "Amiri", + "Inter", + "monospace", +] as const; +const FONT_WEIGHTS = ["regular", "medium", "semibold", "bold"] as const; +const FONT_ALIGNMENTS = ["start", "center", "end"] as const; + +const MUTATE_ROLES = [ + "admin", + "executive_office_manager", + "executive_coord_lead", +] as const; +const APPROVE_ROLES = ["admin", "executive_office_manager"] as const; +const REQUEST_ROLES = [ + "admin", + "executive_ceo", + "executive_office_manager", + "executive_coord_lead", + "executive_coordinator", +] as const; +const ADMIN_AUDIT_ROLES = [ + "admin", + "executive_office_manager", + "executive_coord_lead", +] as const; const READ_ROLES = [ "admin", "executive_ceo", @@ -41,27 +109,7 @@ const READ_ROLES = [ "executive_viewer", ] as const; -const MUTATE_ROLES = [ - "admin", - "executive_office_manager", - "executive_coord_lead", -] as const; - -const APPROVE_ROLES = ["admin", "executive_office_manager"] as const; - -const REQUEST_ROLES = [ - "admin", - "executive_ceo", - "executive_office_manager", - "executive_coord_lead", - "executive_coordinator", -] as const; - -const ADMIN_AUDIT_ROLES = [ - "admin", - "executive_office_manager", - "executive_coord_lead", -] as const; +// ---------- Role helpers (finer-grained, layered on top of requireExecutiveAccess) ---------- async function getRoleNamesForUser(userId: number): Promise> { const ids = await getEffectiveRoleIds(userId); @@ -93,13 +141,163 @@ function makeRequireRoles(allowed: ReadonlyArray) { }; } -const requireRead = makeRequireRoles(READ_ROLES); const requireMutate = makeRequireRoles(MUTATE_ROLES); const requireApprove = makeRequireRoles(APPROVE_ROLES); const requireRequest = makeRequireRoles(REQUEST_ROLES); const requireAdminAudit = makeRequireRoles(ADMIN_AUDIT_ROLES); +// ---------- Zod schemas ---------- + +const dateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "expected YYYY-MM-DD"); +const timeSchema = z + .string() + .regex(/^\d{2}:\d{2}(:\d{2})?$/, "expected HH:MM"); +const positiveIntSchema = z.number().int().positive(); + +const attendeeSchema = z.object({ + name: z.string().trim().min(1).max(200), + title: z.string().trim().max(200).nullable().optional(), + attendanceType: z.enum(ATTENDANCE_TYPES).default("internal"), + sortOrder: z.number().int().min(0).optional(), +}); + +const meetingBaseFields = { + titleAr: z.string().trim().min(1).max(300), + titleEn: z.string().trim().max(300).nullable().optional(), + 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"] }, + ); + +const meetingPatchSchema = z + .object({ + titleAr: meetingBaseFields.titleAr.optional(), + titleEn: meetingBaseFields.titleEn, + 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(), + }) + .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, +}); + +// requestDetails differs by requestType. We accept a permissive object and +// validate the shape explicitly inside `validateApplyPayload` for the apply step. +const requestDetailsSchema = z.record(z.string(), z.unknown()).default({}); + +const requestCreateSchema = z.object({ + meetingId: positiveIntSchema.nullable().optional(), + requestType: z.enum(REQUEST_TYPES), + requestDetails: requestDetailsSchema, +}); + +const requestNestedCreateSchema = z.object({ + requestType: z.enum(REQUEST_TYPES), + requestDetails: requestDetailsSchema, +}); + +// PATCH approvals are status-driven (per spec). `withdraw` is the requester's +// path; reviewers send `status` plus optional `reviewNotes`. +const requestReviewSchema = z.union([ + z.object({ + action: z.literal("withdraw"), + }), + z.object({ + status: z.enum(REQUEST_REVIEW_STATUSES), + reviewNotes: z.string().trim().max(2000).nullable().optional(), + }), +]); + +const taskCreateSchema = z.object({ + taskType: z.string().trim().min(1).max(64), + meetingId: positiveIntSchema.nullable().optional(), + requestId: positiveIntSchema.nullable().optional(), + assignedTo: positiveIntSchema.nullable().optional(), + dueAt: z.string().datetime().nullable().optional(), + notes: z.string().trim().max(5000).nullable().optional(), +}); + +const taskPatchSchema = z.object({ + status: z.enum(TASK_STATUSES).optional(), + taskType: z.string().trim().min(1).max(64).optional(), + assignedTo: positiveIntSchema.nullable().optional(), + dueAt: z.union([z.string().datetime(), z.literal("")]).nullable().optional(), + notes: z.string().trim().max(5000).nullable().optional(), + meetingId: positiveIntSchema.nullable().optional(), +}); + +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(10).max(32).default(14), + fontWeight: z.enum(FONT_WEIGHTS).default("regular"), + alignment: z.enum(FONT_ALIGNMENTS).default("start"), +}); + +// Helper: parse a body with a Zod schema and write a standard 400 if invalid. +function parseBody( + res: Response, + schema: ZodType, + body: unknown, +): T | null { + const r = schema.safeParse(body ?? {}); + if (!r.success) { + const issue = r.error.issues[0]; + const msg = issue + ? `${issue.path.join(".") || "body"}: ${issue.message}` + : "validation"; + res.status(400).json({ error: msg, code: "validation" }); + return null; + } + return r.data; +} + +// ---------- Audit log helper (typed, no `as never`) ---------- + type DbExecutor = typeof db | Parameters[0]>[0]; +type AuditInsert = typeof executiveMeetingAuditLogsTable.$inferInsert; async function logAudit( executor: DbExecutor, @@ -112,154 +310,27 @@ async function logAudit( performedBy: number | null; }, ): Promise { - await executor.insert(executiveMeetingAuditLogsTable).values({ + const row: AuditInsert = { action: opts.action, entityType: opts.entityType, entityId: opts.entityId ?? null, - oldValue: (opts.oldValue ?? null) as never, - newValue: (opts.newValue ?? null) as never, + oldValue: (opts.oldValue ?? null) as AuditInsert["oldValue"], + newValue: (opts.newValue ?? null) as AuditInsert["newValue"], performedBy: opts.performedBy, - }); + }; + await executor.insert(executiveMeetingAuditLogsTable).values(row); } -// ---------- Validation helpers ---------- +// ---------- Common helpers ---------- -type AttendeeInput = { - name: string; - title?: string | null; - attendanceType: "internal" | "virtual" | "external"; - sortOrder?: number; -}; - -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; - -function validString(v: unknown, max: number): string | null { - if (typeof v !== "string") return null; - const t = v.trim(); - if (t.length === 0 || t.length > max) return null; - return t; -} - -function optionalString(v: unknown, max: number): string | null | undefined { - if (v === undefined) return undefined; - if (v === null || v === "") return null; - if (typeof v !== "string") return null; - const t = v.trim(); - if (t.length === 0) return null; - if (t.length > max) return null; - return t; -} - -function parseAttendees(raw: unknown): AttendeeInput[] | null { - if (!Array.isArray(raw)) return null; - const out: AttendeeInput[] = []; - for (const item of raw) { - if (!item || typeof item !== "object") return null; - const r = item as Record; - const name = validString(r.name, 200); - if (!name) return null; - const at = typeof r.attendanceType === "string" ? r.attendanceType : "internal"; - if (!ATTENDANCE_TYPES.includes(at as (typeof ATTENDANCE_TYPES)[number])) return null; - out.push({ - name, - title: optionalString(r.title, 200) ?? null, - attendanceType: at as AttendeeInput["attendanceType"], - sortOrder: typeof r.sortOrder === "number" ? r.sortOrder : 0, - }); - } - return out; -} - -type MeetingPatch = { - titleAr?: string; - titleEn?: string | null; - meetingDate?: string; - dailyNumber?: number; - startTime?: string | null; - endTime?: string | null; - location?: string | null; - meetingUrl?: string | null; - platform?: (typeof PLATFORMS)[number]; - status?: (typeof STATUSES)[number]; - isHighlighted?: number; - notes?: string | null; -}; - -function parseMeetingPatch( - body: Record, - requireBaseFields: boolean, -): { ok: true; data: MeetingPatch } | { ok: false; error: string } { - const out: MeetingPatch = {}; - if ("titleAr" in body) { - const v = validString(body.titleAr, 300); - if (!v) return { ok: false, error: "titleAr is required" }; - out.titleAr = v; - } else if (requireBaseFields) { - return { ok: false, error: "titleAr is required" }; - } - if ("titleEn" in body) { - const v = optionalString(body.titleEn, 300); - out.titleEn = v === undefined ? "" : v; - } - if ("meetingDate" in body) { - if (typeof body.meetingDate !== "string" || !DATE_RE.test(body.meetingDate)) { - return { ok: false, error: "meetingDate must be YYYY-MM-DD" }; - } - out.meetingDate = body.meetingDate; - } else if (requireBaseFields) { - return { ok: false, error: "meetingDate is required" }; - } - if ("dailyNumber" in body && body.dailyNumber !== null && body.dailyNumber !== undefined) { - const n = Number(body.dailyNumber); - if (!Number.isInteger(n) || n <= 0 || n > 1000) { - return { ok: false, error: "dailyNumber must be a positive integer" }; - } - out.dailyNumber = n; - } - for (const k of ["startTime", "endTime"] as const) { - if (k in body) { - if (body[k] === null || body[k] === "") { - out[k] = null; - } else if (typeof body[k] === "string" && TIME_RE.test(body[k] as string)) { - out[k] = body[k] as string; - } else { - return { ok: false, error: `${k} must be HH:MM or null` }; - } - } - } - if (out.startTime && out.endTime && out.startTime > out.endTime) { - return { ok: false, error: "startTime must be <= endTime" }; - } - for (const k of ["location", "meetingUrl", "notes"] as const) { - if (k in body) { - const v = optionalString(body[k], k === "notes" ? 5000 : 500); - out[k] = v === undefined ? null : v; - } - } - if ("platform" in body) { - if (!PLATFORMS.includes(body.platform as (typeof PLATFORMS)[number])) { - return { ok: false, error: "platform invalid" }; - } - out.platform = body.platform as (typeof PLATFORMS)[number]; - } - if ("status" in body) { - if (!STATUSES.includes(body.status as (typeof STATUSES)[number])) { - return { ok: false, error: "status invalid" }; - } - out.status = body.status as (typeof STATUSES)[number]; - } - if ("isHighlighted" in body) { - const n = Number(body.isHighlighted); - out.isHighlighted = n ? 1 : 0; - } - return { ok: true, data: out }; -} - -async function nextDailyNumber(date: string): Promise { - const [row] = await db - .select({ max: sql`max(${executiveMeetingsTable.dailyNumber})` }) +async function nextDailyNumber( + executor: DbExecutor, + date: string, +): Promise { + const [row] = await executor + .select({ + max: sql`max(${executiveMeetingsTable.dailyNumber})`, + }) .from(executiveMeetingsTable) .where(eq(executiveMeetingsTable.meetingDate, date)); return (row?.max ?? 0) + 1; @@ -285,24 +356,21 @@ async function fetchMeetingWithAttendees(id: number) { router.get( "/executive-meetings", - requireAuth, - requireRead, + requireExecutiveAccess, async (req, res): Promise => { const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : ""; - if (dateRaw && !DATE_RE.test(dateRaw)) { + 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; @@ -324,15 +392,17 @@ router.get( } res.json({ date: targetDate, - meetings: meetings.map((m) => ({ ...m, attendees: byMeeting.get(m.id) ?? [] })), + meetings: meetings.map((m) => ({ + ...m, + attendees: byMeeting.get(m.id) ?? [], + })), }); }, ); router.get( "/executive-meetings/dates", - requireAuth, - requireRead, + requireExecutiveAccess, async (_req, res): Promise => { const rows = await db .selectDistinct({ d: executiveMeetingsTable.meetingDate }) @@ -344,7 +414,7 @@ router.get( router.get( "/executive-meetings/me", - requireAuth, + requireExecutiveAccess, async (req, res): Promise => { if (!req.session.userId) { res.status(401).json({ error: "Unauthorized", code: "unauthorized" }); @@ -365,8 +435,7 @@ router.get( router.get( "/executive-meetings/:id", - requireAuth, - requireRead, + requireExecutiveAccess, async (req, res): Promise => { const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) { @@ -384,51 +453,38 @@ router.get( router.post( "/executive-meetings", - requireAuth, + requireExecutiveAccess, requireMutate, async (req, res): Promise => { - const body = (req.body ?? {}) as Record; - const parsed = parseMeetingPatch(body, true); - if (!parsed.ok) { - res.status(400).json({ error: parsed.error, code: "validation" }); - return; - } - const data = parsed.data; - let attendees: AttendeeInput[] = []; - if ("attendees" in body) { - const a = parseAttendees(body.attendees); - if (!a) { - res.status(400).json({ error: "Invalid attendees", code: "validation" }); - return; - } - attendees = a; - } - - const date = data.meetingDate!; + 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(date)); + const dailyNumber = + data.dailyNumber ?? (await nextDailyNumber(tx, data.meetingDate)); + const meetingValues: typeof executiveMeetingsTable.$inferInsert = { + titleAr: data.titleAr, + titleEn: data.titleEn ?? "", + meetingDate: data.meetingDate, + dailyNumber, + startTime: data.startTime ?? null, + endTime: data.endTime ?? null, + location: data.location ?? null, + meetingUrl: data.meetingUrl ?? null, + platform: data.platform ?? "none", + status: data.status ?? "scheduled", + isHighlighted: data.isHighlighted ?? 0, + notes: data.notes ?? null, + createdBy: userId, + updatedBy: userId, + }; const [meeting] = await tx .insert(executiveMeetingsTable) - .values({ - titleAr: data.titleAr!, - titleEn: data.titleEn ?? "", - meetingDate: date, - dailyNumber, - startTime: data.startTime ?? null, - endTime: data.endTime ?? null, - location: data.location ?? null, - meetingUrl: data.meetingUrl ?? null, - platform: data.platform ?? "none", - status: data.status ?? "scheduled", - isHighlighted: data.isHighlighted ?? 0, - notes: data.notes ?? null, - createdBy: userId, - updatedBy: userId, - }) + .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) => ({ @@ -456,7 +512,10 @@ router.post( if (msg.includes("executive_meetings_date_number_unique")) { res .status(409) - .json({ error: "Daily number already used for this date", code: "duplicate_number" }); + .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" }); @@ -466,7 +525,7 @@ router.post( router.patch( "/executive-meetings/:id", - requireAuth, + requireExecutiveAccess, requireMutate, async (req, res): Promise => { const id = Number(req.params.id); @@ -479,56 +538,43 @@ router.patch( res.status(404).json({ error: "Meeting not found", code: "not_found" }); return; } - const body = (req.body ?? {}) as Record; - const parsed = parseMeetingPatch(body, false); - if (!parsed.ok) { - res.status(400).json({ error: parsed.error, code: "validation" }); - return; - } - const data = parsed.data; - let attendees: AttendeeInput[] | null = null; - if ("attendees" in body) { - const a = parseAttendees(body.attendees); - if (!a) { - res.status(400).json({ error: "Invalid attendees", code: "validation" }); - return; - } - attendees = a; - } + const data = parseBody(res, meetingPatchSchema, req.body); + if (!data) return; const userId = req.session.userId!; - try { await db.transaction(async (tx) => { - const updateValues: Record = { updatedBy: userId }; - for (const k of [ - "titleAr", - "titleEn", - "meetingDate", - "dailyNumber", - "startTime", - "endTime", - "location", - "meetingUrl", - "platform", - "status", - "isHighlighted", - "notes", - ] as const) { - if (k in data) updateValues[k] = (data as Record)[k]; - } + const updateValues: Partial = { + updatedBy: userId, + }; + if (data.titleAr !== undefined) updateValues.titleAr = data.titleAr; + if (data.titleEn !== undefined) updateValues.titleEn = 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; + if (data.location !== undefined) updateValues.location = data.location; + if (data.meetingUrl !== undefined) updateValues.meetingUrl = 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 = data.notes; + if (Object.keys(updateValues).length > 1) { await tx .update(executiveMeetingsTable) - .set(updateValues as never) + .set(updateValues) .where(eq(executiveMeetingsTable.id, id)); } - if (attendees !== null) { + let attendees = existing.attendees; + if (data.attendees !== undefined) { await tx .delete(executiveMeetingAttendeesTable) .where(eq(executiveMeetingAttendeesTable.meetingId, id)); - if (attendees.length > 0) { + if (data.attendees.length > 0) { await tx.insert(executiveMeetingAttendeesTable).values( - attendees.map((a, idx) => ({ + data.attendees.map((a, idx) => ({ meetingId: id, name: a.name, title: a.title ?? null, @@ -537,13 +583,18 @@ router.patch( })), ); } + 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: attendees ?? existing.attendees }, + newValue: { ...existing, ...updateValues, attendees }, performedBy: userId, }); }); @@ -554,7 +605,10 @@ router.patch( if (msg.includes("executive_meetings_date_number_unique")) { res .status(409) - .json({ error: "Daily number already used for this date", code: "duplicate_number" }); + .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" }); @@ -564,7 +618,7 @@ router.patch( router.delete( "/executive-meetings/:id", - requireAuth, + requireExecutiveAccess, requireMutate, async (req, res): Promise => { const id = Number(req.params.id); @@ -592,10 +646,9 @@ router.delete( }, ); -// Replace the entire attendee list for a meeting in one transaction. router.put( "/executive-meetings/:id/attendees", - requireAuth, + requireExecutiveAccess, requireMutate, async (req, res): Promise => { const id = Number(req.params.id); @@ -608,20 +661,16 @@ router.put( res.status(404).json({ error: "Meeting not found", code: "not_found" }); return; } - const body = (req.body ?? {}) as Record; - const attendees = parseAttendees(body.attendees); - if (!attendees) { - res.status(400).json({ error: "attendees invalid", code: "validation" }); - 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 (attendees.length > 0) { + if (data.attendees.length > 0) { await tx.insert(executiveMeetingAttendeesTable).values( - attendees.map((a, idx) => ({ + data.attendees.map((a, idx) => ({ meetingId: id, name: a.name, title: a.title ?? null, @@ -635,7 +684,7 @@ router.put( entityType: "meeting", entityId: id, oldValue: { attendees: existing.attendees }, - newValue: { attendees }, + newValue: { attendees: data.attendees }, performedBy: userId, }); }); @@ -644,10 +693,9 @@ router.put( }, ); -// Duplicate a meeting (and its attendees) to another date. router.post( "/executive-meetings/:id/duplicate", - requireAuth, + requireExecutiveAccess, requireMutate, async (req, res): Promise => { const id = Number(req.params.id); @@ -655,17 +703,8 @@ router.post( res.status(404).json({ error: "Not found", code: "not_found" }); return; } - const body = (req.body ?? {}) as Record; - const targetDate = - typeof body.targetDate === "string" && DATE_RE.test(body.targetDate) - ? body.targetDate - : null; - if (!targetDate) { - res - .status(400) - .json({ error: "targetDate required (YYYY-MM-DD)", code: "validation" }); - 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" }); @@ -674,32 +713,27 @@ router.post( const userId = req.session.userId!; try { const inserted = await db.transaction(async (tx) => { - const [{ value: maxNum }] = await tx - .select({ - value: sql`coalesce(max(${executiveMeetingsTable.dailyNumber}), 0)`, - }) - .from(executiveMeetingsTable) - .where(eq(executiveMeetingsTable.meetingDate, targetDate)); - const nextNumber = (maxNum ?? 0) + 1; + const nextNumber = await nextDailyNumber(tx, data.targetDate); + const meetingValues: typeof executiveMeetingsTable.$inferInsert = { + dailyNumber: nextNumber, + titleAr: source.titleAr, + titleEn: source.titleEn, + meetingDate: data.targetDate, + startTime: source.startTime, + endTime: source.endTime, + location: source.location, + meetingUrl: source.meetingUrl, + platform: source.platform, + status: "scheduled", + isHighlighted: 0, + notes: source.notes, + attachments: source.attachments, + createdBy: userId, + updatedBy: userId, + }; const [meeting] = await tx .insert(executiveMeetingsTable) - .values({ - dailyNumber: nextNumber, - titleAr: source.titleAr, - titleEn: source.titleEn, - meetingDate: targetDate, - startTime: source.startTime, - endTime: source.endTime, - location: source.location, - meetingUrl: source.meetingUrl, - platform: source.platform, - status: "scheduled", - isHighlighted: 0, - notes: source.notes, - attachments: source.attachments as never, - createdBy: userId, - updatedBy: userId, - }) + .values(meetingValues) .returning(); if (!meeting) throw new Error("duplicate_failed"); if (source.attendees && source.attendees.length > 0) { @@ -728,9 +762,7 @@ router.post( } 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" }); + res.status(409).json({ error: "Daily number conflict", code: "conflict" }); return; } res.status(500).json({ error: msg, code: "server_error" }); @@ -742,31 +774,9 @@ router.post( // REQUESTS // ===================================================================== -const REQUEST_TYPES = [ - "create", - "edit", - "delete", - "reschedule", - "add_attendee", - "remove_attendee", - "change_location", - "cancel_meeting", - "note", - "highlight", - "unhighlight", - "other", -] as const; -const REQUEST_STATUSES = [ - "new", - "approved", - "rejected", - "withdrawn", - "needs_edit", - "done", -] as const; +const TIME_RE_LOCAL = /^\d{2}:\d{2}(:\d{2})?$/; +const DATE_RE_LOCAL = /^\d{4}-\d{2}-\d{2}$/; -// Pre-validate apply payload OUTSIDE a tx so we can fail-fast with 400. -// Returns null on success, or an error message string. function validateApplyPayload( reqRow: typeof executiveMeetingRequestsTable.$inferSelect, ): string | null { @@ -774,25 +784,22 @@ function validateApplyPayload( if (reqRow.requestType === "reschedule") { const st = typeof details.startTime === "string" ? details.startTime : null; const et = typeof details.endTime === "string" ? details.endTime : null; - const md = - typeof details.meetingDate === "string" ? details.meetingDate : null; - if (st && !TIME_RE.test(st)) return "startTime invalid"; - if (et && !TIME_RE.test(et)) return "endTime invalid"; - if (md && !DATE_RE.test(md)) return "meetingDate invalid"; + const md = typeof details.meetingDate === "string" ? details.meetingDate : null; + if (st && !TIME_RE_LOCAL.test(st)) return "startTime invalid"; + if (et && !TIME_RE_LOCAL.test(et)) return "endTime invalid"; + if (md && !DATE_RE_LOCAL.test(md)) return "meetingDate invalid"; if (st && et && st > et) return "startTime must be <= endTime"; } return null; } -// Apply an approved request to its target meeting and return the updated row. -// Runs inside the caller's transaction; throws on missing meeting / bad shape. async function applyApprovedRequest( tx: DbExecutor, reqRow: typeof executiveMeetingRequestsTable.$inferSelect, performedBy: number, ): Promise { const details = (reqRow.requestDetails ?? {}) as Record; - if (!reqRow.meetingId) return; // create-suggestions: leave for office manager to act on + if (!reqRow.meetingId) return; const meetingId = reqRow.meetingId; const [existing] = await tx .select() @@ -800,20 +807,19 @@ async function applyApprovedRequest( .where(eq(executiveMeetingsTable.id, meetingId)); if (!existing) return; - const update: Record = { updatedBy: performedBy }; + const update: Partial = { + updatedBy: performedBy, + }; switch (reqRow.requestType) { case "reschedule": { - if (typeof details.startTime === "string" && TIME_RE.test(details.startTime)) + if (typeof details.startTime === "string" && TIME_RE_LOCAL.test(details.startTime)) update.startTime = details.startTime; - if (typeof details.endTime === "string" && TIME_RE.test(details.endTime)) + if (typeof details.endTime === "string" && TIME_RE_LOCAL.test(details.endTime)) update.endTime = details.endTime; - if (typeof details.meetingDate === "string" && DATE_RE.test(details.meetingDate)) + if (typeof details.meetingDate === "string" && DATE_RE_LOCAL.test(details.meetingDate)) update.meetingDate = details.meetingDate; - // Effective time-range guard: combine new fields with existing values - const effStart = - (update.startTime as string | undefined) ?? (existing.startTime as string | null); - const effEnd = - (update.endTime as string | undefined) ?? (existing.endTime as string | null); + const effStart = update.startTime ?? existing.startTime; + const effEnd = update.endTime ?? existing.endTime; if (effStart && effEnd && effStart > effEnd) { throw new Error("APPLY_VALIDATION:startTime must be <= endTime"); } @@ -841,8 +847,7 @@ async function applyApprovedRequest( update.isHighlighted = 0; break; case "note": { - const newNote = - typeof details.note === "string" ? details.note.slice(0, 5000) : ""; + const newNote = typeof details.note === "string" ? details.note.slice(0, 5000) : ""; const prev = existing.notes ?? ""; update.notes = prev ? `${prev}\n${newNote}` : newNote; break; @@ -850,15 +855,16 @@ async function applyApprovedRequest( case "add_attendee": { const name = typeof details.name === "string" ? details.name.trim() : ""; if (!name) break; - const title = - typeof details.title === "string" ? details.title.slice(0, 200) : null; + const title = typeof details.title === "string" ? details.title.slice(0, 200) : null; const attendanceType = typeof details.attendanceType === "string" && - ["internal", "virtual", "external"].includes(details.attendanceType) - ? details.attendanceType + (ATTENDANCE_TYPES as readonly string[]).includes(details.attendanceType) + ? (details.attendanceType as (typeof ATTENDANCE_TYPES)[number]) : "internal"; const [{ value: maxOrder }] = await tx - .select({ value: sql`coalesce(max(${executiveMeetingAttendeesTable.sortOrder}), -1)` }) + .select({ + value: sql`coalesce(max(${executiveMeetingAttendeesTable.sortOrder}), -1)`, + }) .from(executiveMeetingAttendeesTable) .where(eq(executiveMeetingAttendeesTable.meetingId, meetingId)); await tx.insert(executiveMeetingAttendeesTable).values({ @@ -890,27 +896,22 @@ async function applyApprovedRequest( if (Object.keys(update).length > 1) { await tx .update(executiveMeetingsTable) - .set(update as never) + .set(update) .where(eq(executiveMeetingsTable.id, meetingId)); } } router.get( "/executive-meetings/requests", - requireAuth, - requireRead, + requireExecutiveAccess, async (req, res): Promise => { const status = typeof req.query.status === "string" ? req.query.status : ""; const mine = req.query.mine === "true" || req.query.mine === "1"; const userId = req.session.userId!; - const conds = []; - if (status && (REQUEST_STATUSES as readonly string[]).includes(status)) { - conds.push(eq(executiveMeetingRequestsTable.status, status)); - } - if (mine) { - conds.push(eq(executiveMeetingRequestsTable.requestedBy, userId)); - } + const conds: SQL[] = []; + if (status) conds.push(eq(executiveMeetingRequestsTable.status, status)); + if (mine) conds.push(eq(executiveMeetingRequestsTable.requestedBy, userId)); const where = conds.length > 0 ? and(...conds) : undefined; const rows = await db @@ -922,7 +923,7 @@ router.get( }) .from(executiveMeetingRequestsTable) .leftJoin(usersTable, eq(usersTable.id, executiveMeetingRequestsTable.requestedBy)) - .where(where as never) + .where(where) .orderBy(desc(executiveMeetingRequestsTable.createdAt)) .limit(200); @@ -941,49 +942,39 @@ router.get( }, ); +// Top-level POST /requests handles "create" (no target meeting yet) and any +// request that doesn't have a numeric meeting id in the URL. Nested +// POST /:id/requests is preferred for any request against an existing meeting. router.post( "/executive-meetings/requests", - requireAuth, + requireExecutiveAccess, requireRequest, async (req, res): Promise => { - const body = (req.body ?? {}) as Record; - const requestType = body.requestType; - if ( - typeof requestType !== "string" || - !(REQUEST_TYPES as readonly string[]).includes(requestType) - ) { - res.status(400).json({ error: "requestType invalid", code: "validation" }); - return; - } - let meetingId: number | null = null; - if (body.meetingId !== undefined && body.meetingId !== null) { - const n = Number(body.meetingId); - if (!Number.isInteger(n) || n <= 0) { - res.status(400).json({ error: "meetingId invalid", code: "validation" }); - return; - } + const data = parseBody(res, requestCreateSchema, req.body); + if (!data) return; + let meetingId = data.meetingId ?? null; + if (meetingId !== null) { const [m] = await db .select({ id: executiveMeetingsTable.id }) .from(executiveMeetingsTable) - .where(eq(executiveMeetingsTable.id, n)); + .where(eq(executiveMeetingsTable.id, meetingId)); if (!m) { res.status(404).json({ error: "Meeting not found", code: "not_found" }); return; } - meetingId = n; } - const requestDetails = (body.requestDetails ?? {}) as Record; const userId = req.session.userId!; const created = await db.transaction(async (tx) => { + const insertValues: typeof executiveMeetingRequestsTable.$inferInsert = { + meetingId, + requestedBy: userId, + requestType: data.requestType, + requestDetails: data.requestDetails, + status: "new", + }; const [row] = await tx .insert(executiveMeetingRequestsTable) - .values({ - meetingId, - requestedBy: userId, - requestType, - requestDetails: requestDetails as never, - status: "new", - }) + .values(insertValues) .returning(); await logAudit(tx, { action: "submit", @@ -998,9 +989,57 @@ router.post( }, ); +router.post( + "/executive-meetings/:id/requests", + requireExecutiveAccess, + requireRequest, + async (req, res): Promise => { + const meetingId = Number(req.params.id); + if (!Number.isInteger(meetingId) || meetingId <= 0) { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + const [m] = await db + .select({ id: executiveMeetingsTable.id }) + .from(executiveMeetingsTable) + .where(eq(executiveMeetingsTable.id, meetingId)); + if (!m) { + res.status(404).json({ error: "Meeting not found", code: "not_found" }); + return; + } + const data = parseBody(res, requestNestedCreateSchema, req.body); + if (!data) return; + const userId = req.session.userId!; + const created = await db.transaction(async (tx) => { + const insertValues: typeof executiveMeetingRequestsTable.$inferInsert = { + meetingId, + requestedBy: userId, + requestType: data.requestType, + requestDetails: data.requestDetails, + status: "new", + }; + const [row] = await tx + .insert(executiveMeetingRequestsTable) + .values(insertValues) + .returning(); + await logAudit(tx, { + action: "submit", + entityType: "request", + entityId: row!.id, + newValue: row!, + performedBy: userId, + }); + return row; + }); + res.status(201).json(created); + }, +); + +// PATCH approvals are status-driven for the office manager. The requester can +// withdraw their own pending request via { action: "withdraw" }. router.patch( "/executive-meetings/requests/:id", - requireAuth, + requireExecutiveAccess, async (req, res): Promise => { const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) { @@ -1016,12 +1055,11 @@ router.patch( res.status(404).json({ error: "Request not found", code: "not_found" }); return; } - const body = (req.body ?? {}) as Record; - const action = body.action; + const data = parseBody(res, requestReviewSchema, req.body); + if (!data) return; const names = await getRoleNamesForUser(userId); - // Withdraw: requester only, only when 'new' - if (action === "withdraw") { + if ("action" in data && data.action === "withdraw") { if (existing.requestedBy !== userId) { res.status(403).json({ error: "Forbidden", code: "forbidden" }); return; @@ -1037,7 +1075,7 @@ router.patch( .where(eq(executiveMeetingRequestsTable.id, id)) .returning(); await logAudit(tx, { - action: "update", + action: "withdraw", entityType: "request", entityId: id, oldValue: existing, @@ -1050,165 +1088,103 @@ router.patch( return; } - // Approve / Reject: approver roles only — atomic state guard in SQL - if (action === "approve" || action === "reject") { - const canApprove = APPROVE_ROLES.some((r) => names.has(r)); - if (!canApprove) { - res.status(403).json({ error: "Forbidden", code: "forbidden" }); + // Status-driven review path (approved | rejected | needs_edit) + const canApprove = APPROVE_ROLES.some((r) => names.has(r)); + if (!canApprove) { + res.status(403).json({ error: "Forbidden", code: "forbidden" }); + return; + } + if (existing.status !== "new") { + res.status(409).json({ error: "Already reviewed", code: "bad_state" }); + return; + } + const review = data as { + status: (typeof REQUEST_REVIEW_STATUSES)[number]; + reviewNotes?: string | null; + }; + + if (review.status === "approved") { + const validationError = validateApplyPayload(existing); + if (validationError) { + res.status(400).json({ error: validationError, code: "validation" }); return; } - if (existing.status !== "new") { - res.status(409).json({ error: "Already reviewed", code: "bad_state" }); - return; - } - // Pre-validate apply payload before opening tx (fail fast for approve) - if (action === "approve") { - const validationError = validateApplyPayload(existing); - if (validationError) { - res.status(400).json({ error: validationError, code: "validation" }); - return; - } - } - const reviewNotes = - typeof body.reviewNotes === "string" ? body.reviewNotes.trim() : null; - const newStatus = action === "approve" ? "approved" : "rejected"; - try { - const updated = await db.transaction(async (tx) => { - // Atomic conditional update: only flip if still 'new' - const [row] = await tx - .update(executiveMeetingRequestsTable) - .set({ - status: newStatus, - reviewedBy: userId, - reviewDecision: newStatus, - reviewNotes, - }) - .where( - and( - eq(executiveMeetingRequestsTable.id, id), - eq(executiveMeetingRequestsTable.status, "new"), - ), - ) - .returning(); - if (!row) { - // Lost race — another reviewer got there first - throw new Error("RACE_ALREADY_REVIEWED"); - } - await logAudit(tx, { - action: action === "approve" ? "approve" : "reject", - entityType: "request", - entityId: id, - oldValue: existing, - newValue: row, - performedBy: userId, - }); - if (action === "approve") { - // Apply request to the underlying meeting (when applicable) - await applyApprovedRequest(tx, row, userId); - if (row.meetingId) { - await logAudit(tx, { - action: "apply", - entityType: "meeting", - entityId: row.meetingId, - oldValue: { fromRequestId: row.id }, - newValue: { requestType: row.requestType, details: row.requestDetails }, - performedBy: userId, - }); - } - // Create a coordination follow-up task linked to this request - const [task] = await tx - .insert(executiveMeetingTasksTable) - .values({ - taskType: `follow_up_${row.requestType}`, - meetingId: row.meetingId, - requestId: row.id, - assignedTo: row.assignedTo ?? null, - status: "pending", - notes: reviewNotes, - }) - .returning(); + } + + try { + const updated = await db.transaction(async (tx) => { + const [row] = await tx + .update(executiveMeetingRequestsTable) + .set({ + status: review.status, + reviewedBy: userId, + reviewDecision: review.status, + reviewNotes: review.reviewNotes ?? null, + }) + .where( + and( + eq(executiveMeetingRequestsTable.id, id), + eq(executiveMeetingRequestsTable.status, "new"), + ), + ) + .returning(); + if (!row) throw new Error("RACE_ALREADY_REVIEWED"); + await logAudit(tx, { + action: review.status, + entityType: "request", + entityId: id, + oldValue: existing, + newValue: row, + performedBy: userId, + }); + if (review.status === "approved") { + await applyApprovedRequest(tx, row, userId); + if (row.meetingId) { await logAudit(tx, { - action: "create", - entityType: "task", - entityId: task!.id, - newValue: task!, + action: "apply", + entityType: "meeting", + entityId: row.meetingId, + oldValue: { fromRequestId: row.id }, + newValue: { requestType: row.requestType, details: row.requestDetails }, performedBy: userId, }); } - return row; - }); - res.json(updated); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (msg === "RACE_ALREADY_REVIEWED") { - res.status(409).json({ error: "Already reviewed", code: "bad_state" }); - return; + const [task] = await tx + .insert(executiveMeetingTasksTable) + .values({ + taskType: `follow_up_${row.requestType}`, + meetingId: row.meetingId, + requestId: row.id, + assignedTo: row.assignedTo ?? null, + status: "pending", + notes: review.reviewNotes ?? null, + }) + .returning(); + await logAudit(tx, { + action: "create", + entityType: "task", + entityId: task!.id, + newValue: task!, + performedBy: userId, + }); } - if (msg.startsWith("APPLY_VALIDATION:")) { - res - .status(400) - .json({ error: msg.slice("APPLY_VALIDATION:".length), code: "validation" }); - return; - } - res.status(500).json({ error: msg, code: "server_error" }); - } - return; - } - - // Office manager: needs_edit (returns to requester) — atomic, only from 'new' - if (action === "needs_edit") { - const canApprove = APPROVE_ROLES.some((r) => names.has(r)); - if (!canApprove) { - res.status(403).json({ error: "Forbidden", code: "forbidden" }); - return; - } - if (existing.status !== "new") { + return row; + }); + res.json(updated); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg === "RACE_ALREADY_REVIEWED") { res.status(409).json({ error: "Already reviewed", code: "bad_state" }); return; } - const reviewNotes = - typeof body.reviewNotes === "string" ? body.reviewNotes.trim() : null; - try { - const updated = await db.transaction(async (tx) => { - const [row] = await tx - .update(executiveMeetingRequestsTable) - .set({ - status: "needs_edit", - reviewedBy: userId, - reviewDecision: "needs_edit", - reviewNotes, - }) - .where( - and( - eq(executiveMeetingRequestsTable.id, id), - eq(executiveMeetingRequestsTable.status, "new"), - ), - ) - .returning(); - if (!row) throw new Error("RACE_ALREADY_REVIEWED"); - await logAudit(tx, { - action: "needs_edit", - entityType: "request", - entityId: id, - oldValue: existing, - newValue: row, - performedBy: userId, - }); - return row; - }); - res.json(updated); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (msg === "RACE_ALREADY_REVIEWED") { - res.status(409).json({ error: "Already reviewed", code: "bad_state" }); - return; - } - res.status(500).json({ error: msg, code: "server_error" }); + if (msg.startsWith("APPLY_VALIDATION:")) { + res + .status(400) + .json({ error: msg.slice("APPLY_VALIDATION:".length), code: "validation" }); + return; } - return; + res.status(500).json({ error: msg, code: "server_error" }); } - - res.status(400).json({ error: "Invalid action", code: "validation" }); }, ); @@ -1216,30 +1192,54 @@ router.patch( // TASKS // ===================================================================== -const TASK_STATUSES = ["pending", "in_progress", "completed", "cancelled"] as const; - router.get( "/executive-meetings/tasks", - requireAuth, - requireRead, + requireExecutiveAccess, async (req, res): Promise => { const status = typeof req.query.status === "string" ? req.query.status : ""; const mine = req.query.mine === "true" || req.query.mine === "1"; const meetingIdRaw = typeof req.query.meetingId === "string" ? req.query.meetingId : ""; + 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 assigneeRaw = Number(req.query.assigneeId); const userId = req.session.userId!; - const conds = []; + const conds: SQL[] = []; if (status && (TASK_STATUSES as readonly string[]).includes(status)) { conds.push(eq(executiveMeetingTasksTable.status, status)); } if (mine) { conds.push(eq(executiveMeetingTasksTable.assignedTo, userId)); } + if (Number.isInteger(assigneeRaw) && assigneeRaw > 0) { + conds.push(eq(executiveMeetingTasksTable.assignedTo, assigneeRaw)); + } if (meetingIdRaw) { const n = Number(meetingIdRaw); if (Number.isInteger(n) && n > 0) { conds.push(eq(executiveMeetingTasksTable.meetingId, n)); } } + 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(executiveMeetingTasksTable.createdAt, start)); + conds.push(lt(executiveMeetingTasksTable.createdAt, end)); + } else { + if (dateFromRaw && DATE_RE_LOCAL.test(dateFromRaw)) { + conds.push( + gte( + executiveMeetingTasksTable.createdAt, + 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(executiveMeetingTasksTable.createdAt, end)); + } + } const where = conds.length > 0 ? and(...conds) : undefined; const rows = await db .select({ @@ -1250,7 +1250,7 @@ router.get( }) .from(executiveMeetingTasksTable) .leftJoin(usersTable, eq(usersTable.id, executiveMeetingTasksTable.assignedTo)) - .where(where as never) + .where(where) .orderBy( asc(executiveMeetingTasksTable.status), desc(executiveMeetingTasksTable.createdAt), @@ -1273,43 +1273,25 @@ router.get( router.post( "/executive-meetings/tasks", - requireAuth, + requireExecutiveAccess, requireMutate, async (req, res): Promise => { - const body = (req.body ?? {}) as Record; - const taskType = validString(body.taskType, 64); - if (!taskType) { - res.status(400).json({ error: "taskType required", code: "validation" }); - return; - } - let meetingId: number | null = null; - if (body.meetingId !== undefined && body.meetingId !== null) { - const n = Number(body.meetingId); - if (Number.isInteger(n) && n > 0) meetingId = n; - } - let assignedTo: number | null = null; - if (body.assignedTo !== undefined && body.assignedTo !== null) { - const n = Number(body.assignedTo); - if (Number.isInteger(n) && n > 0) assignedTo = n; - } - let dueAt: Date | null = null; - if (typeof body.dueAt === "string" && body.dueAt) { - const d = new Date(body.dueAt); - if (!Number.isNaN(d.getTime())) dueAt = d; - } - const notes = optionalString(body.notes, 5000) ?? null; + const data = parseBody(res, taskCreateSchema, req.body); + if (!data) return; const userId = req.session.userId!; const created = await db.transaction(async (tx) => { + const insertValues: typeof executiveMeetingTasksTable.$inferInsert = { + taskType: data.taskType, + meetingId: data.meetingId ?? null, + requestId: data.requestId ?? null, + assignedTo: data.assignedTo ?? null, + dueAt: data.dueAt ? new Date(data.dueAt) : null, + notes: data.notes ?? null, + status: "pending", + }; const [row] = await tx .insert(executiveMeetingTasksTable) - .values({ - taskType, - meetingId, - assignedTo, - dueAt, - notes, - status: "pending", - }) + .values(insertValues) .returning(); await logAudit(tx, { action: "create", @@ -1326,7 +1308,7 @@ router.post( router.patch( "/executive-meetings/tasks/:id", - requireAuth, + requireExecutiveAccess, async (req, res): Promise => { const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) { @@ -1349,51 +1331,21 @@ router.patch( res.status(403).json({ error: "Forbidden", code: "forbidden" }); return; } - const body = (req.body ?? {}) as Record; - const update: Record = {}; - if ("status" in body) { - if (!(TASK_STATUSES as readonly string[]).includes(body.status as string)) { - res.status(400).json({ error: "status invalid", code: "validation" }); - return; - } - update.status = body.status; - if (body.status === "completed") update.completedAt = new Date(); + const data = parseBody(res, taskPatchSchema, req.body); + if (!data) return; + const update: Partial = {}; + if (data.status !== undefined) { + update.status = data.status; + if (data.status === "completed") update.completedAt = new Date(); else if (existing.status === "completed") update.completedAt = null; } - // Only mutators can change assignment / due / type / notes if (isMutator) { - if ("taskType" in body) { - const tt = validString(body.taskType, 64); - if (!tt) { - res.status(400).json({ error: "taskType invalid", code: "validation" }); - return; - } - update.taskType = tt; - } - if ("assignedTo" in body) { - if (body.assignedTo === null) update.assignedTo = null; - else { - const n = Number(body.assignedTo); - if (Number.isInteger(n) && n > 0) update.assignedTo = n; - } - } - if ("dueAt" in body) { - if (body.dueAt === null || body.dueAt === "") update.dueAt = null; - else if (typeof body.dueAt === "string") { - const d = new Date(body.dueAt); - if (!Number.isNaN(d.getTime())) update.dueAt = d; - } - } - if ("notes" in body) { - update.notes = optionalString(body.notes, 5000) ?? null; - } - if ("meetingId" in body) { - if (body.meetingId === null) update.meetingId = null; - else { - const n = Number(body.meetingId); - if (Number.isInteger(n) && n > 0) update.meetingId = n; - } - } + if (data.taskType !== undefined) update.taskType = data.taskType; + if (data.assignedTo !== undefined) update.assignedTo = data.assignedTo; + if (data.dueAt !== undefined) + update.dueAt = data.dueAt && data.dueAt !== "" ? new Date(data.dueAt) : null; + if (data.notes !== undefined) update.notes = data.notes; + if (data.meetingId !== undefined) update.meetingId = data.meetingId; } if (Object.keys(update).length === 0) { res.json(existing); @@ -1402,7 +1354,7 @@ router.patch( const updated = await db.transaction(async (tx) => { const [row] = await tx .update(executiveMeetingTasksTable) - .set(update as never) + .set(update) .where(eq(executiveMeetingTasksTable.id, id)) .returning(); await logAudit(tx, { @@ -1413,7 +1365,6 @@ router.patch( newValue: row, performedBy: userId, }); - // Propagate task completion → originating request becomes "done" if ( row && row.status === "completed" && @@ -1448,7 +1399,7 @@ router.patch( router.delete( "/executive-meetings/tasks/:id", - requireAuth, + requireExecutiveAccess, requireMutate, async (req, res): Promise => { const id = Number(req.params.id); @@ -1487,7 +1438,7 @@ router.delete( router.get( "/executive-meetings/audit-logs", - requireAuth, + requireExecutiveAccess, requireAdminAudit, async (req, res): Promise => { const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : ""; @@ -1500,19 +1451,20 @@ router.get( 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 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) : 100; - const conds = []; - if (dateRaw && DATE_RE.test(dateRaw)) { + Number.isFinite(limitRaw) && limitRaw > 0 + ? Math.min(Math.floor(limitRaw), 200) + : 100; + 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.test(dateFromRaw)) { + if (dateFromRaw && DATE_RE_LOCAL.test(dateFromRaw)) { conds.push( gte( executiveMeetingAuditLogsTable.performedAt, @@ -1520,20 +1472,14 @@ router.get( ), ); } - if (dateToRaw && DATE_RE.test(dateToRaw)) { + 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)); - } + 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 rows = await db .select({ @@ -1544,7 +1490,7 @@ router.get( }) .from(executiveMeetingAuditLogsTable) .leftJoin(usersTable, eq(usersTable.id, executiveMeetingAuditLogsTable.performedBy)) - .where(where as never) + .where(where) .orderBy(desc(executiveMeetingAuditLogsTable.performedAt)) .limit(limit); res.json({ @@ -1563,13 +1509,12 @@ router.get( ); // ===================================================================== -// NOTIFICATIONS (placeholder list) +// NOTIFICATIONS (placeholder list — visible to any executive read role) // ===================================================================== router.get( "/executive-meetings/notifications", - requireAuth, - requireRead, + requireExecutiveAccess, async (_req, res): Promise => { const rows = await db .select({ @@ -1603,12 +1548,11 @@ router.get( router.get( "/executive-meetings/pdf-archives", - requireAuth, - requireRead, + requireExecutiveAccess, async (req, res): Promise => { const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : ""; - const conds = []; - if (dateRaw && DATE_RE.test(dateRaw)) { + const conds: SQL[] = []; + if (dateRaw && DATE_RE_LOCAL.test(dateRaw)) { conds.push(eq(executiveMeetingPdfArchivesTable.archiveDate, dateRaw)); } const where = conds.length > 0 ? and(...conds) : undefined; @@ -1624,7 +1568,7 @@ router.get( usersTable, eq(usersTable.id, executiveMeetingPdfArchivesTable.generatedBy), ) - .where(where as never) + .where(where) .orderBy(desc(executiveMeetingPdfArchivesTable.generatedAt)) .limit(200); res.json({ @@ -1644,24 +1588,11 @@ router.get( router.post( "/executive-meetings/pdf-archives", - requireAuth, - requireRead, + requireExecutiveAccess, async (req, res): Promise => { - const body = (req.body ?? {}) as Record; - const archiveDate = - typeof body.archiveDate === "string" && DATE_RE.test(body.archiveDate) - ? body.archiveDate - : null; - if (!archiveDate) { - res - .status(400) - .json({ error: "archiveDate required (YYYY-MM-DD)", code: "validation" }); - return; - } - const filePath = - typeof body.filePath === "string" && body.filePath.trim() - ? body.filePath.trim().slice(0, 500) - : `print:${archiveDate}`; + 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 @@ -1669,15 +1600,16 @@ router.post( value: sql`coalesce(max(${executiveMeetingPdfArchivesTable.version}), 0)`, }) .from(executiveMeetingPdfArchivesTable) - .where(eq(executiveMeetingPdfArchivesTable.archiveDate, archiveDate)); + .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({ - archiveDate, - filePath, - version: (maxV ?? 0) + 1, - generatedBy: userId, - }) + .values(insertValues) .returning(); await logAudit(tx, { action: "create", @@ -1696,22 +1628,9 @@ router.post( // FONT SETTINGS // ===================================================================== -const FONT_FAMILIES = [ - "system", - "Cairo", - "Tajawal", - "Noto Naskh Arabic", - "Amiri", - "Inter", - "monospace", -] as const; -const FONT_WEIGHTS = ["regular", "medium", "semibold", "bold"] as const; -const FONT_ALIGNMENTS = ["start", "center", "end"] as const; - router.get( "/executive-meetings/font-settings", - requireAuth, - requireRead, + requireExecutiveAccess, async (req, res): Promise => { const userId = req.session.userId!; const rows = await db @@ -1732,90 +1651,90 @@ router.get( }, ); -router.patch( - "/executive-meetings/font-settings", - requireAuth, - requireRead, - async (req, res): Promise => { - const userId = req.session.userId!; - const body = (req.body ?? {}) as Record; - const scope = body.scope === "global" ? "global" : "user"; - if (scope === "global") { - const names = await getRoleNamesForUser(userId); - const ok = ["admin", "executive_office_manager"].some((r) => names.has(r)); - if (!ok) { - res.status(403).json({ error: "Forbidden", code: "forbidden" }); - return; - } +// Shared handler used by both PUT (canonical) and PATCH (alias) so we don't +// rely on Express re-dispatch tricks — both methods run the exact same logic. +const upsertFontSettingsHandler = async ( + req: import("express").Request, + res: import("express").Response, +): Promise => { + const userId = req.session.userId!; + const data = parseBody(res, fontSettingsSchema, req.body); + if (!data) return; + if (data.scope === "global") { + const names = await getRoleNamesForUser(userId); + const ok = APPROVE_ROLES.some((r) => names.has(r)); + if (!ok) { + res.status(403).json({ error: "Forbidden", code: "forbidden" }); + return; } - const fontFamily = - typeof body.fontFamily === "string" && - (FONT_FAMILIES as readonly string[]).includes(body.fontFamily) - ? body.fontFamily - : "system"; - const fontSizeRaw = Number(body.fontSize); - const fontSize = - Number.isInteger(fontSizeRaw) && fontSizeRaw >= 10 && fontSizeRaw <= 32 - ? fontSizeRaw - : 14; - const fontWeight = - typeof body.fontWeight === "string" && - (FONT_WEIGHTS as readonly string[]).includes(body.fontWeight) - ? body.fontWeight - : "regular"; - const alignment = - typeof body.alignment === "string" && - (FONT_ALIGNMENTS as readonly string[]).includes(body.alignment) - ? body.alignment - : "start"; - - const targetUserId = scope === "global" ? null : userId; - const result = await db.transaction(async (tx) => { - const [existing] = await tx - .select() - .from(executiveMeetingFontSettingsTable) - .where( - and( - eq(executiveMeetingFontSettingsTable.scope, scope), - targetUserId === null - ? isNull(executiveMeetingFontSettingsTable.userId) - : eq(executiveMeetingFontSettingsTable.userId, targetUserId), - ), - ); - let row; - if (existing) { - const [updated] = await tx - .update(executiveMeetingFontSettingsTable) - .set({ fontFamily, fontSize, fontWeight, alignment }) - .where(eq(executiveMeetingFontSettingsTable.id, existing.id)) - .returning(); - row = updated; - } else { - const [inserted] = await tx - .insert(executiveMeetingFontSettingsTable) - .values({ - scope, - userId: targetUserId, - fontFamily, - fontSize, - fontWeight, - alignment, - }) - .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; + } + 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, }); - res.json(result); - }, + 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; diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index 1830d67a..ccf35be5 100644 Binary files a/artifacts/tx-os/public/opengraph.jpg and b/artifacts/tx-os/public/opengraph.jpg differ diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 725b78a2..7db5085d 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -748,6 +748,8 @@ "empty": "لا توجد طلبات بانتظار المراجعة.", "approve": "اعتماد", "reject": "رفض", + "needsEdit": "إعادة للتعديل", + "needsEditDone": "تم إرجاع الطلب لمقدّمه", "reviewNotes": "ملاحظات المراجعة", "reviewNotesPlaceholder": "اكتب ملاحظاتك هنا (اختياري)", "approved": "تم الاعتماد", @@ -790,32 +792,47 @@ "action": "الإجراء", "entity": "النوع", "entityId": "المعرف", - "diff": "التفاصيل" + "diff": "التفاصيل", + "changes": "التغييرات" }, + "created": "تم إنشاء", + "removed": "تم حذف", + "moreChanges": "تغييرات أخرى", "entity": { "meeting": "اجتماع", "attendee": "حاضر", "request": "طلب", "task": "مهمة", - "font_settings": "إعدادات الخط" + "font_settings": "إعدادات الخط", + "pdf_archive": "أرشيف PDF" }, "action": { "create": "إنشاء", "update": "تعديل", "delete": "حذف", "approve": "اعتماد", + "approved": "تم الاعتماد", "reject": "رفض", - "submit": "إرسال" + "rejected": "تم الرفض", + "needs_edit": "إعادة للتعديل", + "withdraw": "سحب", + "submit": "إرسال", + "apply": "تطبيق", + "duplicate": "تكرار", + "replace_attendees": "تحديث الحضور", + "done": "تم" } }, "pdf": { "heading": "تصدير / طباعة", "intro": "تستخدم هذه الواجهة طابعة المتصفح لإنتاج نسخة PDF من جدول اليوم. اختر التاريخ ثم اضغط طباعة.", - "print": "طباعة الجدول", + "print": "طباعة وأرشفة", + "printOnly": "طباعة فقط", "openSchedule": "عرض الجدول", "selectDate": "تاريخ الجدول", "archiveSnapshot": "أرشفة نسخة", "archived": "تمت أرشفة النسخة", + "archivedAndPrinted": "تمت أرشفة النسخة وفتح نافذة الطباعة", "archivesHeading": "النسخ المؤرشفة", "noArchives": "لا توجد نسخ مؤرشفة لهذا التاريخ بعد." }, diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index b9417c6f..0ee0d4de 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -745,6 +745,8 @@ "empty": "No requests pending review.", "approve": "Approve", "reject": "Reject", + "needsEdit": "Send back for edits", + "needsEditDone": "Sent back to requester", "reviewNotes": "Review notes", "reviewNotesPlaceholder": "Optional notes for the requester", "approved": "Approved", @@ -787,32 +789,47 @@ "action": "Action", "entity": "Entity", "entityId": "ID", - "diff": "Details" + "diff": "Details", + "changes": "Changes" }, + "created": "Created", + "removed": "Removed", + "moreChanges": "more changes", "entity": { "meeting": "Meeting", "attendee": "Attendee", "request": "Request", "task": "Task", - "font_settings": "Font settings" + "font_settings": "Font settings", + "pdf_archive": "PDF archive" }, "action": { "create": "Create", "update": "Update", "delete": "Delete", "approve": "Approve", + "approved": "Approved", "reject": "Reject", - "submit": "Submit" + "rejected": "Rejected", + "needs_edit": "Needs edit", + "withdraw": "Withdrawn", + "submit": "Submit", + "apply": "Apply", + "duplicate": "Duplicate", + "replace_attendees": "Update attendees", + "done": "Done" } }, "pdf": { "heading": "Print / Export", "intro": "This view uses the browser print dialog to produce a PDF of the day's schedule. Pick a date and click Print.", - "print": "Print schedule", + "print": "Print + archive", + "printOnly": "Print only", "openSchedule": "Open schedule", "selectDate": "Schedule date", "archiveSnapshot": "Archive snapshot", "archived": "Snapshot archived", + "archivedAndPrinted": "Snapshot archived — print dialog opened", "archivesHeading": "Archived snapshots", "noArchives": "No snapshots archived for this date yet." }, diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index dfb7bb77..3e56e0a3 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -416,7 +416,7 @@ export default function ExecutiveMeetingsPage() { /> )} {section === "notifications" && me && ( - + )} {section === "audit" && me && ( @@ -1595,17 +1595,22 @@ function ApprovalsSection({ }); const requests = data?.requests ?? []; - async function review(id: number, action: "approve" | "reject") { + async function review( + id: number, + status: "approved" | "rejected" | "needs_edit", + ) { try { await apiJson(`/api/executive-meetings/requests/${id}`, { method: "PATCH", - body: { action, reviewNotes: notes[id] ?? "" }, + body: { status, reviewNotes: notes[id] ?? "" }, }); toast({ title: - action === "approve" + status === "approved" ? t("executiveMeetings.approvals.approved") - : t("executiveMeetings.approvals.rejected"), + : status === "rejected" + ? t("executiveMeetings.approvals.rejected") + : t("executiveMeetings.approvals.needsEditDone"), }); await Promise.all([ qc.invalidateQueries({ queryKey: ["/api/executive-meetings/requests"] }), @@ -1675,10 +1680,18 @@ function ApprovalsSection({ value={notes[r.id] ?? ""} onChange={(e) => setNotes({ ...notes, [r.id]: e.target.value })} /> -
+
+ +