diff --git a/packages/services/src/page/__tests__/page.test.ts b/packages/services/src/page/__tests__/page.test.ts index e75a08d4..d6ec3b56 100644 --- a/packages/services/src/page/__tests__/page.test.ts +++ b/packages/services/src/page/__tests__/page.test.ts @@ -28,7 +28,9 @@ import { import { createPage, newPage } from "../create"; import { deletePage } from "../delete"; import { getPage, getPageBySlug, getSlugAvailable, listPages } from "../list"; +import { UpdatePageConfigurationInput } from "../schemas"; import { + updatePageConfiguration, updatePageCustomTheme, updatePageGeneral, updatePageLocales, @@ -372,6 +374,66 @@ describe("updatePageGeneral", () => { }); }); +describe("updatePageConfiguration", () => { + test("preserves omitted settings in partial updates", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const p = await createPage({ + ctx, + input: { + workspaceId: ctx.workspace.id, + title: "Configuration", + slug: uniqueSlug("configuration"), + description: "", + customDomain: "", + configuration: { + type: "manual", + value: "manual", + uptime: false, + theme: "default-rounded", + days: 30, + }, + }, + }); + + for (const configuration of [ + { days: 45 }, + {}, + { theme: undefined }, + null, + undefined, + ]) { + await updatePageConfiguration({ + ctx, + input: UpdatePageConfigurationInput.parse({ + id: p.id, + configuration, + }), + }); + const row = await tx + .select() + .from(pageTable) + .where(eq(pageTable.id, p.id)) + .get(); + expect(row?.configuration).toEqual({ + type: "manual", + value: "manual", + uptime: false, + theme: "default-rounded", + days: 45, + }); + } + await expectAuditRow({ + workspaceId: ctx.workspace.id, + action: "page.update", + entityType: "page", + entityId: p.id, + db: tx, + }); + }); + }); +}); + describe("updatePageLocales", () => { test("rejects when plan lacks i18n", async () => { await withTestTransaction(async (tx) => { diff --git a/packages/services/src/page/schemas.ts b/packages/services/src/page/schemas.ts index 30ee8c86..2383c018 100644 --- a/packages/services/src/page/schemas.ts +++ b/packages/services/src/page/schemas.ts @@ -175,16 +175,18 @@ export const UpdatePageLocalesInput = z ); export type UpdatePageLocalesInput = z.infer; -// Reuse the canonical `pageConfigurationSchema` — the read path runs -// stored configuration through it, so anything the update accepts that -// doesn't round-trip here would surface as an opaque parse error at -// status-page render time rather than at write time. The prior -// `z.record(z.string(), z.union([z.string(), z.boolean()]))` happily -// persisted any key/value and broke the read parser on values outside -// the defined enums. +// Match undefined before the read validators can fill in defaults. export const UpdatePageConfigurationInput = z.object({ id: z.number().int(), - configuration: pageConfigurationSchema.nullish(), + configuration: z + .object({ + value: z.undefined().or(pageConfigurationSchema.shape.value), + type: z.undefined().or(pageConfigurationSchema.shape.type), + uptime: z.undefined().or(pageConfigurationSchema.shape.uptime), + theme: z.undefined().or(pageConfigurationSchema.shape.theme), + days: z.undefined().or(pageConfigurationSchema.shape.days), + }) + .nullish(), }); export type UpdatePageConfigurationInput = z.infer< typeof UpdatePageConfigurationInput diff --git a/packages/services/src/page/update.ts b/packages/services/src/page/update.ts index 5737ddfd..06115882 100644 --- a/packages/services/src/page/update.ts +++ b/packages/services/src/page/update.ts @@ -13,8 +13,8 @@ import { import { UpdatePageAppearanceInput, UpdatePageConfigurationInput, - UpdatePageCustomThemeInput, UpdatePageCustomDomainInput, + UpdatePageCustomThemeInput, UpdatePageGeneralInput, UpdatePageLinksInput, UpdatePageLocalesInput, @@ -346,7 +346,11 @@ export async function updatePageConfiguration(args: { .set({ configuration: { ...currentConfiguration, - ...input.configuration, + ...Object.fromEntries( + Object.entries(input.configuration ?? {}).filter( + ([, value]) => value !== undefined, + ), + ), }, updatedAt: new Date(), })