diff --git a/examples/getstarted/config/features.js b/examples/getstarted/config/features.js index c381b7503a..fac8b3ce89 100644 --- a/examples/getstarted/config/features.js +++ b/examples/getstarted/config/features.js @@ -1 +1,5 @@ -module.exports = ({ env }) => ({}); +module.exports = ({ env }) => ({ + future: { + contentReleasesScheduling: env('STRAPI_FUTURE_CONTENT_RELEASES_SCHEDULING', false), + }, +}); diff --git a/packages/core/admin/ee/admin/src/pages/SettingsPage/pages/Webhooks/components/EventsTable.tsx b/packages/core/admin/ee/admin/src/pages/SettingsPage/pages/Webhooks/components/EventsTable.tsx index 832d8ec9fb..040bafb9dc 100644 --- a/packages/core/admin/ee/admin/src/pages/SettingsPage/pages/Webhooks/components/EventsTable.tsx +++ b/packages/core/admin/ee/admin/src/pages/SettingsPage/pages/Webhooks/components/EventsTable.tsx @@ -1,11 +1,21 @@ import { Events } from '../../../../../../../../admin/src/pages/Settings/pages/Webhooks/components/Events'; -const events = { - 'review-workflows': ['review-workflows.updateEntryStage'], +const eeTables = { + 'review-workflows': { + 'review-workflows': ['review-workflows.updateEntryStage'], + }, + releases: { + releases: ['releases.publish'], + }, }; -const getHeaders = () => { - return [{ id: 'review-workflows.updateEntryStage', defaultMessage: 'Stage Change' }]; +const getHeaders = (table: keyof typeof eeTables) => { + switch (table) { + case 'review-workflows': + return () => [{ id: 'review-workflows.updateEntryStage', defaultMessage: 'Stage Change' }]; + case 'releases': + return () => [{ id: 'releases.publish', defaultMessage: 'Publish' }]; + } }; const EventsTableEE = () => { @@ -13,8 +23,12 @@ const EventsTableEE = () => { - - + {(Object.keys(eeTables) as Array).map((table) => ( + <> + + + + ))} ); }; diff --git a/packages/core/content-releases/server/src/__tests__/index.test.ts b/packages/core/content-releases/server/src/__tests__/index.test.ts index f59aac92c4..134983c39d 100644 --- a/packages/core/content-releases/server/src/__tests__/index.test.ts +++ b/packages/core/content-releases/server/src/__tests__/index.test.ts @@ -84,6 +84,9 @@ describe('bootstrap', () => { log: { error: jest.fn(), }, + webhookStore: { + addAllowedEvent: jest.fn(), + }, }; beforeEach(() => { diff --git a/packages/core/content-releases/server/src/bootstrap.ts b/packages/core/content-releases/server/src/bootstrap.ts index d3fd466e85..1aa4165588 100644 --- a/packages/core/content-releases/server/src/bootstrap.ts +++ b/packages/core/content-releases/server/src/bootstrap.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-var-requires */ import type { LoadedStrapi, Entity as StrapiEntity } from '@strapi/types'; -import { RELEASE_ACTION_MODEL_UID } from './constants'; +import { ALLOWED_WEBHOOK_EVENTS, RELEASE_ACTION_MODEL_UID } from './constants'; import { getService } from './utils'; const { features } = require('@strapi/strapi/dist/utils/ee'); @@ -69,6 +69,10 @@ export const bootstrap = async ({ strapi }: { strapi: LoadedStrapi }) => { throw err; }); + + Object.entries(ALLOWED_WEBHOOK_EVENTS).forEach(([key, value]) => { + strapi.webhookStore.addAllowedEvent(key, value); + }); } } }; diff --git a/packages/core/content-releases/server/src/constants.ts b/packages/core/content-releases/server/src/constants.ts index 1b8e42e619..da369257ed 100644 --- a/packages/core/content-releases/server/src/constants.ts +++ b/packages/core/content-releases/server/src/constants.ts @@ -45,3 +45,7 @@ export const ACTIONS = [ pluginName: 'content-releases', }, ]; + +export const ALLOWED_WEBHOOK_EVENTS = { + RELEASES_PUBLISH: '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 3d0f94e7c3..b4b7d2c8a3 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 @@ -26,6 +26,9 @@ const baseStrapiMock = { isEnabled: jest.fn().mockReturnValue(true), }, }, + eventHub: { + emit: jest.fn(), + }, telemetry: { send: jest.fn().mockReturnValue(true), }, diff --git a/packages/core/content-releases/server/src/services/release.ts b/packages/core/content-releases/server/src/services/release.ts index 037bbdc3bb..914c20ba7b 100644 --- a/packages/core/content-releases/server/src/services/release.ts +++ b/packages/core/content-releases/server/src/services/release.ts @@ -4,7 +4,7 @@ import type { LoadedStrapi, EntityService, UID, Schema } from '@strapi/types'; import _ from 'lodash/fp'; -import { RELEASE_ACTION_MODEL_UID, RELEASE_MODEL_UID } from '../constants'; +import { ALLOWED_WEBHOOK_EVENTS, RELEASE_ACTION_MODEL_UID, RELEASE_MODEL_UID } from '../constants'; import type { GetReleases, CreateRelease, @@ -48,639 +48,685 @@ const getGroupName = (queryValue?: ReleaseActionGroupBy) => { } }; -const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({ - async create(releaseData: CreateRelease.Request['body'], { user }: { user: UserInfo }) { - const releaseWithCreatorFields = await setCreatorFields({ user })(releaseData); - - const { - validatePendingReleasesLimit, - validateUniqueNameForPendingRelease, - validateScheduledAtIsLaterThanNow, - } = getService('release-validation', { strapi }); - - await Promise.all([ - validatePendingReleasesLimit(), - validateUniqueNameForPendingRelease(releaseWithCreatorFields.name), - validateScheduledAtIsLaterThanNow(releaseWithCreatorFields.scheduledAt), - ]); - - const release = await strapi.entityService.create(RELEASE_MODEL_UID, { - data: releaseWithCreatorFields, +const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => { + const dispatchWebhook = ( + event: string, + { isPublished, release, error }: { isPublished: boolean; release?: Release; error?: unknown } + ) => { + strapi.eventHub.emit(event, { + isPublished, + error, + release, }); + }; - if ( - strapi.features.future.isEnabled('contentReleasesScheduling') && - releaseWithCreatorFields.scheduledAt + return { + async create(releaseData: CreateRelease.Request['body'], { user }: { user: UserInfo }) { + const releaseWithCreatorFields = await setCreatorFields({ user })(releaseData); + + const { + validatePendingReleasesLimit, + validateUniqueNameForPendingRelease, + validateScheduledAtIsLaterThanNow, + } = getService('release-validation', { strapi }); + + await Promise.all([ + validatePendingReleasesLimit(), + validateUniqueNameForPendingRelease(releaseWithCreatorFields.name), + validateScheduledAtIsLaterThanNow(releaseWithCreatorFields.scheduledAt), + ]); + + const release = await strapi.entityService.create(RELEASE_MODEL_UID, { + data: releaseWithCreatorFields, + }); + + if ( + strapi.features.future.isEnabled('contentReleasesScheduling') && + releaseWithCreatorFields.scheduledAt + ) { + const schedulingService = getService('scheduling', { strapi }); + + await schedulingService.set(release.id, release.scheduledAt); + } + + strapi.telemetry.send('didCreateContentRelease'); + + return release; + }, + + async findOne(id: GetRelease.Request['params']['id'], query = {}) { + const release = await strapi.entityService.findOne(RELEASE_MODEL_UID, id, { + ...query, + }); + + return release; + }, + + findPage(query?: GetReleases.Request['query']) { + return strapi.entityService.findPage(RELEASE_MODEL_UID, { + ...query, + populate: { + actions: { + // @ts-expect-error Ignore missing properties + count: true, + }, + }, + }); + }, + + async findManyWithContentTypeEntryAttached( + contentTypeUid: GetContentTypeEntryReleases.Request['query']['contentTypeUid'], + entryId: GetContentTypeEntryReleases.Request['query']['entryId'] ) { - const schedulingService = getService('scheduling', { strapi }); - - await schedulingService.set(release.id, release.scheduledAt); - } - - strapi.telemetry.send('didCreateContentRelease'); - - return release; - }, - - async findOne(id: GetRelease.Request['params']['id'], query = {}) { - const release = await strapi.entityService.findOne(RELEASE_MODEL_UID, id, { - ...query, - }); - - return release; - }, - - findPage(query?: GetReleases.Request['query']) { - return strapi.entityService.findPage(RELEASE_MODEL_UID, { - ...query, - populate: { - actions: { - // @ts-expect-error Ignore missing properties - count: true, + const releases = await strapi.db.query(RELEASE_MODEL_UID).findMany({ + where: { + actions: { + target_type: contentTypeUid, + target_id: entryId, + }, + releasedAt: { + $null: true, + }, }, - }, - }); - }, + populate: { + // Filter the action to get only the content type entry + actions: { + where: { + target_type: contentTypeUid, + target_id: entryId, + }, + }, + }, + }); - async findManyWithContentTypeEntryAttached( - contentTypeUid: GetContentTypeEntryReleases.Request['query']['contentTypeUid'], - entryId: GetContentTypeEntryReleases.Request['query']['entryId'] - ) { - const releases = await strapi.db.query(RELEASE_MODEL_UID).findMany({ - where: { - actions: { - target_type: contentTypeUid, - target_id: entryId, - }, - releasedAt: { - $null: true, - }, - }, - populate: { - // Filter the action to get only the content type entry - actions: { - where: { + return releases.map((release) => { + if (release.actions?.length) { + const [actionForEntry] = release.actions; + + // Remove the actions key to replace it with an action key + delete release.actions; + + return { + ...release, + action: actionForEntry, + }; + } + + return release; + }); + }, + + async findManyWithoutContentTypeEntryAttached( + contentTypeUid: GetContentTypeEntryReleases.Request['query']['contentTypeUid'], + entryId: GetContentTypeEntryReleases.Request['query']['entryId'] + ) { + // We get the list of releases where the entry is present + const releasesRelated = await strapi.db.query(RELEASE_MODEL_UID).findMany({ + where: { + releasedAt: { + $null: true, + }, + actions: { target_type: contentTypeUid, target_id: entryId, }, }, - }, - }); - - return releases.map((release) => { - if (release.actions?.length) { - const [actionForEntry] = release.actions; - - // Remove the actions key to replace it with an action key - delete release.actions; - - return { - ...release, - action: actionForEntry, - }; - } - - return release; - }); - }, - - async findManyWithoutContentTypeEntryAttached( - contentTypeUid: GetContentTypeEntryReleases.Request['query']['contentTypeUid'], - entryId: GetContentTypeEntryReleases.Request['query']['entryId'] - ) { - // We get the list of releases where the entry is present - const releasesRelated = await strapi.db.query(RELEASE_MODEL_UID).findMany({ - where: { - releasedAt: { - $null: true, - }, - actions: { - target_type: contentTypeUid, - target_id: entryId, - }, - }, - }); - - const releases = await strapi.db.query(RELEASE_MODEL_UID).findMany({ - where: { - $or: [ - { - id: { - $notIn: releasesRelated.map((release) => release.id), - }, - }, - { - actions: null, - }, - ], - releasedAt: { - $null: true, - }, - }, - }); - - return releases.map((release) => { - if (release.actions?.length) { - const [actionForEntry] = release.actions; - - // Remove the actions key to replace it with an action key - delete release.actions; - - return { - ...release, - action: actionForEntry, - }; - } - - return release; - }); - }, - - async update( - id: number, - releaseData: UpdateRelease.Request['body'], - { user }: { user: UserInfo } - ) { - const releaseWithCreatorFields = await setCreatorFields({ user, isEdition: true })(releaseData); - - const { validateUniqueNameForPendingRelease, validateScheduledAtIsLaterThanNow } = getService( - 'release-validation', - { strapi } - ); - - await Promise.all([ - validateUniqueNameForPendingRelease(releaseWithCreatorFields.name, id), - validateScheduledAtIsLaterThanNow(releaseWithCreatorFields.scheduledAt), - ]); - - const release = await strapi.entityService.findOne(RELEASE_MODEL_UID, id); - - if (!release) { - throw new errors.NotFoundError(`No release found for id ${id}`); - } - - if (release.releasedAt) { - throw new errors.ValidationError('Release already published'); - } - - const updatedRelease = await strapi.entityService.update(RELEASE_MODEL_UID, id, { - /* - * The type returned from the entity service: Partial> - * is not compatible with the type we are passing here: UpdateRelease.Request['body'] - */ - // @ts-expect-error see above - data: releaseWithCreatorFields, - }); - - if (strapi.features.future.isEnabled('contentReleasesScheduling')) { - const schedulingService = getService('scheduling', { strapi }); - - if (releaseData.scheduledAt) { - // set function always cancel the previous job if it exists, so we can call it directly - await schedulingService.set(id, releaseData.scheduledAt); - } else if (release.scheduledAt) { - // When user don't send a scheduledAt and we have one on the release, means that user want to unschedule it - schedulingService.cancel(id); - } - } - - strapi.telemetry.send('didUpdateContentRelease'); - - return updatedRelease; - }, - - async createAction( - releaseId: CreateReleaseAction.Request['params']['releaseId'], - action: Pick - ) { - const { validateEntryContentType, validateUniqueEntry } = getService('release-validation', { - strapi, - }); - - await Promise.all([ - validateEntryContentType(action.entry.contentType), - validateUniqueEntry(releaseId, action), - ]); - - const release = await strapi.entityService.findOne(RELEASE_MODEL_UID, releaseId); - - if (!release) { - throw new errors.NotFoundError(`No release found for id ${releaseId}`); - } - - if (release.releasedAt) { - throw new errors.ValidationError('Release already published'); - } - - const { entry, type } = action; - - return strapi.entityService.create(RELEASE_ACTION_MODEL_UID, { - data: { - type, - contentType: entry.contentType, - locale: entry.locale, - entry: { - id: entry.id, - __type: entry.contentType, - __pivot: { field: 'entry' }, - }, - release: releaseId, - }, - populate: { release: { fields: ['id'] }, entry: { fields: ['id'] } }, - }); - }, - - async findActions( - releaseId: GetReleaseActions.Request['params']['releaseId'], - query?: GetReleaseActions.Request['query'] - ) { - const release = await strapi.entityService.findOne(RELEASE_MODEL_UID, releaseId, { - fields: ['id'], - }); - - if (!release) { - throw new errors.NotFoundError(`No release found for id ${releaseId}`); - } - - return strapi.entityService.findPage(RELEASE_ACTION_MODEL_UID, { - ...query, - populate: { - entry: { - populate: '*', - }, - }, - filters: { - release: releaseId, - }, - }); - }, - - async countActions(query: EntityService.Params.Pick) { - return strapi.entityService.count(RELEASE_ACTION_MODEL_UID, query); - }, - - async groupActions(actions: ReleaseAction[], groupBy: ReleaseActionGroupBy) { - const contentTypeUids = actions.reduce((acc, action) => { - if (!acc.includes(action.contentType)) { - acc.push(action.contentType); - } - - return acc; - }, []); - const allReleaseContentTypesDictionary = await this.getContentTypesDataForActions( - contentTypeUids - ); - const allLocalesDictionary = await this.getLocalesDataForActions(); - - const formattedData = actions.map((action: ReleaseAction) => { - const { mainField, displayName } = allReleaseContentTypesDictionary[action.contentType]; - - return { - ...action, - locale: action.locale ? allLocalesDictionary[action.locale] : null, - contentType: { - displayName, - mainFieldValue: action.entry[mainField], - uid: action.contentType, - }, - }; - }); - - const groupName = getGroupName(groupBy); - return _.groupBy(groupName)(formattedData); - }, - - async getLocalesDataForActions() { - if (!strapi.plugin('i18n')) { - return {}; - } - - const allLocales: Locale[] = (await strapi.plugin('i18n').service('locales').find()) || []; - return allLocales.reduce((acc, locale) => { - acc[locale.code] = { name: locale.name, code: locale.code }; - - return acc; - }, {}); - }, - - async getContentTypesDataForActions(contentTypesUids: ReleaseAction['contentType'][]) { - const contentManagerContentTypeService = strapi - .plugin('content-manager') - .service('content-types'); - - const contentTypesData: Record = - {}; - for (const contentTypeUid of contentTypesUids) { - const contentTypeConfig = await contentManagerContentTypeService.findConfiguration({ - uid: contentTypeUid, }); - contentTypesData[contentTypeUid] = { - mainField: contentTypeConfig.settings.mainField, - displayName: strapi.getModel(contentTypeUid).info.displayName, - }; - } - - return contentTypesData; - }, - - getContentTypeModelsFromActions(actions: ReleaseAction[]) { - const contentTypeUids = actions.reduce((acc, action) => { - if (!acc.includes(action.contentType)) { - acc.push(action.contentType); - } - - return acc; - }, []); - - const contentTypeModelsMap = contentTypeUids.reduce( - ( - acc: { [key: ReleaseAction['contentType']]: Schema.ContentType }, - contentTypeUid: ReleaseAction['contentType'] - ) => { - acc[contentTypeUid] = strapi.getModel(contentTypeUid); - - return acc; - }, - {} - ); - - return contentTypeModelsMap; - }, - - async getAllComponents() { - const contentManagerComponentsService = strapi.plugin('content-manager').service('components'); - - const components = await contentManagerComponentsService.findAllComponents(); - - const componentsMap = components.reduce( - (acc: { [key: Schema.Component['uid']]: Schema.Component }, component: Schema.Component) => { - acc[component.uid] = component; - - return acc; - }, - {} - ); - - return componentsMap; - }, - - async delete(releaseId: DeleteRelease.Request['params']['id']) { - const release = (await strapi.entityService.findOne(RELEASE_MODEL_UID, releaseId, { - populate: { - actions: { - fields: ['id'], - }, - }, - })) as unknown as Release; - - if (!release) { - throw new errors.NotFoundError(`No release found for id ${releaseId}`); - } - - if (release.releasedAt) { - throw new errors.ValidationError('Release already published'); - } - - // Only delete the release and its actions is you in fact can delete all the actions and the release - // Otherwise, if the transaction fails it throws an error - await strapi.db.transaction(async () => { - await strapi.db.query(RELEASE_ACTION_MODEL_UID).deleteMany({ + const releases = await strapi.db.query(RELEASE_MODEL_UID).findMany({ where: { - id: { - $in: release.actions.map((action) => action.id), - }, - }, - }); - await strapi.entityService.delete(RELEASE_MODEL_UID, releaseId); - }); - - if (strapi.features.future.isEnabled('contentReleasesScheduling') && release.scheduledAt) { - const schedulingService = getService('scheduling', { strapi }); - await schedulingService.cancel(release.id); - } - - strapi.telemetry.send('didDeleteContentRelease'); - - return release; - }, - - 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: { - fields: ['id'], + $or: [ + { + id: { + $notIn: releasesRelated.map((release) => release.id), }, }, + { + actions: null, + }, + ], + releasedAt: { + $null: true, }, }, - } - )) as unknown as Release; + }); - if (!releaseWithPopulatedActionEntries) { - throw new errors.NotFoundError(`No release found for id ${releaseId}`); - } + return releases.map((release) => { + if (release.actions?.length) { + const [actionForEntry] = release.actions; - if (releaseWithPopulatedActionEntries.releasedAt) { - throw new errors.ValidationError('Release already published'); - } + // Remove the actions key to replace it with an action key + delete release.actions; - if (releaseWithPopulatedActionEntries.actions.length === 0) { - throw new errors.ValidationError('No entries to publish'); - } - - /** - * We separate publish and unpublish actions, grouping them by contentType and extracting only their IDs. Then we can fetch more data for each entry - * We need to separate collectionTypes from singleTypes because findMany work as findOne for singleTypes and publishMany can't be used for singleTypes - */ - const collectionTypeActions: { - [key: UID.ContentType]: { - entriestoPublishIds: ReleaseAction['entry']['id'][]; - entriesToUnpublishIds: ReleaseAction['entry']['id'][]; - }; - } = {}; - const singleTypeActions: { - uid: UID.ContentType; - id: ReleaseAction['entry']['id']; - action: ReleaseAction['type']; - }[] = []; - for (const action of releaseWithPopulatedActionEntries.actions) { - const contentTypeUid = action.contentType; - - if (strapi.contentTypes[contentTypeUid].kind === 'collectionType') { - if (!collectionTypeActions[contentTypeUid]) { - collectionTypeActions[contentTypeUid] = { - entriestoPublishIds: [], - entriesToUnpublishIds: [], + return { + ...release, + action: actionForEntry, }; } - if (action.type === 'publish') { - collectionTypeActions[contentTypeUid].entriestoPublishIds.push(action.entry.id); - } else { - collectionTypeActions[contentTypeUid].entriesToUnpublishIds.push(action.entry.id); - } - } else { - singleTypeActions.push({ - uid: contentTypeUid, - action: action.type, - id: action.entry.id, - }); - } - } + return release; + }); + }, - const entityManagerService = strapi.plugin('content-manager').service('entity-manager'); - const populateBuilderService = strapi.plugin('content-manager').service('populate-builder'); + async update( + id: number, + releaseData: UpdateRelease.Request['body'], + { user }: { user: UserInfo } + ) { + const releaseWithCreatorFields = await setCreatorFields({ user, isEdition: true })( + releaseData + ); - // Only publish the release if all action updates are applied successfully to their entry, otherwise leave everything as is - await strapi.db.transaction(async () => { - // First we publish all the singleTypes - for (const { uid, action, id } of singleTypeActions) { - // @ts-expect-error - populateBuilderService should be a function but is returning service - const populate = await populateBuilderService(uid).populateDeep(Infinity).build(); + const { validateUniqueNameForPendingRelease, validateScheduledAtIsLaterThanNow } = getService( + 'release-validation', + { strapi } + ); - const entry = await strapi.entityService.findOne(uid, id, { populate }); + await Promise.all([ + validateUniqueNameForPendingRelease(releaseWithCreatorFields.name, id), + validateScheduledAtIsLaterThanNow(releaseWithCreatorFields.scheduledAt), + ]); - try { - if (action === 'publish') { - await entityManagerService.publish(entry, uid); - } else { - await entityManagerService.unpublish(entry, uid); - } - } catch (error) { - if ( - error instanceof errors.ApplicationError && - (error.message === 'already.published' || error.message === 'already.draft') - ) { - // We don't want throw an error if the entry is already published or draft - } else { - throw error; - } - } + const release = await strapi.entityService.findOne(RELEASE_MODEL_UID, id); + + if (!release) { + throw new errors.NotFoundError(`No release found for id ${id}`); } - // Then, we can continue with publishing the collectionTypes - for (const contentTypeUid of Object.keys(collectionTypeActions)) { - // @ts-expect-error - populateBuilderService should be a function but is returning service - const populate = await populateBuilderService(contentTypeUid) - .populateDeep(Infinity) - .build(); - - const { entriestoPublishIds, entriesToUnpublishIds } = - collectionTypeActions[contentTypeUid as UID.ContentType]; - - /** - * We need to get the populate entries to be able to publish without errors on components/relations/dynamicZones - * Considering that populate doesn't work well with morph relations we can't get the entries from the Release model - * So, we need to fetch them manually - */ - const entriesToPublish = (await strapi.entityService.findMany( - contentTypeUid as UID.ContentType, - { - filters: { - id: { - $in: entriestoPublishIds, - }, - }, - populate, - } - )) as Entity[]; - - const entriesToUnpublish = (await strapi.entityService.findMany( - contentTypeUid as UID.ContentType, - { - filters: { - id: { - $in: entriesToUnpublishIds, - }, - }, - populate, - } - )) as Entity[]; - - if (entriesToPublish.length > 0) { - await entityManagerService.publishMany(entriesToPublish, contentTypeUid); - } - - if (entriesToUnpublish.length > 0) { - await entityManagerService.unpublishMany(entriesToUnpublish, contentTypeUid); - } + if (release.releasedAt) { + throw new errors.ValidationError('Release already published'); } - }); - // 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: { + const updatedRelease = await strapi.entityService.update(RELEASE_MODEL_UID, id, { /* - * The type returned from the entity service: Partial> looks like it's wrong + * The type returned from the entity service: Partial> + * is not compatible with the type we are passing here: UpdateRelease.Request['body'] */ // @ts-expect-error see above - releasedAt: new Date(), - }, - }); + data: releaseWithCreatorFields, + }); - strapi.telemetry.send('didPublishContentRelease'); + if (strapi.features.future.isEnabled('contentReleasesScheduling')) { + const schedulingService = getService('scheduling', { strapi }); - return release; - }, + if (releaseData.scheduledAt) { + // set function always cancel the previous job if it exists, so we can call it directly + await schedulingService.set(id, releaseData.scheduledAt); + } else if (release.scheduledAt) { + // When user don't send a scheduledAt and we have one on the release, means that user want to unschedule it + schedulingService.cancel(id); + } + } - async updateAction( - actionId: UpdateReleaseAction.Request['params']['actionId'], - releaseId: UpdateReleaseAction.Request['params']['releaseId'], - update: UpdateReleaseAction.Request['body'] - ) { - const updatedAction = await strapi.db.query(RELEASE_ACTION_MODEL_UID).update({ - where: { - id: actionId, - release: { - id: releaseId, - releasedAt: { - $null: true, + strapi.telemetry.send('didUpdateContentRelease'); + + return updatedRelease; + }, + + async createAction( + releaseId: CreateReleaseAction.Request['params']['releaseId'], + action: Pick + ) { + const { validateEntryContentType, validateUniqueEntry } = getService('release-validation', { + strapi, + }); + + await Promise.all([ + validateEntryContentType(action.entry.contentType), + validateUniqueEntry(releaseId, action), + ]); + + const release = await strapi.entityService.findOne(RELEASE_MODEL_UID, releaseId); + + if (!release) { + throw new errors.NotFoundError(`No release found for id ${releaseId}`); + } + + if (release.releasedAt) { + throw new errors.ValidationError('Release already published'); + } + + const { entry, type } = action; + + return strapi.entityService.create(RELEASE_ACTION_MODEL_UID, { + data: { + type, + contentType: entry.contentType, + locale: entry.locale, + entry: { + id: entry.id, + __type: entry.contentType, + __pivot: { field: 'entry' }, + }, + release: releaseId, + }, + populate: { release: { fields: ['id'] }, entry: { fields: ['id'] } }, + }); + }, + + async findActions( + releaseId: GetReleaseActions.Request['params']['releaseId'], + query?: GetReleaseActions.Request['query'] + ) { + const release = await strapi.entityService.findOne(RELEASE_MODEL_UID, releaseId, { + fields: ['id'], + }); + + if (!release) { + throw new errors.NotFoundError(`No release found for id ${releaseId}`); + } + + return strapi.entityService.findPage(RELEASE_ACTION_MODEL_UID, { + ...query, + populate: { + entry: { + populate: '*', }, }, - }, - data: update, - }); + filters: { + release: releaseId, + }, + }); + }, - if (!updatedAction) { - throw new errors.NotFoundError( - `Action with id ${actionId} not found in release with id ${releaseId} or it is already published` + async countActions( + query: EntityService.Params.Pick + ) { + return strapi.entityService.count(RELEASE_ACTION_MODEL_UID, query); + }, + + async groupActions(actions: ReleaseAction[], groupBy: ReleaseActionGroupBy) { + const contentTypeUids = actions.reduce((acc, action) => { + if (!acc.includes(action.contentType)) { + acc.push(action.contentType); + } + + return acc; + }, []); + const allReleaseContentTypesDictionary = await this.getContentTypesDataForActions( + contentTypeUids ); - } + const allLocalesDictionary = await this.getLocalesDataForActions(); - return updatedAction; - }, + const formattedData = actions.map((action: ReleaseAction) => { + const { mainField, displayName } = allReleaseContentTypesDictionary[action.contentType]; - async deleteAction( - actionId: DeleteReleaseAction.Request['params']['actionId'], - releaseId: DeleteReleaseAction.Request['params']['releaseId'] - ) { - const deletedAction = await strapi.db.query(RELEASE_ACTION_MODEL_UID).delete({ - where: { - id: actionId, - release: { - id: releaseId, - releasedAt: { - $null: true, + return { + ...action, + locale: action.locale ? allLocalesDictionary[action.locale] : null, + contentType: { + displayName, + mainFieldValue: action.entry[mainField], + uid: action.contentType, + }, + }; + }); + + const groupName = getGroupName(groupBy); + return _.groupBy(groupName)(formattedData); + }, + + async getLocalesDataForActions() { + if (!strapi.plugin('i18n')) { + return {}; + } + + const allLocales: Locale[] = (await strapi.plugin('i18n').service('locales').find()) || []; + return allLocales.reduce((acc, locale) => { + acc[locale.code] = { name: locale.name, code: locale.code }; + + return acc; + }, {}); + }, + + async getContentTypesDataForActions(contentTypesUids: ReleaseAction['contentType'][]) { + const contentManagerContentTypeService = strapi + .plugin('content-manager') + .service('content-types'); + + const contentTypesData: Record = + {}; + for (const contentTypeUid of contentTypesUids) { + const contentTypeConfig = await contentManagerContentTypeService.findConfiguration({ + uid: contentTypeUid, + }); + + contentTypesData[contentTypeUid] = { + mainField: contentTypeConfig.settings.mainField, + displayName: strapi.getModel(contentTypeUid).info.displayName, + }; + } + + return contentTypesData; + }, + + getContentTypeModelsFromActions(actions: ReleaseAction[]) { + const contentTypeUids = actions.reduce((acc, action) => { + if (!acc.includes(action.contentType)) { + acc.push(action.contentType); + } + + return acc; + }, []); + + const contentTypeModelsMap = contentTypeUids.reduce( + ( + acc: { [key: ReleaseAction['contentType']]: Schema.ContentType }, + contentTypeUid: ReleaseAction['contentType'] + ) => { + acc[contentTypeUid] = strapi.getModel(contentTypeUid); + + return acc; + }, + {} + ); + + return contentTypeModelsMap; + }, + + async getAllComponents() { + const contentManagerComponentsService = strapi + .plugin('content-manager') + .service('components'); + + const components = await contentManagerComponentsService.findAllComponents(); + + const componentsMap = components.reduce( + ( + acc: { [key: Schema.Component['uid']]: Schema.Component }, + component: Schema.Component + ) => { + acc[component.uid] = component; + + return acc; + }, + {} + ); + + return componentsMap; + }, + + async delete(releaseId: DeleteRelease.Request['params']['id']) { + const release = (await strapi.entityService.findOne(RELEASE_MODEL_UID, releaseId, { + populate: { + actions: { + fields: ['id'], }, }, - }, - }); + })) as unknown as Release; - if (!deletedAction) { - throw new errors.NotFoundError( - `Action with id ${actionId} not found in release with id ${releaseId} or it is already published` - ); - } + if (!release) { + throw new errors.NotFoundError(`No release found for id ${releaseId}`); + } - return deletedAction; - }, -}); + if (release.releasedAt) { + throw new errors.ValidationError('Release already published'); + } + + // Only delete the release and its actions is you in fact can delete all the actions and the release + // Otherwise, if the transaction fails it throws an error + await strapi.db.transaction(async () => { + await strapi.db.query(RELEASE_ACTION_MODEL_UID).deleteMany({ + where: { + id: { + $in: release.actions.map((action) => action.id), + }, + }, + }); + await strapi.entityService.delete(RELEASE_MODEL_UID, releaseId); + }); + + if (strapi.features.future.isEnabled('contentReleasesScheduling') && release.scheduledAt) { + const schedulingService = getService('scheduling', { strapi }); + await schedulingService.cancel(release.id); + } + + strapi.telemetry.send('didDeleteContentRelease'); + + return release; + }, + + async publish(releaseId: PublishRelease.Request['params']['id']) { + try { + // 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: { + fields: ['id'], + }, + }, + }, + }, + } + )) 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, grouping them by contentType and extracting only their IDs. Then we can fetch more data for each entry + * We need to separate collectionTypes from singleTypes because findMany work as findOne for singleTypes and publishMany can't be used for singleTypes + */ + const collectionTypeActions: { + [key: UID.ContentType]: { + entriestoPublishIds: ReleaseAction['entry']['id'][]; + entriesToUnpublishIds: ReleaseAction['entry']['id'][]; + }; + } = {}; + const singleTypeActions: { + uid: UID.ContentType; + id: ReleaseAction['entry']['id']; + action: ReleaseAction['type']; + }[] = []; + for (const action of releaseWithPopulatedActionEntries.actions) { + const contentTypeUid = action.contentType; + + if (strapi.contentTypes[contentTypeUid].kind === 'collectionType') { + if (!collectionTypeActions[contentTypeUid]) { + collectionTypeActions[contentTypeUid] = { + entriestoPublishIds: [], + entriesToUnpublishIds: [], + }; + } + + if (action.type === 'publish') { + collectionTypeActions[contentTypeUid].entriestoPublishIds.push(action.entry.id); + } else { + collectionTypeActions[contentTypeUid].entriesToUnpublishIds.push(action.entry.id); + } + } else { + singleTypeActions.push({ + uid: contentTypeUid, + action: action.type, + id: action.entry.id, + }); + } + } + + const entityManagerService = strapi.plugin('content-manager').service('entity-manager'); + const populateBuilderService = strapi.plugin('content-manager').service('populate-builder'); + + // Only publish the release if all action updates are applied successfully to their entry, otherwise leave everything as is + await strapi.db.transaction(async () => { + // First we publish all the singleTypes + for (const { uid, action, id } of singleTypeActions) { + // @ts-expect-error - populateBuilderService should be a function but is returning service + const populate = await populateBuilderService(uid).populateDeep(Infinity).build(); + + const entry = await strapi.entityService.findOne(uid, id, { populate }); + + try { + if (action === 'publish') { + await entityManagerService.publish(entry, uid); + } else { + await entityManagerService.unpublish(entry, uid); + } + } catch (error) { + if ( + error instanceof errors.ApplicationError && + (error.message === 'already.published' || error.message === 'already.draft') + ) { + // We don't want throw an error if the entry is already published or draft + } else { + throw error; + } + } + } + + // Then, we can continue with publishing the collectionTypes + for (const contentTypeUid of Object.keys(collectionTypeActions)) { + // @ts-expect-error - populateBuilderService should be a function but is returning service + const populate = await populateBuilderService(contentTypeUid) + .populateDeep(Infinity) + .build(); + + const { entriestoPublishIds, entriesToUnpublishIds } = + collectionTypeActions[contentTypeUid as UID.ContentType]; + + /** + * We need to get the populate entries to be able to publish without errors on components/relations/dynamicZones + * Considering that populate doesn't work well with morph relations we can't get the entries from the Release model + * So, we need to fetch them manually + */ + const entriesToPublish = (await strapi.entityService.findMany( + contentTypeUid as UID.ContentType, + { + filters: { + id: { + $in: entriestoPublishIds, + }, + }, + populate, + } + )) as Entity[]; + + const entriesToUnpublish = (await strapi.entityService.findMany( + contentTypeUid as UID.ContentType, + { + filters: { + id: { + $in: entriesToUnpublishIds, + }, + }, + populate, + } + )) as Entity[]; + + if (entriesToPublish.length > 0) { + await entityManagerService.publishMany(entriesToPublish, contentTypeUid); + } + + if (entriesToUnpublish.length > 0) { + await entityManagerService.unpublishMany(entriesToUnpublish, 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(), + }, + populate: { + actions: { + // @ts-expect-error is not expecting count but it is working + count: true, + }, + }, + })) as Release; + + if (strapi.features.future.isEnabled('contentReleasesScheduling')) { + dispatchWebhook(ALLOWED_WEBHOOK_EVENTS.RELEASES_PUBLISH, { + isPublished: true, + release, + }); + } + + strapi.telemetry.send('didPublishContentRelease'); + + return release; + } catch (error) { + if (strapi.features.future.isEnabled('contentReleasesScheduling')) { + dispatchWebhook(ALLOWED_WEBHOOK_EVENTS.RELEASES_PUBLISH, { + isPublished: false, + error, + }); + } + + throw error; + } + }, + + async updateAction( + actionId: UpdateReleaseAction.Request['params']['actionId'], + releaseId: UpdateReleaseAction.Request['params']['releaseId'], + update: UpdateReleaseAction.Request['body'] + ) { + const updatedAction = await strapi.db.query(RELEASE_ACTION_MODEL_UID).update({ + where: { + id: actionId, + release: { + id: releaseId, + releasedAt: { + $null: true, + }, + }, + }, + data: update, + }); + + if (!updatedAction) { + throw new errors.NotFoundError( + `Action with id ${actionId} not found in release with id ${releaseId} or it is already published` + ); + } + + return updatedAction; + }, + + async deleteAction( + actionId: DeleteReleaseAction.Request['params']['actionId'], + releaseId: DeleteReleaseAction.Request['params']['releaseId'] + ) { + const deletedAction = await strapi.db.query(RELEASE_ACTION_MODEL_UID).delete({ + where: { + id: actionId, + release: { + id: releaseId, + releasedAt: { + $null: true, + }, + }, + }, + }); + + if (!deletedAction) { + throw new errors.NotFoundError( + `Action with id ${actionId} not found in release with id ${releaseId} or it is already published` + ); + } + + return deletedAction; + }, + }; +}; export default createReleaseService;