From da18dc6ecf968124e09c87f8f799e50680054993 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Wed, 29 Apr 2026 08:07:33 +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. --- artifacts/api-server/src/lib/sanitize.ts | 37 +++++++------------ .../src/routes/executive-meetings.ts | 24 +++++++++--- 2 files changed, 32 insertions(+), 29 deletions(-) diff --git a/artifacts/api-server/src/lib/sanitize.ts b/artifacts/api-server/src/lib/sanitize.ts index e1bc40d0..0329cd2d 100644 --- a/artifacts/api-server/src/lib/sanitize.ts +++ b/artifacts/api-server/src/lib/sanitize.ts @@ -1,16 +1,17 @@ import sanitizeHtml from "sanitize-html"; -const ALLOWED_FONT_FAMILIES = new Set([ - "Cairo", - "Tajawal", - "Amiri", - "Noto Naskh Arabic", - "IBM Plex Sans Arabic", - "system-ui", - "sans-serif", - "serif", - "monospace", -]); +// font-family is validated by a strict regex (no custom functions, no +// type-bypass casts) so sanitize-html can run it through its native +// `regex.test(value)` style validator. +// +// Allowed families: Cairo, Tajawal, Amiri, Noto Naskh Arabic, +// IBM Plex Sans Arabic, system-ui, sans-serif, serif, monospace. +// A value may be a single name (optionally quoted) or a comma-separated +// stack of allowed names. +const FONT_NAME_PART = String.raw`(?:["']?(?:Cairo|Tajawal|Amiri|Noto Naskh Arabic|IBM Plex Sans Arabic|system-ui|sans-serif|serif|monospace)["']?)`; +const FONT_FAMILY_RE = new RegExp( + `^${FONT_NAME_PART}(?:\\s*,\\s*${FONT_NAME_PART})*$`, +); const FONT_SIZE_RE = /^(?:[8-9]|[1-9]\d)px$/; const COLOR_RE = /^(?:#[0-9a-f]{3}|#[0-9a-f]{6}|rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\))$/i; @@ -34,11 +35,7 @@ export const RICH_TEXT_OPTIONS: sanitizeHtml.IOptions = { "text-decoration": [/^(?:none|underline)$/i], "text-align": [/^(?:left|right|center|start|end|justify)$/i], "font-size": [FONT_SIZE_RE], - "font-family": [ - // Allow either an exact known family or a comma-separated list of - // quoted/unquoted families that all match our allowlist. - (value: string) => isAllowedFontFamilyValue(value), - ] as unknown as RegExp[], + "font-family": [FONT_FAMILY_RE], }, }, allowedSchemes: [], @@ -49,14 +46,6 @@ export const RICH_TEXT_OPTIONS: sanitizeHtml.IOptions = { exclusiveFilter: () => false, }; -function isAllowedFontFamilyValue(raw: string): boolean { - // Split on commas, strip whitespace + surrounding quotes, then check each - // candidate name. If any unknown name appears we reject the whole value. - const parts = raw.split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")); - if (parts.length === 0) return false; - return parts.every((p) => ALLOWED_FONT_FAMILIES.has(p)); -} - /** * Sanitize a Tiptap-style rich-text HTML string from a client. * diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index d833e6b1..d22ec4d7 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -951,12 +951,24 @@ router.post( }); } } - // 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. + // Snapshot the day's existing slots (start_time, end_time) sorted + // by `start_time NULLS LAST, daily_number` — this is the canonical + // schedule order. The first row of the new order takes the earliest + // existing slot, the second takes the next, and so on. const slots = allRows .slice() - .sort((a, b) => a.dailyNumber - b.dailyNumber) + .sort((a, b) => { + const aHasTime = a.startTime != null; + const bHasTime = b.startTime != null; + if (aHasTime && bHasTime) { + if (a.startTime! < b.startTime!) return -1; + if (a.startTime! > b.startTime!) return 1; + return a.dailyNumber - b.dailyNumber; + } + if (aHasTime) return -1; // NULLS LAST + if (bHasTime) return 1; + return a.dailyNumber - b.dailyNumber; + }) .map((r) => ({ startTime: r.startTime, endTime: r.endTime })); // Two-phase update to avoid (date, daily_number) unique conflicts // *during* the renumber. @@ -984,7 +996,9 @@ router.post( .where(eq(executiveMeetingsTable.id, id)); } await logAudit(tx, { - action: "reorder", + // Specific reorder action namespace so audit consumers can filter + // schedule reorders separately from per-row meeting writes. + action: "executive_meeting.reorder", entityType: "meeting", entityId: data.orderedIds[0]!, oldValue: {