partner review posts and connection
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: [] },
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user