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 08:07:33 +00:00
parent 93c77f587c
commit da18dc6ecf
2 changed files with 32 additions and 29 deletions
+13 -24
View File
@@ -1,16 +1,17 @@
import sanitizeHtml from "sanitize-html";
const ALLOWED_FONT_FAMILIES = new Set<string>([
"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.
*
@@ -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: {