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 => { 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 => { 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 => (await strapi.db.query('admin::user').findOne({ where: { id }, select: ['id', 'email', 'isActive'], })) as AdminUser | null; const ENTRY_TYPE_LABELS: Record = { 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 => { 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 ): Promise => { 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 => { 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; }); };