Files
sharper.ly/apps/cms/src/lib/partner-review-notifications.ts
T

268 lines
8.6 KiB
TypeScript
Raw Normal View History

2026-10-06 15:36:11 +02:00
import type { Core } from '@strapi/strapi';
2026-10-07 10:16:02 +02:00
import { DEFAULT_EMAIL_TEMPLATES, type EmailTemplateKey } from './email-template-defaults';
import { buildBilingualMail, trySendMail } from './event-mail';
import type { PlaceholderValues } from './email-templates';
2026-10-06 15:36:11 +02:00
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]';
2026-10-07 10:16:02 +02:00
type PartnerReviewTemplateKey = Extract<
EmailTemplateKey,
'partner-review-submitted' | 'partner-review-decision' | 'partner-review-published'
>;
2026-10-06 15:36:11 +02:00
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;
2026-10-07 10:16:02 +02:00
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 => {
2026-10-06 15:36:11 +02:00
const title = document?.title?.trim() || document?.slug?.trim();
2026-10-07 10:16:02 +02:00
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,
});
2026-10-06 15:36:11 +02:00
};
const sendReviewNotification = async (
strapi: Core.Strapi,
to: string[],
2026-10-07 10:16:02 +02:00
mail: ReturnType<typeof buildBilingualMail>
2026-10-06 15:36:11 +02:00
): Promise<void> => {
if (to.length === 0) {
2026-10-07 10:16:02 +02:00
strapi.log.warn(`${LOG_PREFIX} no recipient configured for: ${mail.subject}`);
2026-10-06 15:36:11 +02:00
return;
}
await Promise.all(
to.map((address) =>
2026-10-07 10:16:02 +02:00
trySendMail(strapi, { to: address, ...mail }).then((sent) => {
if (sent) strapi.log.info(`${LOG_PREFIX} sent "${mail.subject}" to ${address}.`);
2026-10-06 15:36:11 +02:00
})
)
);
};
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;
2026-10-07 10:16:02 +02:00
const labelAr = entryLabel(uid, after, 'ar');
const labelEn = entryLabel(uid, after, 'en');
2026-10-06 15:36:11 +02:00
if (
(action === 'create' && after.reviewStatus === 'pending') ||
(action === 'update' && before?.reviewStatus !== 'pending' && after.reviewStatus === 'pending')
) {
const recipients = await getReviewerRecipients(strapi);
await sendReviewNotification(
strapi,
recipients,
2026-10-07 10:16:02 +02:00
buildReviewMail('partner-review-submitted', {
ar: { label: labelAr },
en: { label: labelEn },
})
2026-10-06 15:36:11 +02:00
);
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;
}
2026-10-07 10:16:02 +02:00
const decisionEn = after.reviewStatus === 'rejected' ? 'rejected' : 'changes requested';
const decisionAr = after.reviewStatus === 'rejected' ? 'الرفض' : 'طلب إجراء تغييرات';
const feedback = after.reviewFeedback?.trim();
2026-10-06 15:36:11 +02:00
await sendReviewNotification(
strapi,
[submitter.email],
2026-10-07 10:16:02 +02:00
buildReviewMail('partner-review-decision', {
ar: {
label: labelAr,
decision: decisionAr,
feedback: feedback || '(لم تُقدَّم ملاحظات للمراجعة.)',
},
en: {
label: labelEn,
decision: decisionEn,
feedback: feedback || '(No review feedback was provided.)',
},
})
2026-10-06 15:36:11 +02:00
);
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],
2026-10-07 10:16:02 +02:00
buildReviewMail('partner-review-published', {
ar: { label: labelAr },
en: { label: labelEn },
})
2026-10-06 15:36:11 +02:00
);
}
};
/** 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;
});
};