diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 99c4c8d4..73e46ad0 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -100,6 +100,15 @@ const ADMIN_AUDIT_ROLES = [ "executive_office_manager", "executive_coord_lead", ] as const; +// Tasks are an internal coordination workflow; CEO and viewer roles must NOT +// see the Tasks tab or hit GET /tasks. Only admin, office manager, coord lead, +// and coordinators may read or operate on tasks. +const TASK_VIEW_ROLES = [ + "admin", + "executive_office_manager", + "executive_coord_lead", + "executive_coordinator", +] as const; const READ_ROLES = [ "admin", "executive_ceo", @@ -145,6 +154,7 @@ 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); // ---------- Zod schemas ---------- @@ -245,12 +255,28 @@ const requestReviewSchema = z.union([ }), ]); +// dueAt accepts either a YYYY-MM-DD date (from ) OR a full +// ISO datetime. Date-only is normalized to start-of-day UTC. Empty string is +// treated as null so PATCH can clear a due date. +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: z.string().datetime().nullable().optional(), + dueAt: dueAtSchema, notes: z.string().trim().max(5000).nullable().optional(), }); @@ -258,7 +284,7 @@ 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(), + dueAt: dueAtSchema, notes: z.string().trim().max(5000).nullable().optional(), meetingId: positiveIntSchema.nullable().optional(), }); @@ -429,6 +455,7 @@ router.get( 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)), }); }, ); @@ -907,6 +934,11 @@ router.get( 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 dateFromRaw = + typeof req.query.dateFrom === "string" ? req.query.dateFrom.trim() : ""; + const dateToRaw = + typeof req.query.dateTo === "string" ? req.query.dateTo.trim() : ""; + const requesterRaw = Number(req.query.requester); const userId = req.session.userId!; // SERVER-SIDE LEAST-PRIVILEGE: only approver/admin/audit roles can see @@ -923,6 +955,22 @@ router.get( if (status) conds.push(eq(executiveMeetingRequestsTable.status, status)); if (mine || !canSeeAllRequests) { conds.push(eq(executiveMeetingRequestsTable.requestedBy, userId)); + } else if (Number.isInteger(requesterRaw) && requesterRaw > 0) { + // Approvers may filter by a specific requester; non-approvers ignored. + conds.push(eq(executiveMeetingRequestsTable.requestedBy, requesterRaw)); + } + if (dateFromRaw && /^\d{4}-\d{2}-\d{2}$/.test(dateFromRaw)) { + conds.push( + gte( + executiveMeetingRequestsTable.createdAt, + new Date(`${dateFromRaw}T00:00:00.000Z`), + ), + ); + } + if (dateToRaw && /^\d{4}-\d{2}-\d{2}$/.test(dateToRaw)) { + const end = new Date(`${dateToRaw}T00:00:00.000Z`); + end.setUTCDate(end.getUTCDate() + 1); + conds.push(lt(executiveMeetingRequestsTable.createdAt, end)); } const where = conds.length > 0 ? and(...conds) : undefined; @@ -1207,6 +1255,7 @@ router.patch( router.get( "/executive-meetings/tasks", requireExecutiveAccess, + requireTaskView, async (req, res): Promise => { const status = typeof req.query.status === "string" ? req.query.status : ""; const mine = req.query.mine === "true" || req.query.mine === "1"; @@ -1321,6 +1370,7 @@ router.post( router.patch( "/executive-meetings/tasks/:id", requireExecutiveAccess, + requireTaskView, async (req, res): Promise => { const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) { diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index fd3a24d5..7d5acef9 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -81,6 +81,7 @@ type MeRoles = { canApprove: boolean; canSubmitRequest: boolean; canViewAudit: boolean; + canViewTasks: boolean; }; type RequestRow = { @@ -201,6 +202,7 @@ type MeCapabilities = { canApprove: boolean; canSubmitRequest: boolean; canViewAudit: boolean; + canViewTasks: boolean; }; // Decides whether a navigation tab should be visible at all for the current @@ -224,10 +226,10 @@ function isSectionVisible( case "approvals": return me.canApprove; case "tasks": - // Coordination team (executive_coordinator) needs the Tasks tab even - // though they don't have full mutation rights — they execute follow-ups - // generated from approved requests. canSubmitRequest covers them. - return me.canMutate || me.canApprove || me.canSubmitRequest; + // Tasks tab is gated by the dedicated canViewTasks server-side flag, + // which only includes admin/office_manager/coord_lead/coordinator — + // CEO and viewer roles never see it. + return me.canViewTasks; case "notifications": return me.canRead; case "audit":