feat(content-releases): publish a release (#18973)

* feat(content-releases): publish a release

* feat(content-releases): publish is enabled if you have releases publish permissions

* chore(content-releases): Mark's feedback

* Update packages/core/content-releases/server/src/services/release.ts

Co-authored-by: markkaylor <mark.kaylor@strapi.io>

---------

Co-authored-by: markkaylor <mark.kaylor@strapi.io>
This commit is contained in:
Fernando Chávez 2023-12-07 09:35:57 +01:00 committed by GitHub
parent f803c8a5b6
commit 1cab7003f9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 243 additions and 13 deletions

View File

@ -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**

View File

@ -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;

View File

@ -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'],
},
},
],
},
},
],
};

View File

@ -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');
});
});
});

View File

@ -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<typeof RELEASE_ACTION_MODEL_UID, 'filters'>) {
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<Input<"plugin::content-releases.release">> 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'],

View File

@ -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;
}
}