Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: webhooks #20129

Merged
merged 22 commits into from May 3, 2024
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/core/core/src/services/document-service/entries.ts
Expand Up @@ -48,9 +48,11 @@ const createEntriesService = (uid: UID.ContentType) => {
async function deleteEntry(id: number) {
const componentsToDelete = await components.getComponents(uid, { id });

await strapi.db.query(uid).delete({ where: { id } });
const deletedEntry = await strapi.db.query(uid).delete({ where: { id } });

await components.deleteComponents(uid, componentsToDelete as any, { loadComponents: false });

return deletedEntry;
}

async function updateEntry(entryToUpdate: any, params = {} as any) {
Expand Down
62 changes: 62 additions & 0 deletions packages/core/core/src/services/document-service/events.ts
@@ -0,0 +1,62 @@
import { UID, Utils, Modules, Core } from '@strapi/types';
import { sanitize } from '@strapi/utils';

import { getDeepPopulate } from './utils/populate';

const EVENTS = {
ENTRY_CREATE: 'entry.create',
ENTRY_UPDATE: 'entry.update',
ENTRY_DELETE: 'entry.delete',
ENTRY_PUBLISH: 'entry.publish',
ENTRY_UNPUBLISH: 'entry.unpublish',
ENTRY_DRAFT_DISCARD: 'entry.draft-discard',
};

type EventName = Utils.Object.Values<typeof EVENTS>;

/**
* Manager to trigger entry related events
*
* It will populate the entry if it is not a delete event.
* So the event payload will contain the full entry.
*/
const createEventManager = (strapi: Core.Strapi, uid: UID.Schema) => {
const populate = getDeepPopulate(uid, {});
const model = strapi.getModel(uid);

const emitEvent = async (eventName: EventName, entry: Modules.Documents.AnyDocument) => {
// There is no need to populate the entry if it has been deleted
let populatedEntry = entry;
if (![EVENTS.ENTRY_DELETE, EVENTS.ENTRY_UNPUBLISH].includes(eventName)) {
populatedEntry = await strapi.db.query(uid).findOne({ where: { id: entry.id }, populate });
}

const sanitizedEntry = await sanitize.sanitizers.defaultSanitizeOutput(
{
schema: model,
getModel: (uid) => strapi.getModel(uid as UID.Schema),
},
populatedEntry
);

await strapi.eventHub.emit(eventName, {
model: model.modelName,
uid: model.uid,
entry: sanitizedEntry,
});
};

return {
/**
* strapi.db.query might reuse the transaction used in the doc service request,
* so this is executed after that transaction is committed.
*/
emitEvent(eventName: EventName, entry: Modules.Documents.AnyDocument) {
strapi.db.transaction(({ onCommit }) => {
onCommit(() => emitEvent(eventName, entry));
});
},
};
};

export { createEventManager };
4 changes: 3 additions & 1 deletion packages/core/core/src/services/document-service/index.ts
Expand Up @@ -19,9 +19,11 @@ import { transformData } from './transform/data';
*
*/
export const createDocumentService = (strapi: Core.Strapi): Modules.Documents.Service => {
// Cache the repositories (one per content type)
const repositories = new Map<string, Modules.Documents.ServiceInstance>();
const middlewares = createMiddlewareManager();

// Manager to handle document service middlewares
const middlewares = createMiddlewareManager();
middlewares.use(databaseErrorsMiddleware);

const factory = function factory(uid: UID.ContentType) {
Expand Down
120 changes: 76 additions & 44 deletions packages/core/core/src/services/document-service/repository.ts
@@ -1,4 +1,4 @@
import { omit, assoc, merge } from 'lodash/fp';
import { omit, assoc, merge, curry } from 'lodash/fp';

import { async, contentTypes as contentTypesUtils } from '@strapi/utils';

Expand All @@ -13,13 +13,17 @@ import { createDocumentId } from '../../utils/transform-content-types-to-models'
import { getDeepPopulate } from './utils/populate';
import { transformParamsToQuery } from './transform/query';
import { transformParamsDocumentId } from './transform/id-transform';
import { createEventManager } from './events';

export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
const contentType = strapi.contentType(uid);
const hasDraftAndPublish = contentTypesUtils.hasDraftAndPublish(contentType);

const entries = createEntriesService(uid);

const eventManager = createEventManager(strapi, uid);
const emitEvent = curry(eventManager.emitEvent);

async function findMany(params = {} as any) {
const query = await async.pipe(
DP.defaultToDraft,
Expand Down Expand Up @@ -81,9 +85,13 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
const entriesToDelete = await strapi.db.query(uid).findMany(query);

// Delete all matched entries and its components
await async.map(entriesToDelete, (entryToDelete: any) => entries.delete(entryToDelete.id));
const deletedEntries = await async.map(entriesToDelete, (entryToDelete: any) =>
entries.delete(entryToDelete.id)
);

return { documentId, entries: entriesToDelete };
entriesToDelete.forEach(emitEvent('entry.delete'));

return { documentId, entries: deletedEntries };
}

async function create(opts = {} as any) {
Expand All @@ -99,6 +107,8 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {

const doc = await entries.create(queryParams);

emitEvent('entry.create', doc);

if (hasDraftAndPublish && params.status === 'published') {
return publish({
...params,
Expand All @@ -119,7 +129,7 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
)(params);

// Get deep populate
const entriesToClone = await strapi.db?.query(uid).findMany({
const entriesToClone = await strapi.db.query(uid).findMany({
where: {
...queryParams?.lookup,
documentId,
Expand All @@ -142,6 +152,8 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
)
);

clonedEntries.forEach(emitEvent('entry.create'));

return { documentId: clonedEntries.at(0)?.documentId, entries: clonedEntries };
}

Expand Down Expand Up @@ -171,6 +183,7 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
let updatedDraft = null;
if (entryToUpdate) {
updatedDraft = await entries.update(entryToUpdate, queryParams);
emitEvent('entry.update', updatedDraft);
}

if (!updatedDraft) {
Expand All @@ -183,6 +196,7 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
...queryParams,
data: { ...queryParams.data, documentId },
});
emitEvent('entry.create', updatedDraft);
}
}

Expand Down Expand Up @@ -216,45 +230,56 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
i18n.multiLocaleToLookup(contentType)
)(params);

await deleteDocument({
...queryParams,
documentId,
lookup: { ...queryParams?.lookup, publishedAt: { $ne: null } },
});

// Get deep populate
const draftsToPublish = await strapi.db?.query(uid).findMany({
where: {
...queryParams?.lookup,
documentId,
publishedAt: null,
},
populate: getDeepPopulate(uid, { relationalFields: ['documentId', 'locale'] }),
});
const [draftsToPublish, publishedToDelete] = await Promise.all([
strapi.db.query(uid).findMany({
where: {
...queryParams?.lookup,
documentId,
publishedAt: null, // Ignore lookup
},
// Populate relations, media, compos and dz
populate: getDeepPopulate(uid, { relationalFields: ['documentId', 'locale'] }),
}),
strapi.db.query(uid).findMany({
where: {
...queryParams?.lookup,
documentId,
publishedAt: { $ne: null },
},
select: ['id'],
}),
]);

// Delete all published versions
await async.map(publishedToDelete, (entry: any) => entries.delete(entry.id));

// Transform draft entry data and create published versions
const publishedEntries = await async.map(draftsToPublish, (draft: unknown) =>
entries.publish(draft, queryParams)
);

publishedEntries.forEach(emitEvent('entry.publish'));
return { documentId, entries: publishedEntries };
}

async function unpublish(opts = {} as any) {
const { documentId, ...params } = opts;

const queryParams = await async.pipe(
const query = await async.pipe(
i18n.defaultLocale(contentType),
i18n.multiLocaleToLookup(contentType)
i18n.multiLocaleToLookup(contentType),
transformParamsToQuery(uid),
(query) => assoc('where', { ...query.where, documentId, publishedAt: { $ne: null } }, query)
)(params);

const { entries } = await deleteDocument({
...params,
documentId,
lookup: { ...queryParams?.lookup, publishedAt: { $ne: null } },
});
// Delete all published versions
const versionsToDelete = await strapi.db.query(uid).findMany(query);
const unpublishedEntries = await async.map(versionsToDelete, (entry: any) =>
entries.delete(entry.id)
);

return { documentId, entries };
versionsToDelete.forEach(emitEvent('entry.unpublish'));
return { documentId, entries: unpublishedEntries };
}

async function discardDraft(opts = {} as any) {
Expand All @@ -265,28 +290,35 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
i18n.multiLocaleToLookup(contentType)
)(params);

// Delete all drafts that match query
await deleteDocument({
...queryParams,
documentId,
lookup: { ...queryParams?.lookup, publishedAt: null },
});

// Get deep populate of published versions
const entriesToDraft = await strapi.db?.query(uid).findMany({
where: {
...queryParams?.lookup,
documentId,
publishedAt: { $ne: null },
},
populate: getDeepPopulate(uid, { relationalFields: ['documentId', 'locale'] }),
});
const [versionsToDraft, versionsToDelete] = await Promise.all([
strapi.db.query(uid).findMany({
where: {
...queryParams?.lookup,
documentId,
publishedAt: { $ne: null },
},
// Populate relations, media, compos and dz
populate: getDeepPopulate(uid, { relationalFields: ['documentId', 'locale'] }),
}),
strapi.db.query(uid).findMany({
where: {
...queryParams?.lookup,
documentId,
publishedAt: null,
},
select: ['id'],
}),
]);

// Delete all drafts
await async.map(versionsToDelete, (entry: any) => entries.delete(entry.id));

// Transform published entry data and create draft versions
const draftEntries = await async.map(entriesToDraft, (entry: any) =>
const draftEntries = await async.map(versionsToDraft, (entry: any) =>
entries.discardDraft(entry, queryParams)
);

draftEntries.forEach(emitEvent('entry.draft-discard'));
return { documentId, entries: draftEntries };
}

Expand Down
5 changes: 4 additions & 1 deletion packages/core/core/src/services/event-hub.ts
Expand Up @@ -92,7 +92,10 @@ export default function createEventHub(): EventHub {
},

removeAllListeners() {
return eventHub.destroy();
const destroy = eventHub.destroy();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hum I wonder if we should replace this with just a listeners.clear & add a removeAllSubscribers. & make destroy do

this.removeAllListeners();
this.removeAllSubscribers();

wdyt ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:cheff-kiss: sounds great to me, will do that

// Re-add the default subscriber
subscribers.push(defaultSubscriber);
return destroy;
},

addListener(eventName, listener) {
Expand Down
9 changes: 8 additions & 1 deletion packages/core/core/src/services/webhook-store.ts
Expand Up @@ -107,7 +107,14 @@ export interface WebhookStore {

const createWebhookStore = ({ db }: { db: Database }): WebhookStore => {
return {
allowedEvents: new Map([]),
allowedEvents: new Map([
['ENTRY_CREATE', 'entry.create'],
['ENTRY_UPDATE', 'entry.update'],
['ENTRY_DELETE', 'entry.delete'],
['ENTRY_PUBLISH', 'entry.publish'],
['ENTRY_UNPUBLISH', 'entry.unpublish'],
['ENTRY_DRAFT_DISCARD', 'entry.draft-discard'],
]),
addAllowedEvent(key, value) {
this.allowedEvents.set(key, value);
},
Expand Down
2 changes: 2 additions & 0 deletions tests/api/core/content-manager/api/basic-dp.test.api.js
Expand Up @@ -297,6 +297,7 @@ describe('CM API - Basic', () => {
description: 'Product description updated',
};

// Update the product
await rq({
method: 'PUT',
url: `/content-manager/collection-types/api::product-with-dp.product-with-dp/${body.data.documentId}`,
Expand All @@ -322,6 +323,7 @@ describe('CM API - Basic', () => {
expect(draft.body.data.name).toBe(product.name);
});
});

// FIX: We don't return the draft entry when deleting in v5
test.skip('Delete a draft', async () => {
const res = await rq({
Expand Down