Task #349: Executive Meetings PDF improvements

- Schema: add `font_color` (hex, default #000000) and `logo_object_path`
  to `executive_meeting_font_settings`. Pushed via drizzle-kit.

- PDF renderer:
  - Add `fontColor` to PdfFontPrefs (body cells only; header chrome
    and logo intentionally ignore it to preserve branding).
  - Add `rowColor` to PdfMeeting and a ROW_COLOR_FILL palette kept in
    lockstep with the on-screen swatches; deliberately stop painting
    the legacy `isHighlighted` overlay so the archived PDF reflects
    editorial state instead of the viewer's transient cursor.
  - Add optional `logo: Buffer`. Header now reserves a left-anchored
    logo box and centers the title in the remaining width; bad image
    bytes log + fall back to the no-logo layout instead of crashing.

- API route:
  - Extend fontSettingsSchema with strict #RRGGBB regex and
    /^/objects/<id>$/ regex for logoObjectPath.
  - resolveFontPrefsForUser now returns { font, logoObjectPath }.
  - loadLogoBytes downloads the brand asset via ObjectStorageService.
  - Logo only writable on the global-scope row.
  - PDF labels switched to "قائمة بأسماء حضور الاجتماعات" /
    "Meeting Attendance List" per the user's printed sample.

- Frontend (executive-meetings.tsx):
  - FontPrefs gains fontColor; FontSettingsResponse.global gains
    logoObjectPath; DEFAULT_FONT and effectiveFont updated.
  - buildFontStyle applies fontColor to on-screen rows.
  - FontSettingsSection: native color picker + hex text input;
    logo upload (PNG/JPEG) via @workspace/object-storage-web's
    useUpload, visible only at the global scope for admins.

- Locales: AR/EN keys for fontColor + logo.{label,upload,replace,
  remove,uploading,uploadFailed,globalOnly}.

- Tests: existing font-settings roundtrip extended with fontColor;
  new test rejects malformed fontColor and non-/objects logo paths.

Type-check clean for api-server and tx-os; new font-settings tests
pass. Other test failures in the suite pre-date this change.
This commit is contained in:
riyadhafraa
2026-05-03 14:34:29 +00:00
parent 9fdabc4794
commit 57e8297464
20 changed files with 26597 additions and 28 deletions
+1463
View File
File diff suppressed because it is too large Load Diff
+103 -11
View File
@@ -13,6 +13,23 @@ export type PdfFontPrefs = {
fontSize: number;
fontWeight: "regular" | "bold";
alignment: "start" | "center";
// Hex color applied to body cell text. Header cells (white-on-navy)
// and the brand logo intentionally ignore this so the brand styling
// survives even with extreme user color picks.
fontColor: string;
};
// Mapping from a row-color palette key (`red`/`amber`/...) to the exact
// fill the on-screen schedule uses for that tint. Keep in lockstep with
// `ROW_COLOR_OPTIONS` in artifacts/tx-os/src/pages/executive-meetings.tsx
// so a meeting tinted "amber" in the UI prints with the same swatch.
const ROW_COLOR_FILL: Record<string, string> = {
red: "#fee2e2",
amber: "#fef3c7",
green: "#dcfce7",
blue: "#dbeafe",
violet: "#ede9fe",
gray: "#f3f4f6",
};
export type PdfMeetingAttendee = {
@@ -34,7 +51,18 @@ export type PdfMeeting = {
startTime: string | null;
endTime: string | null;
location: string | null;
isHighlighted: number;
// Legacy "current meeting" highlight overlay (#288). Kept on the
// type for source-compat with older callers, but the renderer now
// intentionally ignores it: per-row tint comes from `rowColor`
// instead, so transient on-screen highlights never get baked into
// the printed archive.
isHighlighted?: number;
// Optional per-meeting palette key from the daily schedule (red /
// amber / green / blue / violet / gray). NULL/undefined = no tint.
// Looked up against ROW_COLOR_FILL above; unknown keys are treated
// as "no tint" rather than throwing so a future palette addition on
// the frontend can roll out without breaking server PDFs.
rowColor?: string | null;
attendees: PdfMeetingAttendee[];
};
@@ -51,6 +79,11 @@ export type RenderPdfInput = {
time: string;
none: string;
};
// Optional brand logo bytes (PNG/JPEG) drawn in the top-left of the
// PDF header. The renderer scales it to fit a small box at the page
// margin and lays the title/date out next to it. Pass `undefined`
// to render the legacy header-without-logo layout.
logo?: Buffer | null;
};
// Resolve fonts relative to the bundled dist file (esbuild rewrites
@@ -337,6 +370,11 @@ type DrawOpts = {
align: "left" | "center" | "right";
baseDirection: "ltr" | "rtl";
mapping: FamilyMapping;
// Hex fill for the text run. Optional so existing internal callers
// (e.g. the title/date header passes that need to stay black or
// navy regardless of the user's fontColor) can keep working without
// a code change.
color?: string;
};
// Draw a single line of text using mixed Arabic/Latin runs (in visual
@@ -375,7 +413,7 @@ function drawMixedLine(
return drawWrappingLine(doc, text, opts);
}
for (const r of measured) {
doc.font(r.fontKey).fontSize(opts.fontSize).fillColor("black");
doc.font(r.fontKey).fontSize(opts.fontSize).fillColor(opts.color ?? "black");
doc.text(r.text, cursorX, opts.y, {
lineBreak: false,
width: r.width + 1,
@@ -397,7 +435,7 @@ function drawWrappingLine(
const arabicChars = [...text].filter(isArabicChar).length;
const dominantArabic = arabicChars * 2 > text.length;
const fk = fontKeyFor(dominantArabic, opts.weight, opts.mapping);
doc.font(fk).fontSize(opts.fontSize).fillColor("black");
doc.font(fk).fontSize(opts.fontSize).fillColor(opts.color ?? "black");
// For Arabic text, run bidi on the whole string so the font sees runs
// already in visual order — this matches what drawMixedLine does for
// single-line cells.
@@ -448,18 +486,61 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise<Buffer>
doc.on("error", reject);
});
// Header — show the schedule title and the date on its own line.
// Header — show the schedule title (and the brand logo when present)
// on a single horizontal band, then the date on its own line below.
// The logo is anchored at the page's left margin regardless of the
// RTL flag, matching the printed sample the user supplied (logo on
// the left even for the Arabic deck). Title text stays centered in
// the remaining horizontal space so an RTL/LTR mix still feels
// balanced.
const titleSize = Math.min(28, input.font.fontSize + 10);
const headerLeftEdge = doc.page.margins.left;
const headerWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const headerTopY = doc.y;
const logoBoxSize = titleSize * 1.8;
let titleX = headerLeftEdge;
let titleWidth = headerWidth;
if (input.logo && input.logo.length > 0) {
try {
// PDFKit's image() places the top-left at (x, y) by default — no
// valign needed for top-anchored placement.
doc.image(input.logo, headerLeftEdge, headerTopY, {
fit: [logoBoxSize, logoBoxSize],
});
// Reserve the logo footprint plus a small gap so the centered
// title doesn't overlap the artwork. We also reserve the same
// footprint on the trailing edge so the title's geometric center
// matches the header's center, not the post-logo center.
const gap = 12;
titleX = headerLeftEdge + logoBoxSize + gap;
titleWidth = headerWidth - 2 * (logoBoxSize + gap);
if (titleWidth < headerWidth * 0.4) {
// Logo + symmetric reservation ate too much space — fall back
// to anchoring the title to the right of the logo only.
titleX = headerLeftEdge + logoBoxSize + gap;
titleWidth = headerWidth - logoBoxSize - gap;
}
// PDFKit moves doc.y after image() in some paths; pin it back so
// the title sits at the same vertical baseline.
doc.y = headerTopY;
} catch (err) {
// Bad image bytes shouldn't kill the whole PDF — log and proceed
// without the logo so the user still gets their schedule.
console.warn("[pdf-renderer] failed to embed logo, continuing without", err);
}
}
drawMixedLine(doc, input.labels.title, {
x: doc.page.margins.left,
y: doc.y,
width: doc.page.width - doc.page.margins.left - doc.page.margins.right,
x: titleX,
y: headerTopY,
width: titleWidth,
fontSize: titleSize,
weight: "bold",
align: "center",
baseDirection: baseDir,
mapping,
});
// Advance past the taller of (title line, logo box) before the date.
doc.y = headerTopY + Math.max(titleSize * 1.25, input.logo ? logoBoxSize : 0);
doc.moveDown(0.4);
drawMixedLine(doc, input.date, {
x: doc.page.margins.left,
@@ -637,10 +718,20 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise<Buffer>
}
const rowTop = doc.y;
if (meeting.isHighlighted) {
doc.save();
doc.rect(tableX, rowTop, tableWidth, rowHeight).fill("#fecaca");
doc.restore();
// Per-meeting saved tint (#288). The transient on-screen
// "isHighlighted" overlay is intentionally NOT painted here so the
// archived PDF reflects the meeting's editorial state, not the
// viewer's current-meeting cursor. Unknown palette keys silently
// map to "no tint" so a future palette addition rolls out without
// breaking server PDFs that were generated before the palette
// grew.
if (meeting.rowColor) {
const fill = ROW_COLOR_FILL[meeting.rowColor];
if (fill) {
doc.save();
doc.rect(tableX, rowTop, tableWidth, rowHeight).fill(fill);
doc.restore();
}
}
// Cell borders.
@@ -678,6 +769,7 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise<Buffer>
align: c.align,
baseDirection: baseDir,
mapping,
color: input.font.fontColor,
});
y += used;
}
@@ -304,12 +304,32 @@ const pdfArchiveCreateSchema = z.object({
filePath: z.string().trim().max(500).optional(),
});
// Hex color in #RRGGBB form. Stored verbatim and forwarded to the PDF
// renderer / inline-style attribute, so reject anything that wouldn't
// round-trip safely through CSS or PDFKit's `fillColor`.
const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
// `/objects/<id>` paths are produced by `ObjectStorageService.normalizeObjectEntityPath`.
// We allow null/empty to mean "no logo" (clear the slot).
const OBJECT_PATH_RE = /^\/objects\/[A-Za-z0-9_\-./]+$/;
const fontSettingsSchema = z.object({
scope: z.enum(["user", "global"]).default("user"),
fontFamily: z.enum(FONT_FAMILIES).default("system"),
fontSize: z.number().int().min(FONT_SIZE_MIN).max(FONT_SIZE_MAX).default(14),
fontWeight: z.enum(FONT_WEIGHTS).default("regular"),
alignment: z.enum(FONT_ALIGNMENTS).default("start"),
fontColor: z
.string()
.regex(HEX_COLOR_RE, "expected #RRGGBB hex color")
.default("#000000"),
// null = explicit clear (UI's "remove logo" gesture). Undefined =
// not touching the field on this PATCH. Only honored when scope is
// "global"; ignored for per-user rows so users can't override the
// org logo with their own.
logoObjectPath: z
.string()
.regex(OBJECT_PATH_RE, "expected /objects/<id> path")
.nullable()
.optional(),
});
function parseBody<T>(
@@ -2544,7 +2564,9 @@ router.delete(
// Resolve the user's effective font preferences exactly the way the
// frontend does: user-scope row wins, otherwise global, otherwise the
// schema defaults.
async function resolveFontPrefsForUser(userId: number): Promise<PdfFontPrefs> {
async function resolveFontPrefsForUser(
userId: number,
): Promise<{ font: PdfFontPrefs; logoObjectPath: string | null }> {
const rows = await db
.select()
.from(executiveMeetingFontSettingsTable)
@@ -2561,13 +2583,37 @@ async function resolveFontPrefsForUser(userId: number): Promise<PdfFontPrefs> {
const globalRow = rows.find((r) => r.scope === "global");
const eff = userRow ?? globalRow;
return {
fontFamily: eff?.fontFamily ?? "system",
fontSize: eff?.fontSize ?? 14,
fontWeight: (eff?.fontWeight as PdfFontPrefs["fontWeight"]) ?? "regular",
alignment: (eff?.alignment as PdfFontPrefs["alignment"]) ?? "start",
font: {
fontFamily: eff?.fontFamily ?? "system",
fontSize: eff?.fontSize ?? 14,
fontWeight: (eff?.fontWeight as PdfFontPrefs["fontWeight"]) ?? "regular",
alignment: (eff?.alignment as PdfFontPrefs["alignment"]) ?? "start",
fontColor: eff?.fontColor ?? "#000000",
},
// Logo lives at the global scope only — per-user rows ignore the
// field. This keeps the brand asset shared across all viewers and
// avoids each user having to re-upload it.
logoObjectPath: globalRow?.logoObjectPath ?? null,
};
}
// Download a `/objects/<id>` path back to a Buffer so PDFKit can embed
// it as a header image. Returns null on any failure (missing object,
// network blip, sidecar offline) — the renderer falls back to the
// header-without-logo layout in that case so we never block the PDF.
async function loadLogoBytes(objectPath: string | null): Promise<Buffer | null> {
if (!objectPath) return null;
try {
const objectStorage = new ObjectStorageService();
const file = await objectStorage.getObjectEntityFile(objectPath);
const [buf] = await file.download();
return buf;
} catch (err) {
console.warn("[executive-meetings] failed to load brand logo", err);
return null;
}
}
// Upload bytes to object storage by signing a PUT URL and streaming the
// buffer through `fetch`. Returns the canonical /objects/<id> path that
// GET /api/storage/objects/* knows how to serve.
@@ -2631,11 +2677,15 @@ router.get(
}
}
const font = await resolveFontPrefsForUser(userId);
const { font, logoObjectPath } = await resolveFontPrefsForUser(userId);
const logo = await loadLogoBytes(logoObjectPath);
// The Arabic title comes verbatim from the printed sample the user
// provided ("قائمة بأسماء حضور الاجتماعات"). The English label
// mirrors its meaning for bilingual exports.
const labels =
lang === "ar"
? {
title: "جدول الاجتماعات التنفيذية",
title: "قائمة بأسماء حضور الاجتماعات",
no: "م",
meeting: "الاجتماع",
attendees: "الحضور",
@@ -2643,7 +2693,7 @@ router.get(
none: "لا توجد اجتماعات في هذا اليوم.",
}
: {
title: "Executive Meetings Schedule",
title: "Meeting Attendance List",
no: "#",
meeting: "Meeting",
attendees: "Attendees",
@@ -2658,6 +2708,7 @@ router.get(
lang,
font,
labels,
logo,
meetings: meetings.map((m) => ({
dailyNumber: m.dailyNumber,
titleAr: m.titleAr,
@@ -2665,7 +2716,12 @@ router.get(
startTime: m.startTime,
endTime: m.endTime,
location: m.location,
isHighlighted: m.isHighlighted ?? 0,
// Forward the saved per-meeting tint (red/amber/green/...)
// so the printed PDF matches the on-screen schedule. The
// legacy `isHighlighted` overlay is intentionally NOT
// forwarded — it represents the viewer's transient
// "current meeting" cursor, not editorial state.
rowColor: m.rowColor,
attendees: (attendeesByMeeting.get(m.id) ?? []).map((a) => ({
name: a.name,
title: a.title,
@@ -2867,6 +2923,10 @@ const upsertFontSettingsHandler = async (
}
}
const targetUserId = data.scope === "global" ? null : userId;
// Logo is a brand asset and only lives on the global row. Per-user
// PATCHes silently drop the field rather than 400-ing so a generic
// "save my prefs" UI doesn't have to special-case the toggle.
const logoForWrite = data.scope === "global" ? data.logoObjectPath : undefined;
const result = await db.transaction(async (tx) => {
const [existing] = await tx
.select()
@@ -2881,14 +2941,22 @@ const upsertFontSettingsHandler = async (
);
let row;
if (existing) {
const updateSet: Partial<typeof executiveMeetingFontSettingsTable.$inferInsert> = {
fontFamily: data.fontFamily,
fontSize: data.fontSize,
fontWeight: data.fontWeight,
alignment: data.alignment,
fontColor: data.fontColor,
};
// Only touch logo when the field was provided AND we're on the
// global row. `null` is a real value (user clicked "remove
// logo") so we have to distinguish from `undefined`.
if (logoForWrite !== undefined) {
updateSet.logoObjectPath = logoForWrite;
}
const [updated] = await tx
.update(executiveMeetingFontSettingsTable)
.set({
fontFamily: data.fontFamily,
fontSize: data.fontSize,
fontWeight: data.fontWeight,
alignment: data.alignment,
})
.set(updateSet)
.where(eq(executiveMeetingFontSettingsTable.id, existing.id))
.returning();
row = updated;
@@ -2900,6 +2968,8 @@ const upsertFontSettingsHandler = async (
fontSize: data.fontSize,
fontWeight: data.fontWeight,
alignment: data.alignment,
fontColor: data.fontColor,
logoObjectPath: logoForWrite ?? null,
};
const [inserted] = await tx
.insert(executiveMeetingFontSettingsTable)
@@ -1812,6 +1812,7 @@ test("Font settings: PUT then GET returns the user-scoped row roundtrip", async
fontSize: 18,
fontWeight: "bold",
alignment: "center",
fontColor: "#1f2937",
});
assert.equal(put.status, 200);
@@ -1825,6 +1826,8 @@ test("Font settings: PUT then GET returns the user-scoped row roundtrip", async
assert.equal(body.user.fontSize, 18);
assert.equal(body.user.fontWeight, "bold");
assert.equal(body.user.alignment, "center");
assert.equal(body.user.fontColor, "#1f2937",
"fontColor must persist via the user-scope row");
// PATCH alias must hit the same upsert handler.
const patch = await api(adminCookie, "PATCH",
@@ -1834,6 +1837,7 @@ test("Font settings: PUT then GET returns the user-scoped row roundtrip", async
fontSize: 14,
fontWeight: "regular",
alignment: "start",
fontColor: "#000000",
});
assert.equal(patch.status, 200);
@@ -1844,6 +1848,37 @@ test("Font settings: PUT then GET returns the user-scoped row roundtrip", async
assert.equal(body2.user.fontSize, 14);
assert.equal(body2.user.fontWeight, "regular");
assert.equal(body2.user.alignment, "start");
assert.equal(body2.user.fontColor, "#000000");
});
test("Font settings: rejects malformed fontColor and logoObjectPath", async () => {
// The hex regex on the API must reject non-#RRGGBB inputs so the
// value can be safely round-tripped through CSS and PDFKit.
const badColor = await api(adminCookie, "PATCH",
"/api/executive-meetings/font-settings", {
scope: "user",
fontFamily: "system",
fontSize: 14,
fontWeight: "regular",
alignment: "start",
fontColor: "red",
});
assert.equal(badColor.status, 400, "non-hex fontColor must 400");
// Anything that isn't a /objects/<id> path should also be rejected
// — we don't want callers smuggling http URLs or relative paths
// into the brand logo slot.
const badPath = await api(adminCookie, "PATCH",
"/api/executive-meetings/font-settings", {
scope: "global",
fontFamily: "system",
fontSize: 14,
fontWeight: "regular",
alignment: "start",
fontColor: "#000000",
logoObjectPath: "https://evil.example.com/logo.png",
});
assert.equal(badPath.status, 400, "non-/objects path must 400");
});
test("router.param: non-numeric :id returns 404 across endpoints (no crash)", async () => {
+10
View File
@@ -1420,6 +1420,16 @@
"fontSize": "حجم الخط",
"fontWeight": "السماكة",
"alignment": "المحاذاة",
"fontColor": "لون الخط",
"logo": {
"label": "شعار المؤسسة (يظهر أعلى ملف PDF)",
"upload": "رفع الشعار",
"replace": "استبدال الشعار",
"remove": "إزالة الشعار",
"uploading": "جاري الرفع...",
"uploadFailed": "فشل رفع الشعار",
"globalOnly": "يحفظ الشعار للمؤسسة بأكملها."
},
"weight": {
"regular": "عادي",
"medium": "متوسط",
+10
View File
@@ -1286,6 +1286,16 @@
"fontSize": "Font size",
"fontWeight": "Weight",
"alignment": "Alignment",
"fontColor": "Font color",
"logo": {
"label": "Organization logo (appears at the top of the PDF)",
"upload": "Upload logo",
"replace": "Replace logo",
"remove": "Remove logo",
"uploading": "Uploading...",
"uploadFailed": "Logo upload failed",
"globalOnly": "The logo is saved for the whole organization."
},
"weight": {
"regular": "Regular",
"medium": "Medium",
@@ -80,6 +80,7 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
import { useToast } from "@/hooks/use-toast";
import { ToastAction } from "@/components/ui/toast";
import {
@@ -205,10 +206,20 @@ type FontPrefs = {
fontSize: number;
fontWeight: "regular" | "bold";
alignment: "start" | "center";
// Hex color (#RRGGBB) the API now persists alongside the other font
// prefs. Default "#000000" matches the historical hard-coded black.
fontColor: string;
};
// Brand asset stored only on the global-scope row. Per-user rows return
// null here; the UI hides the upload control unless the viewer has
// global edit rights.
type GlobalFontExtras = { logoObjectPath: string | null };
type FontSettingsResponse = {
global: ({ scope: "global"; userId: null } & FontPrefs) | null;
global:
| ({ scope: "global"; userId: null } & FontPrefs & GlobalFontExtras)
| null;
user: ({ scope: "user"; userId: number } & FontPrefs) | null;
};
@@ -289,6 +300,7 @@ const DEFAULT_FONT: FontPrefs = {
fontSize: 14,
fontWeight: "regular",
alignment: "start",
fontColor: "#000000",
};
type ColumnId = "number" | "meeting" | "attendees" | "time";
@@ -428,6 +440,7 @@ function buildFontStyle(prefs: FontPrefs): CSSProperties {
fontSize: prefs.fontSize,
fontWeight: fontWeightToCss(prefs.fontWeight),
textAlign: prefs.alignment,
color: prefs.fontColor,
};
}
@@ -785,6 +798,7 @@ function ExecutiveMeetingsPageInner() {
fontSize: src.fontSize,
fontWeight: src.fontWeight,
alignment: src.alignment,
fontColor: src.fontColor ?? "#000000",
};
}, [fontResp]);
@@ -931,6 +945,7 @@ function ExecutiveMeetingsPageInner() {
{section === "settings" && me && (
<SettingsSection
font={effectiveFont}
globalLogoObjectPath={fontResp?.global?.logoObjectPath ?? null}
canEditGlobalFont={me.canEditGlobalFontSettings}
columns={columns}
onColumnsChange={setColumns}
@@ -7175,6 +7190,7 @@ function PdfSection({
function SettingsSection({
font,
globalLogoObjectPath,
canEditGlobalFont,
columns,
onColumnsChange,
@@ -7183,6 +7199,7 @@ function SettingsSection({
t,
}: {
font: FontPrefs;
globalLogoObjectPath: string | null;
canEditGlobalFont: boolean;
columns: ColumnSetting[];
onColumnsChange: (next: ColumnSetting[]) => void;
@@ -7211,6 +7228,7 @@ function SettingsSection({
<section className="bg-white border border-gray-200 rounded-lg p-4 sm:p-6">
<FontSettingsSection
font={font}
globalLogoObjectPath={globalLogoObjectPath}
canEditGlobal={canEditGlobalFont}
t={t}
/>
@@ -7328,10 +7346,12 @@ function AlertPrefsCard({ t }: { t: (k: string) => string }) {
function FontSettingsSection({
font,
globalLogoObjectPath,
canEditGlobal,
t,
}: {
font: FontPrefs;
globalLogoObjectPath: string | null;
canEditGlobal: boolean;
t: (k: string) => string;
}) {
@@ -7339,18 +7359,47 @@ function FontSettingsSection({
const { toast } = useToast();
const [scope, setScope] = useState<"user" | "global">("user");
const [prefs, setPrefs] = useState<FontPrefs>(font);
// Local mirror of the org-wide logo path. Tracks server state but
// also reflects in-flight upload/clear gestures so the preview
// updates immediately without waiting for the next refetch.
const [logoPath, setLogoPath] = useState<string | null>(globalLogoObjectPath);
const [saving, setSaving] = useState(false);
useEffect(() => {
setPrefs(font);
}, [font]);
useEffect(() => {
setLogoPath(globalLogoObjectPath);
}, [globalLogoObjectPath]);
// Logo uploads always target the global brand asset, regardless of
// the scope toggle, because per-user logos aren't a thing. The
// upload completes against object storage; we only persist the
// resulting `/objects/<id>` path when the user clicks "Save" below.
const { uploadFile: uploadLogo, isUploading: isUploadingLogo } = useUpload({
onSuccess: (resp: UploadResponse) => setLogoPath(resp.objectPath),
onError: (err: Error) =>
toast({
title: t("executiveMeetings.fontSettingsPage.logo.uploadFailed"),
description: err.message,
variant: "destructive",
}),
});
async function save() {
setSaving(true);
try {
// Body shape: at the user scope we omit logoObjectPath entirely
// (the API ignores it for user rows anyway, but omitting keeps
// the payload tight). At the global scope we always send the
// current path so "remove logo" persists as `null`.
const body: Record<string, unknown> = { scope, ...prefs };
if (scope === "global") {
body.logoObjectPath = logoPath;
}
await apiJson(`/api/executive-meetings/font-settings`, {
method: "PATCH",
body: { scope, ...prefs },
body,
});
toast({ title: t("executiveMeetings.fontSettingsPage.saved") });
await qc.invalidateQueries({ queryKey: ["/api/executive-meetings/font-settings"] });
@@ -7366,6 +7415,16 @@ function FontSettingsSection({
setPrefs(DEFAULT_FONT);
}
function onLogoFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
// Reset the input so picking the same file twice in a row still
// re-fires onChange — otherwise users hit a confusing dead state
// after a failed upload.
e.target.value = "";
if (!file) return;
void uploadLogo(file);
}
return (
<div className="space-y-4 max-w-2xl">
<h2 className="text-xl sm:text-2xl font-bold text-[#0B1E3F]">
@@ -7471,6 +7530,74 @@ function FontSettingsSection({
</SelectContent>
</Select>
</FormRow>
<FormRow label={t("executiveMeetings.fontSettingsPage.fontColor")}>
<div className="flex items-center gap-2">
{/*
Native color picker keeps the UI dependency-free; we mirror
its value into a text input so users (and screen readers,
and Playwright tests) can read/type the hex directly. The
text input is loosely validated the API re-validates with
a strict /^#[0-9a-fA-F]{6}$/ regex.
*/}
<input
type="color"
value={prefs.fontColor}
onChange={(e) => setPrefs({ ...prefs, fontColor: e.target.value })}
className="h-9 w-12 rounded border border-gray-300 cursor-pointer"
data-testid="em-font-color"
aria-label={t("executiveMeetings.fontSettingsPage.fontColor")}
/>
<Input
value={prefs.fontColor}
onChange={(e) => setPrefs({ ...prefs, fontColor: e.target.value })}
className="font-mono text-sm w-32"
maxLength={7}
/>
</div>
</FormRow>
{canEditGlobal && scope === "global" && (
<FormRow label={t("executiveMeetings.fontSettingsPage.logo.label")}>
<div className="flex flex-col gap-2">
{logoPath && (
<div className="flex items-center gap-3">
<img
src={logoPath}
alt="logo"
className="h-14 w-14 object-contain border border-gray-200 rounded bg-white"
/>
<Button
variant="ghost"
size="sm"
onClick={() => setLogoPath(null)}
data-testid="em-logo-remove"
>
{t("executiveMeetings.fontSettingsPage.logo.remove")}
</Button>
</div>
)}
<label className="inline-flex items-center gap-2 cursor-pointer text-sm text-[#0B1E3F] hover:underline">
<input
type="file"
accept="image/png,image/jpeg"
className="hidden"
onChange={onLogoFileChange}
disabled={isUploadingLogo}
data-testid="em-logo-file"
/>
<span>
{isUploadingLogo
? t("executiveMeetings.fontSettingsPage.logo.uploading")
: logoPath
? t("executiveMeetings.fontSettingsPage.logo.replace")
: t("executiveMeetings.fontSettingsPage.logo.upload")}
</span>
</label>
<p className="text-xs text-gray-500">
{t("executiveMeetings.fontSettingsPage.logo.globalOnly")}
</p>
</div>
</FormRow>
)}
<div className="border-t border-gray-200 pt-3">
<Label className="text-xs text-gray-700">
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+1329
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+285
View File
@@ -0,0 +1,285 @@
import {
pgTable,
serial,
integer,
varchar,
text,
timestamp,
date,
time,
jsonb,
boolean,
index,
uniqueIndex,
check,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { usersTable } from "./users";
// Single source of truth for the row-highlight palette used by the
// daily schedule overlay (#288). Imported by the API route's Zod
// whitelist AND pinned at the database layer via the CHECK constraint
// below (#293), so the same invariant survives any out-of-band write
// path (manual SQL, future bulk jobs, restored backups). NULL means
// "no tint" / default; any other value must be one of these keys.
export const EXECUTIVE_MEETING_ROW_COLOR_KEYS = [
"red",
"amber",
"green",
"blue",
"violet",
"gray",
] as const;
export type ExecutiveMeetingRowColor =
(typeof EXECUTIVE_MEETING_ROW_COLOR_KEYS)[number];
export const executiveMeetingsTable = pgTable(
"executive_meetings",
{
id: serial("id").primaryKey(),
dailyNumber: integer("daily_number").notNull(),
// titleAr/En hold sanitized rich-text HTML (Tiptap output) so they need
// unbounded length. Plain-text rows from before the widening still
// render correctly because they contain no HTML tags.
titleAr: text("title_ar").notNull(),
titleEn: text("title_en").notNull().default(""),
meetingDate: date("meeting_date").notNull(),
startTime: time("start_time", { withTimezone: false }),
endTime: time("end_time", { withTimezone: false }),
location: varchar("location", { length: 300 }),
meetingUrl: text("meeting_url"),
platform: varchar("platform", { length: 32 }).notNull().default("none"),
status: varchar("status", { length: 32 }).notNull().default("scheduled"),
isHighlighted: integer("is_highlighted").notNull().default(0),
notes: text("notes"),
attachments: jsonb("attachments").default([]),
// Optional cell-merge overlay for the schedule grid. When set, the
// schedule renders one merged cell spanning columns
// [mergeStartColumn..mergeEndColumn] and shows mergeText (sanitized
// HTML) instead of the original column values. The original
// titleAr/En + attendees + start/endTime stay untouched in the DB,
// so clearing the merge restores the original cells exactly.
// Allowed values for the column ids match the frontend ColumnId
// enum: "number" | "meeting" | "attendees" | "time".
mergeStartColumn: varchar("merge_start_column", { length: 32 }),
mergeEndColumn: varchar("merge_end_column", { length: 32 }),
mergeText: text("merge_text"),
// Optional row-highlight colour for the daily schedule grid. NULL =
// "default" (no tint). Allowed non-null values come from the
// ROW_COLOR_OPTIONS palette in the frontend (red, amber, green,
// blue, violet, gray) and are validated app-side. Stored on the
// meeting row itself (not per-user) so the colour is shared across
// every viewer in real time, since it carries an editorial
// signal about the meeting (urgent / VIP / etc.) rather than a
// personal viewing preference.
rowColor: varchar("row_color", { length: 16 }),
createdBy: integer("created_by").references(() => usersTable.id, {
onDelete: "set null",
}),
updatedBy: integer("updated_by").references(() => usersTable.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(t) => ({
dateIdx: index("executive_meetings_date_idx").on(t.meetingDate),
dateNumberUnique: uniqueIndex("executive_meetings_date_number_unique").on(
t.meetingDate,
t.dailyNumber,
),
// Defense-in-depth (#293): mirror the API-side ROW_COLOR_KEYS
// whitelist at the DB layer so any out-of-band write path
// (manual SQL, future bulk jobs, restored backups) cannot smuggle
// an unrenderable colour past the Zod guard. NULL is the "no tint"
// sentinel, the six keys are the supported palette. Any other
// value raises a check_violation (PG SQLSTATE 23514) that the
// caller must explicitly handle.
// Literal-SQL form: Postgres CHECK constraint definitions are
// parsed as DDL and reject parameter placeholders ($1, $2, …),
// so the palette keys are inlined as quoted literals via
// `sql.raw`. This is safe because the keys come from a hardcoded
// compile-time constant of single-word identifiers (no user
// input ever reaches this string).
rowColorPaletteCheck: check(
"executive_meetings_row_color_palette_check",
sql`${t.rowColor} IS NULL OR ${t.rowColor} IN (${sql.raw(
EXECUTIVE_MEETING_ROW_COLOR_KEYS.map((k) => `'${k}'`).join(", "),
)})`,
),
}),
);
export const executiveMeetingAttendeesTable = pgTable(
"executive_meeting_attendees",
{
id: serial("id").primaryKey(),
meetingId: integer("meeting_id")
.notNull()
.references(() => executiveMeetingsTable.id, { onDelete: "cascade" }),
// attendee name holds sanitized rich-text HTML so it needs unbounded
// length. Plain-text rows still render correctly (no tags = no parse).
// For `kind = "subheading"` rows, this column stores the user-written
// section label (free text, single value rendered as-is in AR and EN).
name: text("name").notNull(),
title: varchar("title", { length: 200 }),
attendanceType: varchar("attendance_type", { length: 32 })
.notNull()
.default("internal"),
sortOrder: integer("sort_order").notNull().default(0),
// "person" — a real attendee row (default; matches all pre-existing
// data after backfill).
// "subheading" — a user-defined section label inserted between attendees
// inside the same attendance-type group. Numbering, count,
// and virtual-platform extraction MUST ignore these rows.
kind: varchar("kind", { length: 16 }).notNull().default("person"),
},
(t) => ({
meetingIdx: index("executive_meeting_attendees_meeting_idx").on(t.meetingId),
}),
);
export const executiveMeetingNotificationsTable = pgTable(
"executive_meeting_notifications",
{
id: serial("id").primaryKey(),
meetingId: integer("meeting_id").references(() => executiveMeetingsTable.id, {
onDelete: "cascade",
}),
userId: integer("user_id").references(() => usersTable.id, { onDelete: "cascade" }),
notificationType: varchar("notification_type", { length: 64 }).notNull(),
scheduledAt: timestamp("scheduled_at", { withTimezone: true }),
sentAt: timestamp("sent_at", { withTimezone: true }),
status: varchar("status", { length: 32 }).notNull().default("pending"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
);
/**
* Per-user notification preferences for the Executive Meetings module.
*
* Each row toggles a single (userId, notificationType) pair on or off
* for the in-app bell channel and the email channel independently.
*
* Default behaviour when no row exists: BOTH channels ON. This keeps the
* legacy "fan out to everyone, every time" semantics intact for users
* who never visit the preferences UI. Only an explicit opt-out (row
* present + flag set to false) suppresses delivery.
*
* notificationType mirrors the keys passed to
* `recordExecutiveMeetingNotifications` / `sendExecutiveMeetingEmail`:
* - meeting_created
*/
export const executiveMeetingNotificationPrefsTable = pgTable(
"executive_meeting_notification_prefs",
{
id: serial("id").primaryKey(),
userId: integer("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
notificationType: varchar("notification_type", { length: 64 }).notNull(),
inApp: boolean("in_app").notNull().default(true),
email: boolean("email").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(t) => ({
userTypeUnique: uniqueIndex(
"executive_meeting_notification_prefs_user_type_unique",
).on(t.userId, t.notificationType),
}),
);
export const executiveMeetingAuditLogsTable = pgTable(
"executive_meeting_audit_logs",
{
id: serial("id").primaryKey(),
action: varchar("action", { length: 100 }).notNull(),
entityType: varchar("entity_type", { length: 50 }).notNull(),
entityId: integer("entity_id"),
oldValue: jsonb("old_value"),
newValue: jsonb("new_value"),
performedBy: integer("performed_by").references(() => usersTable.id, {
onDelete: "set null",
}),
performedAt: timestamp("performed_at", { withTimezone: true }).notNull().defaultNow(),
},
);
export const executiveMeetingPdfArchivesTable = pgTable(
"executive_meeting_pdf_archives",
{
id: serial("id").primaryKey(),
archiveDate: date("archive_date").notNull(),
// `filePath` doubles as the storage URL for the archived PDF. When the
// PDF was uploaded to object storage the value is a `/objects/<id>`
// path that resolves through GET /api/storage/objects/*. Older rows
// (browser-print snapshots) use the synthetic `print:<date>` form.
filePath: text("file_path").notNull(),
version: integer("version").notNull().default(1),
// Bytes of the generated PDF on disk. Nullable because legacy rows
// (created before the server-side generator) never had a real file.
byteSize: integer("byte_size"),
generatedBy: integer("generated_by").references(() => usersTable.id, {
onDelete: "set null",
}),
generatedAt: timestamp("generated_at", { withTimezone: true }).notNull().defaultNow(),
},
);
// #273: Per-user dismissal/acknowledgment state for the 5-minute
// upcoming-meeting alert. One row per (meetingId, userId). Absence of
// a row means the user has neither acknowledged ("Done") nor dismissed
// ("Delete alert") the alert for that meeting yet, so the alert is
// eligible to fire when the meeting falls inside the 5-minute window.
export const executiveMeetingAlertStateTable = pgTable(
"executive_meeting_alert_state",
{
id: serial("id").primaryKey(),
meetingId: integer("meeting_id")
.notNull()
.references(() => executiveMeetingsTable.id, { onDelete: "cascade" }),
userId: integer("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
dismissed: boolean("dismissed").notNull().default(false),
acknowledged: boolean("acknowledged").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(t) => ({
perUserUnique: uniqueIndex(
"executive_meeting_alert_state_meeting_user_unique",
).on(t.meetingId, t.userId),
}),
);
export const executiveMeetingFontSettingsTable = pgTable(
"executive_meeting_font_settings",
{
id: serial("id").primaryKey(),
scope: varchar("scope", { length: 16 }).notNull().default("global"),
userId: integer("user_id").references(() => usersTable.id, { onDelete: "cascade" }),
fontFamily: varchar("font_family", { length: 100 }).notNull().default("system"),
fontSize: integer("font_size").notNull().default(14),
fontWeight: varchar("font_weight", { length: 16 }).notNull().default("regular"),
alignment: varchar("alignment", { length: 16 }).notNull().default("start"),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
);
export type ExecutiveMeeting = typeof executiveMeetingsTable.$inferSelect;
export type ExecutiveMeetingAttendee = typeof executiveMeetingAttendeesTable.$inferSelect;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10
View File
@@ -274,6 +274,16 @@ export const executiveMeetingFontSettingsTable = pgTable(
fontSize: integer("font_size").notNull().default(14),
fontWeight: varchar("font_weight", { length: 16 }).notNull().default("regular"),
alignment: varchar("alignment", { length: 16 }).notNull().default("start"),
// Hex color (#RRGGBB) applied to body cell text in both the on-screen
// schedule and the generated PDF. Default "#000000" preserves the
// historical black-text appearance for rows created before this
// column existed. Validated app-side by the regex in
// `fontSettingsSchema`.
fontColor: varchar("font_color", { length: 16 }).notNull().default("#000000"),
// Object-storage path (`/objects/<id>`) of the brand logo embedded
// in the top-left of the generated PDF header. NULL = no logo. Only
// meaningful on the global-scope row (the per-user row ignores it).
logoObjectPath: text("logo_object_path"),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
+657
View File
@@ -0,0 +1,657 @@
import { fileURLToPath } from "node:url";
import path from "node:path";
import { readFileSync, existsSync } from "node:fs";
import bidiFactory from "bidi-js";
import PDFDocument from "pdfkit";
import sanitizeHtml from "sanitize-html";
const bidi = bidiFactory();
export type PdfFontPrefs = {
fontFamily: string;
fontSize: number;
fontWeight: "regular" | "bold";
alignment: "start" | "center";
};
export type PdfMeetingAttendee = {
name: string;
title: string | null;
attendanceType?: string | null;
};
export type PdfMeeting = {
dailyNumber: number;
titleAr: string;
titleEn: string | null;
startTime: string | null;
endTime: string | null;
location: string | null;
isHighlighted: number;
attendees: PdfMeetingAttendee[];
};
export type RenderPdfInput = {
date: string;
lang: "ar" | "en";
font: PdfFontPrefs;
meetings: PdfMeeting[];
labels: {
title: string;
no: string;
meeting: string;
attendees: string;
time: string;
none: string;
};
};
// Resolve fonts relative to the bundled dist file (esbuild rewrites
// __dirname via the build banner so this works in both dev and prod).
function fontsDir(): string {
const here =
typeof __dirname === "string"
? __dirname
: path.dirname(fileURLToPath(import.meta.url));
// The fonts are shipped as raw assets next to dist/ — see
// artifacts/api-server/assets/fonts. From dist/ we go up one level.
const candidates = [
path.resolve(here, "..", "assets", "fonts"),
path.resolve(here, "assets", "fonts"),
path.resolve(here, "..", "..", "assets", "fonts"),
];
for (const c of candidates) {
if (existsSync(path.join(c, "NotoNaskhArabic-Regular.ttf"))) return c;
}
throw new Error(
`Could not locate bundled PDF fonts. Tried: ${candidates.join(", ")}`,
);
}
// All font files bundled in artifacts/api-server/assets/fonts. We only
// load the ones each render needs, but list every file here so the
// build step (and font-family mapping below) stays declarative.
const FONT_FILES = {
naskhRegular: "NotoNaskhArabic-Regular.ttf",
naskhBold: "NotoNaskhArabic-Bold.ttf",
arabicSansRegular: "NotoSansArabic-Regular.ttf",
arabicSansBold: "NotoSansArabic-Bold.ttf",
latinRegular: "DejaVuSans.ttf",
latinBold: "DejaVuSans-Bold.ttf",
} as const;
type FontFileKey = keyof typeof FONT_FILES;
// Map every supported user-facing font family to a concrete pair of
// bundled fonts (one for Arabic glyphs, one for Latin glyphs). Naskh
// families (Noto Naskh Arabic, Amiri, system default) use the Naskh
// face; sans families (Cairo, Tajawal) use Noto Sans Arabic — visually
// the closest sans-serif Arabic typeface we can ship without bundling
// every requested vendor font. Latin glyphs always render in DejaVu
// Sans so that mixed-script cells share consistent metrics.
type FamilyMapping = {
arabicRegular: FontFileKey;
arabicBold: FontFileKey;
latinRegular: FontFileKey;
latinBold: FontFileKey;
};
const FAMILY_MAP: Record<string, FamilyMapping> = {
system: {
arabicRegular: "naskhRegular",
arabicBold: "naskhBold",
latinRegular: "latinRegular",
latinBold: "latinBold",
},
Cairo: {
arabicRegular: "arabicSansRegular",
arabicBold: "arabicSansBold",
latinRegular: "latinRegular",
latinBold: "latinBold",
},
Tajawal: {
arabicRegular: "arabicSansRegular",
arabicBold: "arabicSansBold",
latinRegular: "latinRegular",
latinBold: "latinBold",
},
"Noto Naskh Arabic": {
arabicRegular: "naskhRegular",
arabicBold: "naskhBold",
latinRegular: "latinRegular",
latinBold: "latinBold",
},
Amiri: {
arabicRegular: "naskhRegular",
arabicBold: "naskhBold",
latinRegular: "latinRegular",
latinBold: "latinBold",
},
};
function familyMappingFor(family: string): FamilyMapping {
return FAMILY_MAP[family] ?? FAMILY_MAP.system;
}
const ARABIC_FONT_NAMES: ReadonlyArray<string> = [
"Cairo",
"Tajawal",
"Noto Naskh Arabic",
"Amiri",
];
// Bytes are loaded lazily on first render and reused.
let cachedFontBytes: Partial<Record<FontFileKey, Buffer>> = {};
function loadFontBytes(keys: ReadonlyArray<FontFileKey>): Record<FontFileKey, Buffer> {
const dir = fontsDir();
const out = {} as Record<FontFileKey, Buffer>;
for (const k of keys) {
if (!cachedFontBytes[k]) {
cachedFontBytes[k] = readFileSync(path.join(dir, FONT_FILES[k]));
}
out[k] = cachedFontBytes[k] as Buffer;
}
return out;
}
// Fast Arabic-script test (Arabic, Arabic Supplement, Arabic Extended-A,
// Arabic Presentation Forms-A/B). We use it to decide which bundled font
// covers a given character so mixed Arabic/Latin cells can be rendered
// without missing-glyph boxes.
function isArabicChar(ch: string): boolean {
const c = ch.codePointAt(0) ?? 0;
return (
(c >= 0x0600 && c <= 0x06ff) ||
(c >= 0x0750 && c <= 0x077f) ||
(c >= 0x08a0 && c <= 0x08ff) ||
(c >= 0xfb50 && c <= 0xfdff) ||
(c >= 0xfe70 && c <= 0xfeff)
);
}
function isLatinChar(ch: string): boolean {
const c = ch.codePointAt(0) ?? 0;
// Latin, digits, common punctuation, Latin-1 supplement.
return (
(c >= 0x0020 && c <= 0x024f) ||
(c >= 0x2000 && c <= 0x206f) ||
(c >= 0x2070 && c <= 0x209f)
);
}
type Run = { text: string; isArabic: boolean };
// Split a string into runs of consecutive same-script characters so we
// can pick the Arabic font for Arabic glyphs and the Latin font for the
// rest. Whitespace sticks to the surrounding run to avoid a stream of
// 1-char runs.
function splitRunsByScript(text: string): Run[] {
const runs: Run[] = [];
let current: Run | null = null;
for (const ch of text) {
const arabic = isArabicChar(ch);
const latin = isLatinChar(ch);
if (!arabic && !latin) {
// Glyph that neither font may cover — bias toward whatever the
// current run is so we don't fragment unnecessarily.
if (current) {
current.text += ch;
continue;
}
}
const want = arabic;
if (!current || current.isArabic !== want) {
current = { text: ch, isArabic: want };
runs.push(current);
} else {
current.text += ch;
}
}
return runs;
}
// Apply Unicode Bidi to a string and return runs in *visual* order (which
// is the order PDFKit will paint them along the line). Without this, mixed
// Arabic/Latin/digit cells look wrong (e.g. "10:00 - 11:00" inside an
// otherwise-RTL line gets digit-reversed by the renderer).
function visualOrderRuns(text: string, baseDirection: "ltr" | "rtl"): Run[] {
if (!text) return [];
const embeddingLevels = bidi.getEmbeddingLevels(text, baseDirection);
const reordered = bidi.getReorderedString(text, embeddingLevels);
return splitRunsByScript(reordered);
}
function fontKeyFor(
isArabic: boolean,
weight: "regular" | "bold",
mapping: FamilyMapping,
): FontFileKey {
if (isArabic) {
return weight === "bold" ? mapping.arabicBold : mapping.arabicRegular;
}
return weight === "bold" ? mapping.latinBold : mapping.latinRegular;
}
function alignment(
font: PdfFontPrefs,
isRtl: boolean,
): "left" | "center" | "right" {
if (font.alignment === "center") return "center";
// "start" maps to the writing-direction start.
return isRtl ? "right" : "left";
}
function htmlToPlain(input: string | null | undefined): string {
if (!input) return "";
// Strip every tag so we render plain text — table cells already have
// their own font/weight/size from the user's font settings, and PDFKit
// doesn't render arbitrary inline HTML. We DO keep <br> as a newline
// because attendees often use line breaks.
const withBreaks = input
.replace(/<\s*br\s*\/?>/gi, "\n")
.replace(/<\/(p|div|li)>/gi, "\n")
.replace(/<li>/gi, "• ");
const sanitized = sanitizeHtml(withBreaks, {
allowedTags: [],
allowedAttributes: {},
});
// Decode common entities that sanitize-html leaves intact when no tags
// are kept (it returns the inner text).
return sanitized
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function formatTimeRange(
startTime: string | null,
endTime: string | null,
lang: "ar" | "en",
): string {
const fmt = (t: string): string => {
const hhmm = t.slice(0, 5);
const parsed = new Date(`1970-01-01T${hhmm}:00`);
if (Number.isNaN(parsed.getTime())) return hhmm;
return new Intl.DateTimeFormat(lang === "ar" ? "ar" : "en", {
hour: "numeric",
minute: "2-digit",
hour12: true,
// Force Latin digits even for Arabic locale so the Time column
// renders consistently across the schedule and the PDF — matches
// i18nFormatTime in the frontend.
numberingSystem: "latn",
} as Intl.DateTimeFormatOptions).format(parsed);
};
if (startTime && endTime) return `${fmt(startTime)} ${fmt(endTime)}`;
if (startTime) return fmt(startTime);
return "—";
}
type DrawOpts = {
x: number;
y: number;
width: number;
fontSize: number;
weight: "regular" | "bold";
align: "left" | "center" | "right";
baseDirection: "ltr" | "rtl";
mapping: FamilyMapping;
};
// Draw a single line of text using mixed Arabic/Latin runs (in visual
// order). We measure each run with the right font and lay them out
// left-to-right starting from the resolved alignment. Returns the height
// consumed by this line (including a small line-gap).
function drawMixedLine(
doc: PDFKit.PDFDocument,
text: string,
opts: DrawOpts,
): number {
const runs = visualOrderRuns(text, opts.baseDirection);
if (runs.length === 0) {
return opts.fontSize * 1.2;
}
// Measure full line width.
let totalWidth = 0;
const measured = runs.map((r) => {
const fk = fontKeyFor(r.isArabic, opts.weight, opts.mapping);
doc.font(fk).fontSize(opts.fontSize);
const w = doc.widthOfString(r.text);
totalWidth += w;
return { ...r, width: w, fontKey: fk };
});
const overflow = totalWidth - opts.width;
let cursorX = opts.x;
if (opts.align === "center") {
cursorX = opts.x + Math.max(0, (opts.width - totalWidth) / 2);
} else if (opts.align === "right") {
cursorX = opts.x + Math.max(0, opts.width - totalWidth);
}
// If we overflow horizontally, fall back to wrapping via PDFKit's own
// text engine on the first run. Cells in the schedule rarely overflow
// because column widths are generous, but Arabic titles can be long.
if (overflow > 4) {
return drawWrappingLine(doc, text, opts);
}
for (const r of measured) {
doc.font(r.fontKey).fontSize(opts.fontSize).fillColor("black");
doc.text(r.text, cursorX, opts.y, {
lineBreak: false,
width: r.width + 1,
});
cursorX += r.width;
}
return opts.fontSize * 1.25;
}
function drawWrappingLine(
doc: PDFKit.PDFDocument,
text: string,
opts: DrawOpts,
): number {
// Determine the dominant script for the wrapping pass. Without true
// bidi line-breaking we pick the "majority" script font to wrap with;
// this keeps shaping intact for the dominant direction at the cost of
// a slight metric mismatch on the minority runs.
const arabicChars = [...text].filter(isArabicChar).length;
const dominantArabic = arabicChars * 2 > text.length;
const fk = fontKeyFor(dominantArabic, opts.weight, opts.mapping);
doc.font(fk).fontSize(opts.fontSize).fillColor("black");
// For Arabic text, run bidi on the whole string so the font sees runs
// already in visual order — this matches what drawMixedLine does for
// single-line cells.
const reordered = bidi.getReorderedString(
text,
bidi.getEmbeddingLevels(text, opts.baseDirection),
);
const before = doc.y;
doc.text(reordered, opts.x, opts.y, {
width: opts.width,
align: opts.align,
lineBreak: true,
});
return Math.max(opts.fontSize * 1.25, doc.y - before + 2);
}
export async function renderSchedulePdf(input: RenderPdfInput): Promise<Buffer> {
const isRtl = input.lang === "ar";
const baseDir: "ltr" | "rtl" = isRtl ? "rtl" : "ltr";
const doc = new PDFDocument({
size: "A4",
layout: "portrait",
margin: 36, // ~12.7 mm — matches the screen print page's @page margin closely
info: {
Title: `${input.labels.title}${input.date}`,
Producer: "Tx OS Executive Meetings",
},
});
const mapping = familyMappingFor(input.font.fontFamily);
// Load only the four files this family needs (deduped by key).
const neededKeys = Array.from(
new Set<FontFileKey>([
mapping.arabicRegular,
mapping.arabicBold,
mapping.latinRegular,
mapping.latinBold,
]),
);
const bytes = loadFontBytes(neededKeys);
for (const k of neededKeys) {
doc.registerFont(k, bytes[k]);
}
const chunks: Buffer[] = [];
doc.on("data", (c: Buffer) => chunks.push(c));
const done = new Promise<void>((resolve, reject) => {
doc.on("end", resolve);
doc.on("error", reject);
});
// Header — show the schedule title and the date on its own line.
const titleSize = Math.min(28, input.font.fontSize + 10);
drawMixedLine(doc, input.labels.title, {
x: doc.page.margins.left,
y: doc.y,
width: doc.page.width - doc.page.margins.left - doc.page.margins.right,
fontSize: titleSize,
weight: "bold",
align: "center",
baseDirection: baseDir,
mapping,
});
doc.moveDown(0.4);
drawMixedLine(doc, input.date, {
x: doc.page.margins.left,
y: doc.y,
width: doc.page.width - doc.page.margins.left - doc.page.margins.right,
fontSize: Math.max(11, Math.round(input.font.fontSize * 0.85)),
weight: "regular",
align: "center",
baseDirection: baseDir,
mapping,
});
doc.moveDown(0.8);
if (input.meetings.length === 0) {
drawMixedLine(doc, input.labels.none, {
x: doc.page.margins.left,
y: doc.y,
width: doc.page.width - doc.page.margins.left - doc.page.margins.right,
fontSize: input.font.fontSize,
weight: input.font.fontWeight,
align: "center",
baseDirection: baseDir,
mapping,
});
doc.end();
await done;
return Buffer.concat(chunks);
}
// ---- Table layout ----
// Mirror the print page's column widths (6 / 30 / 44 / 20).
const tableX = doc.page.margins.left;
const tableWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const colWidths = [
Math.round(tableWidth * 0.06),
Math.round(tableWidth * 0.3),
Math.round(tableWidth * 0.44),
0, // last col absorbs rounding
];
colWidths[3] = tableWidth - colWidths[0] - colWidths[1] - colWidths[2];
const headerLabels = isRtl
? // RTL: visually right-to-left ordering matches the schedule's
// Arabic layout (#, Meeting, Attendees, Time becomes Time first).
[input.labels.time, input.labels.attendees, input.labels.meeting, input.labels.no]
: [input.labels.no, input.labels.meeting, input.labels.attendees, input.labels.time];
const headerWidths = isRtl ? [...colWidths].reverse() : colWidths;
const cellPadX = 6;
const cellPadY = 5;
const lineHeight = input.font.fontSize * 1.25;
function drawHeader(): number {
const headerHeight = lineHeight + cellPadY * 2;
let cx = tableX;
const headerY = doc.y;
doc.save();
doc.rect(tableX, headerY, tableWidth, headerHeight).fill("#0B1E3F");
doc.restore();
for (let i = 0; i < headerLabels.length; i++) {
const label = headerLabels[i];
const arabicChars = [...label].filter(isArabicChar).length;
const dominantArabic = arabicChars * 2 > label.length;
const fk = fontKeyFor(dominantArabic, "bold", mapping);
doc.font(fk).fontSize(input.font.fontSize).fillColor("white");
const reordered = bidi.getReorderedString(
label,
bidi.getEmbeddingLevels(label, baseDir),
);
doc.text(reordered, cx + cellPadX, headerY + cellPadY, {
width: headerWidths[i] - cellPadX * 2,
align: "center",
lineBreak: false,
});
cx += headerWidths[i];
}
doc.fillColor("black");
doc.y = headerY + headerHeight;
return headerHeight;
}
// Render the table header on page 1.
const startY = doc.y;
const headerHeight = drawHeader();
doc.y = startY + headerHeight;
// ---- Rows ----
for (const meeting of input.meetings) {
const cellsLogical: { text: string; align: "left" | "center" | "right" }[] = [
{ text: String(meeting.dailyNumber), align: "center" },
{
text: htmlToPlain(isRtl ? meeting.titleAr : meeting.titleEn || meeting.titleAr) +
(meeting.location ? `\n${meeting.location}` : ""),
align: alignment(input.font, isRtl),
},
{
text:
meeting.attendees.length === 0
? "—"
: meeting.attendees
.map((a, idx) => {
const name = htmlToPlain(a.name);
const t = a.title?.trim();
return `${idx + 1}- ${name}${t ? ` (${t})` : ""}`;
})
.join("\n"),
align: alignment(input.font, isRtl),
},
{
text: formatTimeRange(meeting.startTime, meeting.endTime, input.lang),
align: "center",
},
];
const cells = isRtl ? [...cellsLogical].reverse() : cellsLogical;
const widths = isRtl ? [...colWidths].reverse() : colWidths;
// Measure the row height by laying out each cell to a temporary
// y-cursor and taking the max. We then redraw at the final position.
const probe = doc;
const probeY = probe.y;
let rowHeight = lineHeight + cellPadY * 2;
const cellHeights: number[] = [];
let cx = tableX;
for (let i = 0; i < cells.length; i++) {
const c = cells[i];
const startProbeY = probeY + cellPadY;
probe.y = startProbeY;
// Reuse drawWrappingLine to compute height — drop the actual draw
// by using a temporary off-screen page would be ideal, but PDFKit
// doesn't expose an undo. We instead measure by counting expected
// wraps at the dominant font.
const lines = c.text.split("\n");
let h = 0;
const arabicChars = [...c.text].filter(isArabicChar).length;
const dominantArabic = arabicChars * 2 > c.text.length;
const fk = fontKeyFor(dominantArabic, input.font.fontWeight, mapping);
doc.font(fk).fontSize(input.font.fontSize);
for (const line of lines) {
if (!line) {
h += lineHeight;
continue;
}
const w = doc.widthOfString(line);
const wraps = Math.max(1, Math.ceil(w / (widths[i] - cellPadX * 2)));
h += wraps * lineHeight;
}
const finalH = h + cellPadY * 2;
cellHeights.push(finalH);
if (finalH > rowHeight) rowHeight = finalH;
cx += widths[i];
}
// Page break check.
if (probeY + rowHeight > doc.page.height - doc.page.margins.bottom) {
doc.addPage();
drawHeader();
}
const rowTop = doc.y;
if (meeting.isHighlighted) {
doc.save();
doc.rect(tableX, rowTop, tableWidth, rowHeight).fill("#fecaca");
doc.restore();
}
// Cell borders.
doc.save();
doc.lineWidth(0.5).strokeColor("#d1d5db");
doc.rect(tableX, rowTop, tableWidth, rowHeight).stroke();
let bx = tableX;
for (let i = 0; i < cells.length - 1; i++) {
bx += widths[i];
doc
.moveTo(bx, rowTop)
.lineTo(bx, rowTop + rowHeight)
.stroke();
}
doc.restore();
// Draw cell contents.
cx = tableX;
for (let i = 0; i < cells.length; i++) {
const c = cells[i];
const cellW = widths[i] - cellPadX * 2;
const lines = c.text.split("\n");
let y = rowTop + cellPadY;
for (const line of lines) {
if (!line) {
y += lineHeight;
continue;
}
const used = drawWrappingLine(doc, line, {
x: cx + cellPadX,
y,
width: cellW,
fontSize: input.font.fontSize,
weight: input.font.fontWeight,
align: c.align,
baseDirection: baseDir,
mapping,
});
y += used;
}
cx += widths[i];
}
doc.y = rowTop + rowHeight;
}
// Footer — generation timestamp and font info.
doc.moveDown(0.6);
const footer = `Generated ${new Date().toISOString()} · font=${input.font.fontFamily} ${input.font.fontSize}px ${input.font.fontWeight}`;
doc
.font(mapping.latinRegular)
.fontSize(8)
.fillColor("#6b7280")
.text(footer, doc.page.margins.left, doc.y, {
width: doc.page.width - doc.page.margins.left - doc.page.margins.right,
align: "left",
});
doc.end();
await done;
return Buffer.concat(chunks);
}
// Keep the explicit "is this an arabic-leaning family?" check exposed for
// tests / future logic that wants to know the user's font choice.
export function fontFamilyIsArabic(name: string): boolean {
return ARABIC_FONT_NAMES.includes(name);
}
+710
View File
@@ -0,0 +1,710 @@
import { fileURLToPath } from "node:url";
import path from "node:path";
import { readFileSync, existsSync } from "node:fs";
import bidiFactory from "bidi-js";
import PDFDocument from "pdfkit";
import sanitizeHtml from "sanitize-html";
const bidi = bidiFactory();
export type PdfFontPrefs = {
fontFamily: string;
fontSize: number;
fontWeight: "regular" | "bold";
alignment: "start" | "center";
};
export type PdfMeetingAttendee = {
name: string;
title: string | null;
attendanceType?: string | null;
// "person" (default) or "subheading". The renderer skips subheadings
// for the running attendee number and prints them as "— label —".
// Typed as a plain string (rather than the literal union) because the
// DB column is just a varchar; the renderer normalises unknown values
// back to "person" at the comparison site.
kind?: string | null;
};
export type PdfMeeting = {
dailyNumber: number;
titleAr: string;
titleEn: string | null;
startTime: string | null;
endTime: string | null;
location: string | null;
isHighlighted: number;
attendees: PdfMeetingAttendee[];
};
export type RenderPdfInput = {
date: string;
lang: "ar" | "en";
font: PdfFontPrefs;
meetings: PdfMeeting[];
labels: {
title: string;
no: string;
meeting: string;
attendees: string;
time: string;
none: string;
};
};
// Resolve fonts relative to the bundled dist file (esbuild rewrites
// __dirname via the build banner so this works in both dev and prod).
function fontsDir(): string {
const here =
typeof __dirname === "string"
? __dirname
: path.dirname(fileURLToPath(import.meta.url));
// The fonts are shipped as raw assets next to dist/ — see
// artifacts/api-server/assets/fonts. From dist/ we go up one level.
const candidates = [
path.resolve(here, "..", "assets", "fonts"),
path.resolve(here, "assets", "fonts"),
path.resolve(here, "..", "..", "assets", "fonts"),
];
for (const c of candidates) {
if (existsSync(path.join(c, "NotoNaskhArabic-Regular.ttf"))) return c;
}
throw new Error(
`Could not locate bundled PDF fonts. Tried: ${candidates.join(", ")}`,
);
}
// All font files bundled in artifacts/api-server/assets/fonts. We only
// load the ones each render needs, but list every file here so the
// build step (and font-family mapping below) stays declarative.
const FONT_FILES = {
naskhRegular: "NotoNaskhArabic-Regular.ttf",
naskhBold: "NotoNaskhArabic-Bold.ttf",
arabicSansRegular: "NotoSansArabic-Regular.ttf",
arabicSansBold: "NotoSansArabic-Bold.ttf",
latinRegular: "DejaVuSans.ttf",
latinBold: "DejaVuSans-Bold.ttf",
} as const;
type FontFileKey = keyof typeof FONT_FILES;
// Map every supported user-facing font family to a concrete pair of
// bundled fonts (one for Arabic glyphs, one for Latin glyphs). Naskh
// families (Noto Naskh Arabic, Amiri, system default) use the Naskh
// face; sans families (Cairo, Tajawal) use Noto Sans Arabic — visually
// the closest sans-serif Arabic typeface we can ship without bundling
// every requested vendor font. Latin glyphs always render in DejaVu
// Sans so that mixed-script cells share consistent metrics.
type FamilyMapping = {
arabicRegular: FontFileKey;
arabicBold: FontFileKey;
latinRegular: FontFileKey;
latinBold: FontFileKey;
};
// Web fonts (DIN Next LT Arabic, Tajawal, Helvetica Neue LT Arabic,
// Helvetica Neue, Majalla) are not bundled into the PDF renderer
// because we do not ship their font files server-side. Instead we map
// each web family to the closest visual stand-in already bundled here:
// sans-serif Arabic families render with NotoSansArabic; serif/Naskh
// stand-ins use NotoNaskhArabic; pure-Latin families (Helvetica Neue)
// fall back to the Naskh pair so Arabic glyphs in mixed strings still
// render correctly. This keeps PDFs readable without ballooning the
// server bundle.
const FAMILY_MAP: Record<string, FamilyMapping> = {
system: {
arabicRegular: "naskhRegular",
arabicBold: "naskhBold",
latinRegular: "latinRegular",
latinBold: "latinBold",
},
"DIN Next LT Arabic": {
arabicRegular: "arabicSansRegular",
arabicBold: "arabicSansBold",
latinRegular: "latinRegular",
latinBold: "latinBold",
},
Tajawal: {
arabicRegular: "arabicSansRegular",
arabicBold: "arabicSansBold",
latinRegular: "latinRegular",
latinBold: "latinBold",
},
"Helvetica Neue LT Arabic": {
arabicRegular: "arabicSansRegular",
arabicBold: "arabicSansBold",
latinRegular: "latinRegular",
latinBold: "latinBold",
},
"Helvetica Neue": {
arabicRegular: "naskhRegular",
arabicBold: "naskhBold",
latinRegular: "latinRegular",
latinBold: "latinBold",
},
Majalla: {
arabicRegular: "naskhRegular",
arabicBold: "naskhBold",
latinRegular: "latinRegular",
latinBold: "latinBold",
},
};
function familyMappingFor(family: string): FamilyMapping {
return FAMILY_MAP[family] ?? FAMILY_MAP.system;
}
// Family names that primarily target Arabic script. Used by
// `fontFamilyIsArabic` for downstream callers that branch on
// script — kept aligned with FAMILY_MAP. "Helvetica Neue" (the Latin
// face) is intentionally excluded; the others all ship Arabic glyphs.
const ARABIC_FONT_NAMES: ReadonlyArray<string> = [
"DIN Next LT Arabic",
"Tajawal",
"Helvetica Neue LT Arabic",
"Majalla",
];
// Bytes are loaded lazily on first render and reused.
let cachedFontBytes: Partial<Record<FontFileKey, Buffer>> = {};
function loadFontBytes(keys: ReadonlyArray<FontFileKey>): Record<FontFileKey, Buffer> {
const dir = fontsDir();
const out = {} as Record<FontFileKey, Buffer>;
for (const k of keys) {
if (!cachedFontBytes[k]) {
cachedFontBytes[k] = readFileSync(path.join(dir, FONT_FILES[k]));
}
out[k] = cachedFontBytes[k] as Buffer;
}
return out;
}
// Fast Arabic-script test (Arabic, Arabic Supplement, Arabic Extended-A,
// Arabic Presentation Forms-A/B). We use it to decide which bundled font
// covers a given character so mixed Arabic/Latin cells can be rendered
// without missing-glyph boxes.
function isArabicChar(ch: string): boolean {
const c = ch.codePointAt(0) ?? 0;
return (
(c >= 0x0600 && c <= 0x06ff) ||
(c >= 0x0750 && c <= 0x077f) ||
(c >= 0x08a0 && c <= 0x08ff) ||
(c >= 0xfb50 && c <= 0xfdff) ||
(c >= 0xfe70 && c <= 0xfeff)
);
}
function isLatinChar(ch: string): boolean {
const c = ch.codePointAt(0) ?? 0;
// Latin, digits, common punctuation, Latin-1 supplement.
return (
(c >= 0x0020 && c <= 0x024f) ||
(c >= 0x2000 && c <= 0x206f) ||
(c >= 0x2070 && c <= 0x209f)
);
}
type Run = { text: string; isArabic: boolean };
// Split a string into runs of consecutive same-script characters so we
// can pick the Arabic font for Arabic glyphs and the Latin font for the
// rest. Whitespace sticks to the surrounding run to avoid a stream of
// 1-char runs.
function splitRunsByScript(text: string): Run[] {
const runs: Run[] = [];
let current: Run | null = null;
for (const ch of text) {
const arabic = isArabicChar(ch);
const latin = isLatinChar(ch);
if (!arabic && !latin) {
// Glyph that neither font may cover — bias toward whatever the
// current run is so we don't fragment unnecessarily.
if (current) {
current.text += ch;
continue;
}
}
const want = arabic;
if (!current || current.isArabic !== want) {
current = { text: ch, isArabic: want };
runs.push(current);
} else {
current.text += ch;
}
}
return runs;
}
// Apply Unicode Bidi to a string and return runs in *visual* order (which
// is the order PDFKit will paint them along the line). Without this, mixed
// Arabic/Latin/digit cells look wrong (e.g. "10:00 - 11:00" inside an
// otherwise-RTL line gets digit-reversed by the renderer).
function visualOrderRuns(text: string, baseDirection: "ltr" | "rtl"): Run[] {
if (!text) return [];
const embeddingLevels = bidi.getEmbeddingLevels(text, baseDirection);
const reordered = bidi.getReorderedString(text, embeddingLevels);
return splitRunsByScript(reordered);
}
function fontKeyFor(
isArabic: boolean,
weight: "regular" | "bold",
mapping: FamilyMapping,
): FontFileKey {
if (isArabic) {
return weight === "bold" ? mapping.arabicBold : mapping.arabicRegular;
}
return weight === "bold" ? mapping.latinBold : mapping.latinRegular;
}
function alignment(
font: PdfFontPrefs,
isRtl: boolean,
): "left" | "center" | "right" {
if (font.alignment === "center") return "center";
// "start" maps to the writing-direction start.
return isRtl ? "right" : "left";
}
function htmlToPlain(input: string | null | undefined): string {
if (!input) return "";
// Strip every tag so we render plain text — table cells already have
// their own font/weight/size from the user's font settings, and PDFKit
// doesn't render arbitrary inline HTML. We DO keep <br> as a newline
// because attendees often use line breaks.
const withBreaks = input
.replace(/<\s*br\s*\/?>/gi, "\n")
.replace(/<\/(p|div|li)>/gi, "\n")
.replace(/<li>/gi, "• ");
const sanitized = sanitizeHtml(withBreaks, {
allowedTags: [],
allowedAttributes: {},
});
// Decode common entities that sanitize-html leaves intact when no tags
// are kept (it returns the inner text).
return sanitized
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function formatTimeRange(
startTime: string | null,
endTime: string | null,
lang: "ar" | "en",
): string {
const fmt = (t: string): string => {
const hhmm = t.slice(0, 5);
const parsed = new Date(`1970-01-01T${hhmm}:00`);
if (Number.isNaN(parsed.getTime())) return hhmm;
// 12-hour with NO AM/PM (en) or ص/م (ar) suffix — matches the
// on-screen schedule. We `formatToParts` and drop the `dayPeriod`,
// then trim trailing/leading whitespace literals (Intl may use
// U+00A0 / U+202F — String.prototype.trim handles them all).
// Force Latin digits via -u-nu-latn so the PDF Time column renders
// consistently regardless of language.
const locale = lang === "ar" ? "ar-u-nu-latn" : "en-US-u-nu-latn";
const parts = new Intl.DateTimeFormat(locale, {
hour: "numeric",
minute: "2-digit",
hour12: true,
numberingSystem: "latn",
} as Intl.DateTimeFormatOptions).formatToParts(parsed);
return parts
.filter((p) => p.type !== "dayPeriod")
.map((p) => p.value)
.join("")
.trim();
};
if (startTime && endTime) return `${fmt(startTime)} ${fmt(endTime)}`;
if (startTime) return fmt(startTime);
return "—";
}
type DrawOpts = {
x: number;
y: number;
width: number;
fontSize: number;
weight: "regular" | "bold";
align: "left" | "center" | "right";
baseDirection: "ltr" | "rtl";
mapping: FamilyMapping;
};
// Draw a single line of text using mixed Arabic/Latin runs (in visual
// order). We measure each run with the right font and lay them out
// left-to-right starting from the resolved alignment. Returns the height
// consumed by this line (including a small line-gap).
function drawMixedLine(
doc: PDFKit.PDFDocument,
text: string,
opts: DrawOpts,
): number {
const runs = visualOrderRuns(text, opts.baseDirection);
if (runs.length === 0) {
return opts.fontSize * 1.2;
}
// Measure full line width.
let totalWidth = 0;
const measured = runs.map((r) => {
const fk = fontKeyFor(r.isArabic, opts.weight, opts.mapping);
doc.font(fk).fontSize(opts.fontSize);
const w = doc.widthOfString(r.text);
totalWidth += w;
return { ...r, width: w, fontKey: fk };
});
const overflow = totalWidth - opts.width;
let cursorX = opts.x;
if (opts.align === "center") {
cursorX = opts.x + Math.max(0, (opts.width - totalWidth) / 2);
} else if (opts.align === "right") {
cursorX = opts.x + Math.max(0, opts.width - totalWidth);
}
// If we overflow horizontally, fall back to wrapping via PDFKit's own
// text engine on the first run. Cells in the schedule rarely overflow
// because column widths are generous, but Arabic titles can be long.
if (overflow > 4) {
return drawWrappingLine(doc, text, opts);
}
for (const r of measured) {
doc.font(r.fontKey).fontSize(opts.fontSize).fillColor("black");
doc.text(r.text, cursorX, opts.y, {
lineBreak: false,
width: r.width + 1,
});
cursorX += r.width;
}
return opts.fontSize * 1.25;
}
function drawWrappingLine(
doc: PDFKit.PDFDocument,
text: string,
opts: DrawOpts,
): number {
// Determine the dominant script for the wrapping pass. Without true
// bidi line-breaking we pick the "majority" script font to wrap with;
// this keeps shaping intact for the dominant direction at the cost of
// a slight metric mismatch on the minority runs.
const arabicChars = [...text].filter(isArabicChar).length;
const dominantArabic = arabicChars * 2 > text.length;
const fk = fontKeyFor(dominantArabic, opts.weight, opts.mapping);
doc.font(fk).fontSize(opts.fontSize).fillColor("black");
// For Arabic text, run bidi on the whole string so the font sees runs
// already in visual order — this matches what drawMixedLine does for
// single-line cells.
const reordered = bidi.getReorderedString(
text,
bidi.getEmbeddingLevels(text, opts.baseDirection),
);
const before = doc.y;
doc.text(reordered, opts.x, opts.y, {
width: opts.width,
align: opts.align,
lineBreak: true,
});
return Math.max(opts.fontSize * 1.25, doc.y - before + 2);
}
export async function renderSchedulePdf(input: RenderPdfInput): Promise<Buffer> {
const isRtl = input.lang === "ar";
const baseDir: "ltr" | "rtl" = isRtl ? "rtl" : "ltr";
const doc = new PDFDocument({
size: "A4",
layout: "portrait",
margin: 36, // ~12.7 mm — matches the screen print page's @page margin closely
info: {
Title: `${input.labels.title} — ${input.date}`,
Producer: "Tx OS Executive Meetings",
},
});
const mapping = familyMappingFor(input.font.fontFamily);
// Load only the four files this family needs (deduped by key).
const neededKeys = Array.from(
new Set<FontFileKey>([
mapping.arabicRegular,
mapping.arabicBold,
mapping.latinRegular,
mapping.latinBold,
]),
);
const bytes = loadFontBytes(neededKeys);
for (const k of neededKeys) {
doc.registerFont(k, bytes[k]);
}
const chunks: Buffer[] = [];
doc.on("data", (c: Buffer) => chunks.push(c));
const done = new Promise<void>((resolve, reject) => {
doc.on("end", resolve);
doc.on("error", reject);
});
// Header — show the schedule title and the date on its own line.
const titleSize = Math.min(28, input.font.fontSize + 10);
drawMixedLine(doc, input.labels.title, {
x: doc.page.margins.left,
y: doc.y,
width: doc.page.width - doc.page.margins.left - doc.page.margins.right,
fontSize: titleSize,
weight: "bold",
align: "center",
baseDirection: baseDir,
mapping,
});
doc.moveDown(0.4);
drawMixedLine(doc, input.date, {
x: doc.page.margins.left,
y: doc.y,
width: doc.page.width - doc.page.margins.left - doc.page.margins.right,
fontSize: Math.max(11, Math.round(input.font.fontSize * 0.85)),
weight: "regular",
align: "center",
baseDirection: baseDir,
mapping,
});
doc.moveDown(0.8);
if (input.meetings.length === 0) {
drawMixedLine(doc, input.labels.none, {
x: doc.page.margins.left,
y: doc.y,
width: doc.page.width - doc.page.margins.left - doc.page.margins.right,
fontSize: input.font.fontSize,
weight: input.font.fontWeight,
align: "center",
baseDirection: baseDir,
mapping,
});
doc.end();
await done;
return Buffer.concat(chunks);
}
// ---- Table layout ----
// Mirror the print page's column widths (6 / 30 / 44 / 20).
const tableX = doc.page.margins.left;
const tableWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const colWidths = [
Math.round(tableWidth * 0.06),
Math.round(tableWidth * 0.3),
Math.round(tableWidth * 0.44),
0, // last col absorbs rounding
];
colWidths[3] = tableWidth - colWidths[0] - colWidths[1] - colWidths[2];
const headerLabels = isRtl
? // RTL: visually right-to-left ordering matches the schedule's
// Arabic layout (#, Meeting, Attendees, Time becomes Time first).
[input.labels.time, input.labels.attendees, input.labels.meeting, input.labels.no]
: [input.labels.no, input.labels.meeting, input.labels.attendees, input.labels.time];
const headerWidths = isRtl ? [...colWidths].reverse() : colWidths;
const cellPadX = 6;
const cellPadY = 5;
const lineHeight = input.font.fontSize * 1.25;
function drawHeader(): number {
const headerHeight = lineHeight + cellPadY * 2;
let cx = tableX;
const headerY = doc.y;
doc.save();
doc.rect(tableX, headerY, tableWidth, headerHeight).fill("#0B1E3F");
doc.restore();
for (let i = 0; i < headerLabels.length; i++) {
const label = headerLabels[i];
const arabicChars = [...label].filter(isArabicChar).length;
const dominantArabic = arabicChars * 2 > label.length;
const fk = fontKeyFor(dominantArabic, "bold", mapping);
doc.font(fk).fontSize(input.font.fontSize).fillColor("white");
const reordered = bidi.getReorderedString(
label,
bidi.getEmbeddingLevels(label, baseDir),
);
doc.text(reordered, cx + cellPadX, headerY + cellPadY, {
width: headerWidths[i] - cellPadX * 2,
align: "center",
lineBreak: false,
});
cx += headerWidths[i];
}
doc.fillColor("black");
doc.y = headerY + headerHeight;
return headerHeight;
}
// Render the table header on page 1.
const startY = doc.y;
const headerHeight = drawHeader();
doc.y = startY + headerHeight;
// ---- Rows ----
for (const meeting of input.meetings) {
const cellsLogical: { text: string; align: "left" | "center" | "right" }[] = [
{ text: String(meeting.dailyNumber), align: "center" },
{
text: htmlToPlain(isRtl ? meeting.titleAr : meeting.titleEn || meeting.titleAr) +
(meeting.location ? `\n${meeting.location}` : ""),
align: alignment(input.font, isRtl),
},
{
text:
meeting.attendees.length === 0
? "—"
: (() => {
// Walking person counter — each subheading starts a
// fresh numbered section, so persons restart at 1- after
// every subheading. Same logic as the on-screen
// AttendeeFlow and the client print page.
let personIdx = 0;
return meeting.attendees
.map((a) => {
const isSub = (a.kind ?? "person") === "subheading";
const name = htmlToPlain(a.name);
if (isSub) {
// Reset the in-section counter so the next person
// starts at 1.
personIdx = 0;
// Brackets keep subheadings visually distinct in the
// monospace plain-text PDF cell where bold/colour
// can't carry. Adapter prefix is non-numeric so a
// human reader can scan it as "section label".
return `— ${name} —`;
}
personIdx += 1;
const t = a.title?.trim();
return `${personIdx}- ${name}${t ? ` (${t})` : ""}`;
})
.join("\n");
})(),
align: alignment(input.font, isRtl),
},
{
text: formatTimeRange(meeting.startTime, meeting.endTime, input.lang),
align: "center",
},
];
const cells = isRtl ? [...cellsLogical].reverse() : cellsLogical;
const widths = isRtl ? [...colWidths].reverse() : colWidths;
// Measure the row height by laying out each cell to a temporary
// y-cursor and taking the max. We then redraw at the final position.
const probe = doc;
const probeY = probe.y;
let rowHeight = lineHeight + cellPadY * 2;
const cellHeights: number[] = [];
let cx = tableX;
for (let i = 0; i < cells.length; i++) {
const c = cells[i];
const startProbeY = probeY + cellPadY;
probe.y = startProbeY;
// Reuse drawWrappingLine to compute height — drop the actual draw
// by using a temporary off-screen page would be ideal, but PDFKit
// doesn't expose an undo. We instead measure by counting expected
// wraps at the dominant font.
const lines = c.text.split("\n");
let h = 0;
const arabicChars = [...c.text].filter(isArabicChar).length;
const dominantArabic = arabicChars * 2 > c.text.length;
const fk = fontKeyFor(dominantArabic, input.font.fontWeight, mapping);
doc.font(fk).fontSize(input.font.fontSize);
for (const line of lines) {
if (!line) {
h += lineHeight;
continue;
}
const w = doc.widthOfString(line);
const wraps = Math.max(1, Math.ceil(w / (widths[i] - cellPadX * 2)));
h += wraps * lineHeight;
}
const finalH = h + cellPadY * 2;
cellHeights.push(finalH);
if (finalH > rowHeight) rowHeight = finalH;
cx += widths[i];
}
// Page break check.
if (probeY + rowHeight > doc.page.height - doc.page.margins.bottom) {
doc.addPage();
drawHeader();
}
const rowTop = doc.y;
if (meeting.isHighlighted) {
doc.save();
doc.rect(tableX, rowTop, tableWidth, rowHeight).fill("#fecaca");
doc.restore();
}
// Cell borders.
doc.save();
doc.lineWidth(0.5).strokeColor("#d1d5db");
doc.rect(tableX, rowTop, tableWidth, rowHeight).stroke();
let bx = tableX;
for (let i = 0; i < cells.length - 1; i++) {
bx += widths[i];
doc
.moveTo(bx, rowTop)
.lineTo(bx, rowTop + rowHeight)
.stroke();
}
doc.restore();
// Draw cell contents.
cx = tableX;
for (let i = 0; i < cells.length; i++) {
const c = cells[i];
const cellW = widths[i] - cellPadX * 2;
const lines = c.text.split("\n");
let y = rowTop + cellPadY;
for (const line of lines) {
if (!line) {
y += lineHeight;
continue;
}
const used = drawWrappingLine(doc, line, {
x: cx + cellPadX,
y,
width: cellW,
fontSize: input.font.fontSize,
weight: input.font.fontWeight,
align: c.align,
baseDirection: baseDir,
mapping,
});
y += used;
}
cx += widths[i];
}
doc.y = rowTop + rowHeight;
}
// Footer — generation timestamp and font info.
doc.moveDown(0.6);
const footer = `Generated ${new Date().toISOString()} · font=${input.font.fontFamily} ${input.font.fontSize}px ${input.font.fontWeight}`;
doc
.font(mapping.latinRegular)
.fontSize(8)
.fillColor("#6b7280")
.text(footer, doc.page.margins.left, doc.y, {
width: doc.page.width - doc.page.margins.left - doc.page.margins.right,
align: "left",
});
doc.end();
await done;
return Buffer.concat(chunks);
}
// Keep the explicit "is this an arabic-leaning family?" check exposed for
// tests / future logic that wants to know the user's font choice.
export function fontFamilyIsArabic(name: string): boolean {
return ARABIC_FONT_NAMES.includes(name);
}
+215
View File
@@ -0,0 +1,215 @@
import {
pgTable,
serial,
integer,
varchar,
text,
timestamp,
date,
time,
jsonb,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { usersTable } from "./users";
export const executiveMeetingsTable = pgTable(
"executive_meetings",
{
id: serial("id").primaryKey(),
dailyNumber: integer("daily_number").notNull(),
// titleAr/En hold sanitized rich-text HTML (Tiptap output) so they need
// unbounded length. Plain-text rows from before the widening still
// render correctly because they contain no HTML tags.
titleAr: text("title_ar").notNull(),
titleEn: text("title_en").notNull().default(""),
meetingDate: date("meeting_date").notNull(),
startTime: time("start_time", { withTimezone: false }),
endTime: time("end_time", { withTimezone: false }),
location: varchar("location", { length: 300 }),
meetingUrl: text("meeting_url"),
platform: varchar("platform", { length: 32 }).notNull().default("none"),
status: varchar("status", { length: 32 }).notNull().default("scheduled"),
isHighlighted: integer("is_highlighted").notNull().default(0),
notes: text("notes"),
attachments: jsonb("attachments").default([]),
// Optional cell-merge overlay for the schedule grid. When set, the
// schedule renders one merged cell spanning columns
// [mergeStartColumn..mergeEndColumn] and shows mergeText (sanitized
// HTML) instead of the original column values. The original
// titleAr/En + attendees + start/endTime stay untouched in the DB,
// so clearing the merge restores the original cells exactly.
// Allowed values for the column ids match the frontend ColumnId
// enum: "number" | "meeting" | "attendees" | "time".
mergeStartColumn: varchar("merge_start_column", { length: 32 }),
mergeEndColumn: varchar("merge_end_column", { length: 32 }),
mergeText: text("merge_text"),
createdBy: integer("created_by").references(() => usersTable.id, {
onDelete: "set null",
}),
updatedBy: integer("updated_by").references(() => usersTable.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(t) => ({
dateIdx: index("executive_meetings_date_idx").on(t.meetingDate),
dateNumberUnique: uniqueIndex("executive_meetings_date_number_unique").on(
t.meetingDate,
t.dailyNumber,
),
}),
);
export const executiveMeetingAttendeesTable = pgTable(
"executive_meeting_attendees",
{
id: serial("id").primaryKey(),
meetingId: integer("meeting_id")
.notNull()
.references(() => executiveMeetingsTable.id, { onDelete: "cascade" }),
// attendee name holds sanitized rich-text HTML so it needs unbounded
// length. Plain-text rows still render correctly (no tags = no parse).
name: text("name").notNull(),
title: varchar("title", { length: 200 }),
attendanceType: varchar("attendance_type", { length: 32 })
.notNull()
.default("internal"),
sortOrder: integer("sort_order").notNull().default(0),
},
(t) => ({
meetingIdx: index("executive_meeting_attendees_meeting_idx").on(t.meetingId),
}),
);
export const executiveMeetingRequestsTable = pgTable(
"executive_meeting_requests",
{
id: serial("id").primaryKey(),
meetingId: integer("meeting_id").references(
() => executiveMeetingsTable.id,
{ onDelete: "cascade" },
),
requestedBy: integer("requested_by").references(() => usersTable.id, {
onDelete: "set null",
}),
requestType: varchar("request_type", { length: 64 }).notNull(),
requestDetails: jsonb("request_details"),
status: varchar("status", { length: 32 }).notNull().default("new"),
reviewedBy: integer("reviewed_by").references(() => usersTable.id, {
onDelete: "set null",
}),
reviewDecision: varchar("review_decision", { length: 32 }),
reviewNotes: text("review_notes"),
assignedTo: integer("assigned_to").references(() => usersTable.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
);
export const executiveMeetingTasksTable = pgTable("executive_meeting_tasks", {
id: serial("id").primaryKey(),
requestId: integer("request_id").references(() => executiveMeetingRequestsTable.id, {
onDelete: "cascade",
}),
meetingId: integer("meeting_id").references(() => executiveMeetingsTable.id, {
onDelete: "cascade",
}),
assignedTo: integer("assigned_to").references(() => usersTable.id, {
onDelete: "set null",
}),
taskType: varchar("task_type", { length: 64 }).notNull(),
status: varchar("status", { length: 32 }).notNull().default("pending"),
dueAt: timestamp("due_at", { withTimezone: true }),
completedAt: timestamp("completed_at", { withTimezone: true }),
notes: text("notes"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
});
export const executiveMeetingNotificationsTable = pgTable(
"executive_meeting_notifications",
{
id: serial("id").primaryKey(),
meetingId: integer("meeting_id").references(() => executiveMeetingsTable.id, {
onDelete: "cascade",
}),
userId: integer("user_id").references(() => usersTable.id, { onDelete: "cascade" }),
notificationType: varchar("notification_type", { length: 64 }).notNull(),
scheduledAt: timestamp("scheduled_at", { withTimezone: true }),
sentAt: timestamp("sent_at", { withTimezone: true }),
status: varchar("status", { length: 32 }).notNull().default("pending"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
);
export const executiveMeetingAuditLogsTable = pgTable(
"executive_meeting_audit_logs",
{
id: serial("id").primaryKey(),
action: varchar("action", { length: 100 }).notNull(),
entityType: varchar("entity_type", { length: 50 }).notNull(),
entityId: integer("entity_id"),
oldValue: jsonb("old_value"),
newValue: jsonb("new_value"),
performedBy: integer("performed_by").references(() => usersTable.id, {
onDelete: "set null",
}),
performedAt: timestamp("performed_at", { withTimezone: true }).notNull().defaultNow(),
},
);
export const executiveMeetingPdfArchivesTable = pgTable(
"executive_meeting_pdf_archives",
{
id: serial("id").primaryKey(),
archiveDate: date("archive_date").notNull(),
// `filePath` doubles as the storage URL for the archived PDF. When the
// PDF was uploaded to object storage the value is a `/objects/<id>`
// path that resolves through GET /api/storage/objects/*. Older rows
// (browser-print snapshots) use the synthetic `print:<date>` form.
filePath: text("file_path").notNull(),
version: integer("version").notNull().default(1),
// Bytes of the generated PDF on disk. Nullable because legacy rows
// (created before the server-side generator) never had a real file.
byteSize: integer("byte_size"),
generatedBy: integer("generated_by").references(() => usersTable.id, {
onDelete: "set null",
}),
generatedAt: timestamp("generated_at", { withTimezone: true }).notNull().defaultNow(),
},
);
export const executiveMeetingFontSettingsTable = pgTable(
"executive_meeting_font_settings",
{
id: serial("id").primaryKey(),
scope: varchar("scope", { length: 16 }).notNull().default("global"),
userId: integer("user_id").references(() => usersTable.id, { onDelete: "cascade" }),
fontFamily: varchar("font_family", { length: 100 }).notNull().default("system"),
fontSize: integer("font_size").notNull().default(14),
fontWeight: varchar("font_weight", { length: 16 }).notNull().default("regular"),
alignment: varchar("alignment", { length: 16 }).notNull().default("start"),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
);
export type ExecutiveMeeting = typeof executiveMeetingsTable.$inferSelect;
export type ExecutiveMeetingAttendee = typeof executiveMeetingAttendeesTable.$inferSelect;
export type ExecutiveMeetingRequest = typeof executiveMeetingRequestsTable.$inferSelect;
export type ExecutiveMeetingTask = typeof executiveMeetingTasksTable.$inferSelect;
+916
View File
@@ -0,0 +1,916 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import pg from "pg";
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error("DATABASE_URL must be set to run these tests");
}
const TEST_PASSWORD_HASH =
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
const TEST_PASSWORD = "TestPass123!";
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const created = {
userIds: [],
meetingIds: [],
requestIds: [],
taskIds: [],
};
function uniqueName(prefix) {
return `${prefix}_${Date.now().toString(36)}_${Math.random()
.toString(36)
.slice(2, 8)}`;
}
async function createUser(prefix, roleName) {
const username = uniqueName(prefix);
const { rows } = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'EM Test', 'en', true) RETURNING id`,
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
);
const id = rows[0].id;
created.userIds.push(id);
for (const r of ["user", roleName]) {
await pool.query(
`INSERT INTO user_roles (user_id, role_id)
SELECT $1, id FROM roles WHERE name = $2
ON CONFLICT DO NOTHING`,
[id, r],
);
}
return { id, username };
}
function extractCookie(res) {
const setCookie = res.headers.get("set-cookie");
if (!setCookie) return null;
return setCookie
.split(",")
.map((c) => c.split(";")[0].trim())
.find((c) => c.startsWith("connect.sid=")) ?? null;
}
async function login(username, password) {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
assert.equal(res.status, 200, `login should succeed for ${username}`);
const cookie = extractCookie(res);
assert.ok(cookie, "login response should set a session cookie");
return cookie;
}
async function api(cookie, method, path, body) {
const init = {
method,
headers: {
Cookie: cookie,
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
},
};
if (body !== undefined) init.body = JSON.stringify(body);
return fetch(`${API_BASE}${path}`, init);
}
let adminCookie = null;
let adminUserId = null;
let coordCookie = null;
let coordUserId = null;
let leadCookie = null;
let leadUserId = null;
before(async () => {
adminCookie = await login("admin", "admin123");
const meRes = await api(adminCookie, "GET", "/api/auth/me");
const me = await meRes.json();
adminUserId = me.id ?? me.userId;
const coord = await createUser("em_coord", "executive_coordinator");
coordUserId = coord.id;
coordCookie = await login(coord.username, TEST_PASSWORD);
const lead = await createUser("em_lead", "executive_coord_lead");
leadUserId = lead.id;
leadCookie = await login(lead.username, TEST_PASSWORD);
});
after(async () => {
if (created.taskIds.length > 0) {
await pool.query(`DELETE FROM executive_meeting_tasks WHERE id = ANY($1::int[])`, [
created.taskIds,
]);
}
if (created.requestIds.length > 0) {
await pool.query(`DELETE FROM executive_meeting_requests WHERE id = ANY($1::int[])`, [
created.requestIds,
]);
}
if (created.meetingIds.length > 0) {
await pool.query(`DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`, [
created.meetingIds,
]);
await pool.query(`DELETE FROM executive_meeting_audit_logs WHERE entity_id = ANY($1::int[]) AND entity_type IN ('meeting','request','task')`, [
created.meetingIds,
]);
await pool.query(`DELETE FROM executive_meetings WHERE id = ANY($1::int[])`, [
created.meetingIds,
]);
}
if (created.userIds.length > 0) {
await pool.query(`DELETE FROM executive_meeting_tasks WHERE assigned_to = ANY($1::int[])`, [
created.userIds,
]);
await pool.query(`DELETE FROM executive_meeting_requests WHERE requested_by = ANY($1::int[])`, [
created.userIds,
]);
await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [
created.userIds,
]);
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
created.userIds,
]);
}
await pool.end();
});
const today = new Date().toISOString().slice(0, 10);
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
test("GET /me exposes capability flags including canViewAllTasks", async () => {
const res = await api(adminCookie, "GET", "/api/executive-meetings/me");
assert.equal(res.status, 200);
const body = await res.json();
for (const key of [
"userId",
"roles",
"canRead",
"canMutate",
"canApprove",
"canSubmitRequest",
"canViewAudit",
"canViewTasks",
"canViewAllTasks",
]) {
assert.ok(key in body, `missing ${key} in /me response`);
}
assert.equal(body.canViewAllTasks, true);
const coordRes = await api(coordCookie, "GET", "/api/executive-meetings/me");
const coordMe = await coordRes.json();
assert.equal(coordMe.canViewTasks, true);
assert.equal(coordMe.canViewAllTasks, false);
});
test("Meetings: bilingual title required (titleEn cannot be empty)", async () => {
const missingEn = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "اجتماع",
meetingDate: today,
});
assert.equal(missingEn.status, 400);
const emptyEn = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "اجتماع",
titleEn: "",
meetingDate: today,
});
assert.equal(emptyEn.status, 400);
});
test("Meetings: POST → GET day → DELETE roundtrip", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "اجتماع اختبار",
titleEn: "Test meeting",
meetingDate: today,
startTime: "09:00",
endTime: "10:00",
platform: "none",
status: "scheduled",
isHighlighted: 0,
attendees: [
{ name: "Tester One", attendanceType: "internal", sortOrder: 0 },
],
});
assert.equal(create.status, 201);
const meeting = await create.json();
assert.ok(meeting.id);
created.meetingIds.push(meeting.id);
const day = await api(
adminCookie,
"GET",
`/api/executive-meetings?date=${today}`,
);
assert.equal(day.status, 200);
const dayBody = await day.json();
assert.ok(Array.isArray(dayBody.meetings));
assert.ok(dayBody.meetings.some((m) => m.id === meeting.id));
const del = await api(
adminCookie,
"DELETE",
`/api/executive-meetings/${meeting.id}`,
);
assert.ok(del.status === 200 || del.status === 204);
});
test("Meetings: PUT /attendees replaces the attendee list", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ا",
titleEn: "A",
meetingDate: today,
attendees: [{ name: "Old One", attendanceType: "internal", sortOrder: 0 }],
});
assert.equal(create.status, 201);
const meeting = await create.json();
created.meetingIds.push(meeting.id);
const replace = await api(
adminCookie,
"PUT",
`/api/executive-meetings/${meeting.id}/attendees`,
{
attendees: [
{ name: "New One", title: "Director", attendanceType: "internal", sortOrder: 0 },
{ name: "New Two", title: "Manager", attendanceType: "virtual", sortOrder: 1 },
],
},
);
assert.equal(replace.status, 200);
const fetched = await api(
adminCookie,
"GET",
`/api/executive-meetings/${meeting.id}`,
);
const body = await fetched.json();
assert.equal(body.attendees.length, 2);
assert.ok(body.attendees.find((a) => a.name === "New One"));
assert.ok(!body.attendees.find((a) => a.name === "Old One"));
});
test("Meetings: POST /duplicate clones a meeting onto another date", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ب",
titleEn: "B",
meetingDate: today,
attendees: [{ name: "Dup Att", attendanceType: "internal", sortOrder: 0 }],
});
assert.equal(create.status, 201);
const original = await create.json();
created.meetingIds.push(original.id);
const dup = await api(
adminCookie,
"POST",
`/api/executive-meetings/${original.id}/duplicate`,
{ targetDate: tomorrow },
);
assert.equal(dup.status, 201);
const newRow = await dup.json();
assert.notEqual(newRow.id, original.id);
created.meetingIds.push(newRow.id);
const day = await api(
adminCookie,
"GET",
`/api/executive-meetings?date=${tomorrow}`,
);
const dayBody = await day.json();
assert.ok(dayBody.meetings.some((m) => m.id === newRow.id));
});
test("Requests: POST → admin can list", async () => {
const create = await api(
adminCookie,
"POST",
"/api/executive-meetings/requests",
{
requestType: "note",
requestDetails: { note: "Phase-2 test request" },
},
);
assert.equal(create.status, 201);
const reqRow = await create.json();
assert.ok(reqRow.id);
created.requestIds.push(reqRow.id);
const list = await api(adminCookie, "GET", "/api/executive-meetings/requests");
assert.equal(list.status, 200);
const listBody = await list.json();
assert.ok(Array.isArray(listBody.requests));
assert.ok(listBody.requests.some((r) => r.id === reqRow.id));
});
test("Requests: full review + apply pipeline (approve → apply highlights meeting)", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ت",
titleEn: "T",
meetingDate: today,
attendees: [],
isHighlighted: 0,
});
assert.equal(create.status, 201);
const meeting = await create.json();
created.meetingIds.push(meeting.id);
const reqRes = await api(adminCookie, "POST", "/api/executive-meetings/requests", {
requestType: "highlight",
meetingId: meeting.id,
requestDetails: { note: "highlight-please" },
});
assert.equal(reqRes.status, 201);
const reqRow = await reqRes.json();
created.requestIds.push(reqRow.id);
const approve = await api(
adminCookie,
"PATCH",
`/api/executive-meetings/requests/${reqRow.id}`,
{ status: "approved", reviewNotes: "ok" },
);
assert.equal(approve.status, 200);
const fetched = await api(
adminCookie,
"GET",
`/api/executive-meetings/${meeting.id}`,
);
const body = await fetched.json();
assert.equal(body.isHighlighted, 1);
});
test("Tasks: server-side scoping forces coordinators to assignedTo=self", async () => {
const t1 = await api(adminCookie, "POST", "/api/executive-meetings/tasks", {
taskType: "follow_up",
assignedTo: coordUserId,
notes: "for coord",
});
assert.equal(t1.status, 201);
const task1 = await t1.json();
created.taskIds.push(task1.id);
const t2 = await api(adminCookie, "POST", "/api/executive-meetings/tasks", {
taskType: "follow_up",
assignedTo: leadUserId,
notes: "for lead",
});
assert.equal(t2.status, 201);
const task2 = await t2.json();
created.taskIds.push(task2.id);
const coordList = await api(
coordCookie,
"GET",
`/api/executive-meetings/tasks?mine=0&assigneeId=${leadUserId}`,
);
assert.equal(coordList.status, 200);
const coordBody = await coordList.json();
const coordIds = coordBody.tasks.map((t) => t.id);
assert.ok(coordIds.includes(task1.id));
assert.ok(!coordIds.includes(task2.id));
for (const t of coordBody.tasks) {
assert.equal(t.assignedTo, coordUserId);
}
const leadList = await api(
leadCookie,
"GET",
`/api/executive-meetings/tasks?assigneeId=${leadUserId}`,
);
assert.equal(leadList.status, 200);
const leadBody = await leadList.json();
assert.ok(leadBody.tasks.some((t) => t.id === task2.id));
});
test("Tasks: PATCH supports reassign + notes update", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings/tasks", {
taskType: "follow_up",
assignedTo: coordUserId,
notes: "v1",
});
assert.equal(create.status, 201);
const task = await create.json();
created.taskIds.push(task.id);
const patch = await api(
adminCookie,
"PATCH",
`/api/executive-meetings/tasks/${task.id}`,
{ assignedTo: leadUserId, notes: "v2" },
);
assert.equal(patch.status, 200);
const updated = await patch.json();
assert.equal(updated.assignedTo, leadUserId);
assert.equal(updated.notes, "v2");
});
test("Audit logs: admin can list, plain coordinator gets 403", async () => {
const ok = await api(
adminCookie,
"GET",
"/api/executive-meetings/audit-logs",
);
assert.equal(ok.status, 200);
const body = await ok.json();
assert.ok(Array.isArray(body.logs ?? body.auditLogs ?? body.entries ?? []));
const denied = await api(
coordCookie,
"GET",
"/api/executive-meetings/audit-logs",
);
assert.equal(denied.status, 403);
});
test("Audit logs: dateFrom/dateTo, action, and actorId filters all narrow results", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "س",
titleEn: "S",
meetingDate: today,
});
const meeting = await create.json();
created.meetingIds.push(meeting.id);
const filtered = await api(
adminCookie,
"GET",
`/api/executive-meetings/audit-logs?dateFrom=${today}&dateTo=${today}&action=create&actorId=${adminUserId}&entityType=meeting`,
);
assert.equal(filtered.status, 200);
const body = await filtered.json();
const entries = body.entries ?? body.logs ?? [];
assert.ok(Array.isArray(entries));
for (const e of entries) {
assert.equal(e.action, "create");
assert.equal(e.entityType, "meeting");
assert.equal(e.performedBy, adminUserId);
}
assert.ok(entries.some((e) => e.entityId === meeting.id));
});
test("Notifications: GET filters by ?date= and tolerates empty days", async () => {
const empty = await api(
adminCookie,
"GET",
`/api/executive-meetings/notifications?date=1990-01-01`,
);
assert.equal(empty.status, 200);
const emptyBody = await empty.json();
assert.ok(Array.isArray(emptyBody.notifications));
const todayList = await api(
adminCookie,
"GET",
`/api/executive-meetings/notifications?date=${today}`,
);
assert.equal(todayList.status, 200);
});
test("Font settings: valid combo 200; invalid weight/size/family 400", async () => {
const ok = await api(
adminCookie,
"PATCH",
"/api/executive-meetings/font-settings",
{
scope: "user",
fontFamily: "Cairo",
fontSize: 16,
fontWeight: "bold",
alignment: "center",
},
);
assert.equal(ok.status, 200);
const badWeight = await api(
adminCookie,
"PATCH",
"/api/executive-meetings/font-settings",
{ scope: "user", fontWeight: "medium" },
);
assert.equal(badWeight.status, 400);
const badSize = await api(
adminCookie,
"PATCH",
"/api/executive-meetings/font-settings",
{ scope: "user", fontSize: 30 },
);
assert.equal(badSize.status, 400);
const badFamily = await api(
adminCookie,
"PATCH",
"/api/executive-meetings/font-settings",
{ scope: "user", fontFamily: "Comic Sans" },
);
assert.equal(badFamily.status, 400);
});
test("PDF archives: POST creates a snapshot and GET returns it", async () => {
const post = await api(
adminCookie,
"POST",
"/api/executive-meetings/pdf-archives",
{ archiveDate: today },
);
assert.equal(post.status, 201);
const archive = await post.json();
assert.ok(archive.id);
assert.ok(archive.version >= 1);
const list = await api(
adminCookie,
"GET",
`/api/executive-meetings/pdf-archives?date=${today}`,
);
assert.equal(list.status, 200);
const body = await list.json();
const archives = body.archives ?? body.items ?? body.pdfArchives ?? [];
assert.ok(Array.isArray(archives));
assert.ok(archives.some((a) => a.id === archive.id));
});
test("PDF GET /executive-meetings/pdf returns a real PDF and archives it", async () => {
// Create a meeting with mixed Arabic+Latin attendees + a time range so
// the renderer's bidi/font-segmentation path is exercised.
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "اجتماع الميزانية ‪Q2 2026",
titleEn: "Q2 2026 Budget Review",
meetingDate: today,
startTime: "10:00",
endTime: "11:30",
location: "قاعة كبرى",
attendees: [
{ name: "أحمد العلي", title: "المدير التنفيذي",
attendanceType: "internal", sortOrder: 0 },
{ name: "John Smith", title: "CFO",
attendanceType: "external", sortOrder: 1 },
],
});
assert.equal(create.status, 201);
const meeting = await create.json();
created.meetingIds.push(meeting.id);
// Bad date → 400
const bad = await api(adminCookie, "GET",
"/api/executive-meetings/pdf?date=not-a-date");
assert.equal(bad.status, 400);
// Coordinator without executive role is denied — auth gate matches the
// rest of the executive-meetings module.
const noAuth = await fetch(`${API_BASE}/api/executive-meetings/pdf?date=${today}`);
assert.equal(noAuth.status, 401);
// Happy path: real PDF response with non-trivial body.
const res = await api(adminCookie, "GET",
`/api/executive-meetings/pdf?date=${today}&lang=ar`);
assert.equal(res.status, 200);
assert.match(
res.headers.get("content-type") || "",
/^application\/pdf/,
"must serve application/pdf",
);
assert.match(
res.headers.get("content-disposition") || "",
new RegExp(`attachment;\\s*filename="executive-meetings-${today}\\.pdf"`),
"filename should include the requested date",
);
const buf = Buffer.from(await res.arrayBuffer());
assert.ok(buf.length > 1000, `PDF should be >1KB, got ${buf.length}`);
assert.equal(
buf.subarray(0, 4).toString("ascii"),
"%PDF",
"body must start with PDF magic bytes",
);
// The download must have created an archive row recorded against the
// generating user with byteSize matching the served body.
const list = await api(
adminCookie,
"GET",
`/api/executive-meetings/pdf-archives?date=${today}`,
);
assert.equal(list.status, 200);
const archives = (await list.json()).archives ?? [];
const generated = archives.find(
(a) => typeof a.byteSize === "number" && a.byteSize === buf.length,
);
assert.ok(
generated,
"PDF download must produce an archive row with the served byte_size",
);
assert.ok(generated.generatedBy, "generatedBy must be recorded");
assert.ok(
typeof generated.filePath === "string" && generated.filePath.length > 0,
"filePath/storage_url must be non-empty",
);
// Empty-day handling: another date with no meetings still returns a
// valid PDF (with a "no meetings" message).
const empty = await api(adminCookie, "GET",
`/api/executive-meetings/pdf?date=2099-01-01&lang=en`);
assert.equal(empty.status, 200);
const emptyBuf = Buffer.from(await empty.arrayBuffer());
assert.equal(emptyBuf.subarray(0, 4).toString("ascii"), "%PDF");
// Font-family must affect which font is embedded in the PDF. We render
// the same day twice with different families and assert that:
// 1) Both downloads succeed (200 + %PDF magic).
// 2) The Sans family ("Cairo") embeds NotoSansArabic, while the Naskh
// default ("Noto Naskh Arabic") embeds NotoNaskhArabic. The font's
// PostScript name appears verbatim in the PDF font dictionary.
const setSans = await api(
adminCookie,
"PUT",
"/api/executive-meetings/font-settings",
{
scope: "user",
fontFamily: "Cairo",
fontSize: 16,
fontWeight: "bold",
alignment: "center",
},
);
assert.equal(setSans.status, 200);
const sansRes = await api(
adminCookie,
"GET",
`/api/executive-meetings/pdf?date=${today}&lang=ar`,
);
assert.equal(sansRes.status, 200);
const sansBuf = Buffer.from(await sansRes.arrayBuffer());
assert.equal(sansBuf.subarray(0, 4).toString("ascii"), "%PDF");
const sansAscii = sansBuf.toString("latin1");
assert.ok(
/NotoSansArabic/i.test(sansAscii),
"Cairo family must embed NotoSansArabic in the PDF",
);
assert.ok(
!/NotoNaskhArabic/i.test(sansAscii),
"Cairo family must NOT embed NotoNaskhArabic",
);
const setNaskh = await api(
adminCookie,
"PUT",
"/api/executive-meetings/font-settings",
{
scope: "user",
fontFamily: "Noto Naskh Arabic",
fontSize: 14,
fontWeight: "regular",
alignment: "start",
},
);
assert.equal(setNaskh.status, 200);
const naskhRes = await api(
adminCookie,
"GET",
`/api/executive-meetings/pdf?date=${today}&lang=ar`,
);
assert.equal(naskhRes.status, 200);
const naskhBuf = Buffer.from(await naskhRes.arrayBuffer());
const naskhAscii = naskhBuf.toString("latin1");
assert.ok(
/NotoNaskhArabic/i.test(naskhAscii),
"Noto Naskh Arabic family must embed NotoNaskhArabic in the PDF",
);
assert.ok(
!/NotoSansArabic/i.test(naskhAscii),
"Noto Naskh Arabic family must NOT embed NotoSansArabic",
);
});
test("Sanitization: rich text strips disallowed tags but keeps inline formatting", async () => {
const dirty =
'Hello <script>alert(1)</script><strong style="color:#ff0000">World</strong>' +
'<a href="javascript:bad()">x</a><img src=x onerror=alert(1)>';
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: dirty,
titleEn: dirty,
meetingDate: today,
attendees: [
{ name: 'Tester <em style="color:blue">One</em><script>x</script>',
attendanceType: "internal", sortOrder: 0 },
],
});
assert.equal(create.status, 201);
const meeting = await create.json();
created.meetingIds.push(meeting.id);
const day = await api(adminCookie, "GET",
`/api/executive-meetings?date=${today}`);
const body = await day.json();
const m = body.meetings.find((x) => x.id === meeting.id);
assert.ok(m, "meeting must come back");
// disallowed tags must be stripped
assert.ok(!/<script/i.test(m.titleAr), "script must be stripped from titleAr");
assert.ok(!/<script/i.test(m.titleEn), "script must be stripped from titleEn");
assert.ok(!/<img/i.test(m.titleAr), "img must be stripped");
assert.ok(!/<a /i.test(m.titleAr), "anchor must be stripped");
assert.ok(!/javascript:/i.test(m.titleAr), "javascript: scheme must be gone");
// allowed inline formatting must be preserved
assert.ok(/<strong[^>]*>World<\/strong>/i.test(m.titleAr), "strong must survive");
assert.ok(/color:\s*#ff0000/i.test(m.titleAr), "inline color must survive");
// attendee names get the same treatment
const att = m.attendees.find((a) => /Tester/.test(a.name));
assert.ok(att, "attendee must come back");
assert.ok(!/<script/i.test(att.name), "script stripped from attendee name");
assert.ok(/<em[^>]*>One<\/em>/i.test(att.name), "em must survive in attendee");
});
test("Sanitization: text-align on <p> survives a round-trip via PATCH/GET", async () => {
const m = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "محاذاة", titleEn: "Align", meetingDate: today,
});
const M = await m.json(); created.meetingIds.push(M.id);
const html = '<p style="text-align: right">يمين</p><p style="text-align: center">center</p>';
const patch = await api(adminCookie, "PATCH",
`/api/executive-meetings/${M.id}`, { titleAr: html });
assert.equal(patch.status, 200);
const day = await api(adminCookie, "GET",
`/api/executive-meetings?date=${today}`);
const body = await day.json();
const got = body.meetings.find((x) => x.id === M.id);
assert.ok(got, "meeting must come back");
assert.match(got.titleAr, /<p[^>]*text-align:\s*right[^>]*>يمين<\/p>/i,
"right-aligned <p> survives sanitization");
assert.match(got.titleAr, /<p[^>]*text-align:\s*center[^>]*>center<\/p>/i,
"center-aligned <p> survives sanitization");
});
test("Reorder: POST /reorder renumbers a full day to 1..N and inherits slot times", async () => {
const reorderDate = "2050-01-15";
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أول", titleEn: "First", meetingDate: reorderDate,
startTime: "09:00", endTime: "09:30",
});
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ثاني", titleEn: "Second", meetingDate: reorderDate,
startTime: "10:00", endTime: "10:30",
});
const c = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ثالث", titleEn: "Third", meetingDate: reorderDate,
startTime: "11:00", endTime: "11:30",
});
assert.equal(a.status, 201);
assert.equal(b.status, 201);
assert.equal(c.status, 201);
const A = await a.json(); created.meetingIds.push(A.id);
const B = await b.json(); created.meetingIds.push(B.id);
const C = await c.json(); created.meetingIds.push(C.id);
// reverse the order: C, B, A
const reorder = await api(adminCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: reorderDate, orderedIds: [C.id, B.id, A.id] });
assert.equal(reorder.status, 200);
const day = await api(adminCookie, "GET",
`/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);
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");
const auditRows = await pool.query(
`SELECT action, entity_type, entity_id, new_value
FROM executive_meeting_audit_logs
WHERE action = 'executive_meeting.reorder'
AND entity_id = ANY($1::int[])
ORDER BY id DESC LIMIT 1`,
[[A.id, B.id, C.id]],
);
assert.equal(auditRows.rowCount, 1, "expected one reorder audit row");
assert.equal(auditRows.rows[0].entity_type, "meeting");
const newOrder = auditRows.rows[0].new_value?.order;
assert.deepEqual(newOrder, [C.id, B.id, A.id],
"audit new_value.order must echo the new ordering");
});
test("Reorder: rejects orderedIds containing duplicates with code duplicate_ids", async () => {
const reorderDate = "2050-04-04";
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أ", titleEn: "A", meetingDate: reorderDate,
});
const A = await a.json(); created.meetingIds.push(A.id);
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ب", titleEn: "B", meetingDate: reorderDate,
});
const B = await b.json(); created.meetingIds.push(B.id);
const r = await api(adminCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: reorderDate, orderedIds: [A.id, A.id, B.id] });
assert.equal(r.status, 400);
const body = await r.json();
assert.equal(body.code, "duplicate_ids",
"duplicate ids must be reported with the duplicate_ids code");
});
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,
});
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: '<b>Real</b><script>alert(1)</script><img src=x onerror=alert(1)>',
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(/<b[^>]*>Real<\/b>/i.test(att.name), "<b> must survive");
assert.ok(!/<script/i.test(att.name), "<script> must be stripped");
assert.ok(!/onerror/i.test(att.name), "onerror must be stripped");
});
test("Reorder: rejects ids that do not all belong to meetingDate", async () => {
const sameDay = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "اليوم", titleEn: "Today", meetingDate: today,
});
const other = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "غدًا", titleEn: "Tomorrow",
meetingDate: new Date(Date.now() + 86400000)
.toISOString().slice(0, 10),
});
const S = await sameDay.json(); created.meetingIds.push(S.id);
const O = await other.json(); created.meetingIds.push(O.id);
const r = await api(adminCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: today, orderedIds: [S.id, O.id] });
assert.equal(r.status, 400);
});
test("Reorder: user without executive role is denied (403)", async () => {
const m = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ا", titleEn: "A", meetingDate: today,
});
const M = await m.json(); created.meetingIds.push(M.id);
// a brand-new "user" has only the base 'user' role (no executive perms)
const plain = await createUser("em_plain", "user");
const plainCookie = await login(plain.username, TEST_PASSWORD);
const r = await api(plainCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: today, orderedIds: [M.id] });
assert.equal(r.status, 403);
});