223 lines
7.1 KiB
TypeScript
223 lines
7.1 KiB
TypeScript
import type { Core } from '@strapi/strapi';
|
|||
|
|
|
||
|
|
import { trySendMail } from './event-mail';
|
||
|
|
|
||
|
|
const PARTNER_CONTENT = new Set([
|
||
|
|
'api::resource.resource',
|
||
|
|
'api::news-article.news-article',
|
||
|
|
'api::activity.activity',
|
||
|
|
'api::event.event',
|
||
|
|
'api::opportunity.opportunity',
|
||
|
|
]);
|
||
|
|
const NOTIFIABLE_ACTIONS = new Set(['create', 'update', 'publish']);
|
||
|
|
const REVIEW_RETURN_STATUSES = new Set(['changes-requested', 'rejected']);
|
||
|
|
const LOG_PREFIX = '[partner-review-mail]';
|
||
|
|
|
||
|
|
type ReviewDocument = {
|
||
|
|
documentId?: string;
|
||
|
|
title?: string;
|
||
|
|
slug?: string;
|
||
|
|
reviewStatus?: string | null;
|
||
|
|
reviewFeedback?: string | null;
|
||
|
|
submittedByAdminUserId?: number | null;
|
||
|
|
};
|
||
|
|
type AdminUser = {
|
||
|
|
id: number;
|
||
|
|
email?: string | null;
|
||
|
|
isActive?: boolean;
|
||
|
|
roles?: Array<{ code?: string }>;
|
||
|
|
};
|
||
|
|
|
||
|
|
const readResultDocumentId = (value: unknown): string | undefined => {
|
||
|
|
if (!value || typeof value !== 'object') return undefined;
|
||
|
|
const documentId = (value as { documentId?: unknown }).documentId;
|
||
|
|
return typeof documentId === 'string' && documentId.length > 0 ? documentId : undefined;
|
||
|
|
};
|
||
|
|
|
||
|
|
const readDocument = async (
|
||
|
|
strapi: Core.Strapi,
|
||
|
|
uid: string,
|
||
|
|
documentId: string,
|
||
|
|
locale: string | undefined,
|
||
|
|
status: 'draft' | 'published' = 'draft'
|
||
|
|
): Promise<ReviewDocument | null> => {
|
||
|
|
try {
|
||
|
|
return (await strapi.documents(uid as never).findOne({
|
||
|
|
documentId,
|
||
|
|
...(locale ? { locale } : {}),
|
||
|
|
status,
|
||
|
|
})) as ReviewDocument | null;
|
||
|
|
} catch (error) {
|
||
|
|
strapi.log.warn(`${LOG_PREFIX} could not read ${uid} ${documentId}: ${String(error)}`);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const getReviewerRecipients = async (strapi: Core.Strapi): Promise<string[]> => {
|
||
|
|
const configured = (process.env.PARTNER_REVIEW_NOTIFICATION_EMAILS ?? '')
|
||
|
|
.split(',')
|
||
|
|
.map((address) => address.trim())
|
||
|
|
.filter(Boolean);
|
||
|
|
if (configured.length > 0) return [...new Set(configured)];
|
||
|
|
|
||
|
|
const admins = (await strapi.db.query('admin::user').findMany({
|
||
|
|
select: ['id', 'email', 'isActive'],
|
||
|
|
populate: { roles: { select: ['code'] } },
|
||
|
|
})) as AdminUser[];
|
||
|
|
|
||
|
|
return [
|
||
|
|
...new Set(
|
||
|
|
admins
|
||
|
|
.filter(
|
||
|
|
(admin) => admin.isActive !== false && admin.roles?.some((role) => role.code === 'strapi-super-admin')
|
||
|
|
)
|
||
|
|
.map((admin) => admin.email?.trim())
|
||
|
|
.filter((email): email is string => Boolean(email))
|
||
|
|
),
|
||
|
|
];
|
||
|
|
};
|
||
|
|
|
||
|
|
const getSubmitter = async (strapi: Core.Strapi, id: number): Promise<AdminUser | null> =>
|
||
|
|
(await strapi.db.query('admin::user').findOne({
|
||
|
|
where: { id },
|
||
|
|
select: ['id', 'email', 'isActive'],
|
||
|
|
})) as AdminUser | null;
|
||
|
|
|
||
|
|
const entryLabel = (uid: string, document?: ReviewDocument | null): string => {
|
||
|
|
const title = document?.title?.trim() || document?.slug?.trim();
|
||
|
|
const type = uid.split('.')[1]?.replace(/-/g, ' ') ?? 'content';
|
||
|
|
return title ? `${type}: ${title}` : `a ${type} entry`;
|
||
|
|
};
|
||
|
|
|
||
|
|
const sendReviewNotification = async (
|
||
|
|
strapi: Core.Strapi,
|
||
|
|
to: string[],
|
||
|
|
subject: string,
|
||
|
|
body: string
|
||
|
|
): Promise<void> => {
|
||
|
|
if (to.length === 0) {
|
||
|
|
strapi.log.warn(`${LOG_PREFIX} no recipient configured for: ${subject}`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
await Promise.all(
|
||
|
|
to.map((address) =>
|
||
|
|
trySendMail(strapi, { to: address, subject, text: body }).then((sent) => {
|
||
|
|
if (sent) strapi.log.info(`${LOG_PREFIX} sent "${subject}" to ${address}.`);
|
||
|
|
})
|
||
|
|
)
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
const notifyAfterSave = async ({
|
||
|
|
strapi,
|
||
|
|
uid,
|
||
|
|
action,
|
||
|
|
documentId,
|
||
|
|
locale,
|
||
|
|
before,
|
||
|
|
}: {
|
||
|
|
strapi: Core.Strapi;
|
||
|
|
uid: string;
|
||
|
|
action: string;
|
||
|
|
documentId: string;
|
||
|
|
locale?: string;
|
||
|
|
before: ReviewDocument | null;
|
||
|
|
}): Promise<void> => {
|
||
|
|
const after = await readDocument(strapi, uid, documentId, locale, action === 'publish' ? 'published' : 'draft');
|
||
|
|
if (!after) return;
|
||
|
|
|
||
|
|
const submitterId = after.submittedByAdminUserId;
|
||
|
|
if (typeof submitterId !== 'number') return;
|
||
|
|
|
||
|
|
const label = entryLabel(uid, after);
|
||
|
|
|
||
|
|
if (
|
||
|
|
(action === 'create' && after.reviewStatus === 'pending') ||
|
||
|
|
(action === 'update' && before?.reviewStatus !== 'pending' && after.reviewStatus === 'pending')
|
||
|
|
) {
|
||
|
|
const recipients = await getReviewerRecipients(strapi);
|
||
|
|
await sendReviewNotification(
|
||
|
|
strapi,
|
||
|
|
recipients,
|
||
|
|
`[SHARP-ER] Partner draft ready for review`,
|
||
|
|
`A partner contributor submitted ${label}.\n\nReview it in Strapi Content Manager. It is a draft and is not public until an Editor publishes it.`
|
||
|
|
);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (
|
||
|
|
action === 'update' &&
|
||
|
|
after.reviewStatus &&
|
||
|
|
REVIEW_RETURN_STATUSES.has(after.reviewStatus) &&
|
||
|
|
(before?.reviewStatus !== after.reviewStatus || before?.reviewFeedback !== after.reviewFeedback)
|
||
|
|
) {
|
||
|
|
const submitter = await getSubmitter(strapi, submitterId);
|
||
|
|
if (!submitter?.email || submitter.isActive === false) {
|
||
|
|
strapi.log.warn(`${LOG_PREFIX} no active partner author email for admin user ${submitterId}.`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const decision = after.reviewStatus === 'rejected' ? 'rejected' : 'changes requested';
|
||
|
|
const feedback = after.reviewFeedback?.trim() || '(No review feedback was provided.)';
|
||
|
|
await sendReviewNotification(
|
||
|
|
strapi,
|
||
|
|
[submitter.email],
|
||
|
|
`[SHARP-ER] Review update: ${label}`,
|
||
|
|
`Your ${label} has been marked ${decision}.\n\nReviewer feedback:\n${feedback}\n\nPlease update your draft in Strapi Content Manager and save it to resubmit.`
|
||
|
|
);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (action === 'publish') {
|
||
|
|
const submitter = await getSubmitter(strapi, submitterId);
|
||
|
|
if (!submitter?.email || submitter.isActive === false) {
|
||
|
|
strapi.log.warn(`${LOG_PREFIX} no active partner author email for admin user ${submitterId}.`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
await sendReviewNotification(
|
||
|
|
strapi,
|
||
|
|
[submitter.email],
|
||
|
|
`[SHARP-ER] Your content has been published`,
|
||
|
|
`Your ${label} has been approved and published on the SHARP-ER website.`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
/** Notify reviewers and partner authors after persisted workflow transitions. */
|
||
|
|
export const registerPartnerReviewNotifications = (strapi: Core.Strapi): void => {
|
||
|
|
strapi.documents.use(async (context, next) => {
|
||
|
|
if (!PARTNER_CONTENT.has(context.uid) || !NOTIFIABLE_ACTIONS.has(context.action)) return next();
|
||
|
|
|
||
|
|
const requestContext = strapi.requestContext.get();
|
||
|
|
if (!requestContext?.state?.user || !requestContext.request.path.startsWith('/content-manager/')) {
|
||
|
|
return next();
|
||
|
|
}
|
||
|
|
|
||
|
|
const params = context.params as { documentId?: string; locale?: string };
|
||
|
|
const before =
|
||
|
|
params.documentId && (context.action === 'update' || context.action === 'publish')
|
||
|
|
? await readDocument(strapi, context.uid, params.documentId, params.locale)
|
||
|
|
: null;
|
||
|
|
const result = await next();
|
||
|
|
const documentId = params.documentId ?? readResultDocumentId(result);
|
||
|
|
if (!documentId) return result;
|
||
|
|
|
||
|
|
// Run after Strapi has persisted the draft or review decision. Delivery
|
||
|
|
// failures are logged and never roll back a successful content save.
|
||
|
|
void notifyAfterSave({
|
||
|
|
strapi,
|
||
|
|
uid: context.uid,
|
||
|
|
action: context.action,
|
||
|
|
documentId,
|
||
|
|
locale: params.locale,
|
||
|
|
before,
|
||
|
|
}).catch((error) => {
|
||
|
|
strapi.log.error(`${LOG_PREFIX} notification failed for ${context.uid} ${documentId}: ${String(error)}`);
|
||
|
|
});
|
||
|
|
|
||
|
|
return result;
|
||
|
|
});
|
||
|
|
};
|