partner review posts and connection
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import { errors } from '@strapi/utils';
|
||||
import type { Core } from '@strapi/strapi';
|
||||
|
||||
const PARTNER_CONTENT: Record<string, { field: string }> = {
|
||||
'api::resource.resource': { field: 'partnerOrganisations' },
|
||||
'api::news-article.news-article': { field: 'authorOrganisation' },
|
||||
'api::activity.activity': { field: 'implementingPartners' },
|
||||
'api::event.event': { field: 'implementingPartners' },
|
||||
'api::opportunity.opportunity': { field: 'implementingPartners' },
|
||||
};
|
||||
|
||||
const MUTATING_ACTIONS = new Set(['create', 'update', 'publish', 'unpublish', 'delete', 'clone']);
|
||||
|
||||
type AdminUser = { id: number };
|
||||
type PartnerAccess = { partner?: { documentId?: string } | null };
|
||||
type AdminRole = { code: string };
|
||||
|
||||
/** Enforce partner ownership at the Document Service boundary, including Admin UI saves. */
|
||||
export const registerPartnerContentAccess = (strapi: Core.Strapi): void => {
|
||||
strapi.documents.use(async (context, next) => {
|
||||
const partnerContent = PARTNER_CONTENT[context.uid];
|
||||
if (!partnerContent || !MUTATING_ACTIONS.has(context.action)) return next();
|
||||
|
||||
const ctx = strapi.requestContext.get();
|
||||
// Trusted internal jobs and seeders do not have an HTTP request context.
|
||||
if (!ctx) return next();
|
||||
|
||||
const user = ctx.state?.user as AdminUser | undefined;
|
||||
const isContentManager = ctx.request.path.startsWith('/content-manager/');
|
||||
if (!user || !isContentManager) {
|
||||
throw new errors.ForbiddenError('Only assigned CMS users can submit partner content.');
|
||||
}
|
||||
|
||||
if (context.action === 'publish') {
|
||||
const publishParams = context.params as { documentId?: string; locale?: string };
|
||||
if (publishParams.documentId) {
|
||||
const draft = (await strapi.documents(context.uid as never).findOne({
|
||||
documentId: publishParams.documentId,
|
||||
...(publishParams.locale ? { locale: publishParams.locale } : {}),
|
||||
status: 'draft',
|
||||
})) as { submittedByAdminUserId?: number | null; reviewStatus?: string | null } | null;
|
||||
|
||||
if (draft && draft.reviewStatus !== 'approved') {
|
||||
throw new errors.ForbiddenError('This draft must be marked Approved before it can be published.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mapping = (await strapi.db.query('api::partner-access.partner-access').findOne({
|
||||
where: { adminUserId: user.id },
|
||||
populate: { partner: true },
|
||||
select: ['id'],
|
||||
})) as PartnerAccess | null;
|
||||
|
||||
const admin = (await strapi.db.query('admin::user').findOne({
|
||||
where: { id: user.id },
|
||||
populate: { roles: { select: ['code'] } },
|
||||
select: ['id'],
|
||||
})) as (AdminUser & { roles?: AdminRole[] }) | null;
|
||||
const roleCodes = new Set(admin?.roles?.map(({ code }) => code) ?? []);
|
||||
|
||||
// A mapped user is scoped even if their role changes. Super Admin remains
|
||||
// the break-glass administrator; Editors can review only when unassigned.
|
||||
if (mapping) {
|
||||
if (roleCodes.has('strapi-super-admin')) return next();
|
||||
if (context.action !== 'create' && context.action !== 'update') {
|
||||
throw new errors.ForbiddenError('Partner users can save drafts but cannot publish or delete content.');
|
||||
}
|
||||
|
||||
const partnerDocumentId = mapping.partner?.documentId;
|
||||
if (!partnerDocumentId) {
|
||||
throw new errors.ForbiddenError('Your CMS account has no valid partner assignment. Contact an administrator.');
|
||||
}
|
||||
|
||||
const data = (context.params.data ?? {}) as Record<string, unknown>;
|
||||
context.params.data = data as typeof context.params.data;
|
||||
data[partnerContent.field] = { set: [partnerDocumentId] };
|
||||
// Feedback is private reviewer metadata; partner users cannot submit or
|
||||
// erase it via a crafted Admin request.
|
||||
data.reviewFeedback = null;
|
||||
data.reviewStatus = 'pending';
|
||||
data.submittedByAdminUserId = user.id;
|
||||
return next();
|
||||
}
|
||||
|
||||
if (roleCodes.has('strapi-super-admin') || roleCodes.has('strapi-editor')) return next();
|
||||
|
||||
// Authors without an assignment fail closed rather than creating content
|
||||
// with a partner chosen by the browser.
|
||||
if (roleCodes.has('strapi-author')) {
|
||||
throw new errors.ForbiddenError('Your CMS account is not assigned to a partner. Contact an administrator.');
|
||||
}
|
||||
|
||||
throw new errors.ForbiddenError('This CMS account cannot submit partner content.');
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,222 @@
|
||||
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;
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user