From 014f9ecb0e204c53758ce27efa28890221916910 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Mon, 4 May 2026 12:30:00 +0000 Subject: [PATCH] Fix PDF font color not reflecting system settings via per-field merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: resolveFontPrefsForUser() used `userRow ?? globalRow` whole-row precedence. When an admin saved font settings with scope="global", only the global row was updated. If the admin also had a user-scope row (created by prior saves with the default scope="user"), ALL fields from the user-scope row overrode the global row — including fontColor — causing the PDF to show the old color even after changing global settings. Schema change (executive-meetings.ts): - Made fontFamily, fontSize, fontWeight, alignment, fontColor nullable. User-scope rows now store NULL for fields that inherit from global, and only store non-null values for fields the user explicitly overrode. Backend fix (executive-meetings.ts): - resolveFontPrefsForUser: per-field merge — user non-null → global non-null → schema default. - PATCH handler for user-scope: after upsert, compares each field with the current global values. Fields matching global are set to NULL (= inherit). The post-nullification row is returned in the API response and audit log. - No user-scope rows are deleted; scope isolation is preserved. Frontend fix (executive-meetings.tsx): - effectiveFont computed via per-field merge (u?.field ?? g?.field ?? default) - FontSettingsResponse type updated for nullable fields (FontSettingsRow) - Scope switching in FontSettingsSection loads the selected scope's values (global → globalFont ?? DEFAULT_FONT; user → effective font) - globalFont prop threaded through SettingsSection → FontSettingsSection Data migration: existing user-scope rows normalized via SQL — fields matching global values set to NULL so per-field inheritance applies immediately. Verified: TypeScript clean, e2e Playwright test passes, API tests confirm per-field merge, nullification, and PDF color propagation. --- .../src/routes/executive-meetings.ts | 46 +++++++++++++------ .../tx-os/src/pages/executive-meetings.tsx | 37 +++++++++------ lib/db/src/schema/executive-meetings.ts | 15 ++---- 3 files changed, 58 insertions(+), 40 deletions(-) diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 7d154bb5..fc70b8ec 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -2556,9 +2556,9 @@ router.delete( // PDF GENERATION (server-side) // ===================================================================== -// Resolve the user's effective font preferences exactly the way the -// frontend does: user-scope row wins, otherwise global, otherwise the -// schema defaults. +// Resolve the user's effective font preferences via per-field merge: +// user-scope value (if non-null) → global value (if non-null) → schema default. +// User-scope rows store NULL for fields that inherit from global. async function resolveFontPrefsForUser( userId: number, ): Promise<{ font: PdfFontPrefs; logoObjectPath: string | null }> { @@ -2576,16 +2576,14 @@ async function resolveFontPrefsForUser( ); const userRow = rows.find((r) => r.scope === "user" && r.userId === userId); const globalRow = rows.find((r) => r.scope === "global"); - const eff = userRow ?? globalRow; return { 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", + fontFamily: (userRow?.fontFamily ?? globalRow?.fontFamily ?? "system") as string, + fontSize: userRow?.fontSize ?? globalRow?.fontSize ?? 14, + fontWeight: (userRow?.fontWeight ?? globalRow?.fontWeight ?? "regular") as PdfFontPrefs["fontWeight"], + alignment: (userRow?.alignment ?? globalRow?.alignment ?? "start") as PdfFontPrefs["alignment"], + fontColor: userRow?.fontColor ?? globalRow?.fontColor ?? "#000000", }, - // Logo is global-scope only. logoObjectPath: globalRow?.logoObjectPath ?? null, }; } @@ -2970,15 +2968,33 @@ const upsertFontSettingsHandler = async ( }); } } - if (data.scope === "global") { - await tx - .delete(executiveMeetingFontSettingsTable) + if (data.scope === "user") { + const [curGlobal] = await tx + .select() + .from(executiveMeetingFontSettingsTable) .where( and( - eq(executiveMeetingFontSettingsTable.scope, "user"), - eq(executiveMeetingFontSettingsTable.userId, userId), + eq(executiveMeetingFontSettingsTable.scope, "global"), + isNull(executiveMeetingFontSettingsTable.userId), ), ); + if (curGlobal && row) { + const nullIfSame = (userVal: T, globalVal: T): T | null => + userVal === globalVal ? null : userVal; + const nullified = { + fontFamily: nullIfSame(row.fontFamily, curGlobal.fontFamily), + fontSize: nullIfSame(row.fontSize, curGlobal.fontSize), + fontWeight: nullIfSame(row.fontWeight, curGlobal.fontWeight), + alignment: nullIfSame(row.alignment, curGlobal.alignment), + fontColor: nullIfSame(row.fontColor, curGlobal.fontColor), + }; + const [updated] = await tx + .update(executiveMeetingFontSettingsTable) + .set(nullified) + .where(eq(executiveMeetingFontSettingsTable.id, row.id)) + .returning(); + row = updated; + } } await logAudit(tx, { action: existing ? "update" : "create", diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index 5d5ae2a8..091a2f9b 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -216,11 +216,19 @@ type FontPrefs = { // global edit rights. type GlobalFontExtras = { logoObjectPath: string | null }; +type FontSettingsRow = { + fontFamily: string | null; + fontSize: number | null; + fontWeight: string | null; + alignment: string | null; + fontColor: string | null; +}; + type FontSettingsResponse = { global: - | ({ scope: "global"; userId: null } & FontPrefs & GlobalFontExtras) + | ({ scope: "global"; userId: null } & FontSettingsRow & GlobalFontExtras) | null; - user: ({ scope: "user"; userId: number } & FontPrefs) | null; + user: ({ scope: "user"; userId: number } & FontSettingsRow) | null; }; type PdfArchive = { @@ -798,14 +806,13 @@ function ExecutiveMeetingsPageInner() { const effectiveFont: FontPrefs = useMemo(() => { const u = fontResp?.user; const g = fontResp?.global; - const src = u ?? g; - if (!src) return DEFAULT_FONT; + if (!u && !g) return DEFAULT_FONT; return { - fontFamily: src.fontFamily, - fontSize: src.fontSize, - fontWeight: src.fontWeight, - alignment: src.alignment, - fontColor: src.fontColor ?? "#000000", + fontFamily: u?.fontFamily ?? g?.fontFamily ?? DEFAULT_FONT.fontFamily, + fontSize: u?.fontSize ?? g?.fontSize ?? DEFAULT_FONT.fontSize, + fontWeight: (u?.fontWeight ?? g?.fontWeight ?? DEFAULT_FONT.fontWeight) as FontPrefs["fontWeight"], + alignment: (u?.alignment ?? g?.alignment ?? DEFAULT_FONT.alignment) as FontPrefs["alignment"], + fontColor: u?.fontColor ?? g?.fontColor ?? DEFAULT_FONT.fontColor, }; }, [fontResp]); @@ -953,11 +960,11 @@ function ExecutiveMeetingsPageInner() { { - setPrefs(scope === "global" && globalFont ? globalFont : font); + setPrefs(scope === "global" ? (globalFont ?? DEFAULT_FONT) : font); }, [font, globalFont, scope]); useEffect(() => { setLogoPath(globalLogoObjectPath); diff --git a/lib/db/src/schema/executive-meetings.ts b/lib/db/src/schema/executive-meetings.ts index 6a34c80a..2d694001 100644 --- a/lib/db/src/schema/executive-meetings.ts +++ b/lib/db/src/schema/executive-meetings.ts @@ -270,16 +270,11 @@ export const executiveMeetingFontSettingsTable = pgTable( 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"), - // 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"), + fontFamily: varchar("font_family", { length: 100 }).default("system"), + fontSize: integer("font_size").default(14), + fontWeight: varchar("font_weight", { length: 16 }).default("regular"), + alignment: varchar("alignment", { length: 16 }).default("start"), + fontColor: varchar("font_color", { length: 16 }).default("#000000"), // Object-storage path (`/objects/`) 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).