268 lines
8.6 KiB
TypeScript
268 lines
8.6 KiB
TypeScript
import type { Core } from '@strapi/strapi';
|
|
|
|
import { DEFAULT_EMAIL_TEMPLATES, type EmailTemplateKey } from './email-template-defaults';
|
|
import { buildBilingualMail, trySendMail } from './event-mail';
|
|
import type { PlaceholderValues } from './email-templates';
|
|
|
|
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 PartnerReviewTemplateKey = Extract<
|
|
EmailTemplateKey,
|
|
'partner-review-submitted' | 'partner-review-decision' | 'partner-review-published'
|
|
>;
|
|
|
|
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 ENTRY_TYPE_LABELS: Record<string, { ar: string; en: string }> = {
|
|
resource: { ar: 'مورد', en: 'resource' },
|
|
'news-article': { ar: 'مقال إخباري', en: 'news article' },
|
|
activity: { ar: 'نشاط', en: 'activity' },
|
|
event: { ar: 'فعالية', en: 'event' },
|
|
opportunity: { ar: 'فرصة', en: 'opportunity' },
|
|
};
|
|
|
|
const entryLabel = (uid: string, document: ReviewDocument | null | undefined, locale: 'ar' | 'en'): string => {
|
|
const title = document?.title?.trim() || document?.slug?.trim();
|
|
const typeSlug = uid.split('.')[1] ?? 'content';
|
|
const type = ENTRY_TYPE_LABELS[typeSlug]?.[locale] ?? (locale === 'ar' ? 'محتوى' : typeSlug.replace(/-/g, ' '));
|
|
if (title) return `${type}: ${title}`;
|
|
return locale === 'ar' ? `عنصر من نوع ${type}` : `a ${type} entry`;
|
|
};
|
|
|
|
const buildReviewMail = (
|
|
key: PartnerReviewTemplateKey,
|
|
vars: { ar: PlaceholderValues; en: PlaceholderValues }
|
|
): ReturnType<typeof buildBilingualMail> => {
|
|
const template = DEFAULT_EMAIL_TEMPLATES[key];
|
|
return buildBilingualMail({
|
|
subjectAr: template.ar.subject,
|
|
subjectEn: template.en.subject,
|
|
bodyAr: template.ar.body,
|
|
bodyEn: template.en.body,
|
|
vars,
|
|
});
|
|
};
|
|
|
|
const sendReviewNotification = async (
|
|
strapi: Core.Strapi,
|
|
to: string[],
|
|
mail: ReturnType<typeof buildBilingualMail>
|
|
): Promise<void> => {
|
|
if (to.length === 0) {
|
|
strapi.log.warn(`${LOG_PREFIX} no recipient configured for: ${mail.subject}`);
|
|
return;
|
|
}
|
|
|
|
await Promise.all(
|
|
to.map((address) =>
|
|
trySendMail(strapi, { to: address, ...mail }).then((sent) => {
|
|
if (sent) strapi.log.info(`${LOG_PREFIX} sent "${mail.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 labelAr = entryLabel(uid, after, 'ar');
|
|
const labelEn = entryLabel(uid, after, 'en');
|
|
|
|
if (
|
|
(action === 'create' && after.reviewStatus === 'pending') ||
|
|
(action === 'update' && before?.reviewStatus !== 'pending' && after.reviewStatus === 'pending')
|
|
) {
|
|
const recipients = await getReviewerRecipients(strapi);
|
|
await sendReviewNotification(
|
|
strapi,
|
|
recipients,
|
|
buildReviewMail('partner-review-submitted', {
|
|
ar: { label: labelAr },
|
|
en: { label: labelEn },
|
|
})
|
|
);
|
|
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 decisionEn = after.reviewStatus === 'rejected' ? 'rejected' : 'changes requested';
|
|
const decisionAr = after.reviewStatus === 'rejected' ? 'الرفض' : 'طلب إجراء تغييرات';
|
|
const feedback = after.reviewFeedback?.trim();
|
|
await sendReviewNotification(
|
|
strapi,
|
|
[submitter.email],
|
|
buildReviewMail('partner-review-decision', {
|
|
ar: {
|
|
label: labelAr,
|
|
decision: decisionAr,
|
|
feedback: feedback || '(لم تُقدَّم ملاحظات للمراجعة.)',
|
|
},
|
|
en: {
|
|
label: labelEn,
|
|
decision: decisionEn,
|
|
feedback: feedback || '(No review feedback was provided.)',
|
|
},
|
|
})
|
|
);
|
|
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],
|
|
buildReviewMail('partner-review-published', {
|
|
ar: { label: labelAr },
|
|
en: { label: labelEn },
|
|
})
|
|
);
|
|
}
|
|
};
|
|
|
|
/** 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;
|
|
});
|
|
};
|