partner review posts and connection

This commit is contained in:
teledrisi
2026-10-06 15:36:11 +02:00
parent 98fae066bc
commit 1c0912378a
18 changed files with 1140 additions and 1 deletions
+3
View File
@@ -149,6 +149,9 @@ SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME=your-username
SMTP_PASSWORD=your-password
# Optional comma-separated reviewer recipients. If empty, partner draft
# notifications are sent to all active Strapi Super Admin accounts.
PARTNER_REVIEW_NOTIFICATION_EMAILS=
DEFAULT_FROM=no-reply@yourdomain.com
DEFAULT_REPLY_TO=support@yourdomain.com
# The values above are also the from/reply-to for the registration
+252
View File
@@ -0,0 +1,252 @@
import * as React from 'react';
import { Badge, Box, Button, Flex, Textarea, Typography } from '@strapi/design-system';
import {
unstable_useContentManagerContext as useContentManagerContext,
unstable_useDocumentActions as useDocumentActions,
type StrapiApp,
useAuth,
} from '@strapi/strapi/admin';
import { useLocation } from 'react-router-dom';
const PARTNER_CONTENT_TYPES = new Set([
'api::resource.resource',
'api::news-article.news-article',
'api::activity.activity',
'api::event.event',
'api::opportunity.opportunity',
]);
type ReviewValues = {
reviewStatus?: string;
reviewFeedback?: string;
};
type ContentFormValues = ReviewValues & Record<string, unknown>;
type ReviewForm = {
values?: ContentFormValues;
initialValues?: ContentFormValues;
modified?: boolean;
isSubmitting?: boolean;
resetForm?: (newInitialValues?: ContentFormValues) => void;
onChange?: (event: { target: { name: string; value: string; type: string } }) => void;
};
const STATUS_PRESENTATION: Record<string, { label: string; variant: 'warning' | 'success' | 'danger' }> = {
pending: { label: 'Pending review', variant: 'warning' },
approved: { label: 'Approved', variant: 'success' },
'changes-requested': { label: 'Changes requested', variant: 'warning' },
rejected: { label: 'Rejected', variant: 'danger' },
};
const ReviewPanelContent = ({ model }: { model: string }) => {
const { form: rawForm, isCreatingEntry, id, collectionType } = useContentManagerContext();
const { update, isLoading: isSavingReview } = useDocumentActions();
const location = useLocation();
const canReview = useAuth('PartnerReviewPanel', ({ user }) =>
user?.roles?.some(({ code }) => code === 'strapi-editor' || code === 'strapi-super-admin') ?? false
);
const form = rawForm as ReviewForm;
if (!PARTNER_CONTENT_TYPES.has(model)) return null;
const values = form.values ?? {};
const status = STATUS_PRESENTATION[values.reviewStatus ?? ''] ? values.reviewStatus! : 'pending';
const statusPresentation = STATUS_PRESENTATION[status];
const feedback = values.reviewFeedback ?? '';
const initialValues = form.initialValues ?? {};
const withoutReviewFields = (formValues: ContentFormValues) =>
Object.fromEntries(
Object.keys(formValues)
.filter((field) => field !== 'reviewStatus' && field !== 'reviewFeedback')
.sort()
.map((field) => [field, formValues[field]])
);
const savedContentValues = withoutReviewFields(initialValues);
const currentContentValues = withoutReviewFields(values);
const hasUnsavedContent = JSON.stringify(savedContentValues) !== JSON.stringify(currentContentValues);
const [isApproving, setIsApproving] = React.useState(false);
const onChange = (name: string, value: string, type: string) =>
form.onChange?.({ target: { name, value, type } });
const approveAndSave = async () => {
if (!id || hasUnsavedContent || isApproving || isSavingReview) return;
setIsApproving(true);
try {
const locale = new URLSearchParams(location.search).get('plugins[i18n][locale]');
const result = await update(
{
collectionType,
model,
documentId: id,
...(locale ? { params: { locale } } : {}),
},
{ reviewStatus: 'approved', reviewFeedback: feedback }
);
if (!result || 'error' in result) return;
form.resetForm?.({
...values,
reviewStatus: 'approved',
reviewFeedback: feedback,
});
} finally {
setIsApproving(false);
}
};
const reviewChangesPending =
status !== (form.initialValues?.reviewStatus ?? 'pending') ||
feedback !== (form.initialValues?.reviewFeedback ?? '');
return (
<Box
background="neutral0"
borderColor="neutral200"
borderStyle="solid"
borderWidth="1px"
hasRadius
padding={4}
width="100%"
>
<Flex direction="column" alignItems="stretch" gap={4}>
<Flex alignItems="center" justifyContent="space-between" gap={2}>
<Typography variant="delta" fontWeight="semiBold" textColor="neutral800">
Partner submission
</Typography>
<Badge variant={statusPresentation.variant}>{statusPresentation.label}</Badge>
</Flex>
<Typography variant="pi" textColor="neutral600" lineHeight={1.5}>
Review the partner’s draft, leave a note if needed, then choose an outcome below.
</Typography>
<Flex direction="column" alignItems="stretch" gap={2}>
<Typography variant="pi" fontWeight="semiBold" textColor="neutral800" tag="label" htmlFor="partner-review-feedback">
Reviewer note
</Typography>
<Textarea
id="partner-review-feedback"
aria-label="Reviewer note"
value={feedback}
disabled={!canReview || isCreatingEntry}
onChange={(event) => onChange('reviewFeedback', event.currentTarget.value, 'textarea')}
rows={4}
placeholder="Share feedback with the partner. A note is required when requesting changes or rejecting."
resize="vertical"
/>
</Flex>
<Flex direction="column" alignItems="stretch" gap={2}>
<Button
fullWidth
size="M"
variant="success"
type="button"
loading={isApproving || isSavingReview}
disabled={!canReview || isCreatingEntry || !id || hasUnsavedContent || form.isSubmitting || isSavingReview}
onClick={() => void approveAndSave().catch(() => undefined)}
>
Approve for publishing
</Button>
<Button
fullWidth
size="M"
variant="secondary"
type="button"
disabled={!canReview || isCreatingEntry || form.isSubmitting || feedback.trim().length === 0}
onClick={() => onChange('reviewStatus', 'changes-requested', 'select')}
>
Request changes
</Button>
<Button
fullWidth
size="S"
variant="danger-light"
type="button"
disabled={!canReview || isCreatingEntry || form.isSubmitting || feedback.trim().length === 0}
onClick={() => onChange('reviewStatus', 'rejected', 'select')}
>
Reject submission
</Button>
</Flex>
{isCreatingEntry ? (
<Typography variant="pi" textColor="neutral600">
Save this entry before reviewing it.
</Typography>
) : !canReview ? (
<Typography variant="pi" textColor="neutral600">
An editor will review this submission.
</Typography>
) : reviewChangesPending || hasUnsavedContent ? (
<Box
background="warning100"
borderRadius="4px"
padding={3}
role="status"
>
<Typography variant="pi" textColor="warning700">
{hasUnsavedContent
? 'Save content edits before approving so the review applies to the saved version.'
: status === 'approved'
? 'Approval is saved immediately and unlocks publishing.'
: 'Save this entry to send your review decision. Publishing stays locked until approval is saved.'}
</Typography>
</Box>
) : null}
</Flex>
</Box>
);
};
const PartnerReviewSidePanel = ({ model }: { model: string }) => ({
title: 'Partner review',
content: <ReviewPanelContent model={model} />,
});
type ActionProps = { model: string; [key: string]: unknown };
type ActionDescription = { disabled?: boolean; [key: string]: unknown } | null | undefined;
type DocumentAction = ((props: ActionProps) => ActionDescription) & {
type?: string;
position?: string | string[];
};
type ContentManagerApis = {
addEditViewSidePanel: (panels: Array<(props: { model: string }) => { title: string; content: React.ReactNode }>) => void;
addDocumentAction: (reducer: (actions: DocumentAction[]) => DocumentAction[]) => void;
};
export default {
bootstrap(app: StrapiApp) {
const contentManager = app.getPlugin('content-manager');
const apis = contentManager.apis as unknown as ContentManagerApis;
apis.addEditViewSidePanel((panels) => [...panels, PartnerReviewSidePanel]);
apis.addDocumentAction((actions) =>
actions.map((action) => {
if (action.type !== 'publish') return action;
const ApprovalGatedPublish: DocumentAction = (props) => {
const { form: rawForm } = useContentManagerContext();
const form = rawForm as ReviewForm;
const base = action(props);
if (base == null) return base;
const requiresApproval = PARTNER_CONTENT_TYPES.has(props.model);
const approvalSaved = form.initialValues?.reviewStatus === 'approved';
const approved = form.values?.reviewStatus === 'approved';
const hasUnsavedReview = approved && !approvalSaved;
return {
...base,
disabled:
Boolean(base.disabled) ||
(requiresApproval && (!approved || hasUnsavedReview || form.modified === true)),
};
};
ApprovalGatedPublish.type = action.type;
ApprovalGatedPublish.position = action.position;
return ApprovalGatedPublish;
})
);
},
};
@@ -93,6 +93,35 @@
"relation": "oneToMany",
"target": "api::partner.partner"
},
"reviewFeedback": {
"type": "text",
"private": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"reviewStatus": {
"type": "enumeration",
"enum": ["pending", "approved", "changes-requested", "rejected"],
"default": "pending",
"private": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"submittedByAdminUserId": {
"type": "integer",
"private": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"regions": {
"type": "relation",
"relation": "oneToMany",
@@ -143,6 +143,35 @@
"relation": "oneToMany",
"target": "api::partner.partner"
},
"reviewFeedback": {
"type": "text",
"private": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"reviewStatus": {
"type": "enumeration",
"enum": ["pending", "approved", "changes-requested", "rejected"],
"default": "pending",
"private": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"submittedByAdminUserId": {
"type": "integer",
"private": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"registrationStatus": {
"type": "enumeration",
"enum": [
@@ -90,6 +90,35 @@
"relation": "oneToOne",
"target": "api::partner.partner"
},
"reviewFeedback": {
"type": "text",
"private": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"reviewStatus": {
"type": "enumeration",
"enum": ["pending", "approved", "changes-requested", "rejected"],
"default": "pending",
"private": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"submittedByAdminUserId": {
"type": "integer",
"private": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"publishDate": {
"type": "date",
"required": true,
@@ -149,6 +149,35 @@
"relation": "oneToMany",
"target": "api::partner.partner"
},
"reviewFeedback": {
"type": "text",
"private": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"reviewStatus": {
"type": "enumeration",
"enum": ["pending", "approved", "changes-requested", "rejected"],
"default": "pending",
"private": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"submittedByAdminUserId": {
"type": "integer",
"private": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"coverImage": {
"type": "media",
"multiple": false,
@@ -0,0 +1,32 @@
{
"kind": "collectionType",
"collectionName": "partner_accesses",
"info": {
"singularName": "partner-access",
"pluralName": "partner-accesses",
"displayName": "Partner Access",
"description": "Links one Strapi admin user to the partner organisation they can submit content for."
},
"options": {
"draftAndPublish": false
},
"pluginOptions": {
"i18n": {
"localized": false
}
},
"attributes": {
"adminUserId": {
"type": "integer",
"required": true,
"unique": true,
"min": 1
},
"partner": {
"type": "relation",
"relation": "manyToOne",
"target": "api::partner.partner",
"required": true
}
}
}
@@ -140,6 +140,35 @@
"relation": "oneToMany",
"target": "api::partner.partner"
},
"reviewFeedback": {
"type": "text",
"private": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"reviewStatus": {
"type": "enumeration",
"enum": ["pending", "approved", "changes-requested", "rejected"],
"default": "pending",
"private": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"submittedByAdminUserId": {
"type": "integer",
"private": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"topics": {
"type": "relation",
"relation": "oneToMany",
+6
View File
@@ -5,6 +5,9 @@ import { LOG_PREFIX } from './constants';
import { ensureEmailSettings } from './email-settings';
import { ensureLocales } from './locales';
import { ensurePublicPermissions } from './public-permissions';
import { ensurePartnerReviewPermissions } from './partner-review-permissions';
import { ensurePartnerReviewLayouts } from './partner-review-layouts';
import { ensurePartnerRelationLabels } from './partner-relation-labels';
import { ensureReminderSettings } from './reminder-settings';
export { revokePublicAuthActions } from './auth-permissions';
@@ -32,6 +35,9 @@ export const runBootstrap = async ({ strapi }: { strapi: Core.Strapi }): Promise
await revokePublicAuthActions(strapi);
await ensureReminderSettings(strapi);
await ensureEmailSettings(strapi);
await ensurePartnerReviewPermissions(strapi);
await ensurePartnerReviewLayouts(strapi);
await ensurePartnerRelationLabels(strapi);
strapi.log.debug(`${LOG_PREFIX} bootstrap complete.`);
};
@@ -0,0 +1,70 @@
import type { Core } from '@strapi/strapi';
import { LOG_PREFIX } from './constants';
const PARTNER_RELATIONS: Record<string, string[]> = {
'api::partner.partner': [],
'api::partner-access.partner-access': ['partner'],
'api::resource.resource': ['partnerOrganisations'],
'api::news-article.news-article': ['authorOrganisation'],
'api::activity.activity': ['implementingPartners'],
'api::event.event': ['implementingPartners'],
'api::opportunity.opportunity': ['implementingPartners'],
};
type ContentManagerConfiguration = {
settings: Record<string, unknown>;
metadatas: Record<string, { edit?: Record<string, unknown>; [key: string]: unknown }>;
[key: string]: unknown;
};
type ContentManagerContentTypesService = {
findContentType(uid: string): Record<string, unknown> | undefined;
findConfiguration(contentType: Record<string, unknown>): Promise<ContentManagerConfiguration>;
updateConfiguration(
contentType: Record<string, unknown>,
configuration: ContentManagerConfiguration
): Promise<unknown>;
};
/** Always show Partner relation values by their organisation name in Content Manager. */
export const ensurePartnerRelationLabels = async (strapi: Core.Strapi): Promise<void> => {
const manager = strapi
.plugin('content-manager')
.service<ContentManagerContentTypesService>('content-types');
for (const [uid, fields] of Object.entries(PARTNER_RELATIONS)) {
const contentType = manager.findContentType(uid);
if (!contentType) continue;
const config = await manager.findConfiguration(contentType);
const metadatas = { ...config.metadatas };
let metadataChanged = false;
for (const field of fields) {
const metadata = metadatas[field] ?? {};
if (metadata.edit?.mainField === 'name') continue;
metadatas[field] = {
...metadata,
edit: { ...metadata.edit, mainField: 'name' },
};
metadataChanged = true;
}
const settingsChanged =
uid === 'api::partner.partner' && config.settings.mainField !== 'name';
if (!metadataChanged && !settingsChanged) continue;
await manager.updateConfiguration(contentType, {
...config,
settings:
uid === 'api::partner.partner'
? { ...config.settings, mainField: 'name' }
: config.settings,
metadatas,
});
}
strapi.log.debug(`${LOG_PREFIX} partner relations use organisation names in Content Manager.`);
};
@@ -0,0 +1,68 @@
import type { Core } from '@strapi/strapi';
import { LOG_PREFIX } from './constants';
const PARTNER_REVIEW_TYPES = [
'api::resource.resource',
'api::news-article.news-article',
'api::activity.activity',
'api::event.event',
'api::opportunity.opportunity',
];
const REVIEW_FIELDS = ['reviewStatus', 'reviewFeedback', 'submittedByAdminUserId'];
type EditLayoutItem = { name: string; size?: number };
type ContentManagerConfiguration = {
uid?: string;
settings: Record<string, unknown>;
metadatas: Record<string, { edit?: Record<string, unknown>; [key: string]: unknown }>;
layouts: { edit: EditLayoutItem[][]; [key: string]: unknown };
[key: string]: unknown;
};
type ContentManagerContentTypesService = {
findContentType(uid: string): Record<string, unknown> | undefined;
findConfiguration(contentType: Record<string, unknown>): Promise<ContentManagerConfiguration>;
updateConfiguration(
contentType: Record<string, unknown>,
configuration: ContentManagerConfiguration
): Promise<unknown>;
};
/** Keep workflow fields in the custom review panel, out of the main content form. */
export const ensurePartnerReviewLayouts = async (strapi: Core.Strapi): Promise<void> => {
const manager = strapi
.plugin('content-manager')
.service<ContentManagerContentTypesService>('content-types');
for (const uid of PARTNER_REVIEW_TYPES) {
const contentType = manager.findContentType(uid);
if (!contentType) continue;
const config = await manager.findConfiguration(contentType);
const editLayout = (config.layouts.edit ?? [])
.map((row) => row.filter(({ name }) => !REVIEW_FIELDS.includes(name)))
.filter((row) => row.length > 0);
const metadatas = { ...config.metadatas };
const metadataChanged = REVIEW_FIELDS.some(
(field) => config.metadatas[field]?.edit?.visible !== false
);
for (const field of REVIEW_FIELDS) {
const metadata = metadatas[field] ?? {};
metadatas[field] = {
...metadata,
edit: { ...metadata.edit, visible: false },
};
}
if (JSON.stringify(editLayout) !== JSON.stringify(config.layouts.edit) || metadataChanged) {
await manager.updateConfiguration(contentType, {
...config,
metadatas,
layouts: { ...config.layouts, edit: editLayout },
});
}
}
strapi.log.debug(`${LOG_PREFIX} partner review fields moved into the review panel.`);
};
@@ -0,0 +1,205 @@
import type { Core } from '@strapi/strapi';
import { LOG_PREFIX } from './constants';
const REVIEW_FIELDS: Record<string, { partnerField: string; actions: string[] }> = {
'api::resource.resource': { partnerField: 'partnerOrganisations', actions: ['create', 'read', 'update'] },
'api::news-article.news-article': { partnerField: 'authorOrganisation', actions: ['create', 'read', 'update'] },
'api::activity.activity': { partnerField: 'implementingPartners', actions: ['create', 'read', 'update'] },
'api::event.event': { partnerField: 'implementingPartners', actions: ['create', 'read', 'update'] },
'api::opportunity.opportunity': { partnerField: 'implementingPartners', actions: ['create', 'read', 'update'] },
};
const REVIEWER_FIELDS = ['reviewFeedback', 'reviewStatus'];
const INTERNAL_FIELDS = ['submittedByAdminUserId'];
type Permission = {
id: number;
action: string;
subject: string;
properties?: { fields?: string[]; [key: string]: unknown };
conditions?: unknown[];
};
type AdminRole = { id: number; code: string };
type MappedAdminUser = { id: number; roles?: AdminRole[] };
const PARTNER_READ_ACTION = 'plugin::content-manager.explorer.read';
const PARTNER_WRITE_ACTIONS = [
'plugin::content-manager.explorer.create',
'plugin::content-manager.explorer.update',
'plugin::content-manager.explorer.delete',
'plugin::content-manager.explorer.publish',
];
const PARTNER_PUBLIC_FIELDS = [
'name',
'slug',
'logo',
'logoDark',
'description',
'website',
'partnerRole',
'country',
];
const getMappedPartnerRoleIds = async (strapi: Core.Strapi): Promise<Set<number>> => {
const assignments = (await strapi.db.query('api::partner-access.partner-access').findMany({
select: ['adminUserId'],
})) as Array<{ adminUserId?: number }>;
const userIds = [...new Set(assignments.map(({ adminUserId }) => adminUserId).filter(Boolean))];
if (userIds.length === 0) return new Set();
const users = (await strapi.db.query('admin::user').findMany({
where: { id: { $in: userIds } },
select: ['id'],
populate: { roles: { select: ['id', 'code'] } },
})) as MappedAdminUser[];
return new Set(users.flatMap(({ roles = [] }) => roles.map(({ id }) => id)));
};
const withFields = (
properties: Permission['properties'],
allFields: string[],
transform: (fields: string[]) => string[]
) => {
// In Strapi, an omitted field list means "all fields". Materialize that
// set before filtering so a permission with defaults cannot become empty.
const fields = Array.isArray(properties?.fields) ? properties.fields : allFields;
return { ...properties, fields: transform(fields) };
};
/** Keep partner assignment and reviewer feedback field access aligned with the workflow. */
export const ensurePartnerReviewPermissions = async (strapi: Core.Strapi): Promise<void> => {
const mappedRoleIds = await getMappedPartnerRoleIds(strapi);
const allRoles = (await strapi.db.query('admin::role').findMany({
select: ['id', 'code'],
})) as AdminRole[];
const roles = allRoles.filter(
({ id, code }) => code === 'strapi-author' || code === 'strapi-editor' || mappedRoleIds.has(id)
);
for (const role of roles as Array<{ id: number; code: string }>) {
const permissions = (await strapi.db.query('admin::permission').findMany({
where: { role: { id: role.id }, subject: { $in: Object.keys(REVIEW_FIELDS) } },
select: ['id', 'action', 'subject', 'properties'],
})) as Permission[];
for (const permission of permissions) {
const config = REVIEW_FIELDS[permission.subject];
if (!config || !config.actions.some((action) => permission.action.endsWith(`.${action}`))) continue;
const action = config.actions.find((candidate) => permission.action.endsWith(`.${candidate}`));
if (!action) continue;
const contentTypes = strapi.contentTypes as unknown as Record<
string,
{ attributes?: Record<string, unknown> }
>;
const allFields = Object.keys(contentTypes[permission.subject]?.attributes ?? {});
const isMappedPartnerAuthor =
mappedRoleIds.has(role.id) &&
role.code !== 'strapi-editor' &&
role.code !== 'strapi-super-admin';
const isPartnerAuthorRole = role.code === 'strapi-author' || isMappedPartnerAuthor;
const properties = withFields(permission.properties, allFields, (fields) => {
const filtered = fields.filter((field) => {
if (INTERNAL_FIELDS.includes(field)) return false;
if (isPartnerAuthorRole && action !== 'read') {
return field !== config.partnerField && !REVIEWER_FIELDS.includes(field);
}
return true;
});
if (role.code === 'strapi-editor' || (isPartnerAuthorRole && action === 'read')) {
for (const field of REVIEWER_FIELDS) {
if (!filtered.includes(field)) filtered.push(field);
}
}
return filtered;
});
if (JSON.stringify(permission.properties ?? {}) !== JSON.stringify(properties)) {
await strapi.db.query('admin::permission').update({
where: { id: permission.id },
data: { properties },
});
}
}
// Partner assignments are an administrative control. Only Super Admins
// should be able to create or change them through Content Manager.
if (
role.code === 'strapi-author' ||
role.code === 'strapi-editor' ||
mappedRoleIds.has(role.id)
) {
await strapi.db.query('admin::permission').deleteMany({
where: { role: { id: role.id }, subject: 'api::partner-access.partner-access' },
});
}
}
await ensureMappedPartnerReadAccess(strapi, allRoles, mappedRoleIds);
strapi.log.debug(`${LOG_PREFIX} partner review role fields reconciled.`);
};
/** Partner authors need to read the public organisation name in relations, but never edit it. */
const ensureMappedPartnerReadAccess = async (
strapi: Core.Strapi,
roles: AdminRole[],
mappedRoleIds: Set<number>
): Promise<void> => {
const protectedRoles = roles.filter(
({ id, code }) =>
mappedRoleIds.has(id) && code !== 'strapi-super-admin' && code !== 'strapi-editor'
);
if (protectedRoles.length === 0) return;
for (const role of protectedRoles) {
const permissions = (await strapi.db.query('admin::permission').findMany({
where: { role: { id: role.id }, subject: 'api::partner.partner' },
select: ['id', 'action', 'properties', 'conditions'],
})) as Permission[];
const readPermission = permissions.find(({ action }) => action === PARTNER_READ_ACTION);
const writePermissions = permissions.filter(({ action }) =>
PARTNER_WRITE_ACTIONS.includes(action)
);
if (writePermissions.length > 0) {
await strapi.db.query('admin::permission').deleteMany({
where: { id: { $in: writePermissions.map(({ id }) => id) } },
});
}
const properties = {
...(readPermission?.properties ?? {}),
fields: PARTNER_PUBLIC_FIELDS,
locales: ['en', 'ar'],
};
if (!readPermission) {
await strapi.db.query('admin::permission').create({
data: {
action: PARTNER_READ_ACTION,
actionParameters: {},
subject: 'api::partner.partner',
properties,
conditions: [],
role: role.id,
},
});
continue;
}
if (
JSON.stringify(readPermission.properties ?? {}) !== JSON.stringify(properties) ||
(readPermission.conditions?.length ?? 0) > 0
) {
await strapi.db.query('admin::permission').update({
where: { id: readPermission.id },
data: { properties, conditions: [] },
});
}
}
};
+6 -1
View File
@@ -2,6 +2,8 @@ import type { Core } from '@strapi/strapi';
import { runBootstrap } from './bootstrap';
import { runEventReminders } from './lib/event-reminders';
import { registerPartnerContentAccess } from './lib/partner-content-access';
import { registerPartnerReviewNotifications } from './lib/partner-review-notifications';
import { runWaitlistPromotion } from './lib/waitlist-promotion';
export default {
@@ -11,7 +13,10 @@ export default {
*
* This gives you an opportunity to extend code.
*/
register(/* { strapi }: { strapi: Core.Strapi } */) {},
register({ strapi }: { strapi: Core.Strapi }) {
registerPartnerReviewNotifications(strapi);
registerPartnerContentAccess(strapi);
},
/**
* An asynchronous bootstrap function that runs before
@@ -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;
});
};