From 2f10ae6dc40e2e4e4496213bae63126e49f1d05f Mon Sep 17 00:00:00 2001 From: Riyadh Date: Tue, 28 Apr 2026 07:19:07 +0000 Subject: [PATCH] =?UTF-8?q?Task=20#108=20=E2=80=94=20Executive=20Meetings?= =?UTF-8?q?=20Phase=202=20(RBAC=20+=20audit=20+=20Zod=20+=20i18n)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schedule UI / shell: - Centered cells, locked RTL column order # | الاجتماع | الحضور | الوقت, attendees column widest, tighter padding, navy/white/gray + red highlight only. - Header label "م" → "#" (ar.json). Schema (lib/db/src/schema/executive-meetings.ts): - executive_meeting_requests.meetingId is now nullable so create-suggestion requests work without an existing meeting. db push applied. Backend (artifacts/api-server/src/routes/executive-meetings.ts) — full rewrite: - All routes (including /me) gated by requireExecutiveAccess. Fine-grained middleware: requireMutate / requireApprove / requireRequest / requireAdminAudit on the appropriate routes. - Zod schemas validate every mutating body via parseBody(). - All mutations write to executiveMeetingAuditLogsTable via logAudit(). - New nested route POST /:id/requests in addition to top-level requests POST. - Status-driven approvals: PATCH /requests/:id accepts {status: 'approved'|'rejected'|'needs_edit', reviewNotes?} or {action:'withdraw'} for the requester. - PUT /font-settings (canonical) plus PATCH alias — both wired directly to the same shared upsertFontSettingsHandler (no method-rewrite hack). - GET /tasks supports date and assigneeId filters. - Removed all `as never` casts via $inferInsert/Partial typing. Auth middleware (artifacts/api-server/src/middlewares/auth.ts): - Added requireExecutiveAccess gating EXECUTIVE_READ_ROLES (admin + executive_*). Frontend (artifacts/tx-os/src/pages/executive-meetings.tsx): - Notifications visibility now uses canRead (was canViewAudit). - Approvals: review() sends {status, reviewNotes} and a new "Send back for edits" (needs_edit) button is rendered alongside Approve/Reject. - PdfSection: primary "Print + Archive" button (testid em-print) does archive then print in one click; em-print-only and em-archive remain. - AuditSection: 6th column renders the new in-file AuditDiffSummary component (compact "field: old → new" diff with truncation). - Print page (executive-meetings-print.tsx): kept its inline bilingual T table — it loads in a standalone print window before the i18n provider mounts, so an inline dictionary is the right pattern. No hardcoded UI text remains in the main page. i18n (en.json + ar.json): - New keys executiveMeetings.approvals.{needsEdit, needsEditDone}, .audit.col.changes, .audit.{created, removed, moreChanges}, .audit.entity.pdf_archive, .audit.action.{approved, rejected, needs_edit, withdraw, apply, duplicate, replace_attendees, done}, .pdf.{print = "Print + archive"/"طباعة وأرشفة", printOnly, archivedAndPrinted}. Validation: - runTest e2e passed: login, schedule loads with "#" header, Manage tab creates "اجتماع تجريبي E2E", Approvals/PDF/Audit tabs render with expected testids and the new Changes column. - curl smoke: login 200, /me 200, dates 200, create meeting 201, nested POST /:id/requests 201, PATCH /requests/:id status approval 200, audit-logs 200, DELETE meeting 204, PUT and PATCH /font-settings both 200 with persisted data. - Architect re-reviewed twice: VERDICT: APPROVED on the second pass after fixing /me gating and the PATCH font-settings dispatch. Out of scope (handled in follow-ups #110/#111/#112): real notifications delivery, server-side PDF rendering, mobile polish. Drift: none material. Followed plan T1–T8. --- artifacts/api-server/package.json | 3 +- artifacts/api-server/src/middlewares/auth.ts | 49 + .../src/routes/executive-meetings.ts | 1477 ++++++++--------- artifacts/tx-os/public/opengraph.jpg | Bin 25902 -> 25853 bytes artifacts/tx-os/src/locales/ar.json | 25 +- artifacts/tx-os/src/locales/en.json | 25 +- .../tx-os/src/pages/executive-meetings.tsx | 204 ++- pnpm-lock.yaml | 5 +- pnpm-workspace.yaml | 2 +- 9 files changed, 983 insertions(+), 807 deletions(-) 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 1830d67ae98a9194eee190cc906fb2ceec9c7685..ccf35be511a6c7bafbb3f35ca6f65c7e6ba912f5 100644 GIT binary patch literal 25853 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 literal 25902 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 Qbho`g15M>*`saiH1$ )} {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 })} /> -
+
+ +