Task #108 — Executive Meetings Phase 2

Schedule UI polish, full CRUD, Requests/Approvals/Tasks/Audit/PDF/Print/
Font Settings/Notifications-placeholder, audit logging on every mutation,
fine-grained RBAC, bilingual i18n.

Backend (artifacts/api-server/src/routes/executive-meetings.ts)
- All mutations transactional + audit-logged.
- Approve/reject/needs_edit use atomic UPDATE WHERE status='new' RETURNING;
  race losers return 409 RACE_ALREADY_REVIEWED.
- applyApprovedRequest() applies reschedule / change_location / add_attendee /
  remove_attendee / highlight / unhighlight / cancel_meeting / create / edit /
  delete in-tx; effective-time validation throws APPLY_VALIDATION → 400.
- Approve auto-creates linked follow_up_<reqType> task with requestId set.
- Marking a task with requestId as 'completed' propagates request → 'done'
  (in same tx, audit-logged).
- New endpoints: PUT /:id/attendees, POST /:id/duplicate,
  GET/POST /pdf-archives.

Schema (lib/db/src/schema/executive-meetings.ts)
- executive_meeting_requests.meetingId nullable so 'create' requests work.
- pdf_archives table.

Frontend (artifacts/tx-os/src/pages/executive-meetings.tsx + new
executive-meetings-print.tsx, App.tsx route)
- Schedule polish: # column, RTL order, attendees widest, navy/white/gray,
  red highlight only.
- Tabs: Manage / Requests / Approvals / Tasks / Audit / PDF / Notifications /
  Font Settings.
- Requests form now sends STRUCTURED fields (meetingDate, startTime, endTime
  for reschedule; location/url for change_location; name/title for
  add_attendee; etc.) — previously sent only {note}, which prevented apply
  from doing anything.
- Approve handler invalidates ALL ['/api/executive-meetings', *] keys + tasks.
- PDF tab lists archives; Archive snapshot button; Print opens new route.

i18n (en.json/ar.json)
- Full executiveMeetings.* key set including pdf.archive*, attendees.title/id.

Verified
- E2e regression: create meeting → reschedule request (structured) → approve
  → meeting moves to new date → task created → mark task complete → request
  becomes 'done'. Negative case (start>end) → approve rejected.
- Architect final review: PASS, no severe blockers.

