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-5 fixes (this commit):
- 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.
This commit is contained in:
@@ -111,6 +111,15 @@ const TASK_VIEW_ROLES = [
|
||||
"executive_coord_lead",
|
||||
"executive_coordinator",
|
||||
] 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 = [
|
||||
"admin",
|
||||
"executive_office_manager",
|
||||
"executive_coord_lead",
|
||||
] as const;
|
||||
const READ_ROLES = [
|
||||
"admin",
|
||||
"executive_ceo",
|
||||
@@ -458,6 +467,7 @@ router.get(
|
||||
canSubmitRequest: REQUEST_ROLES.some((r) => names.has(r)),
|
||||
canViewAudit: ADMIN_AUDIT_ROLES.some((r) => names.has(r)),
|
||||
canViewTasks: TASK_VIEW_ROLES.some((r) => names.has(r)),
|
||||
canViewAllTasks: TASK_BROAD_VIEW_ROLES.some((r) => names.has(r)),
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1267,16 +1277,25 @@ router.get(
|
||||
const dateToRaw = typeof req.query.dateTo === "string" ? req.query.dateTo.trim() : "";
|
||||
const assigneeRaw = Number(req.query.assigneeId);
|
||||
const userId = req.session.userId!;
|
||||
const names = await getRoleNamesForUser(userId);
|
||||
const hasBroadView = TASK_BROAD_VIEW_ROLES.some((r) => names.has(r));
|
||||
const conds: SQL[] = [];
|
||||
if (status && (TASK_STATUSES as readonly string[]).includes(status)) {
|
||||
conds.push(eq(executiveMeetingTasksTable.status, status));
|
||||
}
|
||||
// Plain coordinators (no broad-view role) are hard-scoped to their own
|
||||
// assignments — any `mine`/`assigneeId` query parameters they submit are
|
||||
// ignored and replaced with `assignedTo = currentUserId`.
|
||||
if (!hasBroadView) {
|
||||
conds.push(eq(executiveMeetingTasksTable.assignedTo, userId));
|
||||
} else {
|
||||
if (mine) {
|
||||
conds.push(eq(executiveMeetingTasksTable.assignedTo, userId));
|
||||
}
|
||||
if (Number.isInteger(assigneeRaw) && assigneeRaw > 0) {
|
||||
conds.push(eq(executiveMeetingTasksTable.assignedTo, assigneeRaw));
|
||||
}
|
||||
}
|
||||
if (meetingIdRaw) {
|
||||
const n = Number(meetingIdRaw);
|
||||
if (Number.isInteger(n) && n > 0) {
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import pg from "pg";
|
||||
|
||||
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL must be set to run these tests");
|
||||
}
|
||||
|
||||
const TEST_PASSWORD_HASH =
|
||||
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
|
||||
const TEST_PASSWORD = "TestPass123!";
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
|
||||
const created = {
|
||||
userIds: [],
|
||||
meetingIds: [],
|
||||
requestIds: [],
|
||||
taskIds: [],
|
||||
};
|
||||
|
||||
function uniqueName(prefix) {
|
||||
return `${prefix}_${Date.now().toString(36)}_${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}`;
|
||||
}
|
||||
|
||||
async function createUser(prefix, roleName) {
|
||||
const username = uniqueName(prefix);
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, 'EM Test', 'en', true) RETURNING id`,
|
||||
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
|
||||
);
|
||||
const id = rows[0].id;
|
||||
created.userIds.push(id);
|
||||
const roles = ["user", roleName];
|
||||
for (const r of roles) {
|
||||
await pool.query(
|
||||
`INSERT INTO user_roles (user_id, role_id)
|
||||
SELECT $1, id FROM roles WHERE name = $2
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[id, r],
|
||||
);
|
||||
}
|
||||
return { id, username };
|
||||
}
|
||||
|
||||
function extractCookie(res) {
|
||||
const setCookie = res.headers.get("set-cookie");
|
||||
if (!setCookie) return null;
|
||||
return setCookie
|
||||
.split(",")
|
||||
.map((c) => c.split(";")[0].trim())
|
||||
.find((c) => c.startsWith("connect.sid=")) ?? null;
|
||||
}
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
assert.equal(res.status, 200, `login should succeed for ${username}`);
|
||||
const cookie = extractCookie(res);
|
||||
assert.ok(cookie, "login response should set a session cookie");
|
||||
return cookie;
|
||||
}
|
||||
|
||||
async function api(cookie, method, path, body) {
|
||||
const init = {
|
||||
method,
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
};
|
||||
if (body !== undefined) init.body = JSON.stringify(body);
|
||||
return fetch(`${API_BASE}${path}`, init);
|
||||
}
|
||||
|
||||
let adminCookie = null;
|
||||
let adminUserId = null;
|
||||
let coordCookie = null;
|
||||
let coordUserId = null;
|
||||
let leadCookie = null;
|
||||
let leadUserId = null;
|
||||
|
||||
before(async () => {
|
||||
// Reuse the seeded admin (admin/admin123) for the broad-view path.
|
||||
adminCookie = await login("admin", "admin123");
|
||||
const meRes = await api(adminCookie, "GET", "/api/auth/me");
|
||||
const me = await meRes.json();
|
||||
adminUserId = me.id ?? me.userId;
|
||||
|
||||
// Create a fresh plain coordinator and a coord_lead so we can check the
|
||||
// server-side scoping behavior of GET /tasks (coordinator must only ever
|
||||
// see their own tasks even with mine=0/assigneeId=other).
|
||||
const coord = await createUser("em_coord", "executive_coordinator");
|
||||
coordUserId = coord.id;
|
||||
coordCookie = await login(coord.username, TEST_PASSWORD);
|
||||
|
||||
const lead = await createUser("em_lead", "executive_coord_lead");
|
||||
leadUserId = lead.id;
|
||||
leadCookie = await login(lead.username, TEST_PASSWORD);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
// Best-effort cleanup; per-test rows may have already been deleted.
|
||||
if (created.taskIds.length > 0) {
|
||||
await pool.query(`DELETE FROM executive_meeting_tasks WHERE id = ANY($1::int[])`, [
|
||||
created.taskIds,
|
||||
]);
|
||||
}
|
||||
if (created.requestIds.length > 0) {
|
||||
await pool.query(`DELETE FROM executive_meeting_requests WHERE id = ANY($1::int[])`, [
|
||||
created.requestIds,
|
||||
]);
|
||||
}
|
||||
if (created.meetingIds.length > 0) {
|
||||
await pool.query(`DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`, [
|
||||
created.meetingIds,
|
||||
]);
|
||||
await pool.query(`DELETE FROM executive_meeting_audit_logs WHERE entity_id = ANY($1::int[]) AND entity_type IN ('meeting','request','task')`, [
|
||||
created.meetingIds,
|
||||
]);
|
||||
await pool.query(`DELETE FROM executive_meetings WHERE id = ANY($1::int[])`, [
|
||||
created.meetingIds,
|
||||
]);
|
||||
}
|
||||
if (created.userIds.length > 0) {
|
||||
await pool.query(`DELETE FROM executive_meeting_tasks WHERE assigned_to = ANY($1::int[])`, [
|
||||
created.userIds,
|
||||
]);
|
||||
await pool.query(`DELETE FROM executive_meeting_requests WHERE requested_by = ANY($1::int[])`, [
|
||||
created.userIds,
|
||||
]);
|
||||
await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [
|
||||
created.userIds,
|
||||
]);
|
||||
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
|
||||
created.userIds,
|
||||
]);
|
||||
}
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
|
||||
test("GET /executive-meetings/me exposes the full capability flag set including canViewAllTasks", async () => {
|
||||
const res = await api(adminCookie, "GET", "/api/executive-meetings/me");
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
for (const key of [
|
||||
"userId",
|
||||
"roles",
|
||||
"canRead",
|
||||
"canMutate",
|
||||
"canApprove",
|
||||
"canSubmitRequest",
|
||||
"canViewAudit",
|
||||
"canViewTasks",
|
||||
"canViewAllTasks",
|
||||
]) {
|
||||
assert.ok(key in body, `missing ${key} in /me response`);
|
||||
}
|
||||
assert.equal(body.canViewAllTasks, true);
|
||||
|
||||
const coordRes = await api(coordCookie, "GET", "/api/executive-meetings/me");
|
||||
const coordMe = await coordRes.json();
|
||||
assert.equal(coordMe.canViewTasks, true);
|
||||
assert.equal(
|
||||
coordMe.canViewAllTasks,
|
||||
false,
|
||||
"plain coordinators must not get cross-user task visibility",
|
||||
);
|
||||
});
|
||||
|
||||
test("Meetings: POST creates → GET day returns it → DELETE removes it", async () => {
|
||||
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "اجتماع اختبار",
|
||||
titleEn: "Test meeting",
|
||||
meetingDate: today,
|
||||
startTime: "09:00",
|
||||
endTime: "10:00",
|
||||
platform: "none",
|
||||
status: "scheduled",
|
||||
isHighlighted: 0,
|
||||
attendees: [
|
||||
{ name: "Tester One", attendanceType: "internal", sortOrder: 0 },
|
||||
],
|
||||
});
|
||||
assert.equal(create.status, 201);
|
||||
const meeting = await create.json();
|
||||
assert.ok(meeting.id);
|
||||
created.meetingIds.push(meeting.id);
|
||||
|
||||
const day = await api(
|
||||
adminCookie,
|
||||
"GET",
|
||||
`/api/executive-meetings?date=${today}`,
|
||||
);
|
||||
assert.equal(day.status, 200);
|
||||
const dayBody = await day.json();
|
||||
assert.ok(Array.isArray(dayBody.meetings));
|
||||
assert.ok(dayBody.meetings.some((m) => m.id === meeting.id));
|
||||
|
||||
const del = await api(
|
||||
adminCookie,
|
||||
"DELETE",
|
||||
`/api/executive-meetings/${meeting.id}`,
|
||||
);
|
||||
assert.ok(del.status === 200 || del.status === 204);
|
||||
});
|
||||
|
||||
test("Requests: POST creates → admin GET sees it; coord scope filter respects requester", async () => {
|
||||
const create = await api(
|
||||
adminCookie,
|
||||
"POST",
|
||||
"/api/executive-meetings/requests",
|
||||
{
|
||||
requestType: "note",
|
||||
requestDetails: { note: "Phase-2 test request" },
|
||||
},
|
||||
);
|
||||
assert.equal(create.status, 201);
|
||||
const reqRow = await create.json();
|
||||
assert.ok(reqRow.id);
|
||||
created.requestIds.push(reqRow.id);
|
||||
|
||||
const list = await api(adminCookie, "GET", "/api/executive-meetings/requests");
|
||||
assert.equal(list.status, 200);
|
||||
const listBody = await list.json();
|
||||
assert.ok(Array.isArray(listBody.requests));
|
||||
assert.ok(listBody.requests.some((r) => r.id === reqRow.id));
|
||||
});
|
||||
|
||||
test("Tasks: server-side scoping forces coordinators to assignedTo=self", async () => {
|
||||
// Admin creates a task assigned to the coordinator and another assigned to
|
||||
// the lead. The coordinator must only see the first one even when they pass
|
||||
// mine=0 / assigneeId=lead.
|
||||
const t1 = await api(adminCookie, "POST", "/api/executive-meetings/tasks", {
|
||||
taskType: "follow_up",
|
||||
assignedTo: coordUserId,
|
||||
notes: "for coord",
|
||||
});
|
||||
assert.equal(t1.status, 201);
|
||||
const task1 = await t1.json();
|
||||
created.taskIds.push(task1.id);
|
||||
|
||||
const t2 = await api(adminCookie, "POST", "/api/executive-meetings/tasks", {
|
||||
taskType: "follow_up",
|
||||
assignedTo: leadUserId,
|
||||
notes: "for lead",
|
||||
});
|
||||
assert.equal(t2.status, 201);
|
||||
const task2 = await t2.json();
|
||||
created.taskIds.push(task2.id);
|
||||
|
||||
// Plain coordinator: mine=0 + assigneeId=leadUserId attempts override; the
|
||||
// server must ignore both and force assignedTo=coordUserId.
|
||||
const coordList = await api(
|
||||
coordCookie,
|
||||
"GET",
|
||||
`/api/executive-meetings/tasks?mine=0&assigneeId=${leadUserId}`,
|
||||
);
|
||||
assert.equal(coordList.status, 200);
|
||||
const coordBody = await coordList.json();
|
||||
const coordIds = coordBody.tasks.map((t) => t.id);
|
||||
assert.ok(
|
||||
coordIds.includes(task1.id),
|
||||
"coordinator should see their own task",
|
||||
);
|
||||
assert.ok(
|
||||
!coordIds.includes(task2.id),
|
||||
"coordinator must NOT see another user's task even with assigneeId override",
|
||||
);
|
||||
for (const t of coordBody.tasks) {
|
||||
assert.equal(
|
||||
t.assignedTo,
|
||||
coordUserId,
|
||||
"every task returned to a coordinator must be assigned to them",
|
||||
);
|
||||
}
|
||||
|
||||
// Lead: broad view honors assigneeId.
|
||||
const leadList = await api(
|
||||
leadCookie,
|
||||
"GET",
|
||||
`/api/executive-meetings/tasks?assigneeId=${leadUserId}`,
|
||||
);
|
||||
assert.equal(leadList.status, 200);
|
||||
const leadBody = await leadList.json();
|
||||
assert.ok(leadBody.tasks.some((t) => t.id === task2.id));
|
||||
});
|
||||
|
||||
test("Audit logs: admin can list, plain coordinator gets 403", async () => {
|
||||
const ok = await api(
|
||||
adminCookie,
|
||||
"GET",
|
||||
"/api/executive-meetings/audit-logs",
|
||||
);
|
||||
assert.equal(ok.status, 200);
|
||||
const body = await ok.json();
|
||||
assert.ok(Array.isArray(body.logs ?? body.auditLogs ?? body.entries ?? []));
|
||||
|
||||
const denied = await api(
|
||||
coordCookie,
|
||||
"GET",
|
||||
"/api/executive-meetings/audit-logs",
|
||||
);
|
||||
assert.equal(denied.status, 403);
|
||||
});
|
||||
|
||||
test("Notifications: GET filters by ?date= and tolerates empty days", async () => {
|
||||
const empty = await api(
|
||||
adminCookie,
|
||||
"GET",
|
||||
`/api/executive-meetings/notifications?date=1990-01-01`,
|
||||
);
|
||||
assert.equal(empty.status, 200);
|
||||
const emptyBody = await empty.json();
|
||||
assert.ok(Array.isArray(emptyBody.notifications));
|
||||
|
||||
const todayList = await api(
|
||||
adminCookie,
|
||||
"GET",
|
||||
`/api/executive-meetings/notifications?date=${today}`,
|
||||
);
|
||||
assert.equal(todayList.status, 200);
|
||||
});
|
||||
|
||||
test("Font settings: valid combo returns 200; invalid weight or out-of-range size returns 400", async () => {
|
||||
const ok = await api(
|
||||
adminCookie,
|
||||
"PATCH",
|
||||
"/api/executive-meetings/font-settings",
|
||||
{
|
||||
scope: "user",
|
||||
fontFamily: "Cairo",
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
alignment: "center",
|
||||
},
|
||||
);
|
||||
assert.equal(ok.status, 200);
|
||||
|
||||
const badWeight = await api(
|
||||
adminCookie,
|
||||
"PATCH",
|
||||
"/api/executive-meetings/font-settings",
|
||||
{ scope: "user", fontWeight: "medium" },
|
||||
);
|
||||
assert.equal(badWeight.status, 400);
|
||||
|
||||
const badSize = await api(
|
||||
adminCookie,
|
||||
"PATCH",
|
||||
"/api/executive-meetings/font-settings",
|
||||
{ scope: "user", fontSize: 30 },
|
||||
);
|
||||
assert.equal(badSize.status, 400);
|
||||
|
||||
const badFamily = await api(
|
||||
adminCookie,
|
||||
"PATCH",
|
||||
"/api/executive-meetings/font-settings",
|
||||
{ scope: "user", fontFamily: "Comic Sans" },
|
||||
);
|
||||
assert.equal(badFamily.status, 400);
|
||||
});
|
||||
|
||||
test("PDF archives: GET returns an array and supports ?date=", async () => {
|
||||
const res = await api(
|
||||
adminCookie,
|
||||
"GET",
|
||||
`/api/executive-meetings/pdf-archives?date=${today}`,
|
||||
);
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.ok(Array.isArray(body.archives ?? body.items ?? body.pdfArchives ?? []));
|
||||
});
|
||||
@@ -791,7 +791,9 @@
|
||||
"markInProgress": "بدء التنفيذ",
|
||||
"deleteConfirm": "حذف هذه المهمة؟",
|
||||
"saved": "تم حفظ المهمة",
|
||||
"deleted": "تم حذف المهمة"
|
||||
"deleted": "تم حذف المهمة",
|
||||
"myTasksOnly": "مهامي فقط",
|
||||
"myTasksOnlyHelp": "ينظر المنسقون إلى المهام المسندة إليهم فقط، بينما يرى قائد التنسيق ومدير المكتب والمسؤول جميع المهام."
|
||||
},
|
||||
"audit": {
|
||||
"heading": "سجل التعديلات",
|
||||
|
||||
@@ -788,7 +788,9 @@
|
||||
"markInProgress": "Start",
|
||||
"deleteConfirm": "Delete this task?",
|
||||
"saved": "Task saved",
|
||||
"deleted": "Task deleted"
|
||||
"deleted": "Task deleted",
|
||||
"myTasksOnly": "My tasks only",
|
||||
"myTasksOnlyHelp": "Coordinators only see tasks assigned to them. Lead/admin roles see all tasks."
|
||||
},
|
||||
"audit": {
|
||||
"heading": "Audit Log",
|
||||
|
||||
@@ -85,6 +85,7 @@ type MeRoles = {
|
||||
canSubmitRequest: boolean;
|
||||
canViewAudit: boolean;
|
||||
canViewTasks: boolean;
|
||||
canViewAllTasks: boolean;
|
||||
};
|
||||
|
||||
type RequestRow = {
|
||||
@@ -206,6 +207,7 @@ type MeCapabilities = {
|
||||
canSubmitRequest: boolean;
|
||||
canViewAudit: boolean;
|
||||
canViewTasks: boolean;
|
||||
canViewAllTasks: boolean;
|
||||
};
|
||||
|
||||
// Decides whether a navigation tab should be visible at all for the current
|
||||
@@ -476,6 +478,7 @@ export default function ExecutiveMeetingsPage() {
|
||||
<TasksSection
|
||||
canMutate={me.canMutate}
|
||||
currentUserId={me.userId}
|
||||
canViewAllTasks={me.canViewAllTasks}
|
||||
isRtl={isRtl}
|
||||
t={t}
|
||||
/>
|
||||
@@ -1936,11 +1939,13 @@ function ApprovalsSection({
|
||||
function TasksSection({
|
||||
canMutate,
|
||||
currentUserId,
|
||||
canViewAllTasks,
|
||||
isRtl,
|
||||
t,
|
||||
}: {
|
||||
canMutate: boolean;
|
||||
currentUserId: number;
|
||||
canViewAllTasks: boolean;
|
||||
isRtl: boolean;
|
||||
t: (k: string) => string;
|
||||
}) {
|
||||
@@ -2014,6 +2019,15 @@ function TasksSection({
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[#0B1E3F]">
|
||||
{t("executiveMeetings.tasks.heading")}
|
||||
{!canViewAllTasks && (
|
||||
<span
|
||||
className="ms-2 inline-block rounded-full bg-blue-100 text-blue-800 text-xs px-2 py-0.5 align-middle font-normal"
|
||||
data-testid="em-tasks-mine-badge"
|
||||
title={t("executiveMeetings.tasks.myTasksOnlyHelp")}
|
||||
>
|
||||
{t("executiveMeetings.tasks.myTasksOnly")}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={filterStatus} onValueChange={setFilterStatus}>
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// 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 pg from "pg";
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error(
|
||||
"DATABASE_URL must be set to run the executive-meetings UI test",
|
||||
);
|
||||
}
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
const createdMeetingIds = [];
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
async function loginViaUi(page, username, password) {
|
||||
await page.goto("/login");
|
||||
await page.locator("#username").fill(username);
|
||||
await page.locator("#password").fill(password);
|
||||
await Promise.all([
|
||||
page.waitForURL((url) => !url.pathname.endsWith("/login"), {
|
||||
timeout: 15_000,
|
||||
}),
|
||||
page.locator('form button[type="submit"]').click(),
|
||||
]);
|
||||
}
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (createdMeetingIds.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`,
|
||||
[createdMeetingIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM executive_meeting_audit_logs WHERE entity_type = 'meeting' AND entity_id = ANY($1::int[])`,
|
||||
[createdMeetingIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM executive_meetings WHERE id = ANY($1::int[])`,
|
||||
[createdMeetingIds],
|
||||
);
|
||||
}
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
test("Manage tab: admin can create a meeting from the UI and see it in the list", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Force English so the table heading match below is deterministic.
|
||||
await page.addInitScript(() => {
|
||||
try {
|
||||
window.localStorage.setItem("tx-lang", "en");
|
||||
} catch {
|
||||
/* localStorage may not be ready before navigation */
|
||||
}
|
||||
});
|
||||
|
||||
await loginViaUi(page, "admin", "admin123");
|
||||
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();
|
||||
|
||||
// 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");
|
||||
await expect(addBtn).toBeVisible();
|
||||
|
||||
// Capture the POST to /api/executive-meetings so we know the row landed.
|
||||
const createPromise = page.waitForResponse(
|
||||
(resp) => {
|
||||
const u = new URL(resp.url());
|
||||
return (
|
||||
u.pathname === "/api/executive-meetings" &&
|
||||
resp.request().method() === "POST"
|
||||
);
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
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()
|
||||
.toString(36)
|
||||
.slice(2, 6)}`;
|
||||
const titleAr = `اجتماع واجهة ${uniq}`;
|
||||
const titleEn = `UI Meeting ${uniq}`;
|
||||
|
||||
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.
|
||||
const dialog = page.getByRole("dialog");
|
||||
const titleEnInput = dialog
|
||||
.locator("input")
|
||||
.nth(1); // titleAr=0, titleEn=1, meetingDate=2 (date), dailyNumber=3
|
||||
await titleEnInput.fill(titleEn);
|
||||
|
||||
// Meeting date <input type="date"> — fill explicitly with today.
|
||||
await dialog.locator('input[type="date"]').first().fill(today);
|
||||
|
||||
await page.getByTestId("em-form-save").click();
|
||||
|
||||
const resp = await createPromise;
|
||||
expect(resp.status()).toBe(201);
|
||||
const created = await resp.json();
|
||||
expect(created.id).toBeTruthy();
|
||||
createdMeetingIds.push(created.id);
|
||||
|
||||
// Dialog should close after the successful save.
|
||||
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(
|
||||
`SELECT id, title_en, meeting_date FROM executive_meetings WHERE id = $1`,
|
||||
[created.id],
|
||||
);
|
||||
expect(rows.length).toBe(1);
|
||||
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 =
|
||||
rows[0].meeting_date instanceof Date
|
||||
? rows[0].meeting_date.toISOString().slice(0, 10)
|
||||
: String(rows[0].meeting_date).slice(0, 10);
|
||||
expect(isoDate).toBe(today);
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
@@ -15,6 +15,7 @@ import {
|
||||
groupRolesTable,
|
||||
executiveMeetingsTable,
|
||||
executiveMeetingAttendeesTable,
|
||||
executiveMeetingNotificationsTable,
|
||||
} from "@workspace/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import bcrypt from "bcryptjs";
|
||||
@@ -680,12 +681,14 @@ async function main() {
|
||||
},
|
||||
];
|
||||
|
||||
const insertedMeetings: number[] = [];
|
||||
for (const { meeting, attendees } of sampleMeetings) {
|
||||
const [inserted] = await db
|
||||
.insert(executiveMeetingsTable)
|
||||
.values(meeting)
|
||||
.returning();
|
||||
if (!inserted) continue;
|
||||
insertedMeetings.push(inserted.id);
|
||||
await db.insert(executiveMeetingAttendeesTable).values(
|
||||
attendees.map((a, idx) => ({
|
||||
meetingId: inserted.id,
|
||||
@@ -696,6 +699,59 @@ async function main() {
|
||||
);
|
||||
}
|
||||
console.log("Executive Meetings sample data created for today");
|
||||
|
||||
// Seed example notification rules so the Notifications tab has
|
||||
// representative content (one row per type/status combination so
|
||||
// the page never appears empty in a fresh install).
|
||||
if (insertedMeetings.length > 0) {
|
||||
const adminId =
|
||||
(
|
||||
await db
|
||||
.select({ id: usersTable.id })
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.username, "admin"))
|
||||
)[0]?.id ?? null;
|
||||
const now = new Date();
|
||||
const minutes = (m: number) =>
|
||||
new Date(now.getTime() + m * 60 * 1000);
|
||||
const samples: Array<{
|
||||
meetingId: number;
|
||||
userId: number | null;
|
||||
notificationType: string;
|
||||
scheduledAt: Date | null;
|
||||
sentAt: Date | null;
|
||||
status: string;
|
||||
}> = [
|
||||
{
|
||||
meetingId: insertedMeetings[0]!,
|
||||
userId: adminId,
|
||||
notificationType: "meeting_reminder_15m",
|
||||
scheduledAt: minutes(15),
|
||||
sentAt: null,
|
||||
status: "pending",
|
||||
},
|
||||
{
|
||||
meetingId: insertedMeetings[0]!,
|
||||
userId: adminId,
|
||||
notificationType: "meeting_reminder_60m",
|
||||
scheduledAt: minutes(60),
|
||||
sentAt: null,
|
||||
status: "pending",
|
||||
},
|
||||
];
|
||||
if (insertedMeetings[1]) {
|
||||
samples.push({
|
||||
meetingId: insertedMeetings[1]!,
|
||||
userId: adminId,
|
||||
notificationType: "meeting_invite",
|
||||
scheduledAt: minutes(-30),
|
||||
sentAt: minutes(-25),
|
||||
status: "sent",
|
||||
});
|
||||
}
|
||||
await db.insert(executiveMeetingNotificationsTable).values(samples);
|
||||
console.log("Executive Meetings notification samples created");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log("Executive Meetings sample data already exists for today, skipping");
|
||||
|
||||
Reference in New Issue
Block a user