From 3417ea8462749c56017c122a97a52d45f8a60b4c Mon Sep 17 00:00:00 2001 From: adrians5j Date: Wed, 14 Oct 2026 13:38:15 +0200 Subject: [PATCH 1/5] fix(api-website-builder): save page changes made in page.beforePublish and page.beforeCreateRevisionFrom Handlers for `page.beforePublish` received the page, but the publish only passed the page id on, so the CMS published the stored revision. Replacing a field on the page in a handler had no effect. `page.beforeCreateRevisionFrom` did not receive the page at all. `PublishPageUseCase` now publishes the page values the handlers left on the event, through a new optional `values` option on the CMS `PublishEntryUseCase`. The values go through the publish data factory, so they are validated like stored values. `page.beforeCreateRevisionFrom` now receives the source page, and the new revision is created with its values. Co-Authored-By: Claude Opus 5.5 --- .../PublishEntry/PublishEntryUseCase.ts | 13 +- .../contentEntry/PublishEntry/abstractions.ts | 11 +- .../__tests__/pages.beforeEvents.test.ts | 125 ++++++++++++++++++ .../domain/page/PageToEntryValuesMapper.ts | 17 +++ .../CreatePageRevisionFromRepository.ts | 2 +- .../CreatePageRevisionFromUseCase.ts | 11 +- .../CreatePageRevisionFrom/abstractions.ts | 21 ++- .../PublishPage/PublishPageRepository.ts | 4 +- .../pages/PublishPage/PublishPageUseCase.ts | 8 +- .../pages/PublishPage/abstractions.ts | 12 +- 10 files changed, 207 insertions(+), 17 deletions(-) create mode 100644 packages/api-website-builder/__tests__/pages.beforeEvents.test.ts create mode 100644 packages/api-website-builder/src/domain/page/PageToEntryValuesMapper.ts diff --git a/packages/api-headless-cms/src/features/contentEntry/PublishEntry/PublishEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/PublishEntryUseCase.ts index e4d9343c5fa..83a050758b4 100644 --- a/packages/api-headless-cms/src/features/contentEntry/PublishEntry/PublishEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/PublishEntryUseCase.ts @@ -1,7 +1,10 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { EntryEventPublisher } from "~/features/contentEntry/EntryEventPublisher/index.js"; -import { PublishEntryUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { + PublishEntryUseCase as UseCaseAbstraction, + type IPublishEntryUseCaseOptions +} from "./abstractions.js"; import { PublishEntryRepository } from "./abstractions.js"; import { AccessControl } from "~/features/shared/abstractions.js"; import { GetRevisionByIdUseCase } from "~/features/contentEntry/GetRevisionById/index.js"; @@ -25,7 +28,8 @@ class PublishEntryUseCaseImpl implements UseCaseAbstraction.Interface { async execute( model: CmsModel, - id: string + id: string, + options?: IPublishEntryUseCaseOptions ): Promise, UseCaseAbstraction.Error>> { const canAccess = await this.accessControl.canAccessEntry({ model, pw: "p" }); if (!canAccess) { @@ -50,7 +54,10 @@ class PublishEntryUseCaseImpl implements UseCaseAbstraction.Interface { return Result.fail(EntryNotAuthorizedError.fromModel(model)); } - const dataResult = await this.createPublishEntryDataFactory.create(model, originalEntry); + const source = options?.values + ? { ...originalEntry, values: options.values } + : originalEntry; + const dataResult = await this.createPublishEntryDataFactory.create(model, source); if (dataResult.isFail()) { return Result.fail(dataResult.error); } diff --git a/packages/api-headless-cms/src/features/contentEntry/PublishEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/abstractions.ts index c93f3c5e567..760a2408f14 100644 --- a/packages/api-headless-cms/src/features/contentEntry/PublishEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/abstractions.ts @@ -10,10 +10,19 @@ import type { /** * PublishEntry Use Case - Publishes an entry revision. */ +export interface IPublishEntryUseCaseOptions { + /** + * Values to publish instead of the revision's stored values. They go through the same + * validation as stored values do. + */ + values?: T; +} + export interface IPublishEntryUseCase { execute( model: CmsModel, - id: string + id: string, + options?: IPublishEntryUseCaseOptions ): Promise, UseCaseError>>; } diff --git a/packages/api-website-builder/__tests__/pages.beforeEvents.test.ts b/packages/api-website-builder/__tests__/pages.beforeEvents.test.ts new file mode 100644 index 00000000000..36fe7d91b71 --- /dev/null +++ b/packages/api-website-builder/__tests__/pages.beforeEvents.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { createContextPlugin } from "@webiny/api"; +import { useHandler } from "./utils/useHandler.js"; +import { pageMocks } from "./mocks/page.mock.js"; +import { CreatePageUseCase } from "~/features/pages/CreatePage/index.js"; +import { GetPageByIdUseCase } from "~/features/pages/GetPageById/index.js"; +import { + PageBeforePublishEventHandler, + PublishPageUseCase +} from "~/features/pages/PublishPage/index.js"; +import { + CreatePageRevisionFromUseCase, + PageBeforeCreateRevisionFromEventHandler +} from "~/features/pages/CreatePageRevisionFrom/index.js"; + +/* + * "Before" event handlers run after authorization and before the write. Whatever they leave on + * the page is what gets saved, and throwing stops the write. The handlers below replace + * `properties` instead of editing it in place, because an in-place edit can reach the write + * through a shared object even when the page itself is never passed on. + */ +const setup = async (register: Parameters[0]) => { + const handler = useHandler({ plugins: [createContextPlugin(register)] }); + const context = await handler.handler(); + + const createResult = await context.container + .resolve(CreatePageUseCase) + .execute(pageMocks.pageA); + if (createResult.isFail()) { + throw createResult.error; + } + + return { context, page: createResult.value }; +}; + +describe("Page before-event handlers", () => { + it("publishes the page as a page.beforePublish handler changed it", async () => { + const { context, page } = await setup(context => { + context.container.registerFactory(PageBeforePublishEventHandler, () => ({ + async handle(event) { + const { page } = event.payload; + page.properties = { ...page.properties, title: "Changed before publish" }; + } + })); + }); + + const result = await context.container.resolve(PublishPageUseCase).execute({ id: page.id }); + if (result.isFail()) { + throw result.error; + } + + expect(result.value.status).toBe("published"); + expect(result.value.properties.title).toBe("Changed before publish"); + + const stored = await context.container.resolve(GetPageByIdUseCase).execute(page.id); + if (stored.isFail()) { + throw stored.error; + } + expect(stored.value.status).toBe("published"); + expect(stored.value.properties.title).toBe("Changed before publish"); + }); + + it("does not publish when a page.beforePublish handler throws", async () => { + const { context, page } = await setup(context => { + context.container.registerFactory(PageBeforePublishEventHandler, () => ({ + async handle() { + throw new Error("Page is not ready to be published."); + } + })); + }); + + await expect( + context.container.resolve(PublishPageUseCase).execute({ id: page.id }) + ).rejects.toThrow("Page is not ready to be published."); + + const stored = await context.container.resolve(GetPageByIdUseCase).execute(page.id); + if (stored.isFail()) { + throw stored.error; + } + expect(stored.value.status).toBe("draft"); + }); + + it("creates the revision as a page.beforeCreateRevisionFrom handler changed it", async () => { + const { context, page } = await setup(context => { + context.container.registerFactory(PageBeforeCreateRevisionFromEventHandler, () => ({ + async handle(event) { + const { page, params } = event.payload; + expect(page.id).toBe(params.id); + page.properties = { ...page.properties, title: "Changed in new revision" }; + } + })); + }); + + const result = await context.container + .resolve(CreatePageRevisionFromUseCase) + .execute({ id: page.id }); + if (result.isFail()) { + throw result.error; + } + + expect(result.value.version).toBe(2); + expect(result.value.properties.title).toBe("Changed in new revision"); + + // The source revision is left as it was. + const source = await context.container.resolve(GetPageByIdUseCase).execute(page.id); + if (source.isFail()) { + throw source.error; + } + expect(source.value.properties.title).toBe("Page A"); + }); + + it("does not create a revision when a page.beforeCreateRevisionFrom handler throws", async () => { + const { context, page } = await setup(context => { + context.container.registerFactory(PageBeforeCreateRevisionFromEventHandler, () => ({ + async handle() { + throw new Error("No new revisions for this page."); + } + })); + }); + + await expect( + context.container.resolve(CreatePageRevisionFromUseCase).execute({ id: page.id }) + ).rejects.toThrow("No new revisions for this page."); + }); +}); diff --git a/packages/api-website-builder/src/domain/page/PageToEntryValuesMapper.ts b/packages/api-website-builder/src/domain/page/PageToEntryValuesMapper.ts new file mode 100644 index 00000000000..900805d2557 --- /dev/null +++ b/packages/api-website-builder/src/domain/page/PageToEntryValuesMapper.ts @@ -0,0 +1,17 @@ +import type { CmsEntryWbPage, WbPage } from "~/domain/page/abstractions.js"; + +/** + * The page fields stored as CMS entry values. Used to save a page that a "before" event + * handler may have changed. + */ +export class PageToEntryValuesMapper { + static toValues(page: WbPage): CmsEntryWbPage { + return { + properties: page.properties, + metadata: page.metadata, + bindings: page.bindings, + elements: page.elements, + extensions: page.extensions + }; + } +} diff --git a/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/CreatePageRevisionFromRepository.ts b/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/CreatePageRevisionFromRepository.ts index d1df2c77f7a..fa80e897c4b 100644 --- a/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/CreatePageRevisionFromRepository.ts +++ b/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/CreatePageRevisionFromRepository.ts @@ -31,7 +31,7 @@ class CreatePageRevisionFromRepositoryImpl implements RepositoryAbstraction.Inte // Create revision from the existing page const result = await this.createRevisionFrom.execute(this.pageModel, params.id, { location: getResult.value.location, - values: {} + values: params.values }); if (result.isFail()) { diff --git a/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/CreatePageRevisionFromUseCase.ts b/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/CreatePageRevisionFromUseCase.ts index b4955a4d806..9ee04cd0a14 100644 --- a/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/CreatePageRevisionFromUseCase.ts +++ b/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/CreatePageRevisionFromUseCase.ts @@ -11,6 +11,7 @@ import { PageBeforeCreateRevisionFromEvent, PageAfterCreateRevisionFromEvent } f import { GetPageByIdUseCase } from "~/features/pages/GetPageById/index.js"; import { WbPermissions } from "~/features/permissions/abstractions.js"; import { PageNotAuthorizedError } from "~/domain/page/errors.js"; +import { PageToEntryValuesMapper } from "~/domain/page/PageToEntryValuesMapper.js"; class CreatePageRevisionFromUseCaseImpl implements UseCaseAbstraction.Interface { constructor( @@ -34,13 +35,17 @@ class CreatePageRevisionFromUseCaseImpl implements UseCaseAbstraction.Interface // Publish before event const beforeEvent = new PageBeforeCreateRevisionFromEvent({ - params + params, + page: getResult.value }); await this.eventPublisher.publish(beforeEvent); - // Execute the create revision operation - const result = await this.repository.execute(params); + // Create the revision with the values the handlers left on the page. + const result = await this.repository.execute({ + id: params.id, + values: PageToEntryValuesMapper.toValues(beforeEvent.payload.page) + }); if (result.isFail()) { return result; diff --git a/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/abstractions.ts b/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/abstractions.ts index 33e99a317aa..9873757f562 100644 --- a/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/abstractions.ts +++ b/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction, type Result } from "@webiny/feature/api"; import type { IEventHandler } from "@webiny/api-core/features/eventPublisher/index.js"; import type { DomainEvent } from "@webiny/api-core/features/eventPublisher/index.js"; -import type { WbPage } from "~/domain/page/abstractions.js"; +import type { CmsEntryWbPage, WbPage } from "~/domain/page/abstractions.js"; import { PageNotFoundError, PagePersistenceError, @@ -21,8 +21,18 @@ export interface ICreateWbPageRevisionFromParams { // Repository Abstraction // ============================================================================ +export interface ICreatePageRevisionFromRepositoryParams extends ICreateWbPageRevisionFromParams { + /** + * The values to create the new revision with. They can differ from the source revision's + * after `page.beforeCreateRevisionFrom`. + */ + values: CmsEntryWbPage; +} + export interface ICreatePageRevisionFromRepository { - execute(params: ICreateWbPageRevisionFromParams): Promise>; + execute( + params: ICreatePageRevisionFromRepositoryParams + ): Promise>; } export interface ICreatePageRevisionFromRepositoryErrors { @@ -39,7 +49,7 @@ export const CreatePageRevisionFromRepository = export namespace CreatePageRevisionFromRepository { export type Interface = ICreatePageRevisionFromRepository; - export type Params = ICreateWbPageRevisionFromParams; + export type Params = ICreatePageRevisionFromRepositoryParams; export type Return = Promise>; export type Error = RepositoryError; } @@ -81,6 +91,11 @@ export namespace CreatePageRevisionFromUseCase { export interface PageBeforeCreateRevisionFromPayload { params: ICreateWbPageRevisionFromParams; + /** + * The page the new revision is created from. Changes made by handlers are what the new + * revision is created with. + */ + page: WbPage; } export interface PageAfterCreateRevisionFromPayload { diff --git a/packages/api-website-builder/src/features/pages/PublishPage/PublishPageRepository.ts b/packages/api-website-builder/src/features/pages/PublishPage/PublishPageRepository.ts index d72c30f5c45..c4a88902fd0 100644 --- a/packages/api-website-builder/src/features/pages/PublishPage/PublishPageRepository.ts +++ b/packages/api-website-builder/src/features/pages/PublishPage/PublishPageRepository.ts @@ -25,7 +25,9 @@ class PublishPageRepositoryImpl implements RepositoryAbstraction.Interface { } // Publish the entry - const result = await this.publishEntry.execute(this.pageModel, params.id); + const result = await this.publishEntry.execute(this.pageModel, params.id, { + values: params.values + }); if (result.isFail()) { if (result.error.code === "Cms/Entry/NotFound") { diff --git a/packages/api-website-builder/src/features/pages/PublishPage/PublishPageUseCase.ts b/packages/api-website-builder/src/features/pages/PublishPage/PublishPageUseCase.ts index e245663cfec..d7d81509d4d 100644 --- a/packages/api-website-builder/src/features/pages/PublishPage/PublishPageUseCase.ts +++ b/packages/api-website-builder/src/features/pages/PublishPage/PublishPageUseCase.ts @@ -8,6 +8,7 @@ import { PageBeforePublishEvent, PageAfterPublishEvent } from "./events.js"; import { GetPageByIdUseCase } from "~/features/pages/GetPageById/index.js"; import { WbPermissions } from "~/features/permissions/abstractions.js"; import { PageNotAuthorizedError } from "~/domain/page/errors.js"; +import { PageToEntryValuesMapper } from "~/domain/page/PageToEntryValuesMapper.js"; class PublishPageUseCaseImpl implements UseCaseAbstraction.Interface { constructor( @@ -44,8 +45,11 @@ class PublishPageUseCaseImpl implements UseCaseAbstraction.Interface { await this.eventPublisher.publish(beforeEvent); - // Execute the publish operation - const result = await this.repository.execute(params); + // Publish the page as the handlers left it. + const result = await this.repository.execute({ + id: params.id, + values: PageToEntryValuesMapper.toValues(beforeEvent.payload.page) + }); if (result.isFail()) { return result; diff --git a/packages/api-website-builder/src/features/pages/PublishPage/abstractions.ts b/packages/api-website-builder/src/features/pages/PublishPage/abstractions.ts index 348aa3b28bc..f915dc03fab 100644 --- a/packages/api-website-builder/src/features/pages/PublishPage/abstractions.ts +++ b/packages/api-website-builder/src/features/pages/PublishPage/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction, type Result } from "@webiny/feature/api"; import type { IEventHandler } from "@webiny/api-core/features/eventPublisher/index.js"; import type { DomainEvent } from "@webiny/api-core/features/eventPublisher/index.js"; -import type { WbPage } from "~/domain/page/abstractions.js"; +import type { CmsEntryWbPage, WbPage } from "~/domain/page/abstractions.js"; import { PageNotFoundError, PagePersistenceError, @@ -16,12 +16,17 @@ export interface IPublishWbPageParams { id: string; } +export interface IPublishPageRepositoryParams extends IPublishWbPageParams { + /** The page values to publish. They can differ from the stored ones after `page.beforePublish`. */ + values: CmsEntryWbPage; +} + // ============================================================================ // Repository Abstraction // ============================================================================ export interface IPublishPageRepository { - execute(params: IPublishWbPageParams): Promise>; + execute(params: IPublishPageRepositoryParams): Promise>; } export interface IPublishPageRepositoryErrors { @@ -37,7 +42,7 @@ export const PublishPageRepository = createAbstraction( export namespace PublishPageRepository { export type Interface = IPublishPageRepository; - export type Params = IPublishWbPageParams; + export type Params = IPublishPageRepositoryParams; export type Return = Promise>; export type Error = RepositoryError; } @@ -74,6 +79,7 @@ export namespace PublishPageUseCase { // ============================================================================ export interface PageBeforePublishPayload { + /** The page about to be published. Changes made by handlers are what gets published. */ page: WbPage; } From cf12038517e97441811e1f52a47d43956269707f Mon Sep 17 00:00:00 2001 From: adrians5j Date: Thu, 15 Oct 2026 22:48:29 +0200 Subject: [PATCH 2/5] fix(api-website-builder): give page before-events one shape and save what handlers leave in input `page.beforePublish` and `page.beforeCreateRevisionFrom` now carry `{ original, input: { id, data } }`, like `page.beforeUpdate`. `original` is the stored revision and `input.data` is what gets saved. `page.beforePublish` keeps `page` as a deprecated alias of `original`, so existing handlers keep working. Create, update, publish and create-revision-from now save the input as it is on the event after the handlers ran. A handler that replaces `input` or `input.data` is no longer ignored. Before, only in-place edits reached the write, because the use cases saved their own reference to the input. The workflows `page.beforePublish` handler reads `original` instead of the deprecated `page`. Co-Authored-By: Claude Opus 5.5 --- ...alidateWorkflowStateOnPageBeforePublish.ts | 2 +- .../__tests__/pages.beforeEvents.test.ts | 117 +++++++++++++----- .../pages/CreatePage/CreatePageUseCase.ts | 4 +- .../CreatePageRevisionFromUseCase.ts | 8 +- .../CreatePageRevisionFrom/abstractions.ts | 12 +- .../pages/PublishPage/PublishPageUseCase.ts | 6 +- .../pages/PublishPage/abstractions.ts | 9 +- .../pages/UpdatePage/UpdatePageUseCase.ts | 6 +- 8 files changed, 115 insertions(+), 49 deletions(-) diff --git a/packages/api-website-builder-workflows/src/features/PageWorkflows/handlers/ValidateWorkflowStateOnPageBeforePublish.ts b/packages/api-website-builder-workflows/src/features/PageWorkflows/handlers/ValidateWorkflowStateOnPageBeforePublish.ts index 46c1055c7fb..941e236486c 100644 --- a/packages/api-website-builder-workflows/src/features/PageWorkflows/handlers/ValidateWorkflowStateOnPageBeforePublish.ts +++ b/packages/api-website-builder-workflows/src/features/PageWorkflows/handlers/ValidateWorkflowStateOnPageBeforePublish.ts @@ -9,7 +9,7 @@ class ValidateWorkflowStateOnPageBeforePublishImpl constructor(private getTargetState: GetTargetWorkflowStateUseCase.Interface) {} async handle(event: PageBeforePublishEventHandler.Event): Promise { - const { page } = event.payload; + const { original: page } = event.payload; const stateResult = await this.getTargetState.execute({ app: WB_PAGE_APP, diff --git a/packages/api-website-builder/__tests__/pages.beforeEvents.test.ts b/packages/api-website-builder/__tests__/pages.beforeEvents.test.ts index 36fe7d91b71..61f6dc0cac3 100644 --- a/packages/api-website-builder/__tests__/pages.beforeEvents.test.ts +++ b/packages/api-website-builder/__tests__/pages.beforeEvents.test.ts @@ -2,7 +2,14 @@ import { describe, expect, it } from "vitest"; import { createContextPlugin } from "@webiny/api"; import { useHandler } from "./utils/useHandler.js"; import { pageMocks } from "./mocks/page.mock.js"; -import { CreatePageUseCase } from "~/features/pages/CreatePage/index.js"; +import { + CreatePageUseCase, + PageBeforeCreateEventHandler +} from "~/features/pages/CreatePage/index.js"; +import { + PageBeforeUpdateEventHandler, + UpdatePageUseCase +} from "~/features/pages/UpdatePage/index.js"; import { GetPageByIdUseCase } from "~/features/pages/GetPageById/index.js"; import { PageBeforePublishEventHandler, @@ -14,10 +21,10 @@ import { } from "~/features/pages/CreatePageRevisionFrom/index.js"; /* - * "Before" event handlers run after authorization and before the write. Whatever they leave on - * the page is what gets saved, and throwing stops the write. The handlers below replace - * `properties` instead of editing it in place, because an in-place edit can reach the write - * through a shared object even when the page itself is never passed on. + * "Before" event handlers run after authorization and before the write. Whatever they leave in + * `input` is what gets saved, and throwing stops the write. The handlers below replace the + * object instead of editing it in place, because an in-place edit can reach the write through a + * shared object even when the event's value is never passed on. */ const setup = async (register: Parameters[0]) => { const handler = useHandler({ plugins: [createContextPlugin(register)] }); @@ -30,16 +37,70 @@ const setup = async (register: Parameters[0]) => { throw createResult.error; } - return { context, page: createResult.value }; + const getStored = async (id: string) => { + const result = await context.container.resolve(GetPageByIdUseCase).execute(id); + if (result.isFail()) { + throw result.error; + } + return result.value; + }; + + return { context, page: createResult.value, getStored }; }; describe("Page before-event handlers", () => { - it("publishes the page as a page.beforePublish handler changed it", async () => { - const { context, page } = await setup(context => { + it("creates the page with the input a page.beforeCreate handler replaced", async () => { + const { page, getStored } = await setup(context => { + context.container.registerFactory(PageBeforeCreateEventHandler, () => ({ + async handle(event) { + const { input } = event.payload; + event.payload.input = { + ...input, + properties: { ...input.properties, title: "Changed before create" } + }; + } + })); + }); + + expect(page.properties.title).toBe("Changed before create"); + expect((await getStored(page.id)).properties.title).toBe("Changed before create"); + }); + + it("updates the page with the data a page.beforeUpdate handler replaced", async () => { + const { context, page, getStored } = await setup(context => { + context.container.registerFactory(PageBeforeUpdateEventHandler, () => ({ + async handle(event) { + const { original, input } = event.payload; + expect(original.properties.title).toBe("Page A"); + input.data = { + ...input.data, + properties: { ...input.data.properties, title: "Changed before update" } + }; + } + })); + }); + + const result = await context.container.resolve(UpdatePageUseCase).execute(page.id, { + properties: { ...page.properties, title: "Typed by the user" } + }); + if (result.isFail()) { + throw result.error; + } + + expect(result.value.properties.title).toBe("Changed before update"); + expect((await getStored(page.id)).properties.title).toBe("Changed before update"); + }); + + it("publishes the data a page.beforePublish handler replaced", async () => { + const { context, page, getStored } = await setup(context => { context.container.registerFactory(PageBeforePublishEventHandler, () => ({ async handle(event) { - const { page } = event.payload; - page.properties = { ...page.properties, title: "Changed before publish" }; + const { original, input } = event.payload; + expect(original.id).toBe(input.id); + input.data = { + ...input.data, + properties: { ...input.data.properties, title: "Changed before publish" } + }; } })); }); @@ -52,16 +113,13 @@ describe("Page before-event handlers", () => { expect(result.value.status).toBe("published"); expect(result.value.properties.title).toBe("Changed before publish"); - const stored = await context.container.resolve(GetPageByIdUseCase).execute(page.id); - if (stored.isFail()) { - throw stored.error; - } - expect(stored.value.status).toBe("published"); - expect(stored.value.properties.title).toBe("Changed before publish"); + const stored = await getStored(page.id); + expect(stored.status).toBe("published"); + expect(stored.properties.title).toBe("Changed before publish"); }); it("does not publish when a page.beforePublish handler throws", async () => { - const { context, page } = await setup(context => { + const { context, page, getStored } = await setup(context => { context.container.registerFactory(PageBeforePublishEventHandler, () => ({ async handle() { throw new Error("Page is not ready to be published."); @@ -73,20 +131,19 @@ describe("Page before-event handlers", () => { context.container.resolve(PublishPageUseCase).execute({ id: page.id }) ).rejects.toThrow("Page is not ready to be published."); - const stored = await context.container.resolve(GetPageByIdUseCase).execute(page.id); - if (stored.isFail()) { - throw stored.error; - } - expect(stored.value.status).toBe("draft"); + expect((await getStored(page.id)).status).toBe("draft"); }); - it("creates the revision as a page.beforeCreateRevisionFrom handler changed it", async () => { - const { context, page } = await setup(context => { + it("creates the revision with the data a page.beforeCreateRevisionFrom handler replaced", async () => { + const { context, page, getStored } = await setup(context => { context.container.registerFactory(PageBeforeCreateRevisionFromEventHandler, () => ({ async handle(event) { - const { page, params } = event.payload; - expect(page.id).toBe(params.id); - page.properties = { ...page.properties, title: "Changed in new revision" }; + const { original, input, params } = event.payload; + expect(original.id).toBe(params.id); + input.data = { + ...input.data, + properties: { ...input.data.properties, title: "Changed in new revision" } + }; } })); }); @@ -102,11 +159,7 @@ describe("Page before-event handlers", () => { expect(result.value.properties.title).toBe("Changed in new revision"); // The source revision is left as it was. - const source = await context.container.resolve(GetPageByIdUseCase).execute(page.id); - if (source.isFail()) { - throw source.error; - } - expect(source.value.properties.title).toBe("Page A"); + expect((await getStored(page.id)).properties.title).toBe("Page A"); }); it("does not create a revision when a page.beforeCreateRevisionFrom handler throws", async () => { diff --git a/packages/api-website-builder/src/features/pages/CreatePage/CreatePageUseCase.ts b/packages/api-website-builder/src/features/pages/CreatePage/CreatePageUseCase.ts index b6a2d3ad28a..cd759f695ec 100644 --- a/packages/api-website-builder/src/features/pages/CreatePage/CreatePageUseCase.ts +++ b/packages/api-website-builder/src/features/pages/CreatePage/CreatePageUseCase.ts @@ -26,8 +26,8 @@ class CreatePageUseCaseImpl implements UseCaseAbstraction.Interface { await this.eventPublisher.publish(beforeCreateEvent); - // Execute the create operation - const result = await this.repository.execute(data); + // Create the page as the handlers left it. + const result = await this.repository.execute(beforeCreateEvent.payload.input); if (result.isFail()) { return result; diff --git a/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/CreatePageRevisionFromUseCase.ts b/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/CreatePageRevisionFromUseCase.ts index 9ee04cd0a14..cb3cc0534d0 100644 --- a/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/CreatePageRevisionFromUseCase.ts +++ b/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/CreatePageRevisionFromUseCase.ts @@ -34,17 +34,19 @@ class CreatePageRevisionFromUseCaseImpl implements UseCaseAbstraction.Interface } // Publish before event + const original = getResult.value; const beforeEvent = new PageBeforeCreateRevisionFromEvent({ params, - page: getResult.value + original, + input: { id: params.id, data: PageToEntryValuesMapper.toValues(original) } }); await this.eventPublisher.publish(beforeEvent); - // Create the revision with the values the handlers left on the page. + // Create the revision with the data as the handlers left it. const result = await this.repository.execute({ id: params.id, - values: PageToEntryValuesMapper.toValues(beforeEvent.payload.page) + values: beforeEvent.payload.input.data }); if (result.isFail()) { diff --git a/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/abstractions.ts b/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/abstractions.ts index 9873757f562..8ea851eb2f5 100644 --- a/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/abstractions.ts +++ b/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/abstractions.ts @@ -91,11 +91,13 @@ export namespace CreatePageRevisionFromUseCase { export interface PageBeforeCreateRevisionFromPayload { params: ICreateWbPageRevisionFromParams; - /** - * The page the new revision is created from. Changes made by handlers are what the new - * revision is created with. - */ - page: WbPage; + /** The revision the new one is created from. */ + original: WbPage; + /** What the new revision is created with. Handlers can change or replace `data`. */ + input: { + id: string; + data: CmsEntryWbPage; + }; } export interface PageAfterCreateRevisionFromPayload { diff --git a/packages/api-website-builder/src/features/pages/PublishPage/PublishPageUseCase.ts b/packages/api-website-builder/src/features/pages/PublishPage/PublishPageUseCase.ts index d7d81509d4d..864c19e40c0 100644 --- a/packages/api-website-builder/src/features/pages/PublishPage/PublishPageUseCase.ts +++ b/packages/api-website-builder/src/features/pages/PublishPage/PublishPageUseCase.ts @@ -40,15 +40,17 @@ class PublishPageUseCaseImpl implements UseCaseAbstraction.Interface { // Publish before publish event const beforeEvent = new PageBeforePublishEvent({ + original: page, + input: { id: params.id, data: PageToEntryValuesMapper.toValues(page) }, page }); await this.eventPublisher.publish(beforeEvent); - // Publish the page as the handlers left it. + // Publish the data as the handlers left it. const result = await this.repository.execute({ id: params.id, - values: PageToEntryValuesMapper.toValues(beforeEvent.payload.page) + values: beforeEvent.payload.input.data }); if (result.isFail()) { diff --git a/packages/api-website-builder/src/features/pages/PublishPage/abstractions.ts b/packages/api-website-builder/src/features/pages/PublishPage/abstractions.ts index f915dc03fab..53eeea17d48 100644 --- a/packages/api-website-builder/src/features/pages/PublishPage/abstractions.ts +++ b/packages/api-website-builder/src/features/pages/PublishPage/abstractions.ts @@ -79,7 +79,14 @@ export namespace PublishPageUseCase { // ============================================================================ export interface PageBeforePublishPayload { - /** The page about to be published. Changes made by handlers are what gets published. */ + /** The stored revision being published. */ + original: WbPage; + /** What gets published. Handlers can change or replace `data`. */ + input: { + id: string; + data: CmsEntryWbPage; + }; + /** @deprecated Use `original`. Changes to it are not published. */ page: WbPage; } diff --git a/packages/api-website-builder/src/features/pages/UpdatePage/UpdatePageUseCase.ts b/packages/api-website-builder/src/features/pages/UpdatePage/UpdatePageUseCase.ts index edc3cc1a853..dbe3e539e78 100644 --- a/packages/api-website-builder/src/features/pages/UpdatePage/UpdatePageUseCase.ts +++ b/packages/api-website-builder/src/features/pages/UpdatePage/UpdatePageUseCase.ts @@ -45,8 +45,8 @@ class UpdatePageUseCaseImpl implements UseCaseAbstraction.Interface { await this.eventPublisher.publish(beforeEvent); - // Execute the update operation - const result = await this.repository.execute(id, data); + // Update with the data as the handlers left it. + const result = await this.repository.execute(id, beforeEvent.payload.input.data); if (result.isFail()) { return result; @@ -55,7 +55,7 @@ class UpdatePageUseCaseImpl implements UseCaseAbstraction.Interface { // Publish after update event const afterEvent = new PageAfterUpdateEvent({ original, - input: { id, data }, + input: beforeEvent.payload.input, page: result.value }); From c3a066dffb7426ec1a1a1b2ea9a75a6d99aeaadf Mon Sep 17 00:00:00 2001 From: adrians5j Date: Thu, 15 Oct 2026 22:50:58 +0200 Subject: [PATCH 3/5] fix(api-website-builder): drop the page field from page.beforePublish The payload is now `{ original, input: { id, data } }`, the same as `page.beforeUpdate`. `original` is the stored revision; `page` duplicated it. Co-Authored-By: Claude Opus 5.5 --- .../src/features/pages/PublishPage/PublishPageUseCase.ts | 3 +-- .../src/features/pages/PublishPage/abstractions.ts | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/api-website-builder/src/features/pages/PublishPage/PublishPageUseCase.ts b/packages/api-website-builder/src/features/pages/PublishPage/PublishPageUseCase.ts index 864c19e40c0..02d36193186 100644 --- a/packages/api-website-builder/src/features/pages/PublishPage/PublishPageUseCase.ts +++ b/packages/api-website-builder/src/features/pages/PublishPage/PublishPageUseCase.ts @@ -41,8 +41,7 @@ class PublishPageUseCaseImpl implements UseCaseAbstraction.Interface { // Publish before publish event const beforeEvent = new PageBeforePublishEvent({ original: page, - input: { id: params.id, data: PageToEntryValuesMapper.toValues(page) }, - page + input: { id: params.id, data: PageToEntryValuesMapper.toValues(page) } }); await this.eventPublisher.publish(beforeEvent); diff --git a/packages/api-website-builder/src/features/pages/PublishPage/abstractions.ts b/packages/api-website-builder/src/features/pages/PublishPage/abstractions.ts index 53eeea17d48..7602fc6fb53 100644 --- a/packages/api-website-builder/src/features/pages/PublishPage/abstractions.ts +++ b/packages/api-website-builder/src/features/pages/PublishPage/abstractions.ts @@ -86,8 +86,6 @@ export interface PageBeforePublishPayload { id: string; data: CmsEntryWbPage; }; - /** @deprecated Use `original`. Changes to it are not published. */ - page: WbPage; } export interface PageAfterPublishPayload { From 106475e99a78f962fc4a7b94e79b77724989a523 Mon Sep 17 00:00:00 2001 From: adrians5j Date: Fri, 16 Oct 2026 09:27:37 +0200 Subject: [PATCH 4/5] fix(api-website-builder): shape page before-events like CMS and File Manager events Every page before-event now carries `page`, the record that gets saved, next to `original` (the stored revision) and `input` (the request) where those exist. This matches `EntryBefore*` (`entry`) and `FileBefore*` (`file`). - `page.beforeCreate`: `{ input, page }` - `page.beforeUpdate`: `{ original, input, page }`, where `page` is `original` with the update applied - `page.beforePublish`: `{ original, page }` - `page.beforeCreateRevisionFrom`: `{ params, original, page }` `page` starts as a copy, so editing it never changes `original` or `input`. Whatever `page` holds after the handlers run is what gets saved. `IUpdateWbPageData` gains the optional `extensions` that create already had. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/pages.beforeEvents.test.ts | 54 ++++++++++--------- .../pages/CreatePage/CreatePageUseCase.ts | 7 ++- .../features/pages/CreatePage/abstractions.ts | 3 ++ .../CreatePageRevisionFromUseCase.ts | 6 +-- .../CreatePageRevisionFrom/abstractions.ts | 10 ++-- .../pages/PublishPage/PublishPageUseCase.ts | 6 +-- .../pages/PublishPage/abstractions.ts | 7 +-- .../pages/UpdatePage/UpdatePageUseCase.ts | 29 ++++++++-- .../features/pages/UpdatePage/abstractions.ts | 7 +++ 9 files changed, 82 insertions(+), 47 deletions(-) diff --git a/packages/api-website-builder/__tests__/pages.beforeEvents.test.ts b/packages/api-website-builder/__tests__/pages.beforeEvents.test.ts index 61f6dc0cac3..29281708fc7 100644 --- a/packages/api-website-builder/__tests__/pages.beforeEvents.test.ts +++ b/packages/api-website-builder/__tests__/pages.beforeEvents.test.ts @@ -21,10 +21,10 @@ import { } from "~/features/pages/CreatePageRevisionFrom/index.js"; /* - * "Before" event handlers run after authorization and before the write. Whatever they leave in - * `input` is what gets saved, and throwing stops the write. The handlers below replace the - * object instead of editing it in place, because an in-place edit can reach the write through a - * shared object even when the event's value is never passed on. + * "Before" event handlers run after authorization and before the write. Each event carries + * `page`, which is what gets saved, next to `original` (the stored revision) and `input` (the + * request) where those exist. Throwing stops the write. The handlers below replace `page` + * instead of editing it in place, so the tests prove the event's `page` is what gets saved. */ const setup = async (register: Parameters[0]) => { const handler = useHandler({ plugins: [createContextPlugin(register)] }); @@ -49,14 +49,14 @@ const setup = async (register: Parameters[0]) => { }; describe("Page before-event handlers", () => { - it("creates the page with the input a page.beforeCreate handler replaced", async () => { + it("creates the page a page.beforeCreate handler replaced", async () => { const { page, getStored } = await setup(context => { context.container.registerFactory(PageBeforeCreateEventHandler, () => ({ async handle(event) { - const { input } = event.payload; - event.payload.input = { - ...input, - properties: { ...input.properties, title: "Changed before create" } + const { page } = event.payload; + event.payload.page = { + ...page, + properties: { ...page.properties, title: "Changed before create" } }; } })); @@ -66,16 +66,20 @@ describe("Page before-event handlers", () => { expect((await getStored(page.id)).properties.title).toBe("Changed before create"); }); - it("updates the page with the data a page.beforeUpdate handler replaced", async () => { + it("saves the page a page.beforeUpdate handler replaced", async () => { const { context, page, getStored } = await setup(context => { context.container.registerFactory(PageBeforeUpdateEventHandler, () => ({ async handle(event) { - const { original, input } = event.payload; + const { original, input, page } = event.payload; expect(original.properties.title).toBe("Page A"); - input.data = { - ...input.data, - properties: { ...input.data.properties, title: "Changed before update" } + expect(input.data.properties?.title).toBe("Typed by the user"); + // `page` is `original` with the input applied. + expect(page.properties.title).toBe("Typed by the user"); + event.payload.page = { + ...page, + properties: { ...page.properties, title: "Changed before update" } }; + expect(original.properties.title).toBe("Page A"); } })); }); @@ -91,15 +95,15 @@ describe("Page before-event handlers", () => { expect((await getStored(page.id)).properties.title).toBe("Changed before update"); }); - it("publishes the data a page.beforePublish handler replaced", async () => { + it("publishes the page a page.beforePublish handler replaced", async () => { const { context, page, getStored } = await setup(context => { context.container.registerFactory(PageBeforePublishEventHandler, () => ({ async handle(event) { - const { original, input } = event.payload; - expect(original.id).toBe(input.id); - input.data = { - ...input.data, - properties: { ...input.data.properties, title: "Changed before publish" } + const { original, page } = event.payload; + expect(page.id).toBe(original.id); + event.payload.page = { + ...page, + properties: { ...page.properties, title: "Changed before publish" } }; } })); @@ -134,15 +138,15 @@ describe("Page before-event handlers", () => { expect((await getStored(page.id)).status).toBe("draft"); }); - it("creates the revision with the data a page.beforeCreateRevisionFrom handler replaced", async () => { + it("creates the revision from the page a page.beforeCreateRevisionFrom handler replaced", async () => { const { context, page, getStored } = await setup(context => { context.container.registerFactory(PageBeforeCreateRevisionFromEventHandler, () => ({ async handle(event) { - const { original, input, params } = event.payload; + const { original, page, params } = event.payload; expect(original.id).toBe(params.id); - input.data = { - ...input.data, - properties: { ...input.data.properties, title: "Changed in new revision" } + event.payload.page = { + ...page, + properties: { ...page.properties, title: "Changed in new revision" } }; } })); diff --git a/packages/api-website-builder/src/features/pages/CreatePage/CreatePageUseCase.ts b/packages/api-website-builder/src/features/pages/CreatePage/CreatePageUseCase.ts index cd759f695ec..5369055a19e 100644 --- a/packages/api-website-builder/src/features/pages/CreatePage/CreatePageUseCase.ts +++ b/packages/api-website-builder/src/features/pages/CreatePage/CreatePageUseCase.ts @@ -22,12 +22,15 @@ class CreatePageUseCaseImpl implements UseCaseAbstraction.Interface { } // Publish before create event - const beforeCreateEvent = new PageBeforeCreateEvent({ input: data }); + const beforeCreateEvent = new PageBeforeCreateEvent({ + input: data, + page: structuredClone(data) + }); await this.eventPublisher.publish(beforeCreateEvent); // Create the page as the handlers left it. - const result = await this.repository.execute(beforeCreateEvent.payload.input); + const result = await this.repository.execute(beforeCreateEvent.payload.page); if (result.isFail()) { return result; diff --git a/packages/api-website-builder/src/features/pages/CreatePage/abstractions.ts b/packages/api-website-builder/src/features/pages/CreatePage/abstractions.ts index 072b8925fc0..e0a97dbe13c 100644 --- a/packages/api-website-builder/src/features/pages/CreatePage/abstractions.ts +++ b/packages/api-website-builder/src/features/pages/CreatePage/abstractions.ts @@ -73,7 +73,10 @@ export namespace CreatePageUseCase { // ============================================================================ export interface PageBeforeCreatePayload { + /** The create request, as received. */ input: ICreateWbPageParams; + /** What gets created. Starts as a copy of `input`; handlers can change or replace it. */ + page: ICreateWbPageParams; } export interface PageAfterCreatePayload { diff --git a/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/CreatePageRevisionFromUseCase.ts b/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/CreatePageRevisionFromUseCase.ts index cb3cc0534d0..062ecce3a24 100644 --- a/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/CreatePageRevisionFromUseCase.ts +++ b/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/CreatePageRevisionFromUseCase.ts @@ -38,15 +38,15 @@ class CreatePageRevisionFromUseCaseImpl implements UseCaseAbstraction.Interface const beforeEvent = new PageBeforeCreateRevisionFromEvent({ params, original, - input: { id: params.id, data: PageToEntryValuesMapper.toValues(original) } + page: structuredClone(original) }); await this.eventPublisher.publish(beforeEvent); - // Create the revision with the data as the handlers left it. + // Create the revision with the page as the handlers left it. const result = await this.repository.execute({ id: params.id, - values: beforeEvent.payload.input.data + values: PageToEntryValuesMapper.toValues(beforeEvent.payload.page) }); if (result.isFail()) { diff --git a/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/abstractions.ts b/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/abstractions.ts index 8ea851eb2f5..7cf1cf7e877 100644 --- a/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/abstractions.ts +++ b/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/abstractions.ts @@ -93,11 +93,11 @@ export interface PageBeforeCreateRevisionFromPayload { params: ICreateWbPageRevisionFromParams; /** The revision the new one is created from. */ original: WbPage; - /** What the new revision is created with. Handlers can change or replace `data`. */ - input: { - id: string; - data: CmsEntryWbPage; - }; + /** + * What the new revision is created with. Starts as a copy of `original`; handlers can change + * or replace it. + */ + page: WbPage; } export interface PageAfterCreateRevisionFromPayload { diff --git a/packages/api-website-builder/src/features/pages/PublishPage/PublishPageUseCase.ts b/packages/api-website-builder/src/features/pages/PublishPage/PublishPageUseCase.ts index 02d36193186..820efed75df 100644 --- a/packages/api-website-builder/src/features/pages/PublishPage/PublishPageUseCase.ts +++ b/packages/api-website-builder/src/features/pages/PublishPage/PublishPageUseCase.ts @@ -41,15 +41,15 @@ class PublishPageUseCaseImpl implements UseCaseAbstraction.Interface { // Publish before publish event const beforeEvent = new PageBeforePublishEvent({ original: page, - input: { id: params.id, data: PageToEntryValuesMapper.toValues(page) } + page: structuredClone(page) }); await this.eventPublisher.publish(beforeEvent); - // Publish the data as the handlers left it. + // Publish the page as the handlers left it. const result = await this.repository.execute({ id: params.id, - values: beforeEvent.payload.input.data + values: PageToEntryValuesMapper.toValues(beforeEvent.payload.page) }); if (result.isFail()) { diff --git a/packages/api-website-builder/src/features/pages/PublishPage/abstractions.ts b/packages/api-website-builder/src/features/pages/PublishPage/abstractions.ts index 7602fc6fb53..7e8881beb46 100644 --- a/packages/api-website-builder/src/features/pages/PublishPage/abstractions.ts +++ b/packages/api-website-builder/src/features/pages/PublishPage/abstractions.ts @@ -81,11 +81,8 @@ export namespace PublishPageUseCase { export interface PageBeforePublishPayload { /** The stored revision being published. */ original: WbPage; - /** What gets published. Handlers can change or replace `data`. */ - input: { - id: string; - data: CmsEntryWbPage; - }; + /** What gets published. Starts as a copy of `original`; handlers can change or replace it. */ + page: WbPage; } export interface PageAfterPublishPayload { diff --git a/packages/api-website-builder/src/features/pages/UpdatePage/UpdatePageUseCase.ts b/packages/api-website-builder/src/features/pages/UpdatePage/UpdatePageUseCase.ts index dbe3e539e78..fb8a2b6ac56 100644 --- a/packages/api-website-builder/src/features/pages/UpdatePage/UpdatePageUseCase.ts +++ b/packages/api-website-builder/src/features/pages/UpdatePage/UpdatePageUseCase.ts @@ -8,6 +8,21 @@ import { PageBeforeUpdateEvent, PageAfterUpdateEvent } from "./events.js"; import { GetPageByIdUseCase } from "~/features/pages/GetPageById/index.js"; import { WbPermissions } from "~/features/permissions/abstractions.js"; import { PageNotAuthorizedError } from "~/domain/page/errors.js"; +import { PageToEntryValuesMapper } from "~/domain/page/PageToEntryValuesMapper.js"; +import type { WbPage } from "~/domain/page/abstractions.js"; + +/** The page as it will be after the update. Fields the update leaves out keep their stored value. */ +const applyUpdate = (original: WbPage, data: UseCaseAbstraction.UpdateData): WbPage => { + return { + ...original, + location: data.location ?? original.location, + properties: (data.properties as WbPage["properties"] | undefined) ?? original.properties, + metadata: data.metadata ?? original.metadata, + bindings: data.bindings ?? original.bindings, + elements: data.elements ?? original.elements, + extensions: data.extensions ?? original.extensions + }; +}; class UpdatePageUseCaseImpl implements UseCaseAbstraction.Interface { constructor( @@ -40,13 +55,19 @@ class UpdatePageUseCaseImpl implements UseCaseAbstraction.Interface { // Publish before update event const beforeEvent = new PageBeforeUpdateEvent({ original, - input: { id, data } + input: { id, data }, + page: structuredClone(applyUpdate(original, data)) }); await this.eventPublisher.publish(beforeEvent); - // Update with the data as the handlers left it. - const result = await this.repository.execute(id, beforeEvent.payload.input.data); + // Save the page as the handlers left it. + const { page } = beforeEvent.payload; + const result = await this.repository.execute(id, { + ...PageToEntryValuesMapper.toValues(page), + location: page.location, + system: data.system + }); if (result.isFail()) { return result; @@ -55,7 +76,7 @@ class UpdatePageUseCaseImpl implements UseCaseAbstraction.Interface { // Publish after update event const afterEvent = new PageAfterUpdateEvent({ original, - input: beforeEvent.payload.input, + input: { id, data }, page: result.value }); diff --git a/packages/api-website-builder/src/features/pages/UpdatePage/abstractions.ts b/packages/api-website-builder/src/features/pages/UpdatePage/abstractions.ts index 5ff0c0dcc28..0ea419fdcd3 100644 --- a/packages/api-website-builder/src/features/pages/UpdatePage/abstractions.ts +++ b/packages/api-website-builder/src/features/pages/UpdatePage/abstractions.ts @@ -20,6 +20,7 @@ export interface IUpdateWbPageData { metadata?: Record; bindings?: Record; elements?: Record; + extensions?: Record; system?: ICmsEntrySystem; } @@ -82,11 +83,17 @@ export namespace UpdatePageUseCase { // ============================================================================ export interface PageBeforeUpdatePayload { + /** The stored revision being updated. */ original: WbPage; + /** The update request, as received. */ input: { id: string; data: IUpdateWbPageData; }; + /** + * What gets saved: `original` with `input.data` applied. Handlers can change or replace it. + */ + page: WbPage; } export interface PageAfterUpdatePayload { From dbbffca7f97a6063b848f8f689d41a57f465530e Mon Sep 17 00:00:00 2001 From: adrians5j Date: Fri, 16 Oct 2026 12:40:30 +0200 Subject: [PATCH 5/5] fix(api-website-builder): save the page object handlers edit, like CMS events Each use case now saves the `page` object it put on the event, so handlers edit `page` in place. Assigning a new object to `event.payload.page` is not picked up. This matches CMS (`entry`) and File Manager (`file`) before-events. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/pages.beforeEvents.test.ts | 34 +++++++------------ .../pages/CreatePage/CreatePageUseCase.ts | 11 +++--- .../features/pages/CreatePage/abstractions.ts | 2 +- .../CreatePageRevisionFromUseCase.ts | 8 ++--- .../CreatePageRevisionFrom/abstractions.ts | 3 +- .../pages/PublishPage/PublishPageUseCase.ts | 8 ++--- .../pages/PublishPage/abstractions.ts | 2 +- .../pages/UpdatePage/UpdatePageUseCase.ts | 7 ++-- .../features/pages/UpdatePage/abstractions.ts | 2 +- 9 files changed, 31 insertions(+), 46 deletions(-) diff --git a/packages/api-website-builder/__tests__/pages.beforeEvents.test.ts b/packages/api-website-builder/__tests__/pages.beforeEvents.test.ts index 29281708fc7..283c5b74cd0 100644 --- a/packages/api-website-builder/__tests__/pages.beforeEvents.test.ts +++ b/packages/api-website-builder/__tests__/pages.beforeEvents.test.ts @@ -23,8 +23,8 @@ import { /* * "Before" event handlers run after authorization and before the write. Each event carries * `page`, which is what gets saved, next to `original` (the stored revision) and `input` (the - * request) where those exist. Throwing stops the write. The handlers below replace `page` - * instead of editing it in place, so the tests prove the event's `page` is what gets saved. + * request) where those exist. Handlers edit `page` in place, as with CMS and File Manager + * events. Throwing stops the write. */ const setup = async (register: Parameters[0]) => { const handler = useHandler({ plugins: [createContextPlugin(register)] }); @@ -49,15 +49,12 @@ const setup = async (register: Parameters[0]) => { }; describe("Page before-event handlers", () => { - it("creates the page a page.beforeCreate handler replaced", async () => { + it("creates the page a page.beforeCreate handler edited", async () => { const { page, getStored } = await setup(context => { context.container.registerFactory(PageBeforeCreateEventHandler, () => ({ async handle(event) { const { page } = event.payload; - event.payload.page = { - ...page, - properties: { ...page.properties, title: "Changed before create" } - }; + page.properties.title = "Changed before create"; } })); }); @@ -66,7 +63,7 @@ describe("Page before-event handlers", () => { expect((await getStored(page.id)).properties.title).toBe("Changed before create"); }); - it("saves the page a page.beforeUpdate handler replaced", async () => { + it("saves the page a page.beforeUpdate handler edited", async () => { const { context, page, getStored } = await setup(context => { context.container.registerFactory(PageBeforeUpdateEventHandler, () => ({ async handle(event) { @@ -75,11 +72,10 @@ describe("Page before-event handlers", () => { expect(input.data.properties?.title).toBe("Typed by the user"); // `page` is `original` with the input applied. expect(page.properties.title).toBe("Typed by the user"); - event.payload.page = { - ...page, - properties: { ...page.properties, title: "Changed before update" } - }; + page.properties.title = "Changed before update"; + // `page` is a copy, so neither `original` nor `input` changes with it. expect(original.properties.title).toBe("Page A"); + expect(input.data.properties?.title).toBe("Typed by the user"); } })); }); @@ -95,16 +91,13 @@ describe("Page before-event handlers", () => { expect((await getStored(page.id)).properties.title).toBe("Changed before update"); }); - it("publishes the page a page.beforePublish handler replaced", async () => { + it("publishes the page a page.beforePublish handler edited", async () => { const { context, page, getStored } = await setup(context => { context.container.registerFactory(PageBeforePublishEventHandler, () => ({ async handle(event) { const { original, page } = event.payload; expect(page.id).toBe(original.id); - event.payload.page = { - ...page, - properties: { ...page.properties, title: "Changed before publish" } - }; + page.properties.title = "Changed before publish"; } })); }); @@ -138,16 +131,13 @@ describe("Page before-event handlers", () => { expect((await getStored(page.id)).status).toBe("draft"); }); - it("creates the revision from the page a page.beforeCreateRevisionFrom handler replaced", async () => { + it("creates the revision from the page a page.beforeCreateRevisionFrom handler edited", async () => { const { context, page, getStored } = await setup(context => { context.container.registerFactory(PageBeforeCreateRevisionFromEventHandler, () => ({ async handle(event) { const { original, page, params } = event.payload; expect(original.id).toBe(params.id); - event.payload.page = { - ...page, - properties: { ...page.properties, title: "Changed in new revision" } - }; + page.properties.title = "Changed in new revision"; } })); }); diff --git a/packages/api-website-builder/src/features/pages/CreatePage/CreatePageUseCase.ts b/packages/api-website-builder/src/features/pages/CreatePage/CreatePageUseCase.ts index 5369055a19e..b13c5ebdb8d 100644 --- a/packages/api-website-builder/src/features/pages/CreatePage/CreatePageUseCase.ts +++ b/packages/api-website-builder/src/features/pages/CreatePage/CreatePageUseCase.ts @@ -21,16 +21,13 @@ class CreatePageUseCaseImpl implements UseCaseAbstraction.Interface { return Result.fail(new PageNotAuthorizedError()); } - // Publish before create event - const beforeCreateEvent = new PageBeforeCreateEvent({ - input: data, - page: structuredClone(data) - }); + // Publish before create event. Handlers can edit `page`, and that object is what we save. + const pageToCreate = structuredClone(data); + const beforeCreateEvent = new PageBeforeCreateEvent({ input: data, page: pageToCreate }); await this.eventPublisher.publish(beforeCreateEvent); - // Create the page as the handlers left it. - const result = await this.repository.execute(beforeCreateEvent.payload.page); + const result = await this.repository.execute(pageToCreate); if (result.isFail()) { return result; diff --git a/packages/api-website-builder/src/features/pages/CreatePage/abstractions.ts b/packages/api-website-builder/src/features/pages/CreatePage/abstractions.ts index e0a97dbe13c..bde4496d52e 100644 --- a/packages/api-website-builder/src/features/pages/CreatePage/abstractions.ts +++ b/packages/api-website-builder/src/features/pages/CreatePage/abstractions.ts @@ -75,7 +75,7 @@ export namespace CreatePageUseCase { export interface PageBeforeCreatePayload { /** The create request, as received. */ input: ICreateWbPageParams; - /** What gets created. Starts as a copy of `input`; handlers can change or replace it. */ + /** What gets created. Starts as a copy of `input`; handlers can edit it. */ page: ICreateWbPageParams; } diff --git a/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/CreatePageRevisionFromUseCase.ts b/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/CreatePageRevisionFromUseCase.ts index 062ecce3a24..d14839de68d 100644 --- a/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/CreatePageRevisionFromUseCase.ts +++ b/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/CreatePageRevisionFromUseCase.ts @@ -33,20 +33,20 @@ class CreatePageRevisionFromUseCaseImpl implements UseCaseAbstraction.Interface return getResult; } - // Publish before event + // Publish before event. Handlers can edit `page`, and that object is what the revision is created from. const original = getResult.value; + const pageToCreate = structuredClone(original); const beforeEvent = new PageBeforeCreateRevisionFromEvent({ params, original, - page: structuredClone(original) + page: pageToCreate }); await this.eventPublisher.publish(beforeEvent); - // Create the revision with the page as the handlers left it. const result = await this.repository.execute({ id: params.id, - values: PageToEntryValuesMapper.toValues(beforeEvent.payload.page) + values: PageToEntryValuesMapper.toValues(pageToCreate) }); if (result.isFail()) { diff --git a/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/abstractions.ts b/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/abstractions.ts index 7cf1cf7e877..1794890abf2 100644 --- a/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/abstractions.ts +++ b/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/abstractions.ts @@ -94,8 +94,7 @@ export interface PageBeforeCreateRevisionFromPayload { /** The revision the new one is created from. */ original: WbPage; /** - * What the new revision is created with. Starts as a copy of `original`; handlers can change - * or replace it. + * What the new revision is created with. Starts as a copy of `original`; handlers can edit it. */ page: WbPage; } diff --git a/packages/api-website-builder/src/features/pages/PublishPage/PublishPageUseCase.ts b/packages/api-website-builder/src/features/pages/PublishPage/PublishPageUseCase.ts index 820efed75df..a7b2f7e5ad2 100644 --- a/packages/api-website-builder/src/features/pages/PublishPage/PublishPageUseCase.ts +++ b/packages/api-website-builder/src/features/pages/PublishPage/PublishPageUseCase.ts @@ -38,18 +38,18 @@ class PublishPageUseCaseImpl implements UseCaseAbstraction.Interface { return Result.fail(new PageNotAuthorizedError()); } - // Publish before publish event + // Publish before publish event. Handlers can edit `page`, and that object is what we publish. + const pageToPublish = structuredClone(page); const beforeEvent = new PageBeforePublishEvent({ original: page, - page: structuredClone(page) + page: pageToPublish }); await this.eventPublisher.publish(beforeEvent); - // Publish the page as the handlers left it. const result = await this.repository.execute({ id: params.id, - values: PageToEntryValuesMapper.toValues(beforeEvent.payload.page) + values: PageToEntryValuesMapper.toValues(pageToPublish) }); if (result.isFail()) { diff --git a/packages/api-website-builder/src/features/pages/PublishPage/abstractions.ts b/packages/api-website-builder/src/features/pages/PublishPage/abstractions.ts index 7e8881beb46..7b52811bd72 100644 --- a/packages/api-website-builder/src/features/pages/PublishPage/abstractions.ts +++ b/packages/api-website-builder/src/features/pages/PublishPage/abstractions.ts @@ -81,7 +81,7 @@ export namespace PublishPageUseCase { export interface PageBeforePublishPayload { /** The stored revision being published. */ original: WbPage; - /** What gets published. Starts as a copy of `original`; handlers can change or replace it. */ + /** What gets published. Starts as a copy of `original`; handlers can edit it. */ page: WbPage; } diff --git a/packages/api-website-builder/src/features/pages/UpdatePage/UpdatePageUseCase.ts b/packages/api-website-builder/src/features/pages/UpdatePage/UpdatePageUseCase.ts index fb8a2b6ac56..5516c04276b 100644 --- a/packages/api-website-builder/src/features/pages/UpdatePage/UpdatePageUseCase.ts +++ b/packages/api-website-builder/src/features/pages/UpdatePage/UpdatePageUseCase.ts @@ -52,17 +52,16 @@ class UpdatePageUseCaseImpl implements UseCaseAbstraction.Interface { return Result.fail(new PageNotAuthorizedError()); } - // Publish before update event + // Publish before update event. Handlers can edit `page`, and that object is what we save. + const page = structuredClone(applyUpdate(original, data)); const beforeEvent = new PageBeforeUpdateEvent({ original, input: { id, data }, - page: structuredClone(applyUpdate(original, data)) + page }); await this.eventPublisher.publish(beforeEvent); - // Save the page as the handlers left it. - const { page } = beforeEvent.payload; const result = await this.repository.execute(id, { ...PageToEntryValuesMapper.toValues(page), location: page.location, diff --git a/packages/api-website-builder/src/features/pages/UpdatePage/abstractions.ts b/packages/api-website-builder/src/features/pages/UpdatePage/abstractions.ts index 0ea419fdcd3..2b591fbaa91 100644 --- a/packages/api-website-builder/src/features/pages/UpdatePage/abstractions.ts +++ b/packages/api-website-builder/src/features/pages/UpdatePage/abstractions.ts @@ -91,7 +91,7 @@ export interface PageBeforeUpdatePayload { data: IUpdateWbPageData; }; /** - * What gets saved: `original` with `input.data` applied. Handlers can change or replace it. + * What gets saved: `original` with `input.data` applied. Handlers can edit it. */ page: WbPage; }