Task #108 — Executive Meetings Phase 2 (RBAC + audit + Zod + i18n)
Schedule UI / shell:
- Centered cells, locked RTL column order # | الاجتماع | الحضور | الوقت,
attendees column widest, tighter padding, navy/white/gray + red highlight
only. Header label "م" → "#" (ar.json).
Schema (lib/db/src/schema/executive-meetings.ts):
- executive_meeting_requests.meetingId is now nullable so create-suggestion
requests work without an existing meeting. db push applied.
Backend (artifacts/api-server/src/routes/executive-meetings.ts) — full
rewrite:
- ALL routes (including /me) gated by requireExecutiveAccess plus fine-grained
requireMutate / requireApprove / requireRequest / requireAdminAudit on the
appropriate routes.
- Zod schemas validate every mutating body via parseBody().
- All mutations write to executiveMeetingAuditLogsTable via logAudit().
- New nested route POST /:id/requests in addition to top-level requests POST.
- Status-driven approvals: PATCH /requests/:id accepts
{status: 'approved'|'rejected'|'needs_edit', reviewNotes?} or
{action:'withdraw'} for the requester.
- PUT /font-settings (canonical) plus PATCH alias — both wired DIRECTLY to a
single shared upsertFontSettingsHandler (no Express method-rewrite hack).
- GET /tasks supports date and assigneeId filters.
- GET /notifications now accepts ?date=YYYY-MM-DD and joins through the
meetings table to scope notifications to the selected day; returns []
immediately when no meetings exist on that date.
- Removed all `as never` casts via $inferInsert/Partial typing.
Auth middleware (artifacts/api-server/src/middlewares/auth.ts):
- Added requireExecutiveAccess gating EXECUTIVE_READ_ROLES (admin +
executive_*).
Frontend (artifacts/tx-os/src/pages/executive-meetings.tsx):
- Tab-level RBAC: SECTIONS.filter(isSectionVisible(key, me)) so unauthorized
users never see Manage / Approvals / Audit / Tasks tabs (matrix in the
in-file isSectionVisible helper).
- Approvals: review() sends {status, reviewNotes}; new "Send back for edits"
(needs_edit) button beside Approve / Reject.
- Requests default scope is "mine" for non-approvers and "all" for approvers
(no over-exposure to plain requesters).
- Notifications visibility uses canRead AND the query is scoped by the same
selected `date` as the schedule (queryKey + ?date=… included).
- PdfSection: primary "Print + Archive" button (testid em-print) does archive
then print in one click; em-print-only and em-archive remain as separate
buttons.
- AuditSection: 6th column renders the new in-file AuditDiffSummary component
(compact "field: old → new" diff with 6-row truncation and create/remove
fallback).
- New tWithFallback(t, key, fallback) helper used in audit + notifications
cells (avoids strict TFunction overload errors); apiJson rewritten to
extract body/headers separately and stringify rawBody, eliminating the
"Record<string, unknown> not assignable to BodyInit" TS errors. tsc
--noEmit on executive-meetings.tsx is now clean.
- Print page (executive-meetings-print.tsx) intentionally keeps its inline
bilingual T table — it loads in a standalone print window before the i18n
provider mounts, so an inline dictionary is the right pattern.
i18n (en.json + ar.json):
- New keys executiveMeetings.approvals.{needsEdit, needsEditDone},
.audit.col.changes, .audit.{created, removed, moreChanges},
.audit.entity.pdf_archive,
.audit.action.{approved, rejected, needs_edit, withdraw, apply, duplicate,
replace_attendees, done},
.pdf.{print = "Print + archive" / "طباعة وأرشفة", printOnly,
archivedAndPrinted}.
Validation:
- runTest e2e passed: login, schedule loads with "#" header, Manage tab
creates "اجتماع تجريبي E2E", Approvals/PDF/Audit tabs render with
expected testids and the new Changes column.
- curl smoke: login 200, /me 200, dates 200, create meeting 201, nested
POST /:id/requests 201, PATCH /requests/:id status approval 200,
audit-logs 200, DELETE meeting 204, PUT and PATCH /font-settings both
200 with persisted data, /notifications?date=YYYY-MM-DD filters
correctly.
- tsc --noEmit on artifacts/tx-os/src/pages/executive-meetings.tsx is
clean (pre-existing admin.tsx errors are unrelated to this task).
- Architect review issued VERDICT: APPROVED on the previous round; only
the date wiring on NotificationsSection had to be added afterwards
(now done — NotificationsSection({date}) and queryKey/url include
date).
Validation-round-6 fixes (this commit):
- Bilingual title is now mandatory across the API + UI. Backend Zod
schema (meetingBaseFields.titleEn) requires .min(1); the manage form's
client-side `save()` validator now rejects empty titleEn alongside
empty titleAr (single i18n error key reused).
- Tasks: full reassign + edit flow. Coordinator-lead/admin actions now
expose a per-row "Reassign / edit" button (em-task-reassign-{id})
opening a dialog with assignedTo + notes inputs that PATCHes
/executive-meetings/tasks/{id}. New i18n keys
executiveMeetings.tasks.{reassign,reassigned} (AR + EN).
- Audit: filter row now includes From + To date pickers
(em-audit-date-from / -to), an actor ID filter (em-audit-actor) and
an action filter (em-audit-action) on top of the existing entity
filter. Backend already accepts dateFrom/dateTo/action/actorId so
only the UI needed wiring + i18n keys
executiveMeetings.audit.filter.{from,to,actorId} (AR + EN).
- PDF archives: each archive row in the snapshots list now has an
"Open" button (em-archive-open-{id}) that opens the print view for
that snapshot's archiveDate + version in a new tab. New i18n key
executiveMeetings.pdf.openArchive (AR + EN).
- Requests: status filter now exposes the full enum the backend
accepts: new, needs_edit, approved, rejected, withdrawn, done
(status labels were already translated).
- AI-slop cleanup: trimmed verbose explanatory comments in
artifacts/api-server/tests/executive-meetings.test.mjs and the
artifacts/tx-os/tests/executive-meetings-manage-create.spec.mjs.
- Test coverage extended: API test count went from 8 → 14, adding
bilingual-title 400 path, attendees PUT replace, /duplicate roundtrip,
full review-and-apply pipeline (highlight request applied), task
reassign PATCH, audit filter combination (dateFrom/dateTo/action/
actorId/entityType), and pdf-archive POST→GET. All 14 pass against
the live API. The Manage create E2E (1 spec) still passes.
Validation-round-5 fixes:
- Coordinator task scoping (server-side, blocking RBAC fix). Added
TASK_BROAD_VIEW_ROLES (admin + office_manager + coord_lead). Plain
coordinators GET /executive-meetings/tasks now ALWAYS receive
assignedTo=currentUserId — submitted ?mine=0 / ?assigneeId=other are
ignored. /me also exposes a new canViewAllTasks flag for the UI.
- UI mirror: TasksSection receives canViewAllTasks; coordinators see a
small read-only "My tasks only" badge next to the Tasks heading
(em-tasks-mine-badge) explaining the scope. New i18n keys
executiveMeetings.tasks.{myTasksOnly,myTasksOnlyHelp} (AR + EN).
- Seed: scripts/src/seed.ts now imports
executiveMeetingNotificationsTable and inserts 3 sample notification
rules per fresh seed (two pending reminders for meeting #1 + one
sent meeting_invite for meeting #2). Idempotent — only runs the day
the executive_meetings sample data is inserted.
- Tests added (none existed for this surface):
* artifacts/api-server/tests/executive-meetings.test.mjs — 8 happy-path
+ RBAC tests covering /me capability flags (incl canViewAllTasks),
meetings POST/GET/DELETE, requests POST + admin GET, tasks
coordinator scoping (mine=0&assigneeId=lead override is ignored),
audit-logs (200 admin, 403 coordinator), notifications ?date=
filtering, font-settings (200 valid combo, 400 medium weight, 400
size 30, 400 unknown family), and pdf-archives ?date=. All 8 pass.
* artifacts/tx-os/tests/executive-meetings-manage-create.spec.mjs —
Playwright UI test that logs in as admin, opens the Manage tab via
em-nav-manage, fills the create dialog (titleAr + titleEn + date),
saves, asserts POST /api/executive-meetings returns 201, and
verifies the row landed in the DB with the expected title and date.
Validation-round-4 fixes:
- Manage attendee editor: added per-row title input alongside name +
deterministic ChevronUp/ChevronDown reorder controls (sort order is
recomputed on save). New i18n keys
executiveMeetings.manage.attendees.{moveUp,moveDown}.
- Manage tab: per-row Copy button opens a new "Duplicate to date" dialog
that calls POST /executive-meetings/:id/duplicate with a target date
picker; on success it switches the day view to the chosen date. New
i18n keys executiveMeetings.manage.{duplicate, duplicateToDate,
duplicated, duplicateFailed}.
- Print page (executive-meetings-print.tsx) now uses react-i18next with
new locale block executiveMeetings.print.* (AR + EN parity), forces
i18n.changeLanguage(lang) so ?lang=en always prints English. Attendees
cell in the print table is now textAlign: center (matches the Step 1
schedule).
- Font settings tightened to spec: families = system/Cairo/Tajawal/
Noto Naskh Arabic/Amiri (dropped Inter, monospace), weights =
regular/bold (dropped medium, semibold), alignment = start/center
(dropped end), size range = 12–22 (was 10–32). Enforced server-side
in fontSettingsSchema (Zod) and mirrored in the UI selects/range.
Verified: PATCH font-settings returns 200 for valid combo, 400 for
fontWeight="medium", 400 for fontSize=30.
Validation-round-3 fixes:
- Tasks RBAC tightened end-to-end: new TASK_VIEW_ROLES = admin +
office_manager + coord_lead + coordinator. GET /tasks and
PATCH /tasks/:id now require requireTaskView. /me exposes new
canViewTasks flag. Frontend isSectionVisible("tasks") gated on
canViewTasks (so CEO/viewer never sees the Tasks tab or hits the
endpoint). Verified: admin /me returns canViewTasks=true.
- GET /requests gained dateFrom + dateTo (createdAt range) and a
requester filter that is honored only for approver/audit roles
(non-approvers stay locked to their own requests). Verified curl
with ?dateFrom=...&dateTo=... returns 200.
- Task dueAt schema accepts BOTH YYYY-MM-DD (from <input type="date">)
and full ISO datetime; date-only is normalized to start-of-day UTC,
empty string clears the field. Verified curl POST /tasks with
{"dueAt":"2026-12-31"} returns 201 with stored
dueAt = 2026-12-31T00:00:00.000Z.
Validation-round-2 fixes:
- GET /executive-meetings/requests: added server-side least-privilege so
only APPROVE_ROLES + ADMIN_AUDIT_ROLES see other users' requests; every
other executive role is force-scoped to requestedBy = self regardless
of the client's `mine` flag (closes the RBAC overexposure flag).
- isSectionVisible("tasks") now also returns true for canSubmitRequest,
so executive_coordinator sees the Tasks tab (coordinators execute the
follow-ups generated from approved requests).
- Requests "new request" dropdown now exposes ALL twelve backend request
types: create, edit, delete, reschedule, add_attendee, remove_attendee,
change_location, cancel_meeting, note, highlight, unhighlight, other.
- i18n parity: added en.json + ar.json keys for the five missing request
types (add_attendee, remove_attendee, change_location, cancel_meeting,
note) and the two missing statuses (needs_edit, done) under
executiveMeetings.requests.{type,status}.
- Fixed wrong key reference: reschedule form's date row was using
manage.field.date (does not exist) — switched to manage.field.meetingDate.
Drift / out of scope (explicitly deferred to follow-ups #110/#111/#112):
- Real notifications delivery, server-side PDF rendering, attendee
reorder UI (drag/arrows), duplicate-to-date UI, OpenAPI/api-spec sync,
expanded seed data including executive_meeting_notifications sample
rows.
Validation-round-7 fixes:
- Audit logs pagination: backend wraps response in {entries,total,limit,
offset,hasMore}, supports ?offset= (default 0) and lower default
limit=50; UI gained Previous/Next buttons (em-audit-prev/next),
count display "Showing N–M / total" (em-audit-count), and resets to
page 0 whenever any filter changes.
- meetingPatchSchema.titleEn switched to .optional() so PATCH stays
partial-update friendly; create paths still require titleEn via
meetingBaseFields + UI save() guard.
- Restored artifacts/tx-os/public/opengraph.jpg from commit 0b7b571
(was inadvertently overwritten in earlier round).
- Trimmed redundant top-of-file / inline AI-style commentary across
routes/executive-meetings.ts, executive-meetings.tsx, and
tests/executive-meetings-manage-create.spec.mjs while preserving the
section dividers and behavioural notes that explain non-obvious
intent.
- i18n: added common.previous / common.next and
executiveMeetings.audit.pageInfo in both en.json and ar.json.
- All 14 executive-meetings API tests still green; Manage-create E2E
still green.
Round 7 final cleanup:
- Reverted artifacts/tx-os/public/opengraph.jpg to match HEAD exactly
(no diff vs HEAD; file was untouched by this task's intent).
- Trimmed all remaining 50+ char narrative comments in
executive-meetings.tsx (isSectionVisible, attendee reorder,
duplicate-to-date, requests scope default, AuditDiffSummary,
notifications scope, archiveAndPrint).
- Architect re-review: APPROVE.
This commit is contained in:
@@ -35,8 +35,6 @@ import type { Request, Response, NextFunction } from "express";
|
|||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
// Constrain :id to numeric values; otherwise fall through. Required because
|
|
||||||
// path-to-regexp 8 (Express 5) no longer supports inline regex like ":id(\\d+)".
|
|
||||||
router.param("id", (req, res, next, value) => {
|
router.param("id", (req, res, next, value) => {
|
||||||
if (!/^\d+$/.test(String(value))) {
|
if (!/^\d+$/.test(String(value))) {
|
||||||
next("route");
|
next("route");
|
||||||
@@ -70,8 +68,6 @@ const REQUEST_REVIEW_STATUSES = [
|
|||||||
"needs_edit",
|
"needs_edit",
|
||||||
] as const;
|
] as const;
|
||||||
const TASK_STATUSES = ["pending", "in_progress", "completed", "cancelled"] as const;
|
const TASK_STATUSES = ["pending", "in_progress", "completed", "cancelled"] as const;
|
||||||
// Font settings constraints per Phase 2 spec: limited family list, regular/bold
|
|
||||||
// only, start/center alignment only, size 12–22.
|
|
||||||
const FONT_FAMILIES = [
|
const FONT_FAMILIES = [
|
||||||
"system",
|
"system",
|
||||||
"Cairo",
|
"Cairo",
|
||||||
@@ -102,19 +98,12 @@ const ADMIN_AUDIT_ROLES = [
|
|||||||
"executive_office_manager",
|
"executive_office_manager",
|
||||||
"executive_coord_lead",
|
"executive_coord_lead",
|
||||||
] as const;
|
] as const;
|
||||||
// Tasks are an internal coordination workflow; CEO and viewer roles must NOT
|
|
||||||
// see the Tasks tab or hit GET /tasks. Only admin, office manager, coord lead,
|
|
||||||
// and coordinators may read or operate on tasks.
|
|
||||||
const TASK_VIEW_ROLES = [
|
const TASK_VIEW_ROLES = [
|
||||||
"admin",
|
"admin",
|
||||||
"executive_office_manager",
|
"executive_office_manager",
|
||||||
"executive_coord_lead",
|
"executive_coord_lead",
|
||||||
"executive_coordinator",
|
"executive_coordinator",
|
||||||
] as const;
|
] as const;
|
||||||
// Within TASK_VIEW_ROLES, only these "lead" roles get a broad cross-user view.
|
|
||||||
// Plain coordinators see ONLY tasks assigned to themselves — server-side
|
|
||||||
// scoping must force assignedTo = currentUserId for them, regardless of any
|
|
||||||
// `mine`, `assigneeId` query parameters submitted by the client.
|
|
||||||
const TASK_BROAD_VIEW_ROLES = [
|
const TASK_BROAD_VIEW_ROLES = [
|
||||||
"admin",
|
"admin",
|
||||||
"executive_office_manager",
|
"executive_office_manager",
|
||||||
@@ -213,7 +202,7 @@ const meetingCreateSchema = z
|
|||||||
const meetingPatchSchema = z
|
const meetingPatchSchema = z
|
||||||
.object({
|
.object({
|
||||||
titleAr: meetingBaseFields.titleAr.optional(),
|
titleAr: meetingBaseFields.titleAr.optional(),
|
||||||
titleEn: meetingBaseFields.titleEn,
|
titleEn: meetingBaseFields.titleEn.optional(),
|
||||||
meetingDate: meetingBaseFields.meetingDate.optional(),
|
meetingDate: meetingBaseFields.meetingDate.optional(),
|
||||||
dailyNumber: meetingBaseFields.dailyNumber,
|
dailyNumber: meetingBaseFields.dailyNumber,
|
||||||
startTime: meetingBaseFields.startTime,
|
startTime: meetingBaseFields.startTime,
|
||||||
@@ -239,8 +228,6 @@ const duplicateSchema = z.object({
|
|||||||
targetDate: dateSchema,
|
targetDate: dateSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
// requestDetails differs by requestType. We accept a permissive object and
|
|
||||||
// validate the shape explicitly inside `validateApplyPayload` for the apply step.
|
|
||||||
const requestDetailsSchema = z.record(z.string(), z.unknown()).default({});
|
const requestDetailsSchema = z.record(z.string(), z.unknown()).default({});
|
||||||
|
|
||||||
const requestCreateSchema = z.object({
|
const requestCreateSchema = z.object({
|
||||||
@@ -254,8 +241,6 @@ const requestNestedCreateSchema = z.object({
|
|||||||
requestDetails: requestDetailsSchema,
|
requestDetails: requestDetailsSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH approvals are status-driven (per spec). `withdraw` is the requester's
|
|
||||||
// path; reviewers send `status` plus optional `reviewNotes`.
|
|
||||||
const requestReviewSchema = z.union([
|
const requestReviewSchema = z.union([
|
||||||
z.object({
|
z.object({
|
||||||
action: z.literal("withdraw"),
|
action: z.literal("withdraw"),
|
||||||
@@ -266,9 +251,6 @@ const requestReviewSchema = z.union([
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// dueAt accepts either a YYYY-MM-DD date (from <input type="date">) OR a full
|
|
||||||
// ISO datetime. Date-only is normalized to start-of-day UTC. Empty string is
|
|
||||||
// treated as null so PATCH can clear a due date.
|
|
||||||
const dueAtSchema = z
|
const dueAtSchema = z
|
||||||
.union([
|
.union([
|
||||||
z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
@@ -313,7 +295,6 @@ const fontSettingsSchema = z.object({
|
|||||||
alignment: z.enum(FONT_ALIGNMENTS).default("start"),
|
alignment: z.enum(FONT_ALIGNMENTS).default("start"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper: parse a body with a Zod schema and write a standard 400 if invalid.
|
|
||||||
function parseBody<T>(
|
function parseBody<T>(
|
||||||
res: Response,
|
res: Response,
|
||||||
schema: ZodType<T>,
|
schema: ZodType<T>,
|
||||||
@@ -331,7 +312,6 @@ function parseBody<T>(
|
|||||||
return r.data;
|
return r.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Audit log helper (typed, no `as never`) ----------
|
|
||||||
|
|
||||||
type DbExecutor = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0];
|
type DbExecutor = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||||
type AuditInsert = typeof executiveMeetingAuditLogsTable.$inferInsert;
|
type AuditInsert = typeof executiveMeetingAuditLogsTable.$inferInsert;
|
||||||
@@ -1014,9 +994,6 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Top-level POST /requests handles "create" (no target meeting yet) and any
|
|
||||||
// request that doesn't have a numeric meeting id in the URL. Nested
|
|
||||||
// POST /:id/requests is preferred for any request against an existing meeting.
|
|
||||||
router.post(
|
router.post(
|
||||||
"/executive-meetings/requests",
|
"/executive-meetings/requests",
|
||||||
requireExecutiveAccess,
|
requireExecutiveAccess,
|
||||||
@@ -1107,8 +1084,6 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// PATCH approvals are status-driven for the office manager. The requester can
|
|
||||||
// withdraw their own pending request via { action: "withdraw" }.
|
|
||||||
router.patch(
|
router.patch(
|
||||||
"/executive-meetings/requests/:id",
|
"/executive-meetings/requests/:id",
|
||||||
requireExecutiveAccess,
|
requireExecutiveAccess,
|
||||||
@@ -1539,7 +1514,10 @@ router.get(
|
|||||||
const limit =
|
const limit =
|
||||||
Number.isFinite(limitRaw) && limitRaw > 0
|
Number.isFinite(limitRaw) && limitRaw > 0
|
||||||
? Math.min(Math.floor(limitRaw), 200)
|
? Math.min(Math.floor(limitRaw), 200)
|
||||||
: 100;
|
: 50;
|
||||||
|
const offsetRaw = Number(req.query.offset);
|
||||||
|
const offset =
|
||||||
|
Number.isFinite(offsetRaw) && offsetRaw > 0 ? Math.floor(offsetRaw) : 0;
|
||||||
const conds: SQL[] = [];
|
const conds: SQL[] = [];
|
||||||
if (dateRaw && DATE_RE_LOCAL.test(dateRaw)) {
|
if (dateRaw && DATE_RE_LOCAL.test(dateRaw)) {
|
||||||
const start = new Date(`${dateRaw}T00:00:00.000Z`);
|
const start = new Date(`${dateRaw}T00:00:00.000Z`);
|
||||||
@@ -1564,6 +1542,11 @@ router.get(
|
|||||||
if (action) conds.push(eq(executiveMeetingAuditLogsTable.action, action));
|
if (action) conds.push(eq(executiveMeetingAuditLogsTable.action, action));
|
||||||
if (actorId) conds.push(eq(executiveMeetingAuditLogsTable.performedBy, actorId));
|
if (actorId) conds.push(eq(executiveMeetingAuditLogsTable.performedBy, actorId));
|
||||||
const where = conds.length > 0 ? and(...conds) : undefined;
|
const where = conds.length > 0 ? and(...conds) : undefined;
|
||||||
|
const [{ count: totalRaw } = { count: 0 }] = await db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(executiveMeetingAuditLogsTable)
|
||||||
|
.where(where);
|
||||||
|
const total = Number(totalRaw) || 0;
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
l: executiveMeetingAuditLogsTable,
|
l: executiveMeetingAuditLogsTable,
|
||||||
@@ -1575,7 +1558,8 @@ router.get(
|
|||||||
.leftJoin(usersTable, eq(usersTable.id, executiveMeetingAuditLogsTable.performedBy))
|
.leftJoin(usersTable, eq(usersTable.id, executiveMeetingAuditLogsTable.performedBy))
|
||||||
.where(where)
|
.where(where)
|
||||||
.orderBy(desc(executiveMeetingAuditLogsTable.performedAt))
|
.orderBy(desc(executiveMeetingAuditLogsTable.performedAt))
|
||||||
.limit(limit);
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
res.json({
|
res.json({
|
||||||
entries: rows.map((row) => ({
|
entries: rows.map((row) => ({
|
||||||
...row.l,
|
...row.l,
|
||||||
@@ -1587,6 +1571,10 @@ router.get(
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
})),
|
})),
|
||||||
|
total,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
hasMore: offset + rows.length < total,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -581,7 +581,9 @@
|
|||||||
"error": "حدث خطأ",
|
"error": "حدث خطأ",
|
||||||
"success": "تمت العملية بنجاح",
|
"success": "تمت العملية بنجاح",
|
||||||
"language": "English",
|
"language": "English",
|
||||||
"appName": "نظام Tx"
|
"appName": "نظام Tx",
|
||||||
|
"previous": "السابق",
|
||||||
|
"next": "التالي"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"title": "الملاحظات",
|
"title": "الملاحظات",
|
||||||
@@ -807,6 +809,7 @@
|
|||||||
"to": "إلى",
|
"to": "إلى",
|
||||||
"actorId": "معرّف المنفّذ"
|
"actorId": "معرّف المنفّذ"
|
||||||
},
|
},
|
||||||
|
"pageInfo": "عرض",
|
||||||
"all": "الكل",
|
"all": "الكل",
|
||||||
"col": {
|
"col": {
|
||||||
"when": "الوقت",
|
"when": "الوقت",
|
||||||
|
|||||||
@@ -578,7 +578,9 @@
|
|||||||
"error": "An error occurred",
|
"error": "An error occurred",
|
||||||
"success": "Success",
|
"success": "Success",
|
||||||
"language": "العربية",
|
"language": "العربية",
|
||||||
"appName": "Tx OS"
|
"appName": "Tx OS",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"title": "Notes",
|
"title": "Notes",
|
||||||
@@ -804,6 +806,7 @@
|
|||||||
"to": "To",
|
"to": "To",
|
||||||
"actorId": "Actor ID"
|
"actorId": "Actor ID"
|
||||||
},
|
},
|
||||||
|
"pageInfo": "Showing",
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"col": {
|
"col": {
|
||||||
"when": "When",
|
"when": "When",
|
||||||
|
|||||||
@@ -210,10 +210,6 @@ type MeCapabilities = {
|
|||||||
canViewAllTasks: boolean;
|
canViewAllTasks: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Decides whether a navigation tab should be visible at all for the current
|
|
||||||
// user. Hides admin-only / reviewer-only tabs from low-privilege users so they
|
|
||||||
// never see actions they can't perform — addresses the reviewer's RBAC/UI
|
|
||||||
// concern about overexposed navigation.
|
|
||||||
function isSectionVisible(
|
function isSectionVisible(
|
||||||
key: SectionKey,
|
key: SectionKey,
|
||||||
me: MeCapabilities | null | undefined,
|
me: MeCapabilities | null | undefined,
|
||||||
@@ -231,8 +227,6 @@ function isSectionVisible(
|
|||||||
case "approvals":
|
case "approvals":
|
||||||
return me.canApprove;
|
return me.canApprove;
|
||||||
case "tasks":
|
case "tasks":
|
||||||
// Tasks tab is gated by the dedicated canViewTasks server-side flag,
|
|
||||||
// which only includes admin/office_manager/coord_lead/coordinator —
|
|
||||||
// CEO and viewer roles never see it.
|
// CEO and viewer roles never see it.
|
||||||
return me.canViewTasks;
|
return me.canViewTasks;
|
||||||
case "notifications":
|
case "notifications":
|
||||||
@@ -279,9 +273,6 @@ function buildFontStyle(prefs: FontPrefs): CSSProperties {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type-safe wrapper around i18next's `t` that returns the fallback when the
|
|
||||||
// key is missing instead of echoing the raw key. Avoids the strict TFunction
|
|
||||||
// overload errors when calling `t(key, { defaultValue })` directly.
|
|
||||||
function tWithFallback(
|
function tWithFallback(
|
||||||
t: (key: string) => string,
|
t: (key: string) => string,
|
||||||
key: string,
|
key: string,
|
||||||
@@ -781,9 +772,6 @@ function ManageSection({
|
|||||||
const [editing, setEditing] = useState<MeetingFormState | null>(null);
|
const [editing, setEditing] = useState<MeetingFormState | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||||
// Duplicate-to-date dialog state. The chosen target date is sent to the
|
|
||||||
// backend POST /:id/duplicate route which clones the meeting (and its
|
|
||||||
// attendees) onto that date.
|
|
||||||
const [duplicating, setDuplicating] = useState<{
|
const [duplicating, setDuplicating] = useState<{
|
||||||
id: number;
|
id: number;
|
||||||
targetDate: string;
|
targetDate: string;
|
||||||
@@ -1116,9 +1104,6 @@ function MeetingFormDialog({
|
|||||||
arr[i] = { ...arr[i], ...patch } as Attendee;
|
arr[i] = { ...arr[i], ...patch } as Attendee;
|
||||||
onChange({ ...state, attendees: arr });
|
onChange({ ...state, attendees: arr });
|
||||||
}
|
}
|
||||||
// Move an attendee up or down using deterministic arrow controls. The
|
|
||||||
// `sortOrder` field is recomputed on save so the persistent order matches
|
|
||||||
// what the user sees here.
|
|
||||||
function moveAttendee(i: number, dir: -1 | 1) {
|
function moveAttendee(i: number, dir: -1 | 1) {
|
||||||
const j = i + dir;
|
const j = i + dir;
|
||||||
if (j < 0 || j >= state.attendees.length) return;
|
if (j < 0 || j >= state.attendees.length) return;
|
||||||
@@ -1366,9 +1351,6 @@ function RequestsSection({
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [filterStatus, setFilterStatus] = useState<string>("all");
|
const [filterStatus, setFilterStatus] = useState<string>("all");
|
||||||
// Default scope = "mine" for plain requesters; only reviewers/admins land
|
|
||||||
// on "all" so requests are not over-exposed to roles that should primarily
|
|
||||||
// see their own submissions.
|
|
||||||
const [scope, setScope] = useState<"all" | "mine">(
|
const [scope, setScope] = useState<"all" | "mine">(
|
||||||
me.canApprove ? "all" : "mine",
|
me.canApprove ? "all" : "mine",
|
||||||
);
|
);
|
||||||
@@ -1847,8 +1829,6 @@ function ApprovalsSection({
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
qc.invalidateQueries({ queryKey: ["/api/executive-meetings/requests"] }),
|
qc.invalidateQueries({ queryKey: ["/api/executive-meetings/requests"] }),
|
||||||
qc.invalidateQueries({ queryKey: ["/api/executive-meetings/tasks"] }),
|
qc.invalidateQueries({ queryKey: ["/api/executive-meetings/tasks"] }),
|
||||||
// Approval may mutate the meeting (date/time/location/attendees/etc).
|
|
||||||
// Invalidate the schedule cache for ALL dates the user may be viewing.
|
|
||||||
qc.invalidateQueries({
|
qc.invalidateQueries({
|
||||||
predicate: (q) =>
|
predicate: (q) =>
|
||||||
Array.isArray(q.queryKey) &&
|
Array.isArray(q.queryKey) &&
|
||||||
@@ -2316,10 +2296,6 @@ function TasksSection({
|
|||||||
|
|
||||||
// ============ AUDIT ============
|
// ============ AUDIT ============
|
||||||
|
|
||||||
// Compact "field: old → new" summary of an audit entry's old/new JSON values.
|
|
||||||
// Used in the audit log table so reviewers can see what actually changed
|
|
||||||
// without having to expand raw JSON. Falls back to JSON dumps for unknown
|
|
||||||
// shapes so nothing is hidden.
|
|
||||||
function AuditDiffSummary({
|
function AuditDiffSummary({
|
||||||
oldValue,
|
oldValue,
|
||||||
newValue,
|
newValue,
|
||||||
@@ -2403,7 +2379,6 @@ function AuditDiffSummary({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pure create or pure delete — show a compact field summary
|
|
||||||
const single = (newObj ?? oldObj) as Record<string, unknown>;
|
const single = (newObj ?? oldObj) as Record<string, unknown>;
|
||||||
const label = newObj
|
const label = newObj
|
||||||
? t("executiveMeetings.audit.created")
|
? t("executiveMeetings.audit.created")
|
||||||
@@ -2440,7 +2415,18 @@ function AuditSection({
|
|||||||
const [entityType, setEntityType] = useState("all");
|
const [entityType, setEntityType] = useState("all");
|
||||||
const [actionFilter, setActionFilter] = useState("all");
|
const [actionFilter, setActionFilter] = useState("all");
|
||||||
const [actorFilter, setActorFilter] = useState("");
|
const [actorFilter, setActorFilter] = useState("");
|
||||||
const { data, isLoading } = useQuery<{ entries: AuditEntry[] }>({
|
const [page, setPage] = useState(0);
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(0);
|
||||||
|
}, [date, dateTo, entityType, actionFilter, actorFilter]);
|
||||||
|
const { data, isLoading } = useQuery<{
|
||||||
|
entries: AuditEntry[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
}>({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
"/api/executive-meetings/audit-logs",
|
"/api/executive-meetings/audit-logs",
|
||||||
date,
|
date,
|
||||||
@@ -2448,6 +2434,7 @@ function AuditSection({
|
|||||||
entityType,
|
entityType,
|
||||||
actionFilter,
|
actionFilter,
|
||||||
actorFilter,
|
actorFilter,
|
||||||
|
page,
|
||||||
],
|
],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
@@ -2456,6 +2443,8 @@ function AuditSection({
|
|||||||
if (entityType !== "all") qs.set("entityType", entityType);
|
if (entityType !== "all") qs.set("entityType", entityType);
|
||||||
if (actionFilter !== "all") qs.set("action", actionFilter);
|
if (actionFilter !== "all") qs.set("action", actionFilter);
|
||||||
if (actorFilter.trim()) qs.set("actorId", actorFilter.trim());
|
if (actorFilter.trim()) qs.set("actorId", actorFilter.trim());
|
||||||
|
qs.set("limit", String(PAGE_SIZE));
|
||||||
|
qs.set("offset", String(page * PAGE_SIZE));
|
||||||
return apiJson(`/api/executive-meetings/audit-logs?${qs}`);
|
return apiJson(`/api/executive-meetings/audit-logs?${qs}`);
|
||||||
},
|
},
|
||||||
enabled: canView,
|
enabled: canView,
|
||||||
@@ -2603,6 +2592,36 @@ function AuditSection({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-600">
|
||||||
|
<span data-testid="em-audit-count">
|
||||||
|
{t("executiveMeetings.audit.pageInfo")}{" "}
|
||||||
|
{data
|
||||||
|
? `${(data.offset ?? 0) + (entries.length > 0 ? 1 : 0)}–${
|
||||||
|
(data.offset ?? 0) + entries.length
|
||||||
|
} / ${data.total ?? 0}`
|
||||||
|
: "—"}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={page === 0}
|
||||||
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
|
data-testid="em-audit-prev"
|
||||||
|
>
|
||||||
|
{t("common.previous")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!data?.hasMore}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
data-testid="em-audit-next"
|
||||||
|
>
|
||||||
|
{t("common.next")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2620,8 +2639,6 @@ function NotificationsSection({
|
|||||||
isRtl: boolean;
|
isRtl: boolean;
|
||||||
t: (k: string) => string;
|
t: (k: string) => string;
|
||||||
}) {
|
}) {
|
||||||
// Notifications are scoped by the same selected date as the schedule, so
|
|
||||||
// managers see only what is relevant to the day they are reviewing.
|
|
||||||
const { data, isLoading } = useQuery<{ notifications: NotificationRow[] }>({
|
const { data, isLoading } = useQuery<{ notifications: NotificationRow[] }>({
|
||||||
queryKey: ["/api/executive-meetings/notifications", date],
|
queryKey: ["/api/executive-meetings/notifications", date],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -2752,8 +2769,6 @@ function PdfSection({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print + archive in one click: archive the snapshot first, then open the
|
|
||||||
// print view in a new tab so the user can hit Ctrl+P / Save as PDF.
|
|
||||||
async function archiveAndPrint() {
|
async function archiveAndPrint() {
|
||||||
if (!date) return;
|
if (!date) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
|||||||
@@ -1,12 +1,3 @@
|
|||||||
// UI smoke for the Executive Meetings Phase 2 "Manage" tab create flow.
|
|
||||||
// Logs in as the seeded admin (admin/admin123), opens /executive-meetings,
|
|
||||||
// switches to the Manage tab, opens the create dialog, fills the bilingual
|
|
||||||
// title and meeting date, saves, and verifies that:
|
|
||||||
// 1) POST /api/executive-meetings returns 201, and
|
|
||||||
// 2) the new row appears in the Manage table.
|
|
||||||
// Cleans up after itself by deleting the meeting + its attendees + audit logs
|
|
||||||
// directly via the database pool.
|
|
||||||
|
|
||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "@playwright/test";
|
||||||
import pg from "pg";
|
import pg from "pg";
|
||||||
|
|
||||||
@@ -54,7 +45,6 @@ test.afterAll(async () => {
|
|||||||
test("Manage tab: admin can create a meeting from the UI and see it in the list", async ({
|
test("Manage tab: admin can create a meeting from the UI and see it in the list", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
// Force English so the table heading match below is deterministic.
|
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem("tx-lang", "en");
|
window.localStorage.setItem("tx-lang", "en");
|
||||||
@@ -66,16 +56,11 @@ test("Manage tab: admin can create a meeting from the UI and see it in the list"
|
|||||||
await loginViaUi(page, "admin", "admin123");
|
await loginViaUi(page, "admin", "admin123");
|
||||||
await page.goto("/executive-meetings");
|
await page.goto("/executive-meetings");
|
||||||
|
|
||||||
// The Manage tab is keyed by its translated label "Manage Meetings"
|
|
||||||
// (executiveMeetings.section.manage in en.json).
|
|
||||||
await page.getByTestId("em-nav-manage").click();
|
await page.getByTestId("em-nav-manage").click();
|
||||||
|
|
||||||
// Wait for the add button to render (the Manage section uses
|
|
||||||
// data-testid="em-add-meeting" for the Add button).
|
|
||||||
const addBtn = page.getByTestId("em-add-meeting");
|
const addBtn = page.getByTestId("em-add-meeting");
|
||||||
await expect(addBtn).toBeVisible();
|
await expect(addBtn).toBeVisible();
|
||||||
|
|
||||||
// Capture the POST to /api/executive-meetings so we know the row landed.
|
|
||||||
const createPromise = page.waitForResponse(
|
const createPromise = page.waitForResponse(
|
||||||
(resp) => {
|
(resp) => {
|
||||||
const u = new URL(resp.url());
|
const u = new URL(resp.url());
|
||||||
@@ -89,8 +74,6 @@ test("Manage tab: admin can create a meeting from the UI and see it in the list"
|
|||||||
|
|
||||||
await addBtn.click();
|
await addBtn.click();
|
||||||
|
|
||||||
// Fill the dialog. Only the Arabic title has a stable testid; other fields
|
|
||||||
// are reached by their visible label / type.
|
|
||||||
const uniq = `${Date.now().toString(36)}_${Math.random()
|
const uniq = `${Date.now().toString(36)}_${Math.random()
|
||||||
.toString(36)
|
.toString(36)
|
||||||
.slice(2, 6)}`;
|
.slice(2, 6)}`;
|
||||||
@@ -98,7 +81,6 @@ test("Manage tab: admin can create a meeting from the UI and see it in the list"
|
|||||||
const titleEn = `UI Meeting ${uniq}`;
|
const titleEn = `UI Meeting ${uniq}`;
|
||||||
|
|
||||||
await page.getByTestId("em-form-titleAr").fill(titleAr);
|
await page.getByTestId("em-form-titleAr").fill(titleAr);
|
||||||
// Title (EN) Input is the next text input inside the dialog; reach it via
|
|
||||||
// its label row.
|
// its label row.
|
||||||
const dialog = page.getByRole("dialog");
|
const dialog = page.getByRole("dialog");
|
||||||
const titleEnInput = dialog
|
const titleEnInput = dialog
|
||||||
@@ -106,7 +88,6 @@ test("Manage tab: admin can create a meeting from the UI and see it in the list"
|
|||||||
.nth(1); // titleAr=0, titleEn=1, meetingDate=2 (date), dailyNumber=3
|
.nth(1); // titleAr=0, titleEn=1, meetingDate=2 (date), dailyNumber=3
|
||||||
await titleEnInput.fill(titleEn);
|
await titleEnInput.fill(titleEn);
|
||||||
|
|
||||||
// Meeting date <input type="date"> — fill explicitly with today.
|
|
||||||
await dialog.locator('input[type="date"]').first().fill(today);
|
await dialog.locator('input[type="date"]').first().fill(today);
|
||||||
|
|
||||||
await page.getByTestId("em-form-save").click();
|
await page.getByTestId("em-form-save").click();
|
||||||
@@ -117,21 +98,14 @@ test("Manage tab: admin can create a meeting from the UI and see it in the list"
|
|||||||
expect(created.id).toBeTruthy();
|
expect(created.id).toBeTruthy();
|
||||||
createdMeetingIds.push(created.id);
|
createdMeetingIds.push(created.id);
|
||||||
|
|
||||||
// Dialog should close after the successful save.
|
|
||||||
await expect(dialog).toBeHidden({ timeout: 10_000 });
|
await expect(dialog).toBeHidden({ timeout: 10_000 });
|
||||||
|
|
||||||
// Confirm the row was actually persisted to the database (the strongest
|
|
||||||
// possible end-to-end check for the Manage create flow). The Manage
|
|
||||||
// table re-render is filtered by the active date picker, so asserting
|
|
||||||
// against the database is more reliable than scanning DOM rows.
|
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT id, title_en, meeting_date FROM executive_meetings WHERE id = $1`,
|
`SELECT id, title_en, meeting_date FROM executive_meetings WHERE id = $1`,
|
||||||
[created.id],
|
[created.id],
|
||||||
);
|
);
|
||||||
expect(rows.length).toBe(1);
|
expect(rows.length).toBe(1);
|
||||||
expect(rows[0].title_en).toBe(titleEn);
|
expect(rows[0].title_en).toBe(titleEn);
|
||||||
// meeting_date is a `date` column → pg returns a Date object; compare as
|
|
||||||
// YYYY-MM-DD to match what the UI form sent.
|
|
||||||
const isoDate =
|
const isoDate =
|
||||||
rows[0].meeting_date instanceof Date
|
rows[0].meeting_date instanceof Date
|
||||||
? rows[0].meeting_date.toISOString().slice(0, 10)
|
? rows[0].meeting_date.toISOString().slice(0, 10)
|
||||||
|
|||||||
Reference in New Issue
Block a user