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;