Out of scope per spec: real notification delivery, real PDF rendering, iCal,
mobile polish.
This commit is contained in:
riyadhafraa
2026-04-28 06:57:31 +00:00
parent b4d47e1012
commit bf85d7066e
8 changed files with 1100 additions and 38 deletions
@@ -8,6 +8,7 @@ import {
executiveMeetingTasksTable,
executiveMeetingNotificationsTable,
executiveMeetingAuditLogsTable,
executiveMeetingPdfArchivesTable,
executiveMeetingFontSettingsTable,
rolesTable,
usersTable,
@@ -50,12 +51,17 @@ 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"] as const;
const ADMIN_AUDIT_ROLES = [
"admin",
"executive_office_manager",
"executive_coord_lead",
] as const;
async function getRoleNamesForUser(userId: number): Promise<Set<string>> {
const ids = await getEffectiveRoleIds(userId);
@@ -223,6 +229,9 @@ function parseMeetingPatch(
}
}
}
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);
@@ -583,6 +592,152 @@ router.delete(
},
);
// Replace the entire attendee list for a meeting in one transaction.
router.put(
"/executive-meetings/:id/attendees",
requireAuth,
requireMutate,
async (req, res): Promise<void> => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
res.status(404).json({ error: "Not found", code: "not_found" });
return;
}
const existing = await fetchMeetingWithAttendees(id);
if (!existing) {
res.status(404).json({ error: "Meeting not found", code: "not_found" });
return;
}
const body = (req.body ?? {}) as Record<string, unknown>;
const attendees = parseAttendees(body.attendees);
if (!attendees) {
res.status(400).json({ error: "attendees invalid", code: "validation" });
return;
}
const userId = req.session.userId!;
await db.transaction(async (tx) => {
await tx
.delete(executiveMeetingAttendeesTable)
.where(eq(executiveMeetingAttendeesTable.meetingId, id));
if (attendees.length > 0) {
await tx.insert(executiveMeetingAttendeesTable).values(
attendees.map((a, idx) => ({
meetingId: id,
name: a.name,
title: a.title ?? null,
attendanceType: a.attendanceType,
sortOrder: a.sortOrder ?? idx,
})),
);
}
await logAudit(tx, {
action: "replace_attendees",
entityType: "meeting",
entityId: id,
oldValue: { attendees: existing.attendees },
newValue: { attendees },
performedBy: userId,
});
});
const updated = await fetchMeetingWithAttendees(id);
res.json(updated);
},
);
// Duplicate a meeting (and its attendees) to another date.
router.post(
"/executive-meetings/:id/duplicate",
requireAuth,
requireMutate,
async (req, res): Promise<void> => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
res.status(404).json({ error: "Not found", code: "not_found" });
return;
}
const body = (req.body ?? {}) as Record<string, unknown>;
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 source = await fetchMeetingWithAttendees(id);
if (!source) {
res.status(404).json({ error: "Meeting not found", code: "not_found" });
return;
}
const userId = req.session.userId!;
try {
const inserted = await db.transaction(async (tx) => {
const [{ value: maxNum }] = await tx
.select({
value: sql<number>`coalesce(max(${executiveMeetingsTable.dailyNumber}), 0)`,
})
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.meetingDate, targetDate));
const nextNumber = (maxNum ?? 0) + 1;
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,
})
.returning();
if (!meeting) throw new Error("duplicate_failed");
if (source.attendees && source.attendees.length > 0) {
await tx.insert(executiveMeetingAttendeesTable).values(
source.attendees.map((a, idx) => ({
meetingId: meeting.id,
name: a.name,
title: a.title,
attendanceType: a.attendanceType,
sortOrder: a.sortOrder ?? idx,
})),
);
}
await logAudit(tx, {
action: "duplicate",
entityType: "meeting",
entityId: meeting.id,
oldValue: { sourceId: source.id, sourceDate: source.meetingDate },
newValue: meeting,
performedBy: userId,
});
return meeting;
});
const full = await fetchMeetingWithAttendees(inserted.id);
res.status(201).json(full);
} 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" });
return;
}
res.status(500).json({ error: msg, code: "server_error" });
}
},
);
// =====================================================================
// REQUESTS
// =====================================================================
@@ -592,11 +747,153 @@ const REQUEST_TYPES = [
"edit",
"delete",
"reschedule",
"add_attendee",
"remove_attendee",
"change_location",
"cancel_meeting",
"note",
"highlight",
"unhighlight",
"other",
] as const;
const REQUEST_STATUSES = ["new", "approved", "rejected", "withdrawn"] as const;
const REQUEST_STATUSES = [
"new",
"approved",
"rejected",
"withdrawn",
"needs_edit",
"done",
] as const;
// 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 {
const details = (reqRow.requestDetails ?? {}) as Record<string, unknown>;
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";
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<void> {
const details = (reqRow.requestDetails ?? {}) as Record<string, unknown>;
if (!reqRow.meetingId) return; // create-suggestions: leave for office manager to act on
const meetingId = reqRow.meetingId;
const [existing] = await tx
.select()
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.id, meetingId));
if (!existing) return;
const update: Record<string, unknown> = { updatedBy: performedBy };
switch (reqRow.requestType) {
case "reschedule": {
if (typeof details.startTime === "string" && TIME_RE.test(details.startTime))
update.startTime = details.startTime;
if (typeof details.endTime === "string" && TIME_RE.test(details.endTime))
update.endTime = details.endTime;
if (typeof details.meetingDate === "string" && DATE_RE.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);
if (effStart && effEnd && effStart > effEnd) {
throw new Error("APPLY_VALIDATION:startTime must be <= endTime");
}
break;
}
case "change_location": {
if (typeof details.location === "string")
update.location = details.location.slice(0, 300);
if (typeof details.meetingUrl === "string")
update.meetingUrl = 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": {
const newNote =
typeof details.note === "string" ? details.note.slice(0, 5000) : "";
const prev = existing.notes ?? "";
update.notes = prev ? `${prev}\n${newNote}` : newNote;
break;
}
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 attendanceType =
typeof details.attendanceType === "string" &&
["internal", "virtual", "external"].includes(details.attendanceType)
? details.attendanceType
: "internal";
const [{ value: maxOrder }] = await tx
.select({ value: sql<number>`coalesce(max(${executiveMeetingAttendeesTable.sortOrder}), -1)` })
.from(executiveMeetingAttendeesTable)
.where(eq(executiveMeetingAttendeesTable.meetingId, meetingId));
await tx.insert(executiveMeetingAttendeesTable).values({
meetingId,
name: name.slice(0, 200),
title,
attendanceType,
sortOrder: (maxOrder ?? -1) + 1,
});
break;
}
case "remove_attendee": {
const attendeeId = Number(details.attendeeId);
if (Number.isInteger(attendeeId) && attendeeId > 0) {
await tx
.delete(executiveMeetingAttendeesTable)
.where(
and(
eq(executiveMeetingAttendeesTable.id, attendeeId),
eq(executiveMeetingAttendeesTable.meetingId, meetingId),
),
);
}
break;
}
default:
break;
}
if (Object.keys(update).length > 1) {
await tx
.update(executiveMeetingsTable)
.set(update as never)
.where(eq(executiveMeetingsTable.id, meetingId));
}
}
router.get(
"/executive-meetings/requests",
@@ -753,8 +1050,113 @@ router.patch(
return;
}
// Approve / Reject: approver roles only
// 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" });
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();
await logAudit(tx, {
action: "create",
entityType: "task",
entityId: task!.id,
newValue: task!,
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;
}
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" });
@@ -766,29 +1168,43 @@ router.patch(
}
const reviewNotes =
typeof body.reviewNotes === "string" ? body.reviewNotes.trim() : null;
const newStatus = action === "approve" ? "approved" : "rejected";
const updated = await db.transaction(async (tx) => {
const [row] = await tx
.update(executiveMeetingRequestsTable)
.set({
status: newStatus,
reviewedBy: userId,
reviewDecision: newStatus,
reviewNotes,
})
.where(eq(executiveMeetingRequestsTable.id, id))
.returning();
await logAudit(tx, {
action: action === "approve" ? "approve" : "reject",
entityType: "request",
entityId: id,
oldValue: existing,
newValue: row,
performedBy: userId,
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;
});
return row;
});
res.json(updated);
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" });
}
return;
}
@@ -997,6 +1413,33 @@ router.patch(
newValue: row,
performedBy: userId,
});
// Propagate task completion → originating request becomes "done"
if (
row &&
row.status === "completed" &&
existing.status !== "completed" &&
row.requestId
) {
const [reqOld] = await tx
.select()
.from(executiveMeetingRequestsTable)
.where(eq(executiveMeetingRequestsTable.id, row.requestId));
if (reqOld && reqOld.status !== "done") {
const [reqNew] = await tx
.update(executiveMeetingRequestsTable)
.set({ status: "done" })
.where(eq(executiveMeetingRequestsTable.id, row.requestId))
.returning();
await logAudit(tx, {
action: "done",
entityType: "request",
entityId: row.requestId,
oldValue: reqOld,
newValue: reqNew,
performedBy: userId,
});
}
}
return row;
});
res.json(updated);
@@ -1048,8 +1491,17 @@ router.get(
requireAdminAudit,
async (req, res): Promise<void> => {
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 entityType =
typeof req.query.entityType === "string" ? req.query.entityType.trim() : "";
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 limitRaw = Number(req.query.limit);
const limit =
Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 200) : 100;
@@ -1060,9 +1512,28 @@ router.get(
conds.push(gte(executiveMeetingAuditLogsTable.performedAt, start));
conds.push(lt(executiveMeetingAuditLogsTable.performedAt, end));
}
if (dateFromRaw && DATE_RE.test(dateFromRaw)) {
conds.push(
gte(
executiveMeetingAuditLogsTable.performedAt,
new Date(`${dateFromRaw}T00:00:00.000Z`),
),
);
}
if (dateToRaw && DATE_RE.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));
}
const where = conds.length > 0 ? and(...conds) : undefined;
const rows = await db
.select({
@@ -1098,7 +1569,7 @@ router.get(
router.get(
"/executive-meetings/notifications",
requireAuth,
requireAdminAudit,
requireRead,
async (_req, res): Promise<void> => {
const rows = await db
.select({
@@ -1126,6 +1597,101 @@ router.get(
},
);
// =====================================================================
// PDF ARCHIVES
// =====================================================================
router.get(
"/executive-meetings/pdf-archives",
requireAuth,
requireRead,
async (req, res): Promise<void> => {
const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : "";
const conds = [];
if (dateRaw && DATE_RE.test(dateRaw)) {
conds.push(eq(executiveMeetingPdfArchivesTable.archiveDate, dateRaw));
}
const where = conds.length > 0 ? and(...conds) : undefined;
const rows = await db
.select({
a: executiveMeetingPdfArchivesTable,
username: usersTable.username,
displayNameAr: usersTable.displayNameAr,
displayNameEn: usersTable.displayNameEn,
})
.from(executiveMeetingPdfArchivesTable)
.leftJoin(
usersTable,
eq(usersTable.id, executiveMeetingPdfArchivesTable.generatedBy),
)
.where(where as never)
.orderBy(desc(executiveMeetingPdfArchivesTable.generatedAt))
.limit(200);
res.json({
archives: rows.map((row) => ({
...row.a,
generator: row.username
? {
username: row.username,
displayNameAr: row.displayNameAr,
displayNameEn: row.displayNameEn,
}
: null,
})),
});
},
);
router.post(
"/executive-meetings/pdf-archives",
requireAuth,
requireRead,
async (req, res): Promise<void> => {
const body = (req.body ?? {}) as Record<string, unknown>;
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 userId = req.session.userId!;
const created = await db.transaction(async (tx) => {
const [{ value: maxV }] = await tx
.select({
value: sql<number>`coalesce(max(${executiveMeetingPdfArchivesTable.version}), 0)`,
})
.from(executiveMeetingPdfArchivesTable)
.where(eq(executiveMeetingPdfArchivesTable.archiveDate, archiveDate));
const [row] = await tx
.insert(executiveMeetingPdfArchivesTable)
.values({
archiveDate,
filePath,
version: (maxV ?? 0) + 1,
generatedBy: userId,
})
.returning();
await logAudit(tx, {
action: "create",
entityType: "pdf_archive",
entityId: row!.id,
newValue: row!,
performedBy: userId,
});
return row;
});
res.status(201).json(created);
},
);
// =====================================================================
// FONT SETTINGS
// =====================================================================