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":