Executive Meetings: inline editing, drag reorder, and current-meeting highlight

Implements Task #122 — four features on the Executive Meetings schedule:
1. Word-like inline editing of meeting title and attendee names with bold,
   italic, underline, color, font-family, font-size, and text-align controls
   (Tiptap-backed EditableCell + FormattingToolbar).
2. Drag-to-reorder column headers (dnd-kit), persisted in the existing
   em-schedule-cols-v1 storage key. Old up/down buttons removed.
3. Drag-to-reorder rows within a day. New POST /api/executive-meetings/reorder
   endpoint permutes daily_number + start/end times in a single transaction
   using a two-phase negative→final assignment that respects the
   (date, daily_number) UNIQUE constraint and survives partial-day reorders.
   Audit-logged and role-guarded.
4. Highlight the meeting whose start/end window is "now", refreshed every
   60 s. Toggle and color picker live in the customize popover and persist
   to localStorage (em-current-meeting-highlight-v1).

Backend changes
- Widened titleAr / titleEn / attendees.name to text.
- New artifacts/api-server/src/lib/sanitize.ts with an allowlist for
  inline tags + safe inline styles. Applied to POST, PATCH, PUT-attendees,
  duplicate, reorder, and applyApprovedRequest (add_attendee) paths so
  rich text round-trips safely.
- Code-review fixes: duplicate handler re-sanitizes titleAr/titleEn;
  applyApprovedRequest add_attendee sanitizes the inserted name;
  reorder transaction return value cleaned (`byId` removed).
- 5 new tests cover sanitization stripping, reorder happy path, cross-day
  rejection, permission denial, and applyApprovedRequest sanitization.
  Pre-existing app_permissions failures are unrelated (tracked by #126/#127).

Frontend changes
- artifacts/tx-os/src/components/editable-cell.tsx with FormattingToolbar
  (custom FontSize Tiptap extension + @tiptap/extension-text-align).
- ScheduleSection wires saveTitle, saveAttendeeName, reorder mutation
  (optimistic with revert on error), highlight tick, and HighlightPrefs.
- New SortableHeader component for column drag.
- AttendeesCell passes the original attendee index to PUT writes so edits
  reach the right row even after grouping into virtual/internal/external.
- Print page renders sanitized rich text via dangerouslySetInnerHTML on
  names and titles; attendee.title is rendered as a plain text node (XSS).
- Translation keys added in ar.json and en.json.
This commit is contained in:
riyadhafraa
2026-04-29 07:53:51 +00:00
parent 5114b207da
commit 9ec0fc1f90
5 changed files with 190 additions and 16 deletions
@@ -613,9 +613,51 @@ test("Reorder: POST /reorder swaps daily numbers + start times within a day", as
const cAfter = byId.get(C.id);
const bAfter = byId.get(B.id);
const aAfter = byId.get(A.id);
// The first slot now holds C, the third now holds A.
assert.ok(cAfter.dailyNumber < bAfter.dailyNumber);
assert.ok(bAfter.dailyNumber < aAfter.dailyNumber);
// The reorder is a *permutation* of the affected rows' existing slots —
// C now occupies A's old slot, B keeps its slot, and A occupies C's old
// slot. dailyNumber values are reused exactly so that the (date, daily)
// UNIQUE constraint stays valid even when the day contains other
// meetings outside `orderedIds`.
assert.equal(cAfter.dailyNumber, A.dailyNumber);
assert.equal(bAfter.dailyNumber, B.dailyNumber);
assert.equal(aAfter.dailyNumber, C.dailyNumber);
assert.equal(cAfter.startTime.slice(0, 5), "09:00");
assert.equal(bAfter.startTime.slice(0, 5), "10:00");
assert.equal(aAfter.startTime.slice(0, 5), "11:00");
});
test("Apply request (add_attendee) sanitizes attendee.name like direct writes", async () => {
const m = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "اجتماع طلب", titleEn: "Request meeting", meetingDate: today,
});
const M = await m.json(); created.meetingIds.push(M.id);
// POST /api/executive-meetings/:id/requests creates a request bound to
// the meeting. The route validates payload via requestPayloadSchemas.
const reqRes = await api(adminCookie, "POST",
`/api/executive-meetings/${M.id}/requests`, {
requestType: "add_attendee",
requestDetails: {
name: '<b>Real</b><script>alert(1)</script><img src=x onerror=alert(1)>',
attendanceType: "internal",
},
});
assert.equal(reqRes.status, 201);
const reqRow = await reqRes.json();
// PATCH /api/executive-meetings/requests/:id with {status:"approved"}
// routes through applyApprovedRequest -> add_attendee.
const approve = await api(adminCookie, "PATCH",
`/api/executive-meetings/requests/${reqRow.id}`,
{ status: "approved" });
assert.equal(approve.status, 200);
const det = await api(adminCookie, "GET", `/api/executive-meetings/${M.id}`);
const detail = await det.json();
const att = detail.attendees.at(-1);
assert.ok(/<b[^>]*>Real<\/b>/i.test(att.name), "<b> must survive");
assert.ok(!/<script/i.test(att.name), "<script> must be stripped");
assert.ok(!/onerror/i.test(att.name), "onerror must be stripped");
});
test("Reorder: rejects ids that do not all belong to meetingDate", async () => {