UNPKG

strapi-plugin-publisher

Version:

A plugin for Strapi Headless CMS that provides the ability to schedule publishing for any content type.

674 lines (673 loc) 20.8 kB
import * as yup from "yup"; import { factories } from "@strapi/strapi"; import { errors } from "@strapi/utils"; const $schema = "https://json.schemastore.org/package"; const name = "strapi-plugin-publisher"; const version = "2.0.8"; const description = "A plugin for Strapi Headless CMS that provides the ability to schedule publishing for any content type."; const scripts = { lint: "eslint . --fix", format: "prettier --write **/*.{ts,js,json,yml}", build: "strapi-plugin build", watch: "strapi-plugin watch", "watch:link": "strapi-plugin watch:link" }; const exports = { "./strapi-admin": { source: "./admin/src/index.ts", "import": "./dist/admin/index.mjs", require: "./dist/admin/index.js", "default": "./dist/admin/index.js" }, "./strapi-server": { source: "./server/index.js", "import": "./dist/server/index.mjs", require: "./dist/server/index.js", "default": "./dist/server/index.js" }, "./package.json": "./package.json" }; const author = { name: "@ComfortablyCoding", url: "https://github.com/ComfortablyCoding" }; const maintainers = [ { name: "@PluginPal", url: "https://github.com/PluginPal" } ]; const homepage = "https://github.com/PluginPal/strapi-plugin-publisher#readme"; const repository = { type: "git", url: "https://github.com/PluginPal/strapi-plugin-publisher.git" }; const bugs = { url: "https://github.com/PluginPal/strapi-plugin-publisher/issues" }; const dependencies = { lodash: "^4.17.21", "prop-types": "^15.8.1", "react-intl": "^6.6.2", "react-query": "^3.39.3", yup: "^0.32.9" }; const devDependencies = { "@babel/core": "^7.23.3", "@babel/eslint-parser": "^7.23.3", "@babel/preset-react": "^7.23.3", "@strapi/sdk-plugin": "^5.2.7", "@strapi/strapi": "^5.34.0", eslint: "^8.53.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-node": "^11.1.0", "eslint-plugin-react": "^7.33.2", prettier: "^3.1.0", react: "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.0.0", "strip-ansi": "^6.0.1", "styled-components": "^6.0.0" }; const peerDependencies = { "@strapi/design-system": "^2.1.2", "@strapi/icons": "^2.1.2", "@strapi/strapi": "^5.34.0", "@strapi/utils": "^5.34.0", react: "^17.0.0 || ^18.0.0", "react-router-dom": "^6.0.0", "styled-components": "^6.0.0" }; const strapi$1 = { displayName: "Publisher", name: "publisher", description: "A plugin for Strapi Headless CMS that provides the ability to schedule publishing for any content type.", kind: "plugin" }; const engines = { node: ">=20.0.0 <=24.x.x", npm: ">=6.0.0" }; const keywords = [ "strapi", "strapi-plugin", "plugin", "strapi plugin", "publishing", "schedule publish" ]; const license = "MIT"; const pluginPkg = { $schema, name, version, description, scripts, exports, author, maintainers, homepage, repository, bugs, dependencies, devDependencies, peerDependencies, strapi: strapi$1, engines, keywords, license }; const pluginId = pluginPkg.strapi.name; const getPluginService = (name2) => strapi.plugin(pluginId).service(name2); const logMessage = (msg = "") => `[strapi-plugin-${pluginId}]: ${msg}`; const registerCronTasks = ({ strapi: strapi2 }) => { const scheduleExistingActions = async () => { try { const allActions = await getPluginService("action").find({ pagination: { pageSize: 1e3 // Get all actions } }); const now = /* @__PURE__ */ new Date(); const futureActions = []; const pastActions = []; for (const record of allActions.results) { const executeAt = new Date(record.executeAt); if (executeAt > now) { futureActions.push(record); } else { pastActions.push(record); } } strapi2.log.info(logMessage(`Found ${pastActions.length} past actions to execute and ${futureActions.length} future actions to schedule on startup.`)); for (const record of pastActions) { try { await getPluginService("publicationService").toggle(record, record.mode); strapi2.log.info(logMessage(`Executed overdue action ${record.documentId}`)); } catch (error) { strapi2.log.error(logMessage(`Error executing overdue action ${record.documentId}: ${error.message}`)); } } for (const record of futureActions) { getPluginService("action").scheduleCronJob(record); } } catch (error) { strapi2.log.error(logMessage(`Error scheduling existing actions on startup: ${error.message}`)); } }; setImmediate(() => { scheduleExistingActions(); }); }; const bootstrap = ({ strapi: strapi2 }) => { registerCronTasks({ strapi: strapi2 }); }; const pluginConfigSchema = yup.object().shape({ actions: yup.object().shape({ syncFrequency: yup.string().optional() }).optional(), hooks: yup.object().optional(), components: yup.object({ dateTimePicker: yup.object({ step: yup.number().optional(), locale: yup.string().optional() }).optional() }).optional(), contentTypes: yup.array().of(yup.string()).optional() }); const config = { default: () => ({ enabled: true, actions: { syncFrequency: "*/1 * * * *" }, // Hooks allow you to run custom logic around publish/unpublish. // NOTE: If a before* hook explicitly returns false, the action will be cancelled. hooks: { beforePublish: () => { }, afterPublish: () => { }, beforeUnpublish: () => { }, afterUnpublish: () => { } }, components: { dateTimePicker: { step: 5 } } }), validator: async (config2) => { await pluginConfigSchema.validate(config2); } }; const actionContentType = { kind: "collectionType", collectionName: "actions", info: { singularName: "action", pluralName: "actions", displayName: "actions" }, pluginOptions: { "content-manager": { visible: false }, "content-type-builder": { visible: false } }, options: { draftAndPublish: false, comment: "" }, attributes: { executeAt: { type: "datetime", required: true }, mode: { type: "string", required: true }, entityId: { type: "string", required: true }, entitySlug: { type: "string", required: true } } }; const contentTypes = { action: { schema: actionContentType } }; const actionController = factories.createCoreController("plugin::publisher.action"); const settingsController = { /** * Fetch the current plugin settings * * @return {Array} actions */ async find(ctx) { ctx.send({ data: getPluginService("settingsService").get() }); } }; const controllers = { actionController, settingsController }; const validationMiddleware = async (context, next) => { const { uid, action, params } = context; if (uid !== "plugin::publisher.action") return next(); if (action !== "create" && action !== "update") return next(); let publisherAction = params?.data; if (action === "update") { publisherAction = await strapi.documents("plugin::publisher.action").findOne({ documentId: params.documentId }); } const { entityId, entitySlug, mode, locale: actionLocale } = { ...publisherAction, ...params?.data }; if (mode !== "publish") return next(); const populateBuilderService = strapi.plugin("content-manager").service("populate-builder"); const populate = await populateBuilderService(entitySlug).populateDeep(Infinity).build(); const draft = await strapi.documents(entitySlug).findOne({ documentId: entityId, status: "draft", locale: actionLocale, populate }); if (!draft) { throw new errors.NotFoundError( `No draft found for ${entitySlug} with documentId "${entityId}"${actionLocale ? ` and locale "${actionLocale}".` : "."}` ); } const locale = actionLocale || draft.locale; const published = await strapi.documents(entitySlug).findOne({ documentId: entityId, status: "published", locale, populate }); const model = strapi.contentType(entitySlug); const isEmptyValue = (value, { multiple, repeatable }) => { if (multiple || repeatable) return !Array.isArray(value) || value.length === 0; return value === null || value === void 0; }; const collectRequiredMissing = (schema, dataNode, pathArr = []) => { const errs = []; const attrs = schema?.attributes || {}; for (const [name2, attr] of Object.entries(attrs)) { const nextPath = [...pathArr, name2]; const value = dataNode ? dataNode[name2] : void 0; if (attr.type === "media") { if (attr.required && isEmptyValue(value, { multiple: !!attr.multiple })) { errs.push({ path: nextPath, message: "This field is required" }); } continue; } if (attr.type === "relation") { const many = ["oneToMany", "manyToMany", "morphToMany"].includes(attr.relation) || typeof attr.relation === "string" && attr.relation.toLowerCase().includes("many"); if (attr.required && isEmptyValue(value, { multiple: many })) { errs.push({ path: nextPath, message: "This field is required" }); } continue; } if (attr.type === "component") { if (attr.required && isEmptyValue(value, { repeatable: !!attr.repeatable })) { errs.push({ path: nextPath, message: "This field is required" }); continue; } const compSchema = strapi.components[attr.component]; if (attr.repeatable && Array.isArray(value)) { value.forEach((item, idx) => { errs.push(...collectRequiredMissing(compSchema, item, [...nextPath, idx])); }); } else if (value) { errs.push(...collectRequiredMissing(compSchema, value, nextPath)); } continue; } if (attr.type === "dynamiczone") { if (attr.required && (!Array.isArray(value) || value.length === 0)) { errs.push({ path: nextPath, message: "This field is required" }); continue; } if (Array.isArray(value)) { value.forEach((dzItem, idx) => { const compUid = dzItem?.__component; if (!compUid) return; const compSchema = strapi.components[compUid]; errs.push(...collectRequiredMissing(compSchema, dzItem, [...nextPath, idx])); }); } continue; } } return errs; }; try { await strapi.entityValidator.validateEntityCreation( model, draft, { isDraft: false, locale }, published ); } catch (e) { const name2 = e?.name || e?.constructor?.name; const isValidationLike = Array.isArray(e?.details?.errors) || /ValidationError/i.test(name2 || ""); if (isValidationLike) { const core = (e.details?.errors || []).map((er) => ({ path: er.path || er.name || "", message: er.message || "This field is required" })); const extras = collectRequiredMissing(model, draft); const merged = [...core, ...extras]; throw new errors.ValidationError( "There are validation errors in your document. Please fix them so you can publish.", { errors: merged } ); } throw e; } const extrasAfterPass = collectRequiredMissing(model, draft); if (extrasAfterPass.length > 0) { throw new errors.ValidationError( "There are validation errors in your document. Please fix them so you can publish.", { errors: extrasAfterPass } ); } return next(); }; const register = ({ strapi: strapi2 }) => { strapi2.documents.use(validationMiddleware); }; const actionRoutes$1 = [ { method: "GET", path: "/actions", handler: "actionController.find" }, { method: "POST", path: "/actions", handler: "actionController.create" }, { method: "DELETE", path: "/actions/:id", handler: "actionController.delete" }, { method: "PUT", path: "/actions/:id", handler: "actionController.update" } ]; const settingsRoutes = [ { method: "GET", path: "/settings", handler: "settingsController.find" } ]; const admin = { type: "admin", routes: [...actionRoutes$1, ...settingsRoutes] }; const actionRoutes = [ { method: "GET", path: "/actions", handler: "actionController.find" }, { method: "POST", path: "/actions", handler: "actionController.create" }, { method: "DELETE", path: "/actions/:id", handler: "actionController.delete" }, { method: "PUT", path: "/actions/:id", handler: "actionController.update" } ]; const contentApi = { type: "content-api", routes: [...actionRoutes] }; const routes = { admin, "content-api": contentApi }; const actionService = factories.createCoreService("plugin::publisher.action", ({ strapi: strapi2 }) => ({ /** * Schedule a cron job for a specific action */ scheduleCronJob(action) { const taskName = `publisherAction_${action.documentId}`; const executeAt = new Date(action.executeAt); if (executeAt <= /* @__PURE__ */ new Date()) { strapi2.log.warn(logMessage(`Action ${action.documentId} has an executeAt time in the past, skipping cron job creation.`)); return; } strapi2.log.info(logMessage(`Scheduling cron job for action ${action.documentId} at ${executeAt.toISOString()}`)); strapi2.cron.add({ [taskName]: { async task() { try { const currentAction = await strapi2.documents("plugin::publisher.action").findOne({ documentId: action.documentId }); if (!currentAction) { strapi2.log.warn(logMessage(`Action ${action.documentId} no longer exists, skipping execution.`)); return; } await getPluginService("publicationService").toggle(currentAction, currentAction.mode); strapi2.log.info(logMessage(`Successfully executed action ${action.documentId}`)); } catch (error) { strapi2.log.error(logMessage(`Error executing action ${action.documentId}: ${error.message}`)); } finally { strapi2.cron.remove(taskName); } }, options: executeAt } }); }, /** * Remove a scheduled cron job */ removeCronJob(actionDocumentId) { const taskName = `publisherAction_${actionDocumentId}`; try { strapi2.cron.remove(taskName); strapi2.log.info(logMessage(`Removed cron job for action ${actionDocumentId}`)); } catch (error) { strapi2.log.warn(logMessage(`Could not remove cron job ${taskName}: ${error.message}`)); } }, /** * Override create to schedule cron job */ async create(...args) { const result = await super.create(...args); this.scheduleCronJob(result); return result; }, /** * Override update to reschedule cron job */ async update(documentId, ...args) { this.removeCronJob(documentId); const result = await super.update(documentId, ...args); this.scheduleCronJob(result); return result; }, /** * Override delete to remove cron job */ async delete(documentId, ...args) { this.removeCronJob(documentId); const result = await super.delete(documentId, ...args); return result; } })); const ENTRY_PUBLISH = "entry.publish"; const ENTRY_UNPUBLISH = "entry.unpublish"; const emitService = ({ strapi: strapi2 }) => ({ async emit(event, uid, entity) { const model = strapi2.getModel(uid); const sanitizedEntity = await strapi2.contentAPI.sanitize.output(entity, model); await strapi2.eventHub.emit(event, { model: model.modelName, entry: sanitizedEntity }); }, async publish(uid, entity) { await this.emit(ENTRY_PUBLISH, uid, entity); }, async unpublish(uid, entity) { await this.emit(ENTRY_UNPUBLISH, uid, entity); } }); const getPluginEntityUid = (entity) => `plugin::${pluginId}.${entity}`; const actionUId = getPluginEntityUid("action"); const publicationService = ({ strapi: strapi2 }) => ({ /** * Publish a single record * */ async publish(uid, entityId, { locale, publishedAt }) { try { const { hooks } = getPluginService("settingsService").get(); const entity = await strapi2.documents(uid).findOne({ documentId: entityId, locale }); const publisherActionAllowed = await hooks.beforePublish({ strapi: strapi2, uid, entity }); if (publisherActionAllowed === false) { strapi2.log.info(logMessage(`Publish aborted by beforePublish hook for document id "${entityId}"${locale ? ` and locale "${locale}"` : ""} of type "${uid}".`)); return; } let publishedEntity = await strapi2.documents(uid).publish({ documentId: entityId, locale }); if (publishedAt) { const publishedRecord = publishedEntity.entries?.[0]; if (publishedRecord?.id) { await strapi2.db.query(uid).update({ where: { id: publishedRecord.id }, data: { publishedAt } }); publishedEntity = await strapi2.documents(uid).findOne({ documentId: entityId, locale, status: "published" }); } } await getPluginService("emitService").publish(uid, publishedEntity); strapi2.log.info(logMessage(`Successfully published document with id "${entityId}"${locale ? ` and locale "${locale}"` : ""} of type "${uid}".`)); await hooks.afterPublish({ strapi: strapi2, uid, entity: publishedEntity }); } catch (error) { strapi2.log.error(logMessage(`An error occurred when trying to publish document with id "${entityId}"${locale ? ` and locale "${locale}"` : ""} of type "${uid}": "${error}"`)); } }, /** * Unpublish a single record * */ async unpublish(uid, entityId, { locale }) { try { const { hooks } = getPluginService("settingsService").get(); const entity = await strapi2.documents(uid).findOne({ documentId: entityId, locale }); const publisherActionAllowed = await hooks.beforeUnpublish({ strapi: strapi2, uid, entity }); if (publisherActionAllowed === false) { strapi2.log.info(logMessage(`Unpublish aborted by beforeUnpublish hook for document id "${entityId}"${locale ? ` and locale "${locale}"` : ""} of type "${uid}".`)); return; } const unpublishedEntity = await strapi2.documents(uid).unpublish({ documentId: entityId, locale }); await getPluginService("emitService").unpublish(uid, unpublishedEntity); strapi2.log.info(logMessage(`Successfully unpublished document with id "${entityId}"${locale ? ` and locale "${locale}"` : ""} of type "${uid}".`)); await hooks.afterUnpublish({ strapi: strapi2, uid, entity: unpublishedEntity }); } catch (error) { strapi2.log.error(logMessage(`An error occurred when trying to unpublish document with id "${entityId}"${locale ? ` and locale "${locale}"` : ""} of type "${uid}": "${error}"`)); } }, /** * Toggle a records publication state * */ async toggle(record, mode) { const entityId = record.entityId || 1; const publishedEntity = await strapi2.documents(record.entitySlug).findOne({ documentId: entityId, status: "published", ...record.locale ? { locale: record.locale } : {} }); const draftEntity = await strapi2.documents(record.entitySlug).findOne({ documentId: entityId, status: "draft", ...record.locale ? { locale: record.locale } : {} }); const isPublished = !!publishedEntity; const isDraft = !!draftEntity; const isModified = isPublished && isDraft && draftEntity.updatedAt > publishedEntity.updatedAt; if (mode === "publish" && (!isPublished && isDraft || isModified)) { await this.publish(record.entitySlug, entityId, { publishedAt: record.executeAt ? new Date(record.executeAt) : /* @__PURE__ */ new Date(), locale: record.locale }); } else if (mode === "unpublish" && isPublished) { await this.unpublish(record.entitySlug, entityId, { locale: record.locale }); } await strapi2.documents(actionUId).delete({ documentId: record.documentId }); } }); const settingsService = ({ strapi: strapi2 }) => ({ get() { return strapi2.config.get(`plugin::${pluginId}`); } }); const services = { action: actionService, emitService, publicationService, settingsService }; const index = { bootstrap, register, config, contentTypes, controllers, routes, services }; export { index as default };