Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions admin/src/components/Action/Action.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ const Action = ({ mode, documentId, entitySlug, locale }) => {
setIsLoading(false);
if (data) {
setActionId(data.documentId);
setExecuteAt(data.executeAt);
// Convert UTC date from server to local Date object for DateTimePicker
setExecuteAt(data.executeAt ? new Date(data.executeAt) : null);
setIsEditing(true);
} else {
setActionId(0);
Expand All @@ -57,7 +58,6 @@ const Action = ({ mode, documentId, entitySlug, locale }) => {
// Handlers
function handleDateChange(date) {
setExecuteAt(date);
//setExecuteAt(date.toISOString());
}

const handleOnEdit = () => {
Expand Down
144 changes: 123 additions & 21 deletions server/middlewares/validate-before-scheduling.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,13 @@ import { errors } from '@strapi/utils';
const validationMiddleware = async (context, next) => {
const { uid, action, params } = context;
// Run this middleware only for the publisher action.
if (uid !== 'plugin::publisher.action') {
return next();
}
if (uid !== 'plugin::publisher.action') return next();

// Run it only for the create and update actions.
if (action !== 'create' && action !== 'update') {
return next();
}
if (action !== 'create' && action !== 'update') return next();

// The create action will have the data directly.
let publisherAction = params.data;
let publisherAction = params?.data;

// The update action might have incomplete data, so we need to fetch it.
if (action === 'update') {
Expand All @@ -25,13 +21,11 @@ const validationMiddleware = async (context, next) => {
// The complete, and possibly updated, publisher action.
const { entityId, entitySlug, mode, locale: actionLocale } = {
...publisherAction,
...params.data,
...params?.data,
};

// Run it only for the publish mode.
if (mode !== 'publish') {
return next();
}
if (mode !== 'publish') return next();

const populateBuilderService = strapi.plugin('content-manager').service('populate-builder');
const populate = await populateBuilderService(entitySlug).populateDeep(Infinity).build();
Expand All @@ -46,28 +40,136 @@ const validationMiddleware = async (context, next) => {

if (!draft) {
throw new errors.NotFoundError(
`No draft found for ${entitySlug} with documentId "${entityId}"${actionLocale ? ` and locale "${actionLocale}".` : '.'}`
`No draft found for ${entitySlug} with documentId "${entityId}"${
actionLocale ? ` and locale "${actionLocale}".` : '.'
}`
);
}

// If no locale was provided in params.data, fill it in from the draft
// If no locale was provided in params.data, fill it in from the draft.
const locale = actionLocale || draft.locale;

// Fetch the published entity in this same locale
// Fetch the published entity in this same locale.
const published = await strapi.documents(entitySlug).findOne({
documentId: entityId,
status: 'published',
locale,
populate,
});

// Validate the draft before scheduling the publication.
await strapi.entityValidator.validateEntityCreation(
strapi.contentType(entitySlug),
draft,
{ isDraft: false, locale },
published
);
const model = strapi.contentType(entitySlug);

// ---------- helpers ----------
const isEmptyValue = (value, { multiple, repeatable }) => {
if (multiple || repeatable) return !Array.isArray(value) || value.length === 0;
return value === null || value === undefined;
};

// Minimal custom check: only required media/relations + nested structure inside components/DZ.
const collectRequiredMissing = (schema, dataNode, pathArr = []) => {
const errs = [];
const attrs = schema?.attributes || {};

for (const [name, attr] of Object.entries(attrs)) {
const nextPath = [...pathArr, name];
const value = dataNode ? dataNode[name] : undefined;

// Media fields
if (attr.type === 'media') {
if (attr.required && isEmptyValue(value, { multiple: !!attr.multiple })) {
errs.push({ path: nextPath, message: 'This field is required' });
}
continue;
}

// Relations
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;
}

// Components (repeatable or single)
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;
}

// Dynamic zones
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;
};

// ---------- run core validator, normalize, and (optionally) add extras ----------
try {
await strapi.entityValidator.validateEntityCreation(
model,
draft,
{ isDraft: false, locale },
published
);
} catch (e) {
const name = e?.name || e?.constructor?.name;
const isValidationLike =
Array.isArray(e?.details?.errors) || /ValidationError/i.test(name || '');

if (isValidationLike) {
// Use core errors and supplement with missing media/relations if needed.
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;
}

// Enforce required media/relations even if core validator passed
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();
};
Expand Down
37 changes: 29 additions & 8 deletions server/services/publication-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export default ({ strapi }) => ({
* Publish a single record
*
*/
async publish(uid, entityId, { locale }) {
async publish(uid, entityId, { locale, publishedAt }) {
try {
const { hooks } = getPluginService('settingsService').get();

Expand All @@ -25,18 +25,39 @@ export default ({ strapi }) => ({
return;
}

const publishedEntity = await strapi.documents(uid).publish({
let publishedEntity = await strapi.documents(uid).publish({
documentId: entityId,
locale,
});

// If a custom publishedAt is provided, update it directly via the database layer
if (publishedAt) {
// Get the internal ID of the published entry
const publishedRecord = publishedEntity.entries?.[0];

if (publishedRecord?.id) {
// Use db.query to directly update the publishedAt field in the database
await strapi.db.query(uid).update({
where: { id: publishedRecord.id },
data: { publishedAt },
});

// Fetch the updated entity to return the correct data
publishedEntity = await strapi.documents(uid).findOne({
documentId: entityId,
locale,
status: 'published',
});
}
}

await getPluginService('emitService').publish(uid, publishedEntity);

strapi.log.info(logMessage(`Successfully published document with id "${entityId}"${locale ? ` and locale "${locale}"` : ''} of type "${uid}".`));
strapi.log.info(logMessage(`Successfully published document with id "${entityId}"${locale ? ` and locale "${locale}"` : ''} of type "${uid}".`));

await hooks.afterPublish({ strapi, uid, entity: publishedEntity });
} catch (error) {
strapi.log.error(logMessage(`An error occurred when trying to publish document with id "${entityId}"${locale ? ` and locale "${locale}"` : ''} of type "${uid}": "${error}"`));
strapi.log.error(logMessage(`An error occurred when trying to publish document with id "${entityId}"${locale ? ` and locale "${locale}"` : ''} of type "${uid}": "${error}"`));
}
},
/**
Expand Down Expand Up @@ -67,11 +88,11 @@ export default ({ strapi }) => ({

await getPluginService('emitService').unpublish(uid, unpublishedEntity);

strapi.log.info(logMessage(`Successfully unpublished document with id "${entityId}"${locale ? ` and locale "${locale}"` : ''} of type "${uid}".`));
strapi.log.info(logMessage(`Successfully unpublished document with id "${entityId}"${locale ? ` and locale "${locale}"` : ''} of type "${uid}".`));

await hooks.afterUnpublish({ strapi, uid, entity: unpublishedEntity });
} catch (error) {
strapi.log.error(logMessage(`An error occurred when trying to unpublish document with id "${entityId}"${locale ? ` and locale "${locale}"` : ''} of type "${uid}": "${error}"`));
strapi.log.error(logMessage(`An error occurred when trying to unpublish document with id "${entityId}"${locale ? ` and locale "${locale}"` : ''} of type "${uid}": "${error}"`));
}
},
/**
Expand All @@ -85,14 +106,14 @@ export default ({ strapi }) => ({
const publishedEntity = await strapi.documents(record.entitySlug).findOne({
documentId: entityId,
status: 'published',
locale: record.locale
...(record.locale ? { locale: record.locale } : {}),
});

// Find the draft version of the entity
const draftEntity = await strapi.documents(record.entitySlug).findOne({
documentId: entityId,
status: 'draft',
locale: record.locale
...(record.locale ? { locale: record.locale } : {}),
});

// Determine the current state of the entity
Expand Down