diff --git a/docs/docs/docs/01-core/content-releases/01-backend.md b/docs/docs/docs/01-core/content-releases/01-backend.md index eff94126e6..27451a2a7f 100644 --- a/docs/docs/docs/01-core/content-releases/01-backend.md +++ b/docs/docs/docs/01-core/content-releases/01-backend.md @@ -83,6 +83,11 @@ packages/core/content-releases/server/src/routes/release.ts } ``` +**Publish a release**: + +- method: `POST` +- endpoint: `/content-releases/:id/publish` + ### Release Action **Create a release action** diff --git a/packages/core/content-releases/server/src/controllers/release.ts b/packages/core/content-releases/server/src/controllers/release.ts index 0199c3a2cd..28809e94bb 100644 --- a/packages/core/content-releases/server/src/controllers/release.ts +++ b/packages/core/content-releases/server/src/controllers/release.ts @@ -5,6 +5,7 @@ import { validateRelease } from './validation/release'; import type { CreateRelease, UpdateRelease, + PublishRelease, GetRelease, Release, } from '../../../shared/contracts/releases'; @@ -134,6 +135,18 @@ const releaseController = { data: await permissionsManager.sanitizeOutput(release), }; }, + + async publish(ctx: Koa.Context) { + const user: PublishRelease.Request['state']['user'] = ctx.state.user; + const id: PublishRelease.Request['params']['id'] = ctx.params.id; + + const releaseService = getService('release', { strapi }); + const release = await releaseService.publish(id, { user }); + + ctx.body = { + data: release, + }; + }, }; export default releaseController; diff --git a/packages/core/content-releases/server/src/routes/release.ts b/packages/core/content-releases/server/src/routes/release.ts index fb993d4e6e..bb49925e21 100644 --- a/packages/core/content-releases/server/src/routes/release.ts +++ b/packages/core/content-releases/server/src/routes/release.ts @@ -65,5 +65,21 @@ export default { ], }, }, + { + method: 'POST', + path: '/:id/publish', + handler: 'release.publish', + config: { + policies: [ + 'admin::isAuthenticatedAdmin', + { + name: 'admin::hasPermissions', + config: { + actions: ['plugin::content-releases.publish'], + }, + }, + ], + }, + }, ], }; diff --git a/packages/core/content-releases/server/src/services/__tests__/release.test.ts b/packages/core/content-releases/server/src/services/__tests__/release.test.ts index ee1e751ce6..fb2339aadd 100644 --- a/packages/core/content-releases/server/src/services/__tests__/release.test.ts +++ b/packages/core/content-releases/server/src/services/__tests__/release.test.ts @@ -1,4 +1,4 @@ -import createReleaseService from "../release"; +import createReleaseService from '../release'; const baseStrapiMock = { utils: { @@ -12,12 +12,12 @@ const mockUser = { id: 1, username: 'user', email: 'user@strapi.io', - firstname: 'John', - isActive: true, - blocked: false, + firstname: 'John', + isActive: true, + blocked: false, preferedLanguage: 'en', - roles: [], - createdAt: '01/01/1900', + roles: [], + createdAt: '01/01/1900', updatedAt: '01/01/1900', }; @@ -42,12 +42,12 @@ describe('release service', () => { ); }); }); - + describe('findActions', () => { it('throws an error if the release does not exist', () => { const strapiMock = { ...baseStrapiMock, - entityService: { + entityService: { findOne: jest.fn().mockReturnValue(null), }, }; @@ -55,7 +55,97 @@ describe('release service', () => { // @ts-expect-error Ignore missing properties const releaseService = createReleaseService({ strapi: strapiMock }); - expect(() => releaseService.findActions(1, ['api::contentType.contentType'], {})).rejects.toThrow('No release found for id 1'); + expect(() => + releaseService.findActions(1, ['api::contentType.contentType'], {}) + ).rejects.toThrow('No release found for id 1'); + }); + }); + + describe('publish', () => { + it('throws an error if the release does not exist', () => { + const strapiMock = { + ...baseStrapiMock, + entityService: { + findOne: jest.fn().mockReturnValue(null), + }, + }; + + // @ts-expect-error Ignore missing properties + const releaseService = createReleaseService({ strapi: strapiMock }); + + expect(() => releaseService.publish(1)).rejects.toThrow('No release found for id 1'); + }); + + it('throws an error if the release is already published', () => { + const strapiMock = { + ...baseStrapiMock, + entityService: { + findOne: jest.fn().mockReturnValue({ releasedAt: new Date() }), + }, + }; + + // @ts-expect-error Ignore missing properties + const releaseService = createReleaseService({ strapi: strapiMock }); + + expect(() => releaseService.publish(1)).rejects.toThrow('Release already published'); + }); + + it('throws an error if the release have 0 actions', () => { + const strapiMock = { + ...baseStrapiMock, + entityService: { + findOne: jest.fn().mockReturnValue({ releasedAt: null, actions: [] }), + }, + }; + + // @ts-expect-error Ignore missing properties + const releaseService = createReleaseService({ strapi: strapiMock }); + + expect(() => releaseService.publish(1)).rejects.toThrow('No entries to publish'); + }); + + it('calls publishMany for each contentType with the right actions', async () => { + const mockPublishMany = jest.fn(); + const mockUnpublishMany = jest.fn(); + + const strapiMock = { + ...baseStrapiMock, + db: { + transaction: jest.fn().mockImplementation((cb) => cb()), + }, + plugin: jest.fn().mockReturnValue({ + service: jest.fn().mockReturnValue({ + publishMany: mockPublishMany, + unpublishMany: mockUnpublishMany, + }), + }), + entityService: { + findOne: jest.fn().mockReturnValue({ + releasedAt: null, + actions: [ + { + contentType: 'contentType', + type: 'publish', + entry: { id: 1 }, + }, + { + contentType: 'contentType', + type: 'unpublish', + entry: { id: 2 }, + }, + ], + }), + update: jest.fn().mockReturnValue({}), + }, + }; + + // @ts-expect-error Ignore missing properties + const releaseService = createReleaseService({ strapi: strapiMock }); + + await releaseService.publish(1); + + expect(mockPublishMany).toHaveBeenCalledWith([{ id: 1 }], 'contentType'); + expect(mockUnpublishMany).toHaveBeenCalledWith([{ id: 2 }], 'contentType'); }); }); }); diff --git a/packages/core/content-releases/server/src/services/release.ts b/packages/core/content-releases/server/src/services/release.ts index 5fc5c86e97..2eea7adbff 100644 --- a/packages/core/content-releases/server/src/services/release.ts +++ b/packages/core/content-releases/server/src/services/release.ts @@ -5,12 +5,14 @@ import type { GetReleases, CreateRelease, UpdateRelease, + PublishRelease, GetRelease, Release, } from '../../../shared/contracts/releases'; import type { CreateReleaseAction, GetReleaseActions, + ReleaseAction, UpdateReleaseAction, DeleteReleaseAction, } from '../../../shared/contracts/release-actions'; @@ -125,7 +127,7 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({ async countActions(query: EntityService.Params.Pick) { return strapi.entityService.count(RELEASE_ACTION_MODEL_UID, query); }, - async findReleaseContentTypesMainFields(releaseId: Release['id']) { + async findReleaseContentTypes(releaseId: Release['id']) { const contentTypesFromReleaseActions: { contentType: UID.ContentType }[] = await strapi.db .queryBuilder(RELEASE_ACTION_MODEL_UID) .select('content_type') @@ -139,9 +141,10 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({ .groupBy('content_type') .execute(); - const contentTypesUids = contentTypesFromReleaseActions.map( - ({ contentType: contentTypeUid }) => contentTypeUid - ); + return contentTypesFromReleaseActions.map(({ contentType: contentTypeUid }) => contentTypeUid); + }, + async findReleaseContentTypesMainFields(releaseId: Release['id']) { + const contentTypesUids = await this.findReleaseContentTypes(releaseId); const contentManagerContentTypeService = strapi .plugin('content-manager') @@ -162,6 +165,90 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({ return contentTypesMeta; }, + async publish(releaseId: PublishRelease.Request['params']['id']) { + // We need to pass the type because entityService.findOne is not returning the correct type + const releaseWithPopulatedActionEntries = (await strapi.entityService.findOne( + RELEASE_MODEL_UID, + releaseId, + { + populate: { + actions: { + populate: { + entry: true, + }, + }, + }, + } + )) as unknown as Release; + + if (!releaseWithPopulatedActionEntries) { + throw new errors.NotFoundError(`No release found for id ${releaseId}`); + } + + if (releaseWithPopulatedActionEntries.releasedAt) { + throw new errors.ValidationError('Release already published'); + } + + if (releaseWithPopulatedActionEntries.actions.length === 0) { + throw new errors.ValidationError('No entries to publish'); + } + + /** + * We separate publish and unpublish actions group by content type + */ + const actions: { + [key: UID.ContentType]: { + publish: ReleaseAction['entry'][]; + unpublish: ReleaseAction['entry'][]; + }; + } = {}; + for (const action of releaseWithPopulatedActionEntries.actions) { + const contentTypeUid = action.contentType; + + if (!actions[contentTypeUid]) { + actions[contentTypeUid] = { + publish: [], + unpublish: [], + }; + } + + if (action.type === 'publish') { + actions[contentTypeUid].publish.push(action.entry); + } else { + actions[contentTypeUid].unpublish.push(action.entry); + } + } + + const entityManagerService = strapi.plugin('content-manager').service('entity-manager'); + + // Only publish the release if all action updates are applied successfully to their entry, otherwise leave everything as is + await strapi.db.transaction(async () => { + for (const contentTypeUid of Object.keys(actions)) { + const { publish, unpublish } = actions[contentTypeUid as UID.ContentType]; + + if (publish.length > 0) { + await entityManagerService.publishMany(publish, contentTypeUid); + } + + if (unpublish.length > 0) { + await entityManagerService.unpublishMany(unpublish, contentTypeUid); + } + } + }); + + // When the transaction fails it throws an error, when it is successful proceed to updating the release + const release = await strapi.entityService.update(RELEASE_MODEL_UID, releaseId, { + data: { + /* + * The type returned from the entity service: Partial> looks like it's wrong + */ + // @ts-expect-error see above + releasedAt: new Date(), + }, + }); + + return release; + }, async updateAction( actionId: UpdateReleaseAction.Request['params']['actionId'], releaseId: UpdateReleaseAction.Request['params']['releaseId'], diff --git a/packages/core/content-releases/shared/contracts/releases.ts b/packages/core/content-releases/shared/contracts/releases.ts index 845b697957..8c061885b3 100644 --- a/packages/core/content-releases/shared/contracts/releases.ts +++ b/packages/core/content-releases/shared/contracts/releases.ts @@ -100,3 +100,22 @@ export declare namespace UpdateRelease { error?: errors.ApplicationError | errors.ValidationError; } } + +/** + * POST /content-releases/:releaseId/publish - Publish a release + */ +export declare namespace PublishRelease { + export interface Request { + state: { + user: UserInfo; + }; + params: { + id: Release['id']; + }; + } + + export interface Response { + data: ReleaseDataResponse; + error?: errors.ApplicationError | errors.ValidationError; + } +}