diff --git a/artifacts/api-server/src/lib/sanitize.ts b/artifacts/api-server/src/lib/sanitize.ts index e2938f38..8cd958ac 100644 --- a/artifacts/api-server/src/lib/sanitize.ts +++ b/artifacts/api-server/src/lib/sanitize.ts @@ -1,13 +1,7 @@ import sanitizeHtml from "sanitize-html"; -// 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, +// Allowed font 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})*$`, @@ -17,9 +11,6 @@ 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; export const RICH_TEXT_OPTIONS: sanitizeHtml.IOptions = { - //

is included so Tiptap's TextAlign extension (applied to paragraph - // nodes) can persist text-align: left/center/right via the inline style - // allowlist below. Block tags beyond

stay disallowed. allowedTags: ["p", "span", "strong", "b", "em", "i", "u", "br"], allowedAttributes: { p: ["style"], @@ -45,8 +36,6 @@ export const RICH_TEXT_OPTIONS: sanitizeHtml.IOptions = { allowedSchemes: [], disallowedTagsMode: "discard", enforceHtmlBoundary: false, - // Keep text content of disallowed tags rather than dropping it entirely; - // this matters when a user pastes from Word and we strip the wrapper. exclusiveFilter: () => false, }; diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index d22ec4d7..739e76bd 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -931,7 +931,6 @@ router.post( } try { await db.transaction(async (tx) => { - // Load *all* meetings on this date — full-day reorder is required. const allRows = await tx .select() .from(executiveMeetingsTable) @@ -951,10 +950,6 @@ router.post( }); } } - // 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) => { @@ -965,14 +960,11 @@ router.post( if (a.startTime! > b.startTime!) return 1; return a.dailyNumber - b.dailyNumber; } - if (aHasTime) return -1; // NULLS LAST + if (aHasTime) return -1; 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. - // Phase 1: park into negative daily_numbers. for (let i = 0; i < data.orderedIds.length; i++) { const id = data.orderedIds[i]!; await tx @@ -980,8 +972,6 @@ router.post( .set({ dailyNumber: -(i + 1), updatedBy: userId }) .where(eq(executiveMeetingsTable.id, id)); } - // 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]!; @@ -996,8 +986,6 @@ router.post( .where(eq(executiveMeetingsTable.id, id)); } await logAudit(tx, { - // 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]!, @@ -1014,7 +1002,6 @@ router.post( }, performedBy: userId, }); - // Defensive post-check: dailyNumbers must be exactly 1..N. const updated = await tx .select() .from(executiveMeetingsTable) diff --git a/artifacts/api-server/tests/executive-meetings.test.mjs b/artifacts/api-server/tests/executive-meetings.test.mjs index 54819262..372bb9c7 100644 --- a/artifacts/api-server/tests/executive-meetings.test.mjs +++ b/artifacts/api-server/tests/executive-meetings.test.mjs @@ -585,9 +585,6 @@ test("Sanitization: text-align on

survives a round-trip via PATCH/GET", asyn titleAr: "محاذاة", titleEn: "Align", meetingDate: today, }); const M = await m.json(); created.meetingIds.push(M.id); - // Tiptap's TextAlign extension emits paragraphs like - //

. The sanitizer must allow that - // structure so Word-like alignment persists. const html = '

يمين

center

'; const patch = await api(adminCookie, "PATCH", `/api/executive-meetings/${M.id}`, { titleAr: html }); @@ -604,7 +601,6 @@ test("Sanitization: text-align on

survives a round-trip via PATCH/GET", asyn }); 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: reorderDate, @@ -638,16 +634,12 @@ test("Reorder: POST /reorder renumbers a full day to 1..N and inherits slot time const cAfter = byId.get(C.id); const bAfter = byId.get(B.id); const aAfter = byId.get(A.id); - // 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"); - // The reorder route must write an audit row with the namespaced action - // `executive_meeting.reorder` so downstream filters can target it. const auditRows = await pool.query( `SELECT action, entity_type, entity_id, new_value FROM executive_meeting_audit_logs @@ -664,9 +656,6 @@ test("Reorder: POST /reorder renumbers a full day to 1..N and inherits slot time }); test("Reorder: rejects orderedIds containing duplicates with code duplicate_ids", async () => { - // Even though full-day membership would otherwise be satisfied, sending the - // same id twice must be refused so we never assign the same dailyNumber to - // two distinct rows during the two-phase renumber. const reorderDate = "2050-04-04"; const a = await api(adminCookie, "POST", "/api/executive-meetings", { titleAr: "أ", titleEn: "A", meetingDate: reorderDate, diff --git a/artifacts/tx-os/src/components/editable-cell.tsx b/artifacts/tx-os/src/components/editable-cell.tsx index 6ff1c32c..aba2d99a 100644 --- a/artifacts/tx-os/src/components/editable-cell.tsx +++ b/artifacts/tx-os/src/components/editable-cell.tsx @@ -57,12 +57,6 @@ const FONT_SIZE_OPTIONS = [ { value: "28px", label: "28" }, ]; -// Tiptap doesn't ship a font-size mark in v3.22 — we add one as a textStyle -// attribute so it round-trips through `` (which -// the server allowlists in sanitize.ts). -// -// Module augmentation: register setFontSize/unsetFontSize on Tiptap's -// Commands map so call-sites stay strongly typed (no `as never` casts). declare module "@tiptap/core" { interface Commands { fontSize: { @@ -175,8 +169,6 @@ export function EditableCell({ ], content: value || "", editable: editing && !disabled, - // Editor renders into a contenteditable div. We don't auto-focus on - // mount; we focus when the user enters edit mode. editorProps: { attributes: { class: @@ -194,7 +186,6 @@ export function EditableCell({ return true; } if (event.key === "Enter" && !multiline && !event.shiftKey) { - // Single-line cells: Enter saves instead of inserting newline. event.preventDefault(); saveEdit(); return true; @@ -206,8 +197,6 @@ export function EditableCell({ [], ); - // Keep the editor's content in sync when the value prop changes (e.g. - // after a successful save the parent re-renders with the new server value). useEffect(() => { if (!editor) return; if (!editing && editor.getHTML() !== (value || "

")) { @@ -219,7 +208,6 @@ export function EditableCell({ if (!editor) return; editor.setEditable(editing && !disabled); if (editing) { - // Focus on the next tick so the editor is rendered first. const id = setTimeout(() => editor.commands.focus("end"), 0); return () => clearTimeout(id); } @@ -240,22 +228,17 @@ export function EditableCell({ const saveEdit = useCallback(async () => { if (!editor) return; const html = editor.getHTML(); - // Treat an empty editor (just

) as empty string. const stripped = html === "

" ? "" : html; setEditing(false); if (stripped !== value) { try { await onSave(stripped); } catch { - // Parent shows toast; revert local content to last known good value. editor.commands.setContent(value || "", { emitUpdate: false }); } } }, [editor, onSave, value]); - // Click outside the editor+toolbar saves and exits edit mode. We use a - // tiny defer on blur because clicking a toolbar button briefly takes focus - // out of the editor. const onFocusOut = (e: React.FocusEvent) => { if (!editing) return; const next = e.relatedTarget as Node | null; @@ -273,10 +256,6 @@ export function EditableCell({ } }; - // Static (read-only) rendering. The server is the source of truth for - // sanitization on write, but we still re-sanitize on read with DOMPurify - // as a defense-in-depth boundary in case any legacy/out-of-band row - // slipped past the server allowlist. if (!editor) { return (
void; onCancel: () => void; }) { - // Re-render when selection changes so active states reflect cursor position. const [, setTick] = useState(0); useEffect(() => { const update = () => setTick((n) => n + 1); @@ -376,8 +354,6 @@ function FormattingToolbar({ return (
e.preventDefault()} data-testid="em-edit-toolbar" > @@ -482,9 +458,6 @@ function FormattingToolbar({ value={(editor.getAttributes("textStyle").fontSize as string) || ""} onChange={(e) => { const v = e.target.value; - // setFontSize / unsetFontSize are registered on Tiptap's Commands - // map via the `declare module "@tiptap/core"` augmentation above, - // so they're strongly typed without casts. if (v) editor.chain().focus().setFontSize(v).run(); else editor.chain().focus().unsetFontSize().run(); }} diff --git a/artifacts/tx-os/src/pages/executive-meetings-print.tsx b/artifacts/tx-os/src/pages/executive-meetings-print.tsx index 10ed4457..cccebf9a 100644 --- a/artifacts/tx-os/src/pages/executive-meetings-print.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings-print.tsx @@ -37,11 +37,6 @@ function getQuery(): { date: string; lang: "ar" | "en" } { return { date, lang }; } -// All printable strings live in the project locale files under -// `executiveMeetings.print.*`, keeping AR + EN parity with the rest of the -// app. We force i18next into the language requested by the URL so a -// "Print English" link always renders English regardless of the active -// session language. const PRINT_KEYS = { title: "executiveMeetings.print.title", no: "executiveMeetings.print.no", diff --git a/lib/api-zod/src/manual.ts b/lib/api-zod/src/manual.ts index a25a9aa0..c7942109 100644 --- a/lib/api-zod/src/manual.ts +++ b/lib/api-zod/src/manual.ts @@ -2,7 +2,7 @@ import { z } from "zod"; export const ExecutiveMeetingsReorderBody = z.object({ meetingDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), - orderedIds: z.array(z.number().int().positive()).min(1).max(200), + orderedIds: z.array(z.number().int().positive()).min(1).max(1000), }); export type ExecutiveMeetingsReorderBodyT = z.infer<