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:
Riyadh
2026-04-29 08:03:25 +00:00
parent 592350339d
commit 335f351c9b
10 changed files with 173 additions and 82 deletions
@@ -33,6 +33,7 @@ import {
getEffectiveRoleIds,
} from "../middlewares/auth";
import { sanitizeRichText } from "../lib/sanitize";
import { ExecutiveMeetingsReorderBody } from "@workspace/api-zod";
import type { Request, Response, NextFunction } from "express";
const router: IRouter = Router();
@@ -234,13 +235,9 @@ const duplicateSchema = z.object({
targetDate: dateSchema,
});
const reorderSchema = z.object({
meetingDate: dateSchema,
orderedIds: z
.array(z.number().int().positive())
.min(2, "Need at least 2 meetings to reorder")
.max(200),
});
// Reorder schema lives in @workspace/api-zod (lib/api-zod/src/manual.ts) so
// frontend and server share the same contract for POST /reorder.
const reorderSchema = ExecutiveMeetingsReorderBody;
const dateOnly = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const timeHm = z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/);
@@ -934,42 +931,36 @@ router.post(
}
try {
await db.transaction(async (tx) => {
const rows = await tx
// Load *all* meetings on this date — full-day reorder is required.
const allRows = await tx
.select()
.from(executiveMeetingsTable)
.where(
and(
eq(executiveMeetingsTable.meetingDate, data.meetingDate),
inArray(executiveMeetingsTable.id, data.orderedIds),
),
);
if (rows.length !== data.orderedIds.length) {
throw Object.assign(new Error("missing_or_cross_day"), {
.where(eq(executiveMeetingsTable.meetingDate, data.meetingDate));
if (allRows.length !== data.orderedIds.length) {
throw Object.assign(new Error("incomplete_day_reorder"), {
httpStatus: 400,
code: "invalid_ids",
code: "incomplete_day",
});
}
// 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
const dayIds = new Set(allRows.map((r) => r.id));
for (const id of data.orderedIds) {
if (!dayIds.has(id)) {
throw Object.assign(new Error("invalid_or_cross_day_id"), {
httpStatus: 400,
code: "invalid_ids",
});
}
}
// Snapshot the day's existing slots (start_time, end_time) sorted by
// current daily_number. We redistribute them in the new order so the
// first row of the new order takes the earliest existing slot, etc.
const slots = allRows
.slice()
.sort((a, b) => a.dailyNumber - b.dailyNumber)
.map((r) => ({
startTime: r.startTime,
endTime: r.endTime,
dailyNumber: r.dailyNumber,
}));
.map((r) => ({ startTime: r.startTime, endTime: r.endTime }));
// Two-phase update to avoid (date, daily_number) unique conflicts
// *during* the swap.
// Phase 1: park into negative numbers.
// *during* the renumber.
// Phase 1: park into negative daily_numbers.
for (let i = 0; i < data.orderedIds.length; i++) {
const id = data.orderedIds[i]!;
await tx
@@ -977,7 +968,8 @@ router.post(
.set({ dailyNumber: -(i + 1), updatedBy: userId })
.where(eq(executiveMeetingsTable.id, id));
}
// Phase 2: assign final slot from the snapshot above.
// Phase 2: assign final daily_number = i+1 with the matching slot's
// start/end time.
for (let i = 0; i < data.orderedIds.length; i++) {
const id = data.orderedIds[i]!;
const slot = slots[i]!;
@@ -986,7 +978,7 @@ router.post(
.set({
startTime: slot.startTime,
endTime: slot.endTime,
dailyNumber: slot.dailyNumber,
dailyNumber: i + 1,
updatedBy: userId,
})
.where(eq(executiveMeetingsTable.id, id));
@@ -997,7 +989,7 @@ router.post(
entityId: data.orderedIds[0]!,
oldValue: {
meetingDate: data.meetingDate,
order: rows
order: allRows
.slice()
.sort((a, b) => a.dailyNumber - b.dailyNumber)
.map((r) => r.id),
@@ -1008,16 +1000,11 @@ router.post(
},
performedBy: userId,
});
// Defensive post-check: dailyNumbers must be exactly 1..N.
const updated = await tx
.select()
.from(executiveMeetingsTable)
.where(
and(
eq(executiveMeetingsTable.meetingDate, data.meetingDate),
inArray(executiveMeetingsTable.id, data.orderedIds),
),
);
// Validate no two share dailyNumber post-update (defensive).
.where(eq(executiveMeetingsTable.meetingDate, data.meetingDate));
const seen = new Set<number>();
for (const u of updated) {
if (seen.has(u.dailyNumber)) {
@@ -1025,7 +1012,6 @@ router.post(
}
seen.add(u.dailyNumber);
}
return { updated };
});
// Return the updated meetings in the new order, with attendees.
const idToFetch = data.orderedIds;
@@ -580,17 +580,19 @@ test("Sanitization: rich text strips disallowed tags but keeps inline formatting
assert.ok(/<em[^>]*>One<\/em>/i.test(att.name), "em must survive in attendee");
});
test("Reorder: POST /reorder swaps daily numbers + start times within a day", async () => {
test("Reorder: POST /reorder renumbers a full day to 1..N and inherits slot times", async () => {
// Use a fresh future date to guarantee the reorder is full-day.
const reorderDate = "2050-01-15";
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أول", titleEn: "First", meetingDate: today,
titleAr: "أول", titleEn: "First", meetingDate: reorderDate,
startTime: "09:00", endTime: "09:30",
});
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ثاني", titleEn: "Second", meetingDate: today,
titleAr: "ثاني", titleEn: "Second", meetingDate: reorderDate,
startTime: "10:00", endTime: "10:30",
});
const c = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ثالث", titleEn: "Third", meetingDate: today,
titleAr: "ثالث", titleEn: "Third", meetingDate: reorderDate,
startTime: "11:00", endTime: "11:30",
});
assert.equal(a.status, 201);
@@ -603,29 +605,48 @@ test("Reorder: POST /reorder swaps daily numbers + start times within a day", as
// reverse the order: C, B, A
const reorder = await api(adminCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: today, orderedIds: [C.id, B.id, A.id] });
{ meetingDate: reorderDate, orderedIds: [C.id, B.id, A.id] });
assert.equal(reorder.status, 200);
const day = await api(adminCookie, "GET",
`/api/executive-meetings?date=${today}`);
`/api/executive-meetings?date=${reorderDate}`);
const body = await day.json();
const byId = new Map(body.meetings.map((m) => [m.id, m]));
const cAfter = byId.get(C.id);
const bAfter = byId.get(B.id);
const aAfter = byId.get(A.id);
// 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);
// Full-day reorder => dailyNumber recomputed as 1..N in the new order;
// each row inherits the corresponding slot's start/end time.
assert.equal(cAfter.dailyNumber, 1);
assert.equal(bAfter.dailyNumber, 2);
assert.equal(aAfter.dailyNumber, 3);
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("Reorder: rejects an incomplete-day request (orderedIds missing some)", async () => {
const reorderDate = "2050-02-20";
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أ", titleEn: "A", meetingDate: reorderDate,
startTime: "08:00", endTime: "08:30",
});
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ب", titleEn: "B", meetingDate: reorderDate,
startTime: "09:00", endTime: "09:30",
});
const A = await a.json(); created.meetingIds.push(A.id);
const B = await b.json(); created.meetingIds.push(B.id);
// Send only one of the two — server must refuse so 1..N renumber stays safe.
const r = await api(adminCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: reorderDate, orderedIds: [A.id] });
assert.equal(r.status, 400);
const body = await r.json();
assert.equal(body.code, "incomplete_day");
});
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,