From 592350339dd56f661cb7d95a9d8c7b8eac8adc07 Mon Sep 17 00:00:00 2001 From: Riyadh Date: Wed, 29 Apr 2026 07:53:51 +0000 Subject: [PATCH] Executive Meetings: inline editing, drag reorder, and current-meeting highlight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/routes/executive-meetings.ts | 29 +++-- .../tests/executive-meetings.test.mjs | 48 +++++++- artifacts/tx-os/package.json | 1 + .../tx-os/src/components/editable-cell.tsx | 116 +++++++++++++++++- pnpm-lock.yaml | 12 ++ 5 files changed, 190 insertions(+), 16 deletions(-) diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index c4b02efc..ee459aa5 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -949,8 +949,16 @@ router.post( code: "invalid_ids", }); } - // Snapshot the current (start_time, end_time, daily_number) sorted - // by current daily_number — the slots we will redistribute. + // Snapshot the current (start_time, end_time, daily_number) ordered + // by current daily_number — these are the slots we redistribute as + // a permutation among the affected rows. We deliberately reuse + // the existing daily_numbers instead of renumbering 1..N, because + // the day may contain meetings outside `orderedIds` (partial + // reorder) and renumbering would collide with the + // (meeting_date, daily_number) UNIQUE constraint. Reusing the + // affected rows' own slot values is a true permutation, so it is + // both contiguous within the selection and conflict-free against + // the rest of the day. const slots = rows .slice() .sort((a, b) => a.dailyNumber - b.dailyNumber) @@ -959,8 +967,8 @@ router.post( endTime: r.endTime, dailyNumber: r.dailyNumber, })); - const byId = new Map(rows.map((r) => [r.id, r] as const)); - // Two-phase update to avoid (date, daily_number) unique conflicts. + // Two-phase update to avoid (date, daily_number) unique conflicts + // *during* the swap. // Phase 1: park into negative numbers. for (let i = 0; i < data.orderedIds.length; i++) { const id = data.orderedIds[i]!; @@ -969,7 +977,7 @@ router.post( .set({ dailyNumber: -(i + 1), updatedBy: userId }) .where(eq(executiveMeetingsTable.id, id)); } - // Phase 2: assign final slot. + // Phase 2: assign final slot from the snapshot above. for (let i = 0; i < data.orderedIds.length; i++) { const id = data.orderedIds[i]!; const slot = slots[i]!; @@ -1017,7 +1025,7 @@ router.post( } seen.add(u.dailyNumber); } - return { updated, byId }; + return { updated }; }); // Return the updated meetings in the new order, with attendees. const idToFetch = data.orderedIds; @@ -1117,7 +1125,12 @@ async function applyApprovedRequest( break; } case "add_attendee": { - const name = typeof details.name === "string" ? details.name.trim() : ""; + const rawName = typeof details.name === "string" ? details.name.trim() : ""; + if (!rawName) break; + // Same sanitization boundary as the direct attendee POST/PUT paths so + // an approved request cannot smuggle script/event-handler markup into + // the rich-text attendee.name column. + const name = sanitizeRichText(rawName.slice(0, 5000)); if (!name) break; const title = typeof details.title === "string" ? details.title.slice(0, 200) : null; const attendanceType = @@ -1133,7 +1146,7 @@ async function applyApprovedRequest( .where(eq(executiveMeetingAttendeesTable.meetingId, meetingId)); await tx.insert(executiveMeetingAttendeesTable).values({ meetingId, - name: name.slice(0, 200), + name, title, attendanceType, sortOrder: (maxOrder ?? -1) + 1, diff --git a/artifacts/api-server/tests/executive-meetings.test.mjs b/artifacts/api-server/tests/executive-meetings.test.mjs index 6fbd928e..6a2e8455 100644 --- a/artifacts/api-server/tests/executive-meetings.test.mjs +++ b/artifacts/api-server/tests/executive-meetings.test.mjs @@ -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: 'Real', + 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(/]*>Real<\/b>/i.test(att.name), " must survive"); + assert.ok(!/