diff --git a/package-lock.json b/package-lock.json index 66ecbf0..3f3b142 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9058,7 +9058,7 @@ }, "node_modules/payloadcms-relationships": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/DrMint/payloadcms-relationships.git#aa65c94f14fa36abe1b482a56fd82d4df3cbfb3e", + "resolved": "git+ssh://git@github.com/DrMint/payloadcms-relationships.git#d71e75b32936aac38201ae8740e9336327500476", "dependencies": { "payload": "^2.24.0" } diff --git a/src/collections/WebsiteConfig/WebsiteConfig.ts b/src/collections/WebsiteConfig/WebsiteConfig.ts index c8ef867..e5aac47 100644 --- a/src/collections/WebsiteConfig/WebsiteConfig.ts +++ b/src/collections/WebsiteConfig/WebsiteConfig.ts @@ -4,6 +4,7 @@ import { imageField } from "../../fields/imageField/imageField"; import { rowField } from "../../fields/rowField/rowField"; import { getConfigEndpoint } from "./endpoints/getConfigEndpoint"; import { Collections, CollectionGroups } from "../../shared/payload/constants"; +import { globalAfterChangeSendChangesWebhook } from "../../hooks/afterOperationSendChangesWebhook"; const fields = { homeBackgroundImage: "homeBackgroundImage", @@ -30,6 +31,9 @@ export const WebsiteConfig: GlobalConfig = { }, access: { update: mustBeAdmin, read: mustBeAdmin }, endpoints: [getConfigEndpoint], + hooks: { + afterChange: [globalAfterChangeSendChangesWebhook], + }, fields: [ rowField([ { diff --git a/src/hooks/afterOperationSendChangesWebhook.ts b/src/hooks/afterOperationSendChangesWebhook.ts new file mode 100644 index 0000000..c5f4c8c --- /dev/null +++ b/src/hooks/afterOperationSendChangesWebhook.ts @@ -0,0 +1,383 @@ +import { Collections } from "../shared/payload/constants"; +import { getSDKEndpoint } from "../shared/payload/sdk"; +import { EndpointChange } from "../shared/payload/webhooks"; +import { + Audio, + ChronologyEvent, + Collectible, + Currency, + File, + Folder, + Image, + Language, + Page, + Recorder, + Relationship, + Video, + Wording, +} from "../types/collections"; +import { isPayloadType } from "../utils/asserts"; +import { AfterChangeHook, AfterDeleteHook } from "payload/dist/collections/config/types"; +import { GeneratedTypes } from "payload"; +import { uniqueBy } from "../utils/array"; +import { GlobalAfterChangeHook } from "payload/types"; +import { findRelationByID } from "payloadcms-relationships/dist/utils"; +import { RelationshipRemoved } from "payloadcms-relationships"; + +export const afterOutgoingRelationRemovedSendChangesWebhook = async ({ + removedOutgoingRelations, +}: RelationshipRemoved) => { + const changes: EndpointChange[] = []; + + removedOutgoingRelations?.forEach((relation) => + changes.push(...getEndpointChangesFromOutgoingRelation(relation)) + ); + + await sendWebhookMessage(uniqueBy(changes, ({ url }) => url)); +}; + +export const afterChangeSendChangesWebhook: AfterChangeHook = async ({ doc, collection }) => { + if ("_status" in doc && doc._status === "draft") return doc; + await commonLogic(collection.slug as keyof GeneratedTypes["collections"], doc); + return doc; +}; + +export const afterDeleteSendChangesWebhook: AfterDeleteHook = async ({ doc, collection }) => { + await commonLogic(collection.slug as keyof GeneratedTypes["collections"], doc); + return doc; +}; + +export const globalAfterChangeSendChangesWebhook: GlobalAfterChangeHook = async ({ + doc, + global, +}) => { + const changes: EndpointChange[] = []; + + switch (global.slug as keyof GeneratedTypes["globals"]) { + case Collections.WebsiteConfig: + changes.push({ type: "getConfig", url: getSDKEndpoint.getConfigEndpoint() }); + break; + + default: + break; + } + await sendWebhookMessage(uniqueBy(changes, ({ url }) => url)); + return doc; +}; + +const commonLogic = async (slug: keyof GeneratedTypes["collections"], doc: any) => { + if (slug === "relationships") return doc; + if (slug === "payload-migrations") return doc; + if (slug === "payload-preferences") return doc; + + let relation: Relationship; + try { + relation = await findRelationByID(slug, doc.id); + } catch (e) { + relation = { + id: doc.id, + document: { + relationTo: slug, + value: doc, + }, + outgoingRelations: [], + }; + } + + const changes: EndpointChange[] = getEndpointChangesFromDocument(relation.document); + + relation.incomingRelations?.forEach((relation) => + changes.push(...getEndpointChangesFromIncomingRelation(relation)) + ); + + relation.outgoingRelations?.forEach((relation) => + changes.push(...getEndpointChangesFromOutgoingRelation(relation)) + ); + + await sendWebhookMessage(uniqueBy(changes, ({ url }) => url)); +}; + +// ------------------------------------------------------------------------------------------------- + +const getEndpointChangesFromDocument = ({ + relationTo, + value, +}: NonNullable): EndpointChange[] => { + if (!isPayloadType(value)) return []; + switch (relationTo) { + case Collections.Folders: + return getEndpointChangesForFolder(value); + + case Collections.Pages: + return getEndpointChangesForPage(value); + + case Collections.Collectibles: + return getEndpointChangesForCollectible(value); + + case Collections.Audios: + return getEndpointChangesForAudio(value); + + case Collections.Images: + return getEndpointChangesForImage(value); + + case Collections.Videos: + return getEndpointChangesForVideo(value); + + case Collections.Files: + return getEndpointChangesForFile(value); + + case Collections.Recorders: + return getEndpointChangesForRecorder(value); + + case Collections.ChronologyEvents: + return getEndpointChangesForChronologyEvent(value); + + case Collections.Languages: + return getEndpointChangesForLanguage(value); + + case Collections.Currencies: + return getEndpointChangesForCurrency(value); + + case Collections.Wordings: + return getEndpointChangesForWording(value); + + case Collections.Attributes: + case Collections.CreditsRole: + case Collections.GenericContents: + case Collections.MediaThumbnails: + case Collections.Scans: + case Collections.Tags: + case Collections.VideosChannels: + case Collections.VideosSubtitles: + default: + return []; + } +}; + +const getEndpointChangesFromIncomingRelation = ({ + relationTo, + value, +}: NonNullable[number]): EndpointChange[] => { + if (!isPayloadType(value)) return []; + switch (relationTo) { + case Collections.Folders: + return getEndpointChangesForFolder(value); + + case Collections.Pages: + return getEndpointChangesForPage(value); + + case Collections.Collectibles: + return getEndpointChangesForCollectible(value); + + case Collections.Audios: + return getEndpointChangesForAudio(value); + + case Collections.Images: + return getEndpointChangesForImage(value); + + case Collections.Videos: + return getEndpointChangesForVideo(value); + + case Collections.Files: + return getEndpointChangesForFile(value); + + case Collections.Recorders: + return getEndpointChangesForRecorder(value); + + case Collections.ChronologyEvents: + return getEndpointChangesForChronologyEvent(value); + + case Collections.Languages: + case Collections.Currencies: + case Collections.Wordings: + case Collections.Attributes: + case Collections.CreditsRole: + case Collections.GenericContents: + case Collections.MediaThumbnails: + case Collections.Scans: + case Collections.Tags: + case Collections.VideosChannels: + case Collections.VideosSubtitles: + default: + return []; + } +}; + +const getEndpointChangesFromOutgoingRelation = ({ + relationTo, + value, +}: NonNullable[number]): EndpointChange[] => { + if (!isPayloadType(value)) return []; + switch (relationTo) { + case Collections.Folders: + return getEndpointChangesForFolder(value); + + case Collections.Pages: + return getEndpointChangesForPage(value); + + case Collections.Collectibles: + return getEndpointChangesForCollectible(value); + + case Collections.Audios: + return getEndpointChangesForAudio(value); + + case Collections.Images: + return getEndpointChangesForImage(value); + + case Collections.Videos: + return getEndpointChangesForVideo(value); + + case Collections.Files: + return getEndpointChangesForFile(value); + + case Collections.Languages: + case Collections.Currencies: + case Collections.Wordings: + case Collections.Attributes: + case Collections.CreditsRole: + case Collections.GenericContents: + case Collections.MediaThumbnails: + case Collections.Scans: + case Collections.Tags: + case Collections.VideosChannels: + case Collections.VideosSubtitles: + case Collections.ChronologyEvents: + case Collections.Recorders: + default: + return []; + } +}; + +const getEndpointChangesForFolder = ({ slug }: Folder): EndpointChange[] => [ + { type: "getFolder", slug, url: getSDKEndpoint.getFolderEndpoint(slug) }, +]; + +const getEndpointChangesForLanguage = (_: Language): EndpointChange[] => [ + { type: "getLanguages", url: getSDKEndpoint.getLanguagesEndpoint() }, +]; + +const getEndpointChangesForCurrency = (_: Currency): EndpointChange[] => [ + { type: "getCurrencies", url: getSDKEndpoint.getCurrenciesEndpoint() }, +]; + +const getEndpointChangesForWording = (_: Wording): EndpointChange[] => [ + { type: "getWordings", url: getSDKEndpoint.getWordingsEndpoint() }, +]; + +const getEndpointChangesForPage = ({ slug }: Page): EndpointChange[] => [ + { type: "getPage", slug, url: getSDKEndpoint.getPageEndpoint(slug) }, +]; + +const getEndpointChangesForCollectible = ({ + slug, + gallery, + scans, + scansEnabled, +}: Collectible): EndpointChange[] => { + const changes: EndpointChange[] = []; + + if (gallery && gallery.length > 0) { + changes.push({ + type: "getCollectibleGallery", + slug, + url: getSDKEndpoint.getCollectibleGalleryEndpoint(slug), + }); + gallery.forEach((_, indexNumber) => { + const index = indexNumber.toString(); + changes.push({ + type: "getCollectibleGalleryImage", + slug, + index: index, + url: getSDKEndpoint.getCollectibleGalleryImageEndpoint(slug, index), + }); + }); + } + + if (scans && scansEnabled) { + changes.push({ + type: "getCollectibleScans", + slug, + url: getSDKEndpoint.getCollectibleScansEndpoint(slug), + }); + + // TODO: Add other changes for cover, obi, dustjacket... + + scans.pages?.forEach((_, indexNumber) => { + const index = indexNumber.toString(); + changes.push({ + type: "getCollectibleScanPage", + slug, + index: index, + url: getSDKEndpoint.getCollectibleScanPageEndpoint(slug, index), + }); + }); + } + + return changes; +}; + +const getEndpointChangesForAudio = ({ id }: Audio): EndpointChange[] => [ + { type: "getAudioByID", id, url: getSDKEndpoint.getAudioByIDEndpoint(id) }, +]; + +const getEndpointChangesForImage = ({ id }: Image): EndpointChange[] => [ + { type: "getImageByID", id, url: getSDKEndpoint.getImageByIDEndpoint(id) }, +]; + +const getEndpointChangesForVideo = ({ id }: Video): EndpointChange[] => [ + { type: "getVideoByID", id, url: getSDKEndpoint.getVideoByIDEndpoint(id) }, +]; + +const getEndpointChangesForFile = ({ id }: File): EndpointChange[] => [ + { type: "getFileByID", id, url: getSDKEndpoint.getFileByIDEndpoint(id) }, +]; + +const getEndpointChangesForRecorder = ({ id }: Recorder): EndpointChange[] => [ + { type: "getRecorderByID", id, url: getSDKEndpoint.getRecorderByIDEndpoint(id) }, +]; + +const getEndpointChangesForChronologyEvent = ({ id }: ChronologyEvent): EndpointChange[] => [ + { + type: "getChronologyEventByID", + id, + url: getSDKEndpoint.getChronologyEventByIDEndpoint(id), + }, + { + type: "getChronologyEvents", + url: getSDKEndpoint.getChronologyEventsEndpoint(), + }, +]; + +// ------------------------------------------------------------------------------------------------- + +const webhookTargets: { url: string; token: string }[] = [ + { + url: process.env.WEB_SERVER_HOOK_URL ?? "", + token: process.env.WEB_SERVER_HOOK_TOKEN ?? "", + }, + { + url: process.env.MEILISEARCH_HOOK_URL ?? "", + token: process.env.MEILISEARCH_HOOK_TOKEN ?? "", + }, +]; + +const sendWebhookMessage = async (changes: EndpointChange[]) => { + if (changes.length === 0) return; + try { + await Promise.all( + webhookTargets.flatMap(({ url, token }) => { + if (!url) return; + return fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(changes), + method: "POST", + }); + }) + ); + } catch (e) { + console.warn("Error while sending webhook", e); + } +}; diff --git a/src/payload.config.ts b/src/payload.config.ts index c5b26ee..1fccb35 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -34,6 +34,7 @@ import { Collections } from "./shared/payload/constants"; import { relationshipsPlugin } from "payloadcms-relationships"; import { shownOnlyToAdmin } from "./accesses/collections/shownOnlyToAdmin"; import { mustBeAdmin } from "./accesses/fields/mustBeAdmin"; +import { afterOutgoingRelationRemovedSendChangesWebhook } from "./hooks/afterOperationSendChangesWebhook"; const configuredSftpAdapter = sftpAdapter({ connectOptions: { @@ -100,6 +101,7 @@ export default buildConfig({ }, plugins: [ relationshipsPlugin({ + // rebuildOnInit: true, collectionConfig: { admin: { hidden: shownOnlyToAdmin, @@ -110,6 +112,7 @@ export default buildConfig({ delete: mustBeAdmin, }, }, + onOutgoingRelationRemoved: afterOutgoingRelationRemovedSendChangesWebhook, }), cloudStorage({ diff --git a/src/shared b/src/shared index 7d6f5ff..3582a48 160000 --- a/src/shared +++ b/src/shared @@ -1 +1 @@ -Subproject commit 7d6f5ffb704f4ecddfb0e0982ce9eb79d39d450d +Subproject commit 3582a48bd12a66e99121d9fbc01681971a7d943a diff --git a/src/types/collections.ts b/src/types/collections.ts index ddef918..4222a25 100644 --- a/src/types/collections.ts +++ b/src/types/collections.ts @@ -1248,88 +1248,90 @@ export interface Relationship { } )[] | null; - outgoingRelations: ( - | { - relationTo: "pages"; - value: string | Page; - } - | { - relationTo: "collectibles"; - value: string | Collectible; - } - | { - relationTo: "folders"; - value: string | Folder; - } - | { - relationTo: "chronology-events"; - value: string | ChronologyEvent; - } - | { - relationTo: "images"; - value: string | Image; - } - | { - relationTo: "audios"; - value: string | Audio; - } - | { - relationTo: "media-thumbnails"; - value: string | MediaThumbnail; - } - | { - relationTo: "videos"; - value: string | Video; - } - | { - relationTo: "videos-subtitles"; - value: string | VideoSubtitle; - } - | { - relationTo: "videos-channels"; - value: string | VideosChannel; - } - | { - relationTo: "files"; - value: string | File; - } - | { - relationTo: "scans"; - value: string | Scan; - } - | { - relationTo: "tags"; - value: string | Tag; - } - | { - relationTo: "attributes"; - value: string | Attribute; - } - | { - relationTo: "credits-roles"; - value: string | CreditsRole; - } - | { - relationTo: "recorders"; - value: string | Recorder; - } - | { - relationTo: "languages"; - value: string | Language; - } - | { - relationTo: "currencies"; - value: string | Currency; - } - | { - relationTo: "wordings"; - value: string | Wording; - } - | { - relationTo: "generic-contents"; - value: string | GenericContent; - } - )[]; + outgoingRelations?: + | ( + | { + relationTo: "pages"; + value: string | Page; + } + | { + relationTo: "collectibles"; + value: string | Collectible; + } + | { + relationTo: "folders"; + value: string | Folder; + } + | { + relationTo: "chronology-events"; + value: string | ChronologyEvent; + } + | { + relationTo: "images"; + value: string | Image; + } + | { + relationTo: "audios"; + value: string | Audio; + } + | { + relationTo: "media-thumbnails"; + value: string | MediaThumbnail; + } + | { + relationTo: "videos"; + value: string | Video; + } + | { + relationTo: "videos-subtitles"; + value: string | VideoSubtitle; + } + | { + relationTo: "videos-channels"; + value: string | VideosChannel; + } + | { + relationTo: "files"; + value: string | File; + } + | { + relationTo: "scans"; + value: string | Scan; + } + | { + relationTo: "tags"; + value: string | Tag; + } + | { + relationTo: "attributes"; + value: string | Attribute; + } + | { + relationTo: "credits-roles"; + value: string | CreditsRole; + } + | { + relationTo: "recorders"; + value: string | Recorder; + } + | { + relationTo: "languages"; + value: string | Language; + } + | { + relationTo: "currencies"; + value: string | Currency; + } + | { + relationTo: "wordings"; + value: string | Wording; + } + | { + relationTo: "generic-contents"; + value: string | GenericContent; + } + )[] + | null; } /** * This interface was referenced by `Config`'s JSON-Schema diff --git a/src/utils/array.ts b/src/utils/array.ts new file mode 100644 index 0000000..4f3722d --- /dev/null +++ b/src/utils/array.ts @@ -0,0 +1,9 @@ +export const uniqueBy = (array: T[], getKey: (item: T) => K) => { + const alreadyFoundKeys: K[] = []; + return array.filter((item) => { + var currentItemKey = getKey(item); + if (alreadyFoundKeys.includes(currentItemKey)) return false; + alreadyFoundKeys.push(currentItemKey); + return true; + }); +}; diff --git a/src/utils/collectionConfig.ts b/src/utils/collectionConfig.ts index c762bad..56c1a9d 100644 --- a/src/utils/collectionConfig.ts +++ b/src/utils/collectionConfig.ts @@ -1,6 +1,10 @@ import { GeneratedTypes } from "payload"; import { CollectionConfig } from "payload/types"; import { formatToPascalCase } from "./string"; +import { + afterChangeSendChangesWebhook, + afterDeleteSendChangesWebhook, +} from "../hooks/afterOperationSendChangesWebhook"; type CollectionConfigWithPlugins = CollectionConfig; @@ -15,4 +19,9 @@ export type BuildCollectionConfig = Omit< export const buildCollectionConfig = (config: BuildCollectionConfig): CollectionConfig => ({ ...config, typescript: { interface: formatToPascalCase(config.labels.singular) }, + hooks: { + ...config.hooks, + afterChange: [...(config.hooks?.afterChange ?? []), afterChangeSendChangesWebhook], + afterDelete: [...(config.hooks?.afterDelete ?? []), afterDeleteSendChangesWebhook], + }, });