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
+4
View File
@@ -84,6 +84,10 @@ CMS_PORT=1337
# in production (e.g. https://cms.sharper.ly).
CMS_PUBLIC_URL=http://localhost:1337
# Optional comma-separated recipients for new partner draft notifications.
# When empty, notifications go to all active Strapi Super Admin accounts.
PARTNER_REVIEW_NOTIFICATION_EMAILS=
# ---------------------------------------------------------------------------
# WEB APP - Docker mode. In manual mode these live in apps/web/.env.
+30
View File
@@ -266,6 +266,35 @@ every boot; it
You never have to click through the Users & Permissions screens after adding a content type;
restart the CMS and the grant is there.
### Partner content review
Partner contributors use Strapi's **Author** admin role. To onboard one, create the admin
account, then as Super Admin create a **Partner Access** entry in Content Manager with the
admin user ID (from the user detail URL) and the linked Partner organisation. Each admin
account has one assignment; multiple accounts can be assigned to the same partner. Partner
Access has no public Content API and is reserved for Super Admins.
Authors can create and edit their own resource, news article, activity, event, or opportunity
drafts. The CMS enforces their partner assignment on every save, regardless of what the
browser submits, and removes the organisation selector from their edit fields. Authors cannot
publish or delete. An **Editor** reviews the draft in the side panel below Preview. Set its
status to **Approved for publishing** before publishing, or choose **Changes requested** or
**Rejected** and add feedback. The Publish action stays disabled until approval is saved, and
the server rejects publish requests for these content types unless their review status is approved.
The partner author can read the status and feedback in the CMS; both fields are excluded from the public API.
Resubmitting resets the status to **Pending** and clears old feedback.
The CMS also emails reviewers when a partner draft is first submitted or resubmitted, and emails
the submitting admin when the reviewer requests changes, rejects the draft, or publishes it.
Reviewer mail goes to all active Super Admins by default; set the optional comma-separated
`PARTNER_REVIEW_NOTIFICATION_EMAILS` to use a dedicated inbox. These notices use the configured
SMTP provider. Email failures are logged and do not undo a saved draft or review decision.
Strapi's built-in Author role limits authors to entries they created. Different users assigned
to the same partner therefore share the organisation assignment, but not each other's drafts.
Use Editor for central review; partner-wide collaboration would need a separate custom access
policy.
### 6. Create the first admin user
Strapi has no seeded administrator. On first run, open
@@ -901,6 +930,7 @@ Whichever port you pick, `SITE_URL` stays the public origin (`https://sharper.ly
| `STRAPI_URL` | web | the CMS origin, e.g. `https://cms.sharper.ly`. It is also prefixed onto media URLs, so it must be resolvable **by the browser**, not only from the server |
| `STRAPI_TOKEN` | web | only needed for draft/protected content; published content is public |
| `PUBLIC_URL` | cms | `https://cms.sharper.ly` — admin redirects and generated links |
| `PARTNER_REVIEW_NOTIFICATION_EMAILS` | cms | Optional comma-separated addresses for partner draft notices; defaults to active Super Admins |
| `IS_PROXIED` | cms | `true` behind nginx / a load balancer, so `X-Forwarded-*` is trusted |
| `CORS_ORIGINS` | cms | `https://sharper.ly,https://www.sharper.ly` — an empty list blocks every cross-origin browser call, which is the safe default |
| `DATABASE_*` | cms | managed PostgreSQL; set `DATABASE_SSL=true` for a hosted database |
+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;
});
};
+1
View File
@@ -74,6 +74,7 @@ services:
# together with CMS_PORT, and set it to the public origin in production.
PUBLIC_URL: ${CMS_PUBLIC_URL:-http://localhost:1337}
LOG_LEVEL: ${LOG_LEVEL:-info}
PARTNER_REVIEW_NOTIFICATION_EMAILS: ${PARTNER_REVIEW_NOTIFICATION_EMAILS:-}
# Inside the compose network the database is reachable by service name on
# its own port, never through the published one.