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:
@@ -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
|
||||
// =====================================================================
|
||||
|
||||
Reference in New Issue
Block a user