Fix PDF font color not reflecting system settings via per-field merge

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.
This commit is contained in:
Riyadh
2026-05-04 12:30:00 +00:00
parent 557e692e8e
commit a934b56bd3
3 changed files with 58 additions and 40 deletions
@@ -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 = <T>(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",