Skip to content

Commit 12b0521

Browse files
adrians5jclaudePavel910
authored
fix(website-builder): page revision cache, list actions and workflow gating (#6004)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Pavel Denisjuk <pavel@webiny.com>
1 parent 2798ccc commit 12b0521

51 files changed

Lines changed: 1440 additions & 178 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎packages/api-website-builder/__tests__/graphql/pages.gql.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,17 @@ export const DELETE_PAGE = /* GraphQL */ `
139139
}
140140
`;
141141

142+
export const DELETE_PAGE_REVISION = /* GraphQL */ `
143+
mutation DeletePageRevision($id: ID!) {
144+
websiteBuilder {
145+
deletePageRevision(id: $id) {
146+
data
147+
error ${ERROR_FIELD}
148+
}
149+
}
150+
}
151+
`;
152+
142153
export const RESTORE_PAGE = /* GraphQL */ `
143154
mutation RestorePage($id: ID!) {
144155
websiteBuilder {

‎packages/api-website-builder/__tests__/pages.gql.test.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,44 @@ describe("Pages CRUD", () => {
200200
expect(revisions[0].data.websiteBuilder.getPageRevisions.data[1].version).toBe(2);
201201
});
202202

203+
it("should delete a page revision", async () => {
204+
const [createResponse] = await handler.wb.createPage({
205+
data: pageMocks.pageA
206+
});
207+
const page = createResponse.data.websiteBuilder.createPage.data;
208+
209+
await handler.wb.publishPage({ id: page.id });
210+
const [revisionResponse] = await handler.wb.createPageRevisionFrom({ id: page.id });
211+
const newRevision = revisionResponse.data.websiteBuilder.createPageRevisionFrom.data;
212+
213+
const [deleteResponse] = await handler.wb.deletePageRevision({ id: newRevision.id });
214+
215+
expect(deleteResponse.data.websiteBuilder.deletePageRevision.error).toBeNull();
216+
expect(deleteResponse.data.websiteBuilder.deletePageRevision.data).toBe(true);
217+
218+
// Only the deleted revision is gone; the entry and its first revision are kept.
219+
const revisions = await handler.until(
220+
() => handler.wb.getPageRevisions({ entryId: page.entryId }),
221+
([response]) => response.data.websiteBuilder.getPageRevisions.data.length === 1
222+
);
223+
224+
expect(revisions[0].data.websiteBuilder.getPageRevisions.data).toMatchObject([
225+
{ id: page.id, version: 1 }
226+
]);
227+
228+
const [getDeletedResponse] = await handler.wb.getPageById({ id: newRevision.id });
229+
expect(getDeletedResponse.data.websiteBuilder.getPageById.data).toBeNull();
230+
});
231+
232+
it("should not delete a page revision that does not exist", async () => {
233+
const [deleteResponse] = await handler.wb.deletePageRevision({
234+
id: "nonExistingPage#0001"
235+
});
236+
237+
expect(deleteResponse.data.websiteBuilder.deletePageRevision.data).toBeNull();
238+
expect(deleteResponse.data.websiteBuilder.deletePageRevision.error).not.toBeNull();
239+
});
240+
203241
it("should delete a page", async () => {
204242
const [createResponse] = await handler.wb.createPage({
205243
data: pageMocks.pageA

‎packages/api-website-builder/__tests__/utils/createWbSdk.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ export const createWbSdk = (invoke: InvokeCallable) => {
5050
async deletePage(variables = {}) {
5151
return invoke({ body: { query: PAGES_GQL.DELETE_PAGE, variables } });
5252
},
53+
async deletePageRevision(variables = {}) {
54+
return invoke({ body: { query: PAGES_GQL.DELETE_PAGE_REVISION, variables } });
55+
},
5356
async restorePage(variables = {}) {
5457
return invoke({ body: { query: PAGES_GQL.RESTORE_PAGE, variables } });
5558
},

‎packages/api-website-builder/src/WebsiteBuilderFeature.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { ListDeletedPagesFeature } from "./features/pages/ListDeletedPages/featu
2828
import { CreatePageFeature } from "./features/pages/CreatePage/feature.js";
2929
import { CreatePageRevisionFromFeature } from "./features/pages/CreatePageRevisionFrom/feature.js";
3030
import { DeletePageFeature } from "./features/pages/DeletePage/feature.js";
31+
import { DeletePageRevisionFeature } from "./features/pages/DeletePageRevision/feature.js";
3132
import { TrashPageFeature } from "./features/pages/TrashPage/feature.js";
3233
import { RestorePageFeature } from "./features/pages/RestorePage/feature.js";
3334
import { UpdatePageFeature } from "./features/pages/UpdatePage/feature.js";
@@ -104,6 +105,7 @@ export const WebsiteBuilderFeature = createFeature({
104105
CreatePageFeature.register(container);
105106
CreatePageRevisionFromFeature.register(container);
106107
DeletePageFeature.register(container);
108+
DeletePageRevisionFeature.register(container);
107109
TrashPageFeature.register(container);
108110
RestorePageFeature.register(container);
109111
UpdatePageFeature.register(container);
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Result } from "@webiny/feature/api";
2+
import { DeleteEntryRevisionUseCase } from "@webiny/api-headless-cms/features/contentEntry/DeleteEntryRevision";
3+
import { DeletePageRevisionRepository as RepositoryAbstraction } from "./abstractions.js";
4+
import { PageModelProvider } from "~/domain/page/abstractions.js";
5+
import { PageNotFoundError, PagePersistenceError } from "~/domain/page/errors.js";
6+
7+
class DeletePageRevisionRepositoryImpl implements RepositoryAbstraction.Interface {
8+
constructor(
9+
private deleteEntryRevision: DeleteEntryRevisionUseCase.Interface,
10+
private pageModelProvider: PageModelProvider.Interface
11+
) {}
12+
13+
async execute(params: RepositoryAbstraction.Params): RepositoryAbstraction.Return {
14+
const pageModel = await this.pageModelProvider.get();
15+
16+
// The CMS use case takes care of the special cases: deleting the latest revision promotes
17+
// the previous one, and deleting the last remaining revision deletes the whole entry.
18+
const result = await this.deleteEntryRevision.execute(pageModel, params.id);
19+
20+
if (result.isFail()) {
21+
if (result.error.code === "Cms/Entry/NotFound") {
22+
return Result.fail(new PageNotFoundError(params.id));
23+
}
24+
return Result.fail(new PagePersistenceError(result.error));
25+
}
26+
27+
return Result.ok();
28+
}
29+
}
30+
31+
export const DeletePageRevisionRepository = RepositoryAbstraction.createImplementation({
32+
implementation: DeletePageRevisionRepositoryImpl,
33+
dependencies: [DeleteEntryRevisionUseCase, PageModelProvider]
34+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { Result } from "@webiny/feature/api";
2+
import {
3+
EventPublisher,
4+
EventPublisher as EventPublisherAbstraction
5+
} from "@webiny/api-core/features/eventPublisher/index.js";
6+
import {
7+
DeletePageRevisionRepository,
8+
DeletePageRevisionUseCase as UseCaseAbstraction
9+
} from "./abstractions.js";
10+
import { PageRevisionAfterDeleteEvent, PageRevisionBeforeDeleteEvent } from "./events.js";
11+
import { GetPageByIdUseCase } from "~/features/pages/GetPageById/index.js";
12+
import { WbPermissions } from "~/features/permissions/abstractions.js";
13+
import { PageNotAuthorizedError } from "~/domain/page/errors.js";
14+
15+
class DeletePageRevisionUseCaseImpl implements UseCaseAbstraction.Interface {
16+
constructor(
17+
private permissions: WbPermissions.Interface,
18+
private eventPublisher: EventPublisherAbstraction.Interface,
19+
private getPageById: GetPageByIdUseCase.Interface,
20+
private repository: DeletePageRevisionRepository.Interface
21+
) {}
22+
23+
async execute(params: UseCaseAbstraction.Params): UseCaseAbstraction.Return {
24+
// Load the revision first, for the events and for the item-level permission check.
25+
const getResult = await this.getPageById.execute(params.id);
26+
27+
if (getResult.isFail()) {
28+
return Result.fail(getResult.error);
29+
}
30+
31+
const page = getResult.value;
32+
33+
const canDelete = await this.permissions.canDelete("page", page);
34+
if (!canDelete) {
35+
return Result.fail(new PageNotAuthorizedError());
36+
}
37+
38+
await this.eventPublisher.publish(new PageRevisionBeforeDeleteEvent({ page }));
39+
40+
const result = await this.repository.execute(params);
41+
42+
if (result.isFail()) {
43+
return result;
44+
}
45+
46+
await this.eventPublisher.publish(new PageRevisionAfterDeleteEvent({ page }));
47+
48+
return Result.ok();
49+
}
50+
}
51+
52+
export const DeletePageRevisionUseCase = UseCaseAbstraction.createImplementation({
53+
implementation: DeletePageRevisionUseCaseImpl,
54+
dependencies: [WbPermissions, EventPublisher, GetPageByIdUseCase, DeletePageRevisionRepository]
55+
});
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { createAbstraction, type Result } from "@webiny/feature/api";
2+
import type { IEventHandler } from "@webiny/api-core/features/eventPublisher/index.js";
3+
import type { DomainEvent } from "@webiny/api-core/features/eventPublisher/index.js";
4+
import type { WbPage } from "~/domain/page/abstractions.js";
5+
import type {
6+
PageNotAuthorizedError,
7+
PageNotFoundError,
8+
PagePersistenceError
9+
} from "~/domain/page/errors.js";
10+
11+
// ============================================================================
12+
// Type Definitions
13+
// ============================================================================
14+
15+
export interface IDeleteWbPageRevisionParams {
16+
id: string;
17+
}
18+
19+
// ============================================================================
20+
// Repository Abstraction
21+
// ============================================================================
22+
23+
export interface IDeletePageRevisionRepository {
24+
execute(params: IDeleteWbPageRevisionParams): Promise<Result<void, RepositoryError>>;
25+
}
26+
27+
export interface IDeletePageRevisionRepositoryErrors {
28+
notFound: PageNotFoundError;
29+
persistence: PagePersistenceError;
30+
}
31+
32+
type RepositoryError =
33+
IDeletePageRevisionRepositoryErrors[keyof IDeletePageRevisionRepositoryErrors];
34+
35+
export const DeletePageRevisionRepository = createAbstraction<IDeletePageRevisionRepository>(
36+
"Wb/DeletePageRevisionRepository"
37+
);
38+
39+
export namespace DeletePageRevisionRepository {
40+
export type Interface = IDeletePageRevisionRepository;
41+
export type Params = IDeleteWbPageRevisionParams;
42+
export type Return = Promise<Result<void, RepositoryError>>;
43+
export type Error = RepositoryError;
44+
}
45+
46+
// ============================================================================
47+
// Use Case Abstraction
48+
// ============================================================================
49+
50+
export interface IDeletePageRevisionUseCase {
51+
execute(params: IDeleteWbPageRevisionParams): Promise<Result<void, UseCaseError>>;
52+
}
53+
54+
export interface IDeletePageRevisionUseCaseErrors {
55+
notAuthorized: PageNotAuthorizedError;
56+
notFound: PageNotFoundError;
57+
persistence: PagePersistenceError;
58+
}
59+
60+
type UseCaseError = IDeletePageRevisionUseCaseErrors[keyof IDeletePageRevisionUseCaseErrors];
61+
62+
/** Delete a single page revision. */
63+
export const DeletePageRevisionUseCase = createAbstraction<IDeletePageRevisionUseCase>(
64+
"Wb/DeletePageRevisionUseCase"
65+
);
66+
67+
export namespace DeletePageRevisionUseCase {
68+
export type Interface = IDeletePageRevisionUseCase;
69+
export type Params = IDeleteWbPageRevisionParams;
70+
export type Return = Promise<Result<void, UseCaseError>>;
71+
export type Error = UseCaseError;
72+
}
73+
74+
// ============================================================================
75+
// Event Payload Types
76+
// ============================================================================
77+
78+
export interface PageRevisionBeforeDeletePayload {
79+
page: WbPage;
80+
}
81+
82+
export interface PageRevisionAfterDeletePayload {
83+
page: WbPage;
84+
}
85+
86+
// ============================================================================
87+
// Event Handler Abstractions
88+
// ============================================================================
89+
90+
/** Hook into page lifecycle before a page revision is deleted. */
91+
export const PageRevisionBeforeDeleteEventHandler = createAbstraction<
92+
IEventHandler<DomainEvent<PageRevisionBeforeDeletePayload>>
93+
>("Wb/PageRevisionBeforeDeleteEventHandler");
94+
95+
export namespace PageRevisionBeforeDeleteEventHandler {
96+
export type Interface = IEventHandler<DomainEvent<PageRevisionBeforeDeletePayload>>;
97+
export type Event = DomainEvent<PageRevisionBeforeDeletePayload>;
98+
export type Page = WbPage;
99+
}
100+
101+
/** Hook into page lifecycle after a page revision is deleted. */
102+
export const PageRevisionAfterDeleteEventHandler = createAbstraction<
103+
IEventHandler<DomainEvent<PageRevisionAfterDeletePayload>>
104+
>("Wb/PageRevisionAfterDeleteEventHandler");
105+
106+
export namespace PageRevisionAfterDeleteEventHandler {
107+
export type Interface = IEventHandler<DomainEvent<PageRevisionAfterDeletePayload>>;
108+
export type Event = DomainEvent<PageRevisionAfterDeletePayload>;
109+
export type Page = WbPage;
110+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { DomainEvent } from "@webiny/api-core/features/eventPublisher/index.js";
2+
import {
3+
PageRevisionAfterDeleteEventHandler,
4+
PageRevisionBeforeDeleteEventHandler
5+
} from "./abstractions.js";
6+
import type {
7+
PageRevisionAfterDeletePayload,
8+
PageRevisionBeforeDeletePayload
9+
} from "./abstractions.js";
10+
11+
// PageRevisionBeforeDelete Event
12+
export class PageRevisionBeforeDeleteEvent extends DomainEvent<PageRevisionBeforeDeletePayload> {
13+
eventType = "page.revision.beforeDelete" as const;
14+
15+
getHandlerAbstraction() {
16+
return PageRevisionBeforeDeleteEventHandler;
17+
}
18+
}
19+
20+
// PageRevisionAfterDelete Event
21+
export class PageRevisionAfterDeleteEvent extends DomainEvent<PageRevisionAfterDeletePayload> {
22+
eventType = "page.revision.afterDelete" as const;
23+
24+
getHandlerAbstraction() {
25+
return PageRevisionAfterDeleteEventHandler;
26+
}
27+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { createFeature } from "@webiny/feature/api";
2+
import { DeletePageRevisionRepository } from "./DeletePageRevisionRepository.js";
3+
import { DeletePageRevisionUseCase } from "./DeletePageRevisionUseCase.js";
4+
5+
export const DeletePageRevisionFeature = createFeature({
6+
name: "WebsiteBuilder/DeletePageRevision",
7+
register(container) {
8+
container.register(DeletePageRevisionRepository).inSingletonScope();
9+
container.register(DeletePageRevisionUseCase);
10+
}
11+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export {
2+
PageRevisionAfterDeleteEventHandler,
3+
PageRevisionBeforeDeleteEventHandler,
4+
DeletePageRevisionUseCase
5+
} from "./abstractions.js";

0 commit comments

Comments
 (0)