diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 16a4a0d6..34e92c75 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -1,19 +1,37 @@ import { Router, type IRouter } from "express"; -import { eq, asc, inArray } from "drizzle-orm"; +import { eq, asc, desc, inArray, and, sql, gte, lt, isNull, or } from "drizzle-orm"; import { db } from "@workspace/db"; import { executiveMeetingsTable, executiveMeetingAttendeesTable, + executiveMeetingRequestsTable, + executiveMeetingTasksTable, + executiveMeetingNotificationsTable, + executiveMeetingAuditLogsTable, + executiveMeetingFontSettingsTable, rolesTable, + usersTable, } from "@workspace/db"; import { requireAuth, getEffectiveRoleIds } from "../middlewares/auth"; import type { Request, Response, NextFunction } from "express"; const router: IRouter = Router(); -const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +// Constrain the :id placeholder to numeric values; otherwise fall through +// to subsequent routes. Required because path-to-regexp 8 (Express 5) no +// longer supports inline regex like ":id(\\d+)". +router.param("id", (req, res, next, value) => { + if (!/^\d+$/.test(String(value))) { + next("route"); + return; + } + next(); +}); -const EXECUTIVE_ROLE_NAMES = [ +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +const TIME_RE = /^\d{2}:\d{2}(:\d{2})?$/; + +const READ_ROLES = [ "admin", "executive_ceo", "executive_office_manager", @@ -22,41 +40,250 @@ const EXECUTIVE_ROLE_NAMES = [ "executive_viewer", ] as const; -async function requireExecutiveAccess( - req: Request, - res: Response, - next: NextFunction, -): Promise { - if (!req.session.userId) { - res.status(401).json({ error: "Unauthorized" }); - return; - } - const effectiveIds = await getEffectiveRoleIds(req.session.userId); - if (effectiveIds.length === 0) { - res.status(403).json({ error: "Forbidden" }); - return; - } - const allowed = await db - .select({ id: rolesTable.id }) +const MUTATE_ROLES = [ + "admin", + "executive_office_manager", + "executive_coord_lead", +] as const; + +const APPROVE_ROLES = ["admin", "executive_office_manager"] as const; + +const REQUEST_ROLES = [ + "admin", + "executive_office_manager", + "executive_coord_lead", + "executive_coordinator", +] as const; + +const ADMIN_AUDIT_ROLES = ["admin", "executive_office_manager"] as const; + +async function getRoleNamesForUser(userId: number): Promise> { + const ids = await getEffectiveRoleIds(userId); + if (ids.length === 0) return new Set(); + const rows = await db + .select({ name: rolesTable.name }) .from(rolesTable) - .where(inArray(rolesTable.name, EXECUTIVE_ROLE_NAMES as unknown as string[])); - const allowedIds = new Set(allowed.map((r) => r.id)); - const ok = effectiveIds.some((id) => allowedIds.has(id)); - if (!ok) { - res.status(403).json({ error: "Forbidden" }); - return; - } - next(); + .where(inArray(rolesTable.id, ids)); + return new Set(rows.map((r) => r.name)); } +function makeRequireRoles(allowed: ReadonlyArray) { + return async function ( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + if (!req.session.userId) { + res.status(401).json({ error: "Unauthorized", code: "unauthorized" }); + return; + } + const names = await getRoleNamesForUser(req.session.userId); + const ok = allowed.some((r) => names.has(r)); + if (!ok) { + res.status(403).json({ error: "Forbidden", code: "forbidden" }); + return; + } + next(); + }; +} + +const requireRead = makeRequireRoles(READ_ROLES); +const requireMutate = makeRequireRoles(MUTATE_ROLES); +const requireApprove = makeRequireRoles(APPROVE_ROLES); +const requireRequest = makeRequireRoles(REQUEST_ROLES); +const requireAdminAudit = makeRequireRoles(ADMIN_AUDIT_ROLES); + +type DbExecutor = typeof db | Parameters[0]>[0]; + +async function logAudit( + executor: DbExecutor, + opts: { + action: string; + entityType: string; + entityId: number | null; + oldValue?: unknown; + newValue?: unknown; + performedBy: number | null; + }, +): Promise { + await executor.insert(executiveMeetingAuditLogsTable).values({ + action: opts.action, + entityType: opts.entityType, + entityId: opts.entityId ?? null, + oldValue: (opts.oldValue ?? null) as never, + newValue: (opts.newValue ?? null) as never, + performedBy: opts.performedBy, + }); +} + +// ---------- Validation helpers ---------- + +type AttendeeInput = { + name: string; + title?: string | null; + attendanceType: "internal" | "virtual" | "external"; + sortOrder?: number; +}; + +const PLATFORMS = ["none", "webex", "teams", "zoom", "other"] as const; +const STATUSES = ["scheduled", "cancelled", "completed", "postponed"] as const; +const ATTENDANCE_TYPES = ["internal", "virtual", "external"] as const; + +function validString(v: unknown, max: number): string | null { + if (typeof v !== "string") return null; + const t = v.trim(); + if (t.length === 0 || t.length > max) return null; + return t; +} + +function optionalString(v: unknown, max: number): string | null | undefined { + if (v === undefined) return undefined; + if (v === null || v === "") return null; + if (typeof v !== "string") return null; + const t = v.trim(); + if (t.length === 0) return null; + if (t.length > max) return null; + return t; +} + +function parseAttendees(raw: unknown): AttendeeInput[] | null { + if (!Array.isArray(raw)) return null; + const out: AttendeeInput[] = []; + for (const item of raw) { + if (!item || typeof item !== "object") return null; + const r = item as Record; + const name = validString(r.name, 200); + if (!name) return null; + const at = typeof r.attendanceType === "string" ? r.attendanceType : "internal"; + if (!ATTENDANCE_TYPES.includes(at as (typeof ATTENDANCE_TYPES)[number])) return null; + out.push({ + name, + title: optionalString(r.title, 200) ?? null, + attendanceType: at as AttendeeInput["attendanceType"], + sortOrder: typeof r.sortOrder === "number" ? r.sortOrder : 0, + }); + } + return out; +} + +type MeetingPatch = { + titleAr?: string; + titleEn?: string | null; + meetingDate?: string; + dailyNumber?: number; + startTime?: string | null; + endTime?: string | null; + location?: string | null; + meetingUrl?: string | null; + platform?: (typeof PLATFORMS)[number]; + status?: (typeof STATUSES)[number]; + isHighlighted?: number; + notes?: string | null; +}; + +function parseMeetingPatch( + body: Record, + requireBaseFields: boolean, +): { ok: true; data: MeetingPatch } | { ok: false; error: string } { + const out: MeetingPatch = {}; + if ("titleAr" in body) { + const v = validString(body.titleAr, 300); + if (!v) return { ok: false, error: "titleAr is required" }; + out.titleAr = v; + } else if (requireBaseFields) { + return { ok: false, error: "titleAr is required" }; + } + if ("titleEn" in body) { + const v = optionalString(body.titleEn, 300); + out.titleEn = v === undefined ? "" : v; + } + if ("meetingDate" in body) { + if (typeof body.meetingDate !== "string" || !DATE_RE.test(body.meetingDate)) { + return { ok: false, error: "meetingDate must be YYYY-MM-DD" }; + } + out.meetingDate = body.meetingDate; + } else if (requireBaseFields) { + return { ok: false, error: "meetingDate is required" }; + } + if ("dailyNumber" in body && body.dailyNumber !== null && body.dailyNumber !== undefined) { + const n = Number(body.dailyNumber); + if (!Number.isInteger(n) || n <= 0 || n > 1000) { + return { ok: false, error: "dailyNumber must be a positive integer" }; + } + out.dailyNumber = n; + } + for (const k of ["startTime", "endTime"] as const) { + if (k in body) { + if (body[k] === null || body[k] === "") { + out[k] = null; + } else if (typeof body[k] === "string" && TIME_RE.test(body[k] as string)) { + out[k] = body[k] as string; + } else { + return { ok: false, error: `${k} must be HH:MM or null` }; + } + } + } + for (const k of ["location", "meetingUrl", "notes"] as const) { + if (k in body) { + const v = optionalString(body[k], k === "notes" ? 5000 : 500); + out[k] = v === undefined ? null : v; + } + } + if ("platform" in body) { + if (!PLATFORMS.includes(body.platform as (typeof PLATFORMS)[number])) { + return { ok: false, error: "platform invalid" }; + } + out.platform = body.platform as (typeof PLATFORMS)[number]; + } + if ("status" in body) { + if (!STATUSES.includes(body.status as (typeof STATUSES)[number])) { + return { ok: false, error: "status invalid" }; + } + out.status = body.status as (typeof STATUSES)[number]; + } + if ("isHighlighted" in body) { + const n = Number(body.isHighlighted); + out.isHighlighted = n ? 1 : 0; + } + return { ok: true, data: out }; +} + +async function nextDailyNumber(date: string): Promise { + const [row] = await db + .select({ max: sql`max(${executiveMeetingsTable.dailyNumber})` }) + .from(executiveMeetingsTable) + .where(eq(executiveMeetingsTable.meetingDate, date)); + return (row?.max ?? 0) + 1; +} + +async function fetchMeetingWithAttendees(id: number) { + const [meeting] = await db + .select() + .from(executiveMeetingsTable) + .where(eq(executiveMeetingsTable.id, id)); + if (!meeting) return null; + const attendees = await db + .select() + .from(executiveMeetingAttendeesTable) + .where(eq(executiveMeetingAttendeesTable.meetingId, id)) + .orderBy(asc(executiveMeetingAttendeesTable.sortOrder)); + return { ...meeting, attendees }; +} + +// ===================================================================== +// MEETINGS +// ===================================================================== + router.get( "/executive-meetings", requireAuth, - requireExecutiveAccess, + requireRead, async (req, res): Promise => { const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : ""; if (dateRaw && !DATE_RE.test(dateRaw)) { - res.status(400).json({ error: "Invalid 'date' (expected YYYY-MM-DD)" }); + res + .status(400) + .json({ error: "Invalid 'date' (expected YYYY-MM-DD)", code: "bad_date" }); return; } const targetDate = dateRaw || new Date().toISOString().slice(0, 10); @@ -71,37 +298,32 @@ router.get( res.json({ date: targetDate, meetings: [] }); return; } - - const meetingIds = meetings.map((m) => m.id); + const ids = meetings.map((m) => m.id); const attendees = await db .select() .from(executiveMeetingAttendeesTable) - .where(inArray(executiveMeetingAttendeesTable.meetingId, meetingIds)) + .where(inArray(executiveMeetingAttendeesTable.meetingId, ids)) .orderBy( asc(executiveMeetingAttendeesTable.meetingId), asc(executiveMeetingAttendeesTable.sortOrder), ); - const byMeeting = new Map(); for (const a of attendees) { const arr = byMeeting.get(a.meetingId) ?? []; arr.push(a); byMeeting.set(a.meetingId, arr); } - - const result = meetings.map((m) => ({ - ...m, - attendees: byMeeting.get(m.id) ?? [], - })); - - res.json({ date: targetDate, meetings: result }); + res.json({ + date: targetDate, + meetings: meetings.map((m) => ({ ...m, attendees: byMeeting.get(m.id) ?? [] })), + }); }, ); router.get( "/executive-meetings/dates", requireAuth, - requireExecutiveAccess, + requireRead, async (_req, res): Promise => { const rows = await db .selectDistinct({ d: executiveMeetingsTable.meetingDate }) @@ -111,4 +333,923 @@ router.get( }, ); +router.get( + "/executive-meetings/me", + requireAuth, + async (req, res): Promise => { + if (!req.session.userId) { + res.status(401).json({ error: "Unauthorized", code: "unauthorized" }); + return; + } + const names = await getRoleNamesForUser(req.session.userId); + res.json({ + userId: req.session.userId, + roles: Array.from(names), + canRead: READ_ROLES.some((r) => names.has(r)), + canMutate: MUTATE_ROLES.some((r) => names.has(r)), + 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)), + }); + }, +); + +router.get( + "/executive-meetings/:id", + requireAuth, + requireRead, + async (req, res): Promise => { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + const m = await fetchMeetingWithAttendees(id); + if (!m) { + res.status(404).json({ error: "Meeting not found", code: "not_found" }); + return; + } + res.json(m); + }, +); + +router.post( + "/executive-meetings", + requireAuth, + requireMutate, + async (req, res): Promise => { + const body = (req.body ?? {}) as Record; + const parsed = parseMeetingPatch(body, true); + if (!parsed.ok) { + res.status(400).json({ error: parsed.error, code: "validation" }); + return; + } + const data = parsed.data; + let attendees: AttendeeInput[] = []; + if ("attendees" in body) { + const a = parseAttendees(body.attendees); + if (!a) { + res.status(400).json({ error: "Invalid attendees", code: "validation" }); + return; + } + attendees = a; + } + + const date = data.meetingDate!; + const userId = req.session.userId!; + try { + const inserted = await db.transaction(async (tx) => { + const dailyNumber = data.dailyNumber ?? (await nextDailyNumber(date)); + const [meeting] = await tx + .insert(executiveMeetingsTable) + .values({ + titleAr: data.titleAr!, + titleEn: data.titleEn ?? "", + meetingDate: date, + dailyNumber, + startTime: data.startTime ?? null, + endTime: data.endTime ?? null, + location: data.location ?? null, + meetingUrl: data.meetingUrl ?? null, + platform: data.platform ?? "none", + status: data.status ?? "scheduled", + isHighlighted: data.isHighlighted ?? 0, + notes: data.notes ?? null, + createdBy: userId, + updatedBy: userId, + }) + .returning(); + if (!meeting) throw new Error("insert_failed"); + if (attendees.length > 0) { + await tx.insert(executiveMeetingAttendeesTable).values( + attendees.map((a, idx) => ({ + meetingId: meeting.id, + name: a.name, + title: a.title ?? null, + attendanceType: a.attendanceType, + sortOrder: a.sortOrder ?? idx, + })), + ); + } + await logAudit(tx, { + action: "create", + entityType: "meeting", + entityId: meeting.id, + newValue: { ...meeting, attendees }, + 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 already used for this date", code: "duplicate_number" }); + return; + } + res.status(500).json({ error: "Could not create meeting", code: "create_failed" }); + } + }, +); + +router.patch( + "/executive-meetings/:id", + requireAuth, + requireMutate, + async (req, res): Promise => { + 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; + const parsed = parseMeetingPatch(body, false); + if (!parsed.ok) { + res.status(400).json({ error: parsed.error, code: "validation" }); + return; + } + const data = parsed.data; + let attendees: AttendeeInput[] | null = null; + if ("attendees" in body) { + const a = parseAttendees(body.attendees); + if (!a) { + res.status(400).json({ error: "Invalid attendees", code: "validation" }); + return; + } + attendees = a; + } + const userId = req.session.userId!; + + try { + await db.transaction(async (tx) => { + const updateValues: Record = { updatedBy: userId }; + for (const k of [ + "titleAr", + "titleEn", + "meetingDate", + "dailyNumber", + "startTime", + "endTime", + "location", + "meetingUrl", + "platform", + "status", + "isHighlighted", + "notes", + ] as const) { + if (k in data) updateValues[k] = (data as Record)[k]; + } + if (Object.keys(updateValues).length > 1) { + await tx + .update(executiveMeetingsTable) + .set(updateValues as never) + .where(eq(executiveMeetingsTable.id, id)); + } + if (attendees !== null) { + 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: "update", + entityType: "meeting", + entityId: id, + oldValue: existing, + newValue: { ...existing, ...updateValues, attendees: attendees ?? existing.attendees }, + performedBy: userId, + }); + }); + const updated = await fetchMeetingWithAttendees(id); + res.json(updated); + } 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 already used for this date", code: "duplicate_number" }); + return; + } + res.status(500).json({ error: "Could not update meeting", code: "update_failed" }); + } + }, +); + +router.delete( + "/executive-meetings/:id", + requireAuth, + requireMutate, + async (req, res): Promise => { + 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 userId = req.session.userId!; + await db.transaction(async (tx) => { + await tx.delete(executiveMeetingsTable).where(eq(executiveMeetingsTable.id, id)); + await logAudit(tx, { + action: "delete", + entityType: "meeting", + entityId: id, + oldValue: existing, + performedBy: userId, + }); + }); + res.status(204).end(); + }, +); + +// ===================================================================== +// REQUESTS +// ===================================================================== + +const REQUEST_TYPES = [ + "create", + "edit", + "delete", + "reschedule", + "highlight", + "unhighlight", + "other", +] as const; +const REQUEST_STATUSES = ["new", "approved", "rejected", "withdrawn"] as const; + +router.get( + "/executive-meetings/requests", + requireAuth, + requireRead, + 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 userId = req.session.userId!; + + const conds = []; + if (status && (REQUEST_STATUSES as readonly string[]).includes(status)) { + conds.push(eq(executiveMeetingRequestsTable.status, status)); + } + if (mine) { + conds.push(eq(executiveMeetingRequestsTable.requestedBy, userId)); + } + const where = conds.length > 0 ? and(...conds) : undefined; + + const rows = await db + .select({ + r: executiveMeetingRequestsTable, + requesterUsername: usersTable.username, + requesterDisplayNameAr: usersTable.displayNameAr, + requesterDisplayNameEn: usersTable.displayNameEn, + }) + .from(executiveMeetingRequestsTable) + .leftJoin(usersTable, eq(usersTable.id, executiveMeetingRequestsTable.requestedBy)) + .where(where as never) + .orderBy(desc(executiveMeetingRequestsTable.createdAt)) + .limit(200); + + res.json({ + requests: rows.map((row) => ({ + ...row.r, + requester: row.requesterUsername + ? { + username: row.requesterUsername, + displayNameAr: row.requesterDisplayNameAr, + displayNameEn: row.requesterDisplayNameEn, + } + : null, + })), + }); + }, +); + +router.post( + "/executive-meetings/requests", + requireAuth, + requireRequest, + async (req, res): Promise => { + const body = (req.body ?? {}) as Record; + const requestType = body.requestType; + if ( + typeof requestType !== "string" || + !(REQUEST_TYPES as readonly string[]).includes(requestType) + ) { + res.status(400).json({ error: "requestType invalid", code: "validation" }); + return; + } + let meetingId: number | null = null; + if (body.meetingId !== undefined && body.meetingId !== null) { + const n = Number(body.meetingId); + if (!Number.isInteger(n) || n <= 0) { + res.status(400).json({ error: "meetingId invalid", code: "validation" }); + return; + } + const [m] = await db + .select({ id: executiveMeetingsTable.id }) + .from(executiveMeetingsTable) + .where(eq(executiveMeetingsTable.id, n)); + if (!m) { + res.status(404).json({ error: "Meeting not found", code: "not_found" }); + return; + } + meetingId = n; + } + const requestDetails = (body.requestDetails ?? {}) as Record; + const userId = req.session.userId!; + const created = await db.transaction(async (tx) => { + const [row] = await tx + .insert(executiveMeetingRequestsTable) + .values({ + meetingId, + requestedBy: userId, + requestType, + requestDetails: requestDetails as never, + status: "new", + }) + .returning(); + await logAudit(tx, { + action: "submit", + entityType: "request", + entityId: row!.id, + newValue: row!, + performedBy: userId, + }); + return row; + }); + res.status(201).json(created); + }, +); + +router.patch( + "/executive-meetings/requests/:id", + requireAuth, + async (req, res): Promise => { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + const userId = req.session.userId!; + const [existing] = await db + .select() + .from(executiveMeetingRequestsTable) + .where(eq(executiveMeetingRequestsTable.id, id)); + if (!existing) { + res.status(404).json({ error: "Request not found", code: "not_found" }); + return; + } + const body = (req.body ?? {}) as Record; + const action = body.action; + const names = await getRoleNamesForUser(userId); + + // Withdraw: requester only, only when 'new' + if (action === "withdraw") { + if (existing.requestedBy !== userId) { + res.status(403).json({ error: "Forbidden", code: "forbidden" }); + return; + } + if (existing.status !== "new") { + res.status(409).json({ error: "Cannot withdraw", code: "bad_state" }); + return; + } + const updated = await db.transaction(async (tx) => { + const [row] = await tx + .update(executiveMeetingRequestsTable) + .set({ status: "withdrawn" }) + .where(eq(executiveMeetingRequestsTable.id, id)) + .returning(); + await logAudit(tx, { + action: "update", + entityType: "request", + entityId: id, + oldValue: existing, + newValue: row, + performedBy: userId, + }); + return row; + }); + res.json(updated); + return; + } + + // Approve / Reject: approver roles only + 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; + } + 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, + }); + return row; + }); + res.json(updated); + return; + } + + res.status(400).json({ error: "Invalid action", code: "validation" }); + }, +); + +// ===================================================================== +// TASKS +// ===================================================================== + +const TASK_STATUSES = ["pending", "in_progress", "completed", "cancelled"] as const; + +router.get( + "/executive-meetings/tasks", + requireAuth, + requireRead, + 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 meetingIdRaw = typeof req.query.meetingId === "string" ? req.query.meetingId : ""; + const userId = req.session.userId!; + const conds = []; + if (status && (TASK_STATUSES as readonly string[]).includes(status)) { + conds.push(eq(executiveMeetingTasksTable.status, status)); + } + if (mine) { + conds.push(eq(executiveMeetingTasksTable.assignedTo, userId)); + } + if (meetingIdRaw) { + const n = Number(meetingIdRaw); + if (Number.isInteger(n) && n > 0) { + conds.push(eq(executiveMeetingTasksTable.meetingId, n)); + } + } + const where = conds.length > 0 ? and(...conds) : undefined; + const rows = await db + .select({ + t: executiveMeetingTasksTable, + assigneeUsername: usersTable.username, + assigneeDisplayNameAr: usersTable.displayNameAr, + assigneeDisplayNameEn: usersTable.displayNameEn, + }) + .from(executiveMeetingTasksTable) + .leftJoin(usersTable, eq(usersTable.id, executiveMeetingTasksTable.assignedTo)) + .where(where as never) + .orderBy( + asc(executiveMeetingTasksTable.status), + desc(executiveMeetingTasksTable.createdAt), + ) + .limit(200); + res.json({ + tasks: rows.map((row) => ({ + ...row.t, + assignee: row.assigneeUsername + ? { + username: row.assigneeUsername, + displayNameAr: row.assigneeDisplayNameAr, + displayNameEn: row.assigneeDisplayNameEn, + } + : null, + })), + }); + }, +); + +router.post( + "/executive-meetings/tasks", + requireAuth, + requireMutate, + async (req, res): Promise => { + const body = (req.body ?? {}) as Record; + const taskType = validString(body.taskType, 64); + if (!taskType) { + res.status(400).json({ error: "taskType required", code: "validation" }); + return; + } + let meetingId: number | null = null; + if (body.meetingId !== undefined && body.meetingId !== null) { + const n = Number(body.meetingId); + if (Number.isInteger(n) && n > 0) meetingId = n; + } + let assignedTo: number | null = null; + if (body.assignedTo !== undefined && body.assignedTo !== null) { + const n = Number(body.assignedTo); + if (Number.isInteger(n) && n > 0) assignedTo = n; + } + let dueAt: Date | null = null; + if (typeof body.dueAt === "string" && body.dueAt) { + const d = new Date(body.dueAt); + if (!Number.isNaN(d.getTime())) dueAt = d; + } + const notes = optionalString(body.notes, 5000) ?? null; + const userId = req.session.userId!; + const created = await db.transaction(async (tx) => { + const [row] = await tx + .insert(executiveMeetingTasksTable) + .values({ + taskType, + meetingId, + assignedTo, + dueAt, + notes, + status: "pending", + }) + .returning(); + await logAudit(tx, { + action: "create", + entityType: "task", + entityId: row!.id, + newValue: row!, + performedBy: userId, + }); + return row; + }); + res.status(201).json(created); + }, +); + +router.patch( + "/executive-meetings/tasks/:id", + requireAuth, + async (req, res): Promise => { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + const userId = req.session.userId!; + const [existing] = await db + .select() + .from(executiveMeetingTasksTable) + .where(eq(executiveMeetingTasksTable.id, id)); + if (!existing) { + res.status(404).json({ error: "Task not found", code: "not_found" }); + return; + } + const names = await getRoleNamesForUser(userId); + const isMutator = MUTATE_ROLES.some((r) => names.has(r)); + const isAssignee = existing.assignedTo === userId; + if (!isMutator && !isAssignee) { + res.status(403).json({ error: "Forbidden", code: "forbidden" }); + return; + } + const body = (req.body ?? {}) as Record; + const update: Record = {}; + if ("status" in body) { + if (!(TASK_STATUSES as readonly string[]).includes(body.status as string)) { + res.status(400).json({ error: "status invalid", code: "validation" }); + return; + } + update.status = body.status; + if (body.status === "completed") update.completedAt = new Date(); + else if (existing.status === "completed") update.completedAt = null; + } + // Only mutators can change assignment / due / type / notes + if (isMutator) { + if ("taskType" in body) { + const tt = validString(body.taskType, 64); + if (!tt) { + res.status(400).json({ error: "taskType invalid", code: "validation" }); + return; + } + update.taskType = tt; + } + if ("assignedTo" in body) { + if (body.assignedTo === null) update.assignedTo = null; + else { + const n = Number(body.assignedTo); + if (Number.isInteger(n) && n > 0) update.assignedTo = n; + } + } + if ("dueAt" in body) { + if (body.dueAt === null || body.dueAt === "") update.dueAt = null; + else if (typeof body.dueAt === "string") { + const d = new Date(body.dueAt); + if (!Number.isNaN(d.getTime())) update.dueAt = d; + } + } + if ("notes" in body) { + update.notes = optionalString(body.notes, 5000) ?? null; + } + if ("meetingId" in body) { + if (body.meetingId === null) update.meetingId = null; + else { + const n = Number(body.meetingId); + if (Number.isInteger(n) && n > 0) update.meetingId = n; + } + } + } + if (Object.keys(update).length === 0) { + res.json(existing); + return; + } + const updated = await db.transaction(async (tx) => { + const [row] = await tx + .update(executiveMeetingTasksTable) + .set(update as never) + .where(eq(executiveMeetingTasksTable.id, id)) + .returning(); + await logAudit(tx, { + action: "update", + entityType: "task", + entityId: id, + oldValue: existing, + newValue: row, + performedBy: userId, + }); + return row; + }); + res.json(updated); + }, +); + +router.delete( + "/executive-meetings/tasks/:id", + requireAuth, + requireMutate, + async (req, res): Promise => { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + const userId = req.session.userId!; + const [existing] = await db + .select() + .from(executiveMeetingTasksTable) + .where(eq(executiveMeetingTasksTable.id, id)); + if (!existing) { + res.status(404).json({ error: "Task not found", code: "not_found" }); + return; + } + await db.transaction(async (tx) => { + await tx + .delete(executiveMeetingTasksTable) + .where(eq(executiveMeetingTasksTable.id, id)); + await logAudit(tx, { + action: "delete", + entityType: "task", + entityId: id, + oldValue: existing, + performedBy: userId, + }); + }); + res.status(204).end(); + }, +); + +// ===================================================================== +// AUDIT LOGS +// ===================================================================== + +router.get( + "/executive-meetings/audit-logs", + requireAuth, + requireAdminAudit, + async (req, res): Promise => { + const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : ""; + const entityType = + typeof req.query.entityType === "string" ? req.query.entityType.trim() : ""; + const limitRaw = Number(req.query.limit); + const limit = + Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 200) : 100; + const conds = []; + if (dateRaw && DATE_RE.test(dateRaw)) { + const start = new Date(`${dateRaw}T00:00:00.000Z`); + const end = new Date(start.getTime() + 24 * 60 * 60 * 1000); + conds.push(gte(executiveMeetingAuditLogsTable.performedAt, start)); + conds.push(lt(executiveMeetingAuditLogsTable.performedAt, end)); + } + if (entityType) { + conds.push(eq(executiveMeetingAuditLogsTable.entityType, entityType)); + } + const where = conds.length > 0 ? and(...conds) : undefined; + const rows = await db + .select({ + l: executiveMeetingAuditLogsTable, + actorUsername: usersTable.username, + actorDisplayNameAr: usersTable.displayNameAr, + actorDisplayNameEn: usersTable.displayNameEn, + }) + .from(executiveMeetingAuditLogsTable) + .leftJoin(usersTable, eq(usersTable.id, executiveMeetingAuditLogsTable.performedBy)) + .where(where as never) + .orderBy(desc(executiveMeetingAuditLogsTable.performedAt)) + .limit(limit); + res.json({ + entries: rows.map((row) => ({ + ...row.l, + actor: row.actorUsername + ? { + username: row.actorUsername, + displayNameAr: row.actorDisplayNameAr, + displayNameEn: row.actorDisplayNameEn, + } + : null, + })), + }); + }, +); + +// ===================================================================== +// NOTIFICATIONS (placeholder list) +// ===================================================================== + +router.get( + "/executive-meetings/notifications", + requireAuth, + requireAdminAudit, + async (_req, res): Promise => { + const rows = await db + .select({ + n: executiveMeetingNotificationsTable, + userUsername: usersTable.username, + userDisplayNameAr: usersTable.displayNameAr, + userDisplayNameEn: usersTable.displayNameEn, + }) + .from(executiveMeetingNotificationsTable) + .leftJoin(usersTable, eq(usersTable.id, executiveMeetingNotificationsTable.userId)) + .orderBy(desc(executiveMeetingNotificationsTable.createdAt)) + .limit(200); + res.json({ + notifications: rows.map((row) => ({ + ...row.n, + user: row.userUsername + ? { + username: row.userUsername, + displayNameAr: row.userDisplayNameAr, + displayNameEn: row.userDisplayNameEn, + } + : null, + })), + }); + }, +); + +// ===================================================================== +// FONT SETTINGS +// ===================================================================== + +const FONT_FAMILIES = [ + "system", + "Cairo", + "Tajawal", + "Noto Naskh Arabic", + "Amiri", + "Inter", + "monospace", +] as const; +const FONT_WEIGHTS = ["regular", "medium", "semibold", "bold"] as const; +const FONT_ALIGNMENTS = ["start", "center", "end"] as const; + +router.get( + "/executive-meetings/font-settings", + requireAuth, + requireRead, + async (req, res): Promise => { + const userId = req.session.userId!; + const rows = await db + .select() + .from(executiveMeetingFontSettingsTable) + .where( + or( + eq(executiveMeetingFontSettingsTable.scope, "global"), + and( + eq(executiveMeetingFontSettingsTable.scope, "user"), + eq(executiveMeetingFontSettingsTable.userId, userId), + ), + ), + ); + const global = rows.find((r) => r.scope === "global") ?? null; + const user = rows.find((r) => r.scope === "user" && r.userId === userId) ?? null; + res.json({ global, user }); + }, +); + +router.patch( + "/executive-meetings/font-settings", + requireAuth, + requireRead, + async (req, res): Promise => { + const userId = req.session.userId!; + const body = (req.body ?? {}) as Record; + const scope = body.scope === "global" ? "global" : "user"; + if (scope === "global") { + const names = await getRoleNamesForUser(userId); + const ok = ["admin", "executive_office_manager"].some((r) => names.has(r)); + if (!ok) { + res.status(403).json({ error: "Forbidden", code: "forbidden" }); + return; + } + } + const fontFamily = + typeof body.fontFamily === "string" && + (FONT_FAMILIES as readonly string[]).includes(body.fontFamily) + ? body.fontFamily + : "system"; + const fontSizeRaw = Number(body.fontSize); + const fontSize = + Number.isInteger(fontSizeRaw) && fontSizeRaw >= 10 && fontSizeRaw <= 32 + ? fontSizeRaw + : 14; + const fontWeight = + typeof body.fontWeight === "string" && + (FONT_WEIGHTS as readonly string[]).includes(body.fontWeight) + ? body.fontWeight + : "regular"; + const alignment = + typeof body.alignment === "string" && + (FONT_ALIGNMENTS as readonly string[]).includes(body.alignment) + ? body.alignment + : "start"; + + const targetUserId = scope === "global" ? null : userId; + const result = await db.transaction(async (tx) => { + const [existing] = await tx + .select() + .from(executiveMeetingFontSettingsTable) + .where( + and( + eq(executiveMeetingFontSettingsTable.scope, scope), + targetUserId === null + ? isNull(executiveMeetingFontSettingsTable.userId) + : eq(executiveMeetingFontSettingsTable.userId, targetUserId), + ), + ); + let row; + if (existing) { + const [updated] = await tx + .update(executiveMeetingFontSettingsTable) + .set({ fontFamily, fontSize, fontWeight, alignment }) + .where(eq(executiveMeetingFontSettingsTable.id, existing.id)) + .returning(); + row = updated; + } else { + const [inserted] = await tx + .insert(executiveMeetingFontSettingsTable) + .values({ + scope, + userId: targetUserId, + fontFamily, + fontSize, + fontWeight, + alignment, + }) + .returning(); + row = inserted; + } + await logAudit(tx, { + action: existing ? "update" : "create", + entityType: "font_settings", + entityId: row?.id ?? null, + oldValue: existing ?? null, + newValue: row ?? null, + performedBy: userId, + }); + return row; + }); + res.json(result); + }, +); + export default router; diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index 4ebcab26..326f2774 100644 Binary files a/artifacts/tx-os/public/opengraph.jpg and b/artifacts/tx-os/public/opengraph.jpg differ diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 208b3cb9..8e8b5dc6 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -622,7 +622,7 @@ "externalAttendance": "حضور خارجي", "placeholderBody": "هذا القسم قيد التطوير في المراحل القادمة. ستتوفر إدارته الكاملة قريباً.", "col": { - "number": "م", + "number": "#", "meeting": "الاجتماع", "attendees": "الحضور", "time": "الوقت" @@ -637,6 +637,231 @@ "audit": "سجل التعديلات", "pdf": "تصدير PDF", "fontSettings": "إعدادات الخط" + }, + "common": { + "save": "حفظ", + "cancel": "إلغاء", + "delete": "حذف", + "edit": "تعديل", + "add": "إضافة", + "submit": "إرسال", + "close": "إغلاق", + "loading": "جارٍ التحميل...", + "saved": "تم الحفظ", + "saveFailed": "تعذر الحفظ", + "actions": "إجراءات", + "noPermission": "ليست لديك صلاحية لتنفيذ هذا الإجراء", + "yes": "نعم", + "no": "لا", + "all": "الكل", + "filter": "تصفية" + }, + "manage": { + "heading": "إدارة الاجتماعات", + "addMeeting": "إضافة اجتماع", + "editMeeting": "تعديل الاجتماع", + "deleteMeeting": "حذف الاجتماع", + "deleteConfirm": "حذف هذا الاجتماع نهائياً؟", + "noMeetings": "لا توجد اجتماعات لهذا اليوم. اضغط \"إضافة اجتماع\" لإنشاء واحد.", + "field": { + "titleAr": "العنوان (عربي)", + "titleEn": "العنوان (إنجليزي)", + "meetingDate": "التاريخ", + "dailyNumber": "الرقم اليومي", + "dailyNumberHint": "اتركه فارغاً ليتم الترقيم تلقائياً", + "startTime": "وقت البدء", + "endTime": "وقت الانتهاء", + "location": "المكان", + "meetingUrl": "رابط الاجتماع", + "platform": "المنصة", + "status": "الحالة", + "isHighlighted": "تمييز (لون أحمر)", + "notes": "ملاحظات" + }, + "attendees": { + "label": "قائمة الحضور", + "name": "الاسم", + "type": "نوع الحضور", + "add": "إضافة حاضر", + "remove": "إزالة" + }, + "platform": { + "none": "بدون", + "webex": "Webex", + "teams": "Teams", + "zoom": "Zoom", + "other": "أخرى" + }, + "attendanceType": { + "internal": "داخلي", + "virtual": "افتراضي", + "external": "خارجي" + }, + "status": { + "scheduled": "مجدول", + "cancelled": "ملغى", + "completed": "منتهٍ", + "postponed": "مؤجل" + }, + "errors": { + "titleRequired": "العنوان بالعربية مطلوب", + "dateRequired": "التاريخ مطلوب", + "duplicateNumber": "هذا الرقم اليومي مستخدم بالفعل في هذا التاريخ", + "saveFailed": "تعذر حفظ الاجتماع" + } + }, + "requests": { + "heading": "طلبات التعديل", + "newRequest": "طلب جديد", + "myRequests": "طلباتي", + "allRequests": "جميع الطلبات", + "empty": "لا توجد طلبات.", + "filterStatus": "تصفية حسب الحالة", + "submitFor": "اقتراح تعديل على:", + "noTargetMeeting": "اقتراح اجتماع جديد (بدون ربط)", + "details": "التفاصيل", + "detailsPlaceholder": "اشرح ما تقترح بالتفصيل...", + "submitted": "تم إرسال الطلب", + "submitFailed": "تعذر إرسال الطلب", + "requestedBy": "مقدم الطلب", + "requestedAt": "وقت الطلب", + "type": { + "create": "إنشاء اجتماع", + "edit": "تعديل بيانات", + "delete": "حذف", + "reschedule": "إعادة جدولة", + "highlight": "تمييز", + "unhighlight": "إزالة التمييز", + "other": "آخر" + }, + "status": { + "new": "جديد", + "approved": "تمت الموافقة", + "rejected": "مرفوض", + "withdrawn": "مسحوب" + } + }, + "approvals": { + "heading": "موافقات مدير المكتب", + "empty": "لا توجد طلبات بانتظار المراجعة.", + "approve": "اعتماد", + "reject": "رفض", + "reviewNotes": "ملاحظات المراجعة", + "reviewNotesPlaceholder": "اكتب ملاحظاتك هنا (اختياري)", + "approved": "تم الاعتماد", + "rejected": "تم الرفض", + "actionFailed": "تعذر تنفيذ المراجعة" + }, + "tasks": { + "heading": "مهام فريق التنسيق", + "addTask": "إضافة مهمة", + "empty": "لا توجد مهام.", + "field": { + "taskType": "نوع المهمة", + "assignee": "المُكلَّف", + "dueDate": "تاريخ الاستحقاق", + "notes": "ملاحظات", + "meeting": "الاجتماع", + "status": "الحالة" + }, + "status": { + "pending": "بالانتظار", + "in_progress": "قيد التنفيذ", + "completed": "منجزة", + "cancelled": "ملغاة" + }, + "markCompleted": "إنهاء المهمة", + "markInProgress": "بدء التنفيذ", + "deleteConfirm": "حذف هذه المهمة؟", + "saved": "تم حفظ المهمة", + "deleted": "تم حذف المهمة" + }, + "audit": { + "heading": "سجل التعديلات", + "empty": "لا توجد قيود في السجل.", + "filterDate": "تصفية بالتاريخ", + "filterEntity": "النوع", + "all": "الكل", + "col": { + "when": "الوقت", + "actor": "المنفّذ", + "action": "الإجراء", + "entity": "النوع", + "entityId": "المعرف", + "diff": "التفاصيل" + }, + "entity": { + "meeting": "اجتماع", + "attendee": "حاضر", + "request": "طلب", + "task": "مهمة", + "font_settings": "إعدادات الخط" + }, + "action": { + "create": "إنشاء", + "update": "تعديل", + "delete": "حذف", + "approve": "اعتماد", + "reject": "رفض", + "submit": "إرسال" + } + }, + "pdf": { + "heading": "تصدير / طباعة", + "intro": "تستخدم هذه الواجهة طابعة المتصفح لإنتاج نسخة PDF من جدول اليوم. اختر التاريخ ثم اضغط طباعة.", + "print": "طباعة الجدول", + "openSchedule": "عرض الجدول", + "selectDate": "تاريخ الجدول" + }, + "fontSettingsPage": { + "heading": "إعدادات الخط", + "intro": "تطبق هذه الإعدادات على جدول الاجتماعات. يمكنك حفظ تفضيلك الشخصي، ويمكن للمسؤول حفظ الإعدادات العامة لجميع المستخدمين.", + "scope": "النطاق", + "scopeUser": "تفضيلي الشخصي", + "scopeGlobal": "عام (جميع المستخدمين)", + "fontFamily": "نوع الخط", + "fontSize": "حجم الخط", + "fontWeight": "السماكة", + "alignment": "المحاذاة", + "weight": { + "regular": "عادي", + "medium": "متوسط", + "semibold": "نصف عريض", + "bold": "عريض" + }, + "align": { + "start": "بداية", + "center": "وسط", + "end": "نهاية" + }, + "preview": "معاينة", + "previewText": "نص تجريبي ١٢٣ Sample 123", + "save": "حفظ الإعدادات", + "saved": "تم الحفظ", + "reset": "إعادة الافتراضي" + }, + "notificationsPage": { + "heading": "التنبيهات", + "intro": "هذه قائمة التنبيهات المجدولة المرتبطة باجتماعات المكتب. لا يتم الإرسال الفعلي حالياً (وضع تجريبي).", + "empty": "لا توجد تنبيهات مجدولة.", + "col": { + "meeting": "الاجتماع", + "user": "المستخدم", + "type": "النوع", + "scheduledAt": "الموعد", + "status": "الحالة" + }, + "type": { + "reminder": "تذكير", + "change": "تنبيه تعديل", + "cancel": "تنبيه إلغاء" + }, + "status": { + "pending": "بالانتظار", + "sent": "أُرسل", + "skipped": "متخطى", + "failed": "فشل" + } } } } diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 84262b38..2e351eb0 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -634,6 +634,231 @@ "audit": "Audit Log", "pdf": "PDF Export", "fontSettings": "Font Settings" + }, + "common": { + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "add": "Add", + "submit": "Submit", + "close": "Close", + "loading": "Loading...", + "saved": "Saved", + "saveFailed": "Save failed", + "actions": "Actions", + "noPermission": "You do not have permission for this action", + "yes": "Yes", + "no": "No", + "all": "All", + "filter": "Filter" + }, + "manage": { + "heading": "Manage Meetings", + "addMeeting": "Add meeting", + "editMeeting": "Edit meeting", + "deleteMeeting": "Delete meeting", + "deleteConfirm": "Permanently delete this meeting?", + "noMeetings": "No meetings on this day. Click \"Add meeting\" to create one.", + "field": { + "titleAr": "Title (Arabic)", + "titleEn": "Title (English)", + "meetingDate": "Date", + "dailyNumber": "Daily number", + "dailyNumberHint": "Leave blank for auto-numbering", + "startTime": "Start time", + "endTime": "End time", + "location": "Location", + "meetingUrl": "Meeting URL", + "platform": "Platform", + "status": "Status", + "isHighlighted": "Highlight (red)", + "notes": "Notes" + }, + "attendees": { + "label": "Attendees", + "name": "Name", + "type": "Attendance type", + "add": "Add attendee", + "remove": "Remove" + }, + "platform": { + "none": "None", + "webex": "Webex", + "teams": "Teams", + "zoom": "Zoom", + "other": "Other" + }, + "attendanceType": { + "internal": "Internal", + "virtual": "Virtual", + "external": "External" + }, + "status": { + "scheduled": "Scheduled", + "cancelled": "Cancelled", + "completed": "Completed", + "postponed": "Postponed" + }, + "errors": { + "titleRequired": "Arabic title is required", + "dateRequired": "Date is required", + "duplicateNumber": "That daily number already exists for this date", + "saveFailed": "Could not save the meeting" + } + }, + "requests": { + "heading": "Change Requests", + "newRequest": "New request", + "myRequests": "My requests", + "allRequests": "All requests", + "empty": "No requests yet.", + "filterStatus": "Filter by status", + "submitFor": "Suggest a change to:", + "noTargetMeeting": "Propose a brand-new meeting (no link)", + "details": "Details", + "detailsPlaceholder": "Explain your suggestion in detail...", + "submitted": "Request submitted", + "submitFailed": "Could not submit the request", + "requestedBy": "Submitted by", + "requestedAt": "Submitted at", + "type": { + "create": "Create meeting", + "edit": "Edit details", + "delete": "Delete", + "reschedule": "Reschedule", + "highlight": "Highlight", + "unhighlight": "Remove highlight", + "other": "Other" + }, + "status": { + "new": "New", + "approved": "Approved", + "rejected": "Rejected", + "withdrawn": "Withdrawn" + } + }, + "approvals": { + "heading": "Office Manager Approvals", + "empty": "No requests pending review.", + "approve": "Approve", + "reject": "Reject", + "reviewNotes": "Review notes", + "reviewNotesPlaceholder": "Optional notes for the requester", + "approved": "Approved", + "rejected": "Rejected", + "actionFailed": "Could not complete the review" + }, + "tasks": { + "heading": "Coordination Tasks", + "addTask": "Add task", + "empty": "No tasks yet.", + "field": { + "taskType": "Task type", + "assignee": "Assignee", + "dueDate": "Due date", + "notes": "Notes", + "meeting": "Meeting", + "status": "Status" + }, + "status": { + "pending": "Pending", + "in_progress": "In progress", + "completed": "Completed", + "cancelled": "Cancelled" + }, + "markCompleted": "Mark completed", + "markInProgress": "Start", + "deleteConfirm": "Delete this task?", + "saved": "Task saved", + "deleted": "Task deleted" + }, + "audit": { + "heading": "Audit Log", + "empty": "No audit entries.", + "filterDate": "Filter by date", + "filterEntity": "Entity", + "all": "All", + "col": { + "when": "When", + "actor": "Actor", + "action": "Action", + "entity": "Entity", + "entityId": "ID", + "diff": "Details" + }, + "entity": { + "meeting": "Meeting", + "attendee": "Attendee", + "request": "Request", + "task": "Task", + "font_settings": "Font settings" + }, + "action": { + "create": "Create", + "update": "Update", + "delete": "Delete", + "approve": "Approve", + "reject": "Reject", + "submit": "Submit" + } + }, + "pdf": { + "heading": "Print / Export", + "intro": "This view uses the browser print dialog to produce a PDF of the day's schedule. Pick a date and click Print.", + "print": "Print schedule", + "openSchedule": "Open schedule", + "selectDate": "Schedule date" + }, + "fontSettingsPage": { + "heading": "Font Settings", + "intro": "These settings apply to the meetings schedule. You can save your personal preference; admins can also save the global default.", + "scope": "Scope", + "scopeUser": "My preference", + "scopeGlobal": "Global (everyone)", + "fontFamily": "Font family", + "fontSize": "Font size", + "fontWeight": "Weight", + "alignment": "Alignment", + "weight": { + "regular": "Regular", + "medium": "Medium", + "semibold": "Semibold", + "bold": "Bold" + }, + "align": { + "start": "Start", + "center": "Center", + "end": "End" + }, + "preview": "Preview", + "previewText": "Sample text 123 نص تجريبي ١٢٣", + "save": "Save settings", + "saved": "Saved", + "reset": "Reset to default" + }, + "notificationsPage": { + "heading": "Notifications", + "intro": "Scheduled reminders and change-notice records for executive meetings. No actual delivery is wired up yet (placeholder).", + "empty": "No scheduled notifications.", + "col": { + "meeting": "Meeting", + "user": "User", + "type": "Type", + "scheduledAt": "Scheduled", + "status": "Status" + }, + "type": { + "reminder": "Reminder", + "change": "Change notice", + "cancel": "Cancellation notice" + }, + "status": { + "pending": "Pending", + "sent": "Sent", + "skipped": "Skipped", + "failed": "Failed" + } } } } diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index 1d4c0e73..f7ba7ce4 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -1,7 +1,7 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, type CSSProperties } from "react"; import { useTranslation } from "react-i18next"; import { useLocation } from "wouter"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, ArrowRight, @@ -17,12 +17,37 @@ import { ScrollText, FileText, Settings as SettingsIcon, + Plus, + Pencil, + Trash2, + Printer, + Check, + X, } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { useToast } from "@/hooks/use-toast"; type Attendee = { - id: number; - meetingId: number; + id?: number; + meetingId?: number; name: string; title: string | null; attendanceType: "internal" | "virtual" | "external"; @@ -46,9 +71,98 @@ type Meeting = { attendees: Attendee[]; }; -type DayResponse = { - date: string; - meetings: Meeting[]; +type DayResponse = { date: string; meetings: Meeting[] }; + +type MeRoles = { + userId: number; + roles: string[]; + canRead: boolean; + canMutate: boolean; + canApprove: boolean; + canSubmitRequest: boolean; + canViewAudit: boolean; +}; + +type RequestRow = { + id: number; + meetingId: number | null; + requestedBy: number | null; + requestType: string; + requestDetails: unknown; + status: "new" | "approved" | "rejected" | "withdrawn"; + reviewedBy: number | null; + reviewDecision: string | null; + reviewNotes: string | null; + createdAt: string; + updatedAt: string; + requester: { + username: string; + displayNameAr: string | null; + displayNameEn: string | null; + } | null; +}; + +type TaskRow = { + id: number; + requestId: number | null; + meetingId: number | null; + assignedTo: number | null; + taskType: string; + status: "pending" | "in_progress" | "completed" | "cancelled"; + dueAt: string | null; + completedAt: string | null; + notes: string | null; + createdAt: string; + updatedAt: string; + assignee: { + username: string; + displayNameAr: string | null; + displayNameEn: string | null; + } | null; +}; + +type AuditEntry = { + id: number; + action: string; + entityType: string; + entityId: number | null; + oldValue: unknown; + newValue: unknown; + performedBy: number | null; + performedAt: string; + actor: { + username: string; + displayNameAr: string | null; + displayNameEn: string | null; + } | null; +}; + +type NotificationRow = { + id: number; + meetingId: number | null; + userId: number | null; + notificationType: string; + scheduledAt: string | null; + sentAt: string | null; + status: string; + createdAt: string; + user: { + username: string; + displayNameAr: string | null; + displayNameEn: string | null; + } | null; +}; + +type FontPrefs = { + fontFamily: string; + fontSize: number; + fontWeight: "regular" | "medium" | "semibold" | "bold"; + alignment: "start" | "center" | "end"; +}; + +type FontSettingsResponse = { + global: ({ scope: "global"; userId: null } & FontPrefs) | null; + user: ({ scope: "user"; userId: number } & FontPrefs) | null; }; const SECTIONS = [ @@ -65,6 +179,13 @@ const SECTIONS = [ type SectionKey = (typeof SECTIONS)[number]["key"]; +const DEFAULT_FONT: FontPrefs = { + fontFamily: "system", + fontSize: 14, + fontWeight: "regular", + alignment: "start", +}; + function formatTime(t: string | null): string { if (!t) return ""; return t.slice(0, 5); @@ -74,6 +195,46 @@ function todayIso(): string { return new Date().toISOString().slice(0, 10); } +function fontWeightToCss(w: FontPrefs["fontWeight"]): number { + return { regular: 400, medium: 500, semibold: 600, bold: 700 }[w]; +} + +function fontFamilyToCss(name: string): string { + if (name === "system") return ""; + if (/[\s-]/.test(name)) return `"${name}", system-ui, sans-serif`; + return `${name}, system-ui, sans-serif`; +} + +function buildFontStyle(prefs: FontPrefs): CSSProperties { + return { + fontFamily: fontFamilyToCss(prefs.fontFamily) || undefined, + fontSize: prefs.fontSize, + fontWeight: fontWeightToCss(prefs.fontWeight), + textAlign: prefs.alignment, + }; +} + +async function apiJson( + url: string, + init?: RequestInit & { body?: unknown }, +): Promise { + const opts: RequestInit = { + credentials: "include", + headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) }, + ...(init ?? {}), + }; + if (init?.body !== undefined && typeof init.body !== "string") { + opts.body = JSON.stringify(init.body); + } + const res = await fetch(url, opts); + if (res.status === 204) return undefined as T; + const data = (await res.json().catch(() => ({}))) as T & { error?: string }; + if (!res.ok) { + throw new Error((data as { error?: string }).error || `HTTP ${res.status}`); + } + return data; +} + export default function ExecutiveMeetingsPage() { const { t, i18n } = useTranslation(); const [, setLocation] = useLocation(); @@ -94,12 +255,37 @@ export default function ExecutiveMeetingsPage() { }, }); + const { data: me } = useQuery({ + queryKey: ["/api/executive-meetings/me"], + queryFn: () => apiJson("/api/executive-meetings/me"), + }); + + const { data: fontResp } = useQuery({ + queryKey: ["/api/executive-meetings/font-settings"], + queryFn: () => apiJson("/api/executive-meetings/font-settings"), + }); + + const effectiveFont: FontPrefs = useMemo(() => { + const u = fontResp?.user; + const g = fontResp?.global; + const src = u ?? g; + if (!src) return DEFAULT_FONT; + return { + fontFamily: src.fontFamily, + fontSize: src.fontSize, + fontWeight: src.fontWeight, + alignment: src.alignment, + }; + }, [fontResp]); + const meetings = data?.meetings ?? []; return ( -
- {/* Top header */} -
+
+
@@ -153,7 +345,6 @@ export default function ExecutiveMeetingsPage() {
- {/* Section tabs */}