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