diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 2ae7c554..07a9a149 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -184,7 +184,7 @@ const attendeeSchema = z.object({ const meetingBaseFields = { titleAr: z.string().trim().min(1).max(300), - titleEn: z.string().trim().max(300).nullable().optional(), + titleEn: z.string().trim().min(1).max(300), meetingDate: dateSchema, dailyNumber: positiveIntSchema.max(1000).nullable().optional(), startTime: timeSchema.nullable().optional(), diff --git a/artifacts/api-server/tests/executive-meetings.test.mjs b/artifacts/api-server/tests/executive-meetings.test.mjs index aced564a..b44ba94a 100644 --- a/artifacts/api-server/tests/executive-meetings.test.mjs +++ b/artifacts/api-server/tests/executive-meetings.test.mjs @@ -36,8 +36,7 @@ async function createUser(prefix, roleName) { ); const id = rows[0].id; created.userIds.push(id); - const roles = ["user", roleName]; - for (const r of roles) { + for (const r of ["user", roleName]) { await pool.query( `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = $2 @@ -89,15 +88,11 @@ 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); @@ -108,7 +103,6 @@ before(async () => { }); 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, @@ -152,7 +146,7 @@ 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 () => { +test("GET /me exposes capability flags including canViewAllTasks", async () => { const res = await api(adminCookie, "GET", "/api/executive-meetings/me"); assert.equal(res.status, 200); const body = await res.json(); @@ -174,14 +168,25 @@ test("GET /executive-meetings/me exposes the full capability flag set including 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", - ); + assert.equal(coordMe.canViewAllTasks, false); }); -test("Meetings: POST creates → GET day returns it → DELETE removes it", async () => { +test("Meetings: bilingual title required (titleEn cannot be empty)", async () => { + const missingEn = await api(adminCookie, "POST", "/api/executive-meetings", { + titleAr: "اجتماع", + meetingDate: today, + }); + assert.equal(missingEn.status, 400); + + const emptyEn = await api(adminCookie, "POST", "/api/executive-meetings", { + titleAr: "اجتماع", + titleEn: "", + meetingDate: today, + }); + assert.equal(emptyEn.status, 400); +}); + +test("Meetings: POST → GET day → DELETE roundtrip", async () => { const create = await api(adminCookie, "POST", "/api/executive-meetings", { titleAr: "اجتماع اختبار", titleEn: "Test meeting", @@ -218,7 +223,72 @@ test("Meetings: POST creates → GET day returns it → DELETE removes it", asyn assert.ok(del.status === 200 || del.status === 204); }); -test("Requests: POST creates → admin GET sees it; coord scope filter respects requester", async () => { +test("Meetings: PUT /attendees replaces the attendee list", async () => { + const create = await api(adminCookie, "POST", "/api/executive-meetings", { + titleAr: "ا", + titleEn: "A", + meetingDate: today, + attendees: [{ name: "Old One", attendanceType: "internal", sortOrder: 0 }], + }); + assert.equal(create.status, 201); + const meeting = await create.json(); + created.meetingIds.push(meeting.id); + + const replace = await api( + adminCookie, + "PUT", + `/api/executive-meetings/${meeting.id}/attendees`, + { + attendees: [ + { name: "New One", title: "Director", attendanceType: "internal", sortOrder: 0 }, + { name: "New Two", title: "Manager", attendanceType: "virtual", sortOrder: 1 }, + ], + }, + ); + assert.equal(replace.status, 200); + const fetched = await api( + adminCookie, + "GET", + `/api/executive-meetings/${meeting.id}`, + ); + const body = await fetched.json(); + assert.equal(body.attendees.length, 2); + assert.ok(body.attendees.find((a) => a.name === "New One")); + assert.ok(!body.attendees.find((a) => a.name === "Old One")); +}); + +test("Meetings: POST /duplicate clones a meeting onto another date", async () => { + const create = await api(adminCookie, "POST", "/api/executive-meetings", { + titleAr: "ب", + titleEn: "B", + meetingDate: today, + attendees: [{ name: "Dup Att", attendanceType: "internal", sortOrder: 0 }], + }); + assert.equal(create.status, 201); + const original = await create.json(); + created.meetingIds.push(original.id); + + const dup = await api( + adminCookie, + "POST", + `/api/executive-meetings/${original.id}/duplicate`, + { targetDate: tomorrow }, + ); + assert.equal(dup.status, 201); + const newRow = await dup.json(); + assert.notEqual(newRow.id, original.id); + created.meetingIds.push(newRow.id); + + const day = await api( + adminCookie, + "GET", + `/api/executive-meetings?date=${tomorrow}`, + ); + const dayBody = await day.json(); + assert.ok(dayBody.meetings.some((m) => m.id === newRow.id)); +}); + +test("Requests: POST → admin can list", async () => { const create = await api( adminCookie, "POST", @@ -240,10 +310,45 @@ test("Requests: POST creates → admin GET sees it; coord scope filter respects assert.ok(listBody.requests.some((r) => r.id === reqRow.id)); }); +test("Requests: full review + apply pipeline (approve → apply highlights meeting)", async () => { + const create = await api(adminCookie, "POST", "/api/executive-meetings", { + titleAr: "ت", + titleEn: "T", + meetingDate: today, + attendees: [], + isHighlighted: 0, + }); + assert.equal(create.status, 201); + const meeting = await create.json(); + created.meetingIds.push(meeting.id); + + const reqRes = await api(adminCookie, "POST", "/api/executive-meetings/requests", { + requestType: "highlight", + meetingId: meeting.id, + requestDetails: { note: "highlight-please" }, + }); + assert.equal(reqRes.status, 201); + const reqRow = await reqRes.json(); + created.requestIds.push(reqRow.id); + + const approve = await api( + adminCookie, + "PATCH", + `/api/executive-meetings/requests/${reqRow.id}`, + { status: "approved", reviewNotes: "ok" }, + ); + assert.equal(approve.status, 200); + + const fetched = await api( + adminCookie, + "GET", + `/api/executive-meetings/${meeting.id}`, + ); + const body = await fetched.json(); + assert.equal(body.isHighlighted, 1); +}); + 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, @@ -262,8 +367,6 @@ test("Tasks: server-side scoping forces coordinators to assignedTo=self", async 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", @@ -272,23 +375,12 @@ test("Tasks: server-side scoping forces coordinators to assignedTo=self", async 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", - ); + assert.ok(coordIds.includes(task1.id)); + assert.ok(!coordIds.includes(task2.id)); for (const t of coordBody.tasks) { - assert.equal( - t.assignedTo, - coordUserId, - "every task returned to a coordinator must be assigned to them", - ); + assert.equal(t.assignedTo, coordUserId); } - // Lead: broad view honors assigneeId. const leadList = await api( leadCookie, "GET", @@ -299,6 +391,28 @@ test("Tasks: server-side scoping forces coordinators to assignedTo=self", async assert.ok(leadBody.tasks.some((t) => t.id === task2.id)); }); +test("Tasks: PATCH supports reassign + notes update", async () => { + const create = await api(adminCookie, "POST", "/api/executive-meetings/tasks", { + taskType: "follow_up", + assignedTo: coordUserId, + notes: "v1", + }); + assert.equal(create.status, 201); + const task = await create.json(); + created.taskIds.push(task.id); + + const patch = await api( + adminCookie, + "PATCH", + `/api/executive-meetings/tasks/${task.id}`, + { assignedTo: leadUserId, notes: "v2" }, + ); + assert.equal(patch.status, 200); + const updated = await patch.json(); + assert.equal(updated.assignedTo, leadUserId); + assert.equal(updated.notes, "v2"); +}); + test("Audit logs: admin can list, plain coordinator gets 403", async () => { const ok = await api( adminCookie, @@ -317,6 +431,32 @@ test("Audit logs: admin can list, plain coordinator gets 403", async () => { assert.equal(denied.status, 403); }); +test("Audit logs: dateFrom/dateTo, action, and actorId filters all narrow results", async () => { + const create = await api(adminCookie, "POST", "/api/executive-meetings", { + titleAr: "س", + titleEn: "S", + meetingDate: today, + }); + const meeting = await create.json(); + created.meetingIds.push(meeting.id); + + const filtered = await api( + adminCookie, + "GET", + `/api/executive-meetings/audit-logs?dateFrom=${today}&dateTo=${today}&action=create&actorId=${adminUserId}&entityType=meeting`, + ); + assert.equal(filtered.status, 200); + const body = await filtered.json(); + const entries = body.entries ?? body.logs ?? []; + assert.ok(Array.isArray(entries)); + for (const e of entries) { + assert.equal(e.action, "create"); + assert.equal(e.entityType, "meeting"); + assert.equal(e.performedBy, adminUserId); + } + assert.ok(entries.some((e) => e.entityId === meeting.id)); +}); + test("Notifications: GET filters by ?date= and tolerates empty days", async () => { const empty = await api( adminCookie, @@ -335,7 +475,7 @@ test("Notifications: GET filters by ?date= and tolerates empty days", async () = assert.equal(todayList.status, 200); }); -test("Font settings: valid combo returns 200; invalid weight or out-of-range size returns 400", async () => { +test("Font settings: valid combo 200; invalid weight/size/family 400", async () => { const ok = await api( adminCookie, "PATCH", @@ -375,13 +515,26 @@ test("Font settings: valid combo returns 200; invalid weight or out-of-range siz assert.equal(badFamily.status, 400); }); -test("PDF archives: GET returns an array and supports ?date=", async () => { - const res = await api( +test("PDF archives: POST creates a snapshot and GET returns it", async () => { + const post = await api( + adminCookie, + "POST", + "/api/executive-meetings/pdf-archives", + { archiveDate: today }, + ); + assert.equal(post.status, 201); + const archive = await post.json(); + assert.ok(archive.id); + assert.ok(archive.version >= 1); + + const list = 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 ?? [])); + assert.equal(list.status, 200); + const body = await list.json(); + const archives = body.archives ?? body.items ?? body.pdfArchives ?? []; + assert.ok(Array.isArray(archives)); + assert.ok(archives.some((a) => a.id === archive.id)); }); diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index ccf35be5..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 d6e30971..8730b958 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -792,6 +792,8 @@ "deleteConfirm": "حذف هذه المهمة؟", "saved": "تم حفظ المهمة", "deleted": "تم حذف المهمة", + "reassign": "إعادة إسناد / تعديل", + "reassigned": "تم تحديث المهمة", "myTasksOnly": "مهامي فقط", "myTasksOnlyHelp": "ينظر المنسقون إلى المهام المسندة إليهم فقط، بينما يرى قائد التنسيق ومدير المكتب والمسؤول جميع المهام." }, @@ -800,6 +802,11 @@ "empty": "لا توجد قيود في السجل.", "filterDate": "تصفية بالتاريخ", "filterEntity": "النوع", + "filter": { + "from": "من", + "to": "إلى", + "actorId": "معرّف المنفّذ" + }, "all": "الكل", "col": { "when": "الوقت", @@ -861,7 +868,8 @@ "archived": "تمت أرشفة النسخة", "archivedAndPrinted": "تمت أرشفة النسخة وفتح نافذة الطباعة", "archivesHeading": "النسخ المؤرشفة", - "noArchives": "لا توجد نسخ مؤرشفة لهذا التاريخ بعد." + "noArchives": "لا توجد نسخ مؤرشفة لهذا التاريخ بعد.", + "openArchive": "فتح" }, "fontSettingsPage": { "heading": "إعدادات الخط", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 9305cd84..a558434f 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -789,6 +789,8 @@ "deleteConfirm": "Delete this task?", "saved": "Task saved", "deleted": "Task deleted", + "reassign": "Reassign / edit", + "reassigned": "Task updated", "myTasksOnly": "My tasks only", "myTasksOnlyHelp": "Coordinators only see tasks assigned to them. Lead/admin roles see all tasks." }, @@ -797,6 +799,11 @@ "empty": "No audit entries.", "filterDate": "Filter by date", "filterEntity": "Entity", + "filter": { + "from": "From", + "to": "To", + "actorId": "Actor ID" + }, "all": "All", "col": { "when": "When", @@ -846,7 +853,8 @@ "archived": "Snapshot archived", "archivedAndPrinted": "Snapshot archived — print dialog opened", "archivesHeading": "Archived snapshots", - "noArchives": "No snapshots archived for this date yet." + "noArchives": "No snapshots archived for this date yet.", + "openArchive": "Open" }, "print": { "title": "Executive Meetings Schedule", diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index 51f07fff..f628fc3f 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -849,8 +849,11 @@ function ManageSection({ async function save() { if (!editing) return; - if (!editing.titleAr.trim()) { - toast({ title: t("executiveMeetings.manage.errors.titleRequired"), variant: "destructive" }); + if (!editing.titleAr.trim() || !editing.titleEn.trim()) { + toast({ + title: t("executiveMeetings.manage.errors.titleRequired"), + variant: "destructive", + }); return; } setSaving(true); @@ -1518,7 +1521,16 @@ function RequestsSection({ {t("executiveMeetings.common.all")} - {(["new", "approved", "rejected", "withdrawn"] as const).map((s) => ( + {( + [ + "new", + "needs_edit", + "approved", + "rejected", + "withdrawn", + "done", + ] as const + ).map((s) => ( {t(`executiveMeetings.requests.status.${s}`)} @@ -2003,6 +2015,47 @@ function TasksSection({ } } + const [reassignTarget, setReassignTarget] = useState(null); + const [reassignForm, setReassignForm] = useState({ assignedTo: "", notes: "" }); + const [reassignBusy, setReassignBusy] = useState(false); + + function openReassign(task: TaskRow) { + setReassignTarget(task); + setReassignForm({ + assignedTo: task.assignedTo ? String(task.assignedTo) : "", + notes: task.notes ?? "", + }); + } + + async function submitReassign() { + if (!reassignTarget) return; + const body: Record = {}; + const newAssignee = reassignForm.assignedTo.trim(); + if (newAssignee) body.assignedTo = Number(newAssignee); + if (reassignForm.notes !== (reassignTarget.notes ?? "")) { + body.notes = reassignForm.notes; + } + if (Object.keys(body).length === 0) { + setReassignTarget(null); + return; + } + setReassignBusy(true); + try { + await apiJson(`/api/executive-meetings/tasks/${reassignTarget.id}`, { + method: "PATCH", + body, + }); + toast({ title: t("executiveMeetings.tasks.reassigned") }); + setReassignTarget(null); + await qc.invalidateQueries({ queryKey: ["/api/executive-meetings/tasks"] }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + toast({ title: msg, variant: "destructive" }); + } finally { + setReassignBusy(false); + } + } + async function deleteTask(id: number) { if (!window.confirm(t("executiveMeetings.tasks.deleteConfirm"))) return; try { @@ -2129,13 +2182,24 @@ function TasksSection({ )} {canMutate && ( - + <> + + + )} @@ -2146,6 +2210,51 @@ function TasksSection({ + !v && setReassignTarget(null)} + > + + + {t("executiveMeetings.tasks.reassign")} + +
+ + + setReassignForm({ ...reassignForm, assignedTo: e.target.value }) + } + data-testid="em-task-reassign-assignee" + /> + + +