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:
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
<SettingsSection
|
||||
font={effectiveFont}
|
||||
globalFont={fontResp?.global ? {
|
||||
fontFamily: fontResp.global.fontFamily,
|
||||
fontSize: fontResp.global.fontSize,
|
||||
fontWeight: fontResp.global.fontWeight,
|
||||
alignment: fontResp.global.alignment,
|
||||
fontColor: fontResp.global.fontColor ?? "#000000",
|
||||
fontFamily: fontResp.global.fontFamily ?? DEFAULT_FONT.fontFamily,
|
||||
fontSize: fontResp.global.fontSize ?? DEFAULT_FONT.fontSize,
|
||||
fontWeight: (fontResp.global.fontWeight ?? DEFAULT_FONT.fontWeight) as FontPrefs["fontWeight"],
|
||||
alignment: (fontResp.global.alignment ?? DEFAULT_FONT.alignment) as FontPrefs["alignment"],
|
||||
fontColor: fontResp.global.fontColor ?? DEFAULT_FONT.fontColor,
|
||||
} : null}
|
||||
globalLogoObjectPath={fontResp?.global?.logoObjectPath ?? null}
|
||||
canEditGlobalFont={me.canEditGlobalFontSettings}
|
||||
@@ -7401,7 +7408,7 @@ function FontSettingsSection({
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setPrefs(scope === "global" && globalFont ? globalFont : font);
|
||||
setPrefs(scope === "global" ? (globalFont ?? DEFAULT_FONT) : font);
|
||||
}, [font, globalFont, scope]);
|
||||
useEffect(() => {
|
||||
setLogoPath(globalLogoObjectPath);
|
||||
|
||||
@@ -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/<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).
|
||||
|
||||
Reference in New Issue
Block a user