diff --git a/artifacts/api-server/scripts/cleanup-em-requests-tasks.sql b/artifacts/api-server/scripts/cleanup-em-requests-tasks.sql new file mode 100644 index 00000000..e76886f0 --- /dev/null +++ b/artifacts/api-server/scripts/cleanup-em-requests-tasks.sql @@ -0,0 +1,50 @@ +-- #262: Executive Meetings Requests/Approvals/Tasks removal cleanup. +-- +-- Drops the two retired tables and scrubs orphaned rows from the +-- surviving Executive Meetings tables that referenced them by string. +-- Idempotent: safe to re-run. Wraps everything in a single transaction +-- so a partial failure leaves the DB unchanged. +-- +-- Run order: +-- 1) Apply this script to dev / prod databases. +-- 2) Run `pnpm --filter @workspace/db push` to confirm the Drizzle +-- schema and the database are in sync (should be a no-op). +-- +-- Run command (dev): +-- psql "$DATABASE_URL" -f artifacts/api-server/scripts/cleanup-em-requests-tasks.sql + +BEGIN; + +-- 1. Notification preferences rows for retired event types. +DELETE FROM executive_meeting_notification_prefs +WHERE notification_type IN ( + 'request_submitted', + 'request_approved', + 'request_rejected', + 'request_needs_edit', + 'task_assigned', + 'task_completed' +); + +-- 2. Notifications rows for retired event types (executive-meeting domain only). +DELETE FROM executive_meeting_notifications +WHERE notification_type IN ( + 'request_submitted', + 'request_approved', + 'request_rejected', + 'request_needs_edit', + 'task_assigned', + 'task_completed' +); + +-- 3. Audit logs that targeted request/task entities. +DELETE FROM executive_meeting_audit_logs +WHERE entity_type IN ('request', 'task'); + +-- 4. Drop the retired tables themselves. CASCADE removes any FK indices +-- that referenced them; nothing in the surviving schema depends on +-- these two tables. +DROP TABLE IF EXISTS executive_meeting_tasks CASCADE; +DROP TABLE IF EXISTS executive_meeting_requests CASCADE; + +COMMIT; diff --git a/artifacts/api-server/src/lib/executive-meeting-notify.ts b/artifacts/api-server/src/lib/executive-meeting-notify.ts index dcee210f..80a6a695 100644 --- a/artifacts/api-server/src/lib/executive-meeting-notify.ts +++ b/artifacts/api-server/src/lib/executive-meeting-notify.ts @@ -21,14 +21,11 @@ import { logger } from "./logger"; * iterates this list to render one row per type. New event types must be * appended here so users can opt in/out of them. */ +// #262: collapsed to a single event after Requests/Approvals/Tasks were +// removed. Old types (request_*, task_*) are scrubbed from the prefs and +// notifications tables by cleanup-em-requests-tasks.sql. export const EXECUTIVE_MEETING_NOTIFICATION_TYPES = [ "meeting_created", - "request_submitted", - "request_approved", - "request_rejected", - "request_needs_edit", - "task_assigned", - "task_completed", ] as const; export type ExecutiveMeetingNotificationType = diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 4f287a0e..cfc34888 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -17,8 +17,8 @@ import { db } from "@workspace/db"; import { executiveMeetingsTable, executiveMeetingAttendeesTable, - executiveMeetingRequestsTable, - executiveMeetingTasksTable, + // #262: executiveMeetingRequestsTable + executiveMeetingTasksTable + // removed alongside the Requests / Approvals / Tasks tabs. executiveMeetingNotificationsTable, executiveMeetingNotificationPrefsTable, executiveMeetingAuditLogsTable, @@ -87,26 +87,8 @@ const MERGE_COLUMN_INDEX: Record<(typeof MERGE_COLUMNS)[number], number> = { attendees: 2, time: 3, }; -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; +// #262: REQUEST_TYPES, REQUEST_REVIEW_STATUSES, TASK_STATUSES removed +// alongside the Requests / Approvals / Tasks tabs. const FONT_FAMILIES = [ "system", "Cairo", @@ -125,29 +107,13 @@ const MUTATE_ROLES = [ "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 TASK_VIEW_ROLES = [ - "admin", - "executive_office_manager", - "executive_coord_lead", - "executive_coordinator", -] as const; -const TASK_BROAD_VIEW_ROLES = [ - "admin", - "executive_office_manager", - "executive_coord_lead", -] as const; +// #262: REQUEST_ROLES, TASK_VIEW_ROLES, TASK_BROAD_VIEW_ROLES removed +// alongside the Requests / Approvals / Tasks tabs. const READ_ROLES = [ "admin", "executive_ceo", @@ -191,13 +157,17 @@ function makeRequireRoles(allowed: ReadonlyArray) { const requireMutate = makeRequireRoles(MUTATE_ROLES); const requireApprove = makeRequireRoles(APPROVE_ROLES); -const requireRequest = makeRequireRoles(REQUEST_ROLES); const requireAdminAudit = makeRequireRoles(ADMIN_AUDIT_ROLES); -const requireTaskView = makeRequireRoles(TASK_VIEW_ROLES); +// #262: requireRequest + requireTaskView removed with their handlers. // ---------- Zod schemas ---------- const dateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "expected YYYY-MM-DD"); + +// #262: kept after the Requests/Approvals/Tasks block was removed; the +// audit + meetings query handlers still need it to validate ?date=… and +// ?dateFrom/dateTo= inputs. +const DATE_RE_LOCAL = /^\d{4}-\d{2}-\d{2}$/; const timeSchema = z .string() .regex(/^\d{2}:\d{2}(:\d{2})?$/, "expected HH:MM"); @@ -314,162 +284,13 @@ const duplicateSchema = z.object({ // frontend and server share the same contract for POST /reorder. const reorderSchema = ExecutiveMeetingsReorderBody; -const dateOnly = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); -const timeHm = z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/); - -const detailsByType = { - create: z - .object({ - titleAr: z.string().trim().max(300).optional(), - titleEn: z.string().trim().max(300).optional(), - meetingDate: dateOnly.optional(), - startTime: timeHm.optional(), - endTime: timeHm.optional(), - location: z.string().trim().max(300).optional(), - notes: z.string().trim().max(5000).optional(), - }) - .catchall(z.unknown()), - edit: z - .object({ - titleAr: z.string().trim().max(300).optional(), - titleEn: z.string().trim().max(300).optional(), - notes: z.string().trim().max(5000).optional(), - }) - .catchall(z.unknown()), - delete: z - .object({ - reason: z.string().trim().max(5000).optional(), - }) - .catchall(z.unknown()), - reschedule: z - .object({ - meetingDate: dateOnly.optional(), - startTime: timeHm.optional(), - endTime: timeHm.optional(), - notes: z.string().trim().max(5000).optional(), - }) - .catchall(z.unknown()) - .refine( - (v) => Boolean(v.meetingDate || v.startTime || v.endTime), - { message: "reschedule requires meetingDate, startTime, or endTime" }, - ), - add_attendee: z - .object({ - name: z.string().trim().min(1).max(200), - title: z.string().trim().max(200).optional(), - attendanceType: z.enum(ATTENDANCE_TYPES).optional(), - notes: z.string().trim().max(5000).optional(), - }) - .catchall(z.unknown()), - remove_attendee: z - .object({ - attendeeId: z.number().int().positive(), - notes: z.string().trim().max(5000).optional(), - }) - .catchall(z.unknown()), - change_location: z - .object({ - location: z.string().trim().max(300).optional(), - meetingUrl: z.string().trim().max(1000).optional(), - platform: z.enum(PLATFORMS).optional(), - notes: z.string().trim().max(5000).optional(), - }) - .catchall(z.unknown()) - .refine( - (v) => Boolean(v.location || v.meetingUrl || v.platform), - { message: "change_location requires location, meetingUrl, or platform" }, - ), - cancel_meeting: z - .object({ - reason: z.string().trim().max(5000).optional(), - }) - .catchall(z.unknown()), - note: z - .object({ - note: z.string().trim().max(5000).optional(), - text: z.string().trim().max(5000).optional(), - }) - .catchall(z.unknown()) - .refine( - (v) => Boolean((v.note ?? v.text ?? "").trim()), - { message: "note text required" }, - ), - highlight: z - .object({ - notes: z.string().trim().max(5000).optional(), - }) - .catchall(z.unknown()), - unhighlight: z - .object({ - notes: z.string().trim().max(5000).optional(), - }) - .catchall(z.unknown()), - other: z.object({}).catchall(z.unknown()), -} as const; - -const requestPayloadSchemas = z.discriminatedUnion("requestType", [ - z.object({ requestType: z.literal("create"), requestDetails: detailsByType.create }), - z.object({ requestType: z.literal("edit"), requestDetails: detailsByType.edit }), - z.object({ requestType: z.literal("delete"), requestDetails: detailsByType.delete }), - z.object({ requestType: z.literal("reschedule"), requestDetails: detailsByType.reschedule }), - z.object({ requestType: z.literal("add_attendee"), requestDetails: detailsByType.add_attendee }), - z.object({ requestType: z.literal("remove_attendee"), requestDetails: detailsByType.remove_attendee }), - z.object({ requestType: z.literal("change_location"), requestDetails: detailsByType.change_location }), - z.object({ requestType: z.literal("cancel_meeting"), requestDetails: detailsByType.cancel_meeting }), - z.object({ requestType: z.literal("note"), requestDetails: detailsByType.note }), - z.object({ requestType: z.literal("highlight"), requestDetails: detailsByType.highlight }), - z.object({ requestType: z.literal("unhighlight"), requestDetails: detailsByType.unhighlight }), - z.object({ requestType: z.literal("other"), requestDetails: detailsByType.other }), -]); - -const requestCreateSchema = z.intersection( - z.object({ meetingId: positiveIntSchema.nullable().optional() }), - requestPayloadSchemas, -); - -const requestNestedCreateSchema = requestPayloadSchemas; - -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 dueAtSchema = z - .union([ - z.string().regex(/^\d{4}-\d{2}-\d{2}$/), - z.string().datetime(), - z.literal(""), - ]) - .nullable() - .optional() - .transform((v) => { - if (v === "" || v === null || v === undefined) return null; - return /^\d{4}-\d{2}-\d{2}$/.test(v) ? `${v}T00:00:00.000Z` : v; - }); - -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: dueAtSchema, - 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: dueAtSchema, - notes: z.string().trim().max(5000).nullable().optional(), - meetingId: positiveIntSchema.nullable().optional(), -}); +// #262: dateOnly + timeHm helpers removed; only the request payload +// schemas referenced them. +// #262: detailsByType, requestPayloadSchemas, requestCreateSchema, +// requestNestedCreateSchema, requestReviewSchema, dueAtSchema, +// taskCreateSchema, taskPatchSchema removed alongside the +// Requests / Approvals / Tasks handlers. const pdfArchiveCreateSchema = z.object({ archiveDate: dateSchema, filePath: z.string().trim().max(500).optional(), @@ -631,11 +452,11 @@ router.get( roles: Array.from(names), canRead: READ_ROLES.some((r) => names.has(r)), canMutate: MUTATE_ROLES.some((r) => names.has(r)), + // #262: canApprove now only gates the global font-settings write + // path; canSubmitRequest, canViewTasks, canViewAllTasks were + // removed with the Requests/Approvals/Tasks tabs. canApprove: APPROVE_ROLES.some((r) => names.has(r)), - canSubmitRequest: REQUEST_ROLES.some((r) => names.has(r)), canViewAudit: ADMIN_AUDIT_ROLES.some((r) => names.has(r)), - canViewTasks: TASK_VIEW_ROLES.some((r) => names.has(r)), - canViewAllTasks: TASK_BROAD_VIEW_ROLES.some((r) => names.has(r)), }); }, ); @@ -1181,960 +1002,6 @@ router.post( }, ); -// ===================================================================== -// REQUESTS -// ===================================================================== - -const TIME_RE_LOCAL = /^\d{2}:\d{2}(:\d{2})?$/; -const DATE_RE_LOCAL = /^\d{4}-\d{2}-\d{2}$/; - -function validateApplyPayload( - reqRow: typeof executiveMeetingRequestsTable.$inferSelect, -): string | null { - const details = (reqRow.requestDetails ?? {}) as Record; - 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_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; -} - -async function applyApprovedRequest( - tx: DbExecutor, - reqRow: typeof executiveMeetingRequestsTable.$inferSelect, - performedBy: number, -): Promise { - const details = (reqRow.requestDetails ?? {}) as Record; - if (!reqRow.meetingId) return; - const meetingId = reqRow.meetingId; - const [existing] = await tx - .select() - .from(executiveMeetingsTable) - .where(eq(executiveMeetingsTable.id, meetingId)); - if (!existing) return; - - const update: Partial = { - updatedBy: performedBy, - }; - switch (reqRow.requestType) { - case "reschedule": { - if (typeof details.startTime === "string" && TIME_RE_LOCAL.test(details.startTime)) - update.startTime = details.startTime; - if (typeof details.endTime === "string" && TIME_RE_LOCAL.test(details.endTime)) - update.endTime = details.endTime; - if (typeof details.meetingDate === "string" && DATE_RE_LOCAL.test(details.meetingDate)) - update.meetingDate = details.meetingDate; - 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"); - } - break; - } - case "change_location": { - // Same plain-text sanitization boundary as the direct meeting - // POST/PATCH paths so an approved request cannot smuggle markup - // into location/meetingUrl. - if (typeof details.location === "string") - update.location = stripTagsToPlainTextOrNull( - details.location.slice(0, 300), - ); - if (typeof details.meetingUrl === "string") - update.meetingUrl = stripTagsToPlainTextOrNull( - details.meetingUrl.slice(0, 1000), - ); - if ( - typeof details.platform === "string" && - (PLATFORMS as readonly string[]).includes(details.platform) - ) - update.platform = details.platform; - break; - } - case "cancel_meeting": - update.status = "cancelled"; - break; - case "highlight": - update.isHighlighted = 1; - break; - case "unhighlight": - update.isHighlighted = 0; - break; - case "note": { - // Same plain-text sanitization as the direct notes write paths so - // an approved request cannot smuggle markup into the appended note. - const sanitized = - typeof details.note === "string" - ? stripTagsToPlainTextOrNull(details.note.slice(0, 5000)) - : null; - const newNote = sanitized ?? ""; - const prev = existing.notes ?? ""; - update.notes = prev ? `${prev}\n${newNote}` : newNote; - break; - } - case "add_attendee": { - const rawName = typeof details.name === "string" ? details.name.trim() : ""; - if (!rawName) break; - // Same sanitization boundary as the direct attendee POST/PUT paths so - // an approved request cannot smuggle script/event-handler markup into - // the rich-text attendee.name column. - const name = sanitizeRichText(rawName.slice(0, 5000)); - if (!name) break; - // Same plain-text sanitization as the direct attendee write paths - // so an approved request cannot smuggle New Room (Bldg A & B)', - meetingUrl: 'https://x.test/join?id=99&utm=ar', - }, - }, - ); - assert.equal(reqRes.status, 201); - const reqRow = await reqRes.json(); - created.requestIds.push(reqRow.id); - - const approve = await api( - adminCookie, - "PATCH", - `/api/executive-meetings/requests/${reqRow.id}`, - { status: "approved", reviewNotes: "ok" }, - ); - assert.equal(approve.status, 200); - - const fetched = await api( - adminCookie, - "GET", - `/api/executive-meetings/${meeting.id}`, - ); - const body = await fetched.json(); - assert.ok(!/', - attendanceType: "internal", - }, - }); - assert.equal(reqRes.status, 201); - const reqRow = await reqRes.json(); - - // PATCH /api/executive-meetings/requests/:id with {status:"approved"} - // routes through applyApprovedRequest -> add_attendee. - const approve = await api(adminCookie, "PATCH", - `/api/executive-meetings/requests/${reqRow.id}`, - { status: "approved" }); - assert.equal(approve.status, 200); - - const det = await api(adminCookie, "GET", `/api/executive-meetings/${M.id}`); - const detail = await det.json(); - const att = detail.attendees.at(-1); - assert.ok(/]*>Real<\/b>/i.test(att.name), " must survive"); - assert.ok(!/