strapi-plugin-publish-media-validation 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # strapi-plugin-publish-media-validation
2
+
3
+ A Strapi v5 plugin that enforces `required: true` on **media fields at publish time**.
4
+
5
+ ## Why this exists
6
+
7
+ Strapi v5 validates `required: true` on scalar fields (string, integer, etc.) when publishing, but skips that check for media fields (`type: "media"`). This means a content manager can publish an entry with a missing required banner, thumbnail, or any other required image/file — even though the field is marked as required in the schema.
8
+
9
+ This plugin intercepts the Document Service `publish` action and blocks it if any required media field is empty, returning a clear error message instead of silently allowing the publish.
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ npm install strapi-plugin-publish-media-validation
15
+ # or
16
+ yarn add strapi-plugin-publish-media-validation
17
+ # or
18
+ pnpm add strapi-plugin-publish-media-validation
19
+ ```
20
+
21
+ ### pnpm users
22
+
23
+ Because `@strapi/utils` is a peer dependency and pnpm does not hoist packages by default, you also need to add it as a direct dependency:
24
+
25
+ ```bash
26
+ pnpm add @strapi/utils
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ No configuration needed. Once installed, the plugin automatically scans every content type's schema on publish and blocks any entry where a `required: true` media field is empty.
32
+
33
+ **Example schema:**
34
+
35
+ ```json
36
+ {
37
+ "banner": {
38
+ "type": "media",
39
+ "multiple": false,
40
+ "required": true,
41
+ "allowedTypes": ["images"]
42
+ }
43
+ }
44
+ ```
45
+
46
+ If you try to publish without a banner, the admin panel will show:
47
+
48
+ > The following required media fields are empty: Banner
49
+
50
+ ## How it works
51
+
52
+ The plugin registers a [Document Service middleware](https://docs.strapi.io/dev-docs/backend-customization/document-service-middlewares) that:
53
+
54
+ 1. Runs on every `publish` action across all content types
55
+ 2. Reads the model schema to find `type: "media"` fields with `required: true`
56
+ 3. Fetches the draft document and checks each required media field
57
+ 4. Throws a `ValidationError` listing any empty fields, which Strapi maps to a 400 response with the message shown in the admin UI
58
+
59
+ ## Compatibility
60
+
61
+ | Strapi version | Supported |
62
+ |----------------|-----------|
63
+ | v5.x | ✅ |
64
+ | v4.x | ❌ |
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.test.d.ts","sourceRoot":"","sources":["../../../src/server/__tests__/index.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,123 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const utils_1 = require("@strapi/utils");
7
+ const index_1 = __importDefault(require("../index"));
8
+ // Capture the middleware function registered via strapi.documents.use()
9
+ function buildStrapi(modelAttributes, docFields = {}) {
10
+ let capturedMiddleware = null;
11
+ const strapi = {
12
+ documents: Object.assign((uid) => ({
13
+ findOne: jest.fn().mockResolvedValue(docFields),
14
+ }), {
15
+ use: jest.fn((fn) => {
16
+ capturedMiddleware = fn;
17
+ }),
18
+ }),
19
+ getModel: jest.fn().mockReturnValue(modelAttributes !== null ? { attributes: modelAttributes } : null),
20
+ };
21
+ index_1.default.register({ strapi: strapi });
22
+ return {
23
+ strapi,
24
+ runMiddleware: (ctx) => {
25
+ if (!capturedMiddleware)
26
+ throw new Error('middleware not registered');
27
+ const next = jest.fn().mockResolvedValue(undefined);
28
+ return { result: capturedMiddleware(ctx, next), next };
29
+ },
30
+ };
31
+ }
32
+ const baseCtx = {
33
+ action: 'publish',
34
+ uid: 'api::article.article',
35
+ params: { documentId: 'abc123' },
36
+ };
37
+ describe('register', () => {
38
+ it('calls strapi.documents.use once', () => {
39
+ const { strapi } = buildStrapi({});
40
+ expect(strapi.documents.use).toHaveBeenCalledTimes(1);
41
+ });
42
+ });
43
+ describe('middleware', () => {
44
+ it('passes through when action is not publish', async () => {
45
+ const { runMiddleware } = buildStrapi({});
46
+ const { next } = runMiddleware({ ...baseCtx, action: 'create' });
47
+ await next;
48
+ expect(next).toHaveBeenCalled();
49
+ });
50
+ it('passes through when model is null', async () => {
51
+ const { runMiddleware } = buildStrapi(null);
52
+ const { next } = runMiddleware(baseCtx);
53
+ await next;
54
+ expect(next).toHaveBeenCalled();
55
+ });
56
+ it('passes through when model has no required media fields', async () => {
57
+ const { runMiddleware } = buildStrapi({
58
+ title: { type: 'string', required: true },
59
+ cover: { type: 'media', required: false },
60
+ });
61
+ const { next } = runMiddleware(baseCtx);
62
+ await next;
63
+ expect(next).toHaveBeenCalled();
64
+ });
65
+ it('passes through when there is no documentId', async () => {
66
+ const { runMiddleware } = buildStrapi({
67
+ cover: { type: 'media', required: true },
68
+ });
69
+ const { next } = runMiddleware({ ...baseCtx, params: {} });
70
+ await next;
71
+ expect(next).toHaveBeenCalled();
72
+ });
73
+ it('passes through when all required media fields are populated', async () => {
74
+ const { runMiddleware } = buildStrapi({ cover: { type: 'media', required: true } }, { cover: { id: 1, url: '/uploads/img.jpg' } });
75
+ const { result, next } = runMiddleware(baseCtx);
76
+ await result;
77
+ expect(next).toHaveBeenCalled();
78
+ });
79
+ it('throws ValidationError when a required media field is empty', async () => {
80
+ const { runMiddleware } = buildStrapi({ cover: { type: 'media', required: true } }, { cover: null });
81
+ const { result } = runMiddleware(baseCtx);
82
+ await expect(result).rejects.toThrow(utils_1.errors.ValidationError);
83
+ await expect(result).rejects.toThrow('Cover');
84
+ });
85
+ it('lists all missing fields in the error message', async () => {
86
+ const { runMiddleware } = buildStrapi({
87
+ cover: { type: 'media', required: true },
88
+ heroImage: { type: 'media', required: true },
89
+ }, { cover: null, heroImage: null });
90
+ const { result } = runMiddleware(baseCtx);
91
+ await expect(result).rejects.toThrow('Cover');
92
+ await expect(result).rejects.toThrow('Hero Image');
93
+ });
94
+ it('only validates required media fields, not optional ones', async () => {
95
+ const { runMiddleware } = buildStrapi({
96
+ cover: { type: 'media', required: true },
97
+ gallery: { type: 'media', required: false },
98
+ }, { cover: { id: 1 }, gallery: null });
99
+ const { result, next } = runMiddleware(baseCtx);
100
+ await result;
101
+ expect(next).toHaveBeenCalled();
102
+ });
103
+ it('populates only required media fields when fetching the document', async () => {
104
+ const findOne = jest.fn().mockResolvedValue({ cover: { id: 1 } });
105
+ const strapi = {
106
+ documents: Object.assign(() => ({ findOne }), {
107
+ use: jest.fn((fn) => fn(baseCtx, jest.fn().mockResolvedValue(undefined))),
108
+ }),
109
+ getModel: jest.fn().mockReturnValue({
110
+ attributes: {
111
+ cover: { type: 'media', required: true },
112
+ title: { type: 'string', required: true },
113
+ },
114
+ }),
115
+ };
116
+ index_1.default.register({ strapi: strapi });
117
+ expect(findOne).toHaveBeenCalledWith({
118
+ documentId: 'abc123',
119
+ populate: { cover: true },
120
+ });
121
+ });
122
+ });
123
+ //# sourceMappingURL=index.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.test.js","sourceRoot":"","sources":["../../../src/server/__tests__/index.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,14 @@
1
+ import type { Core } from '@strapi/strapi';
2
+ declare const _default: {
3
+ register: ({ strapi }: {
4
+ strapi: Core.Strapi;
5
+ }) => void;
6
+ bootstrap: (_args: {
7
+ strapi: Core.Strapi;
8
+ }) => void;
9
+ destroy: (_args: {
10
+ strapi: Core.Strapi;
11
+ }) => void;
12
+ };
13
+ export default _default;
14
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAO,MAAM,gBAAgB,CAAC;;2BAGlB;QAAE,MAAM,EAAE,IAAI,CAAC,MAAM,CAAA;KAAE;uBAqC3B;QAAE,MAAM,EAAE,IAAI,CAAC,MAAM,CAAA;KAAE;qBACzB;QAAE,MAAM,EAAE,IAAI,CAAC,MAAM,CAAA;KAAE;;AAE/C,wBAAgD"}
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const utils_1 = require("@strapi/utils");
4
+ const register = ({ strapi }) => {
5
+ strapi.documents.use(async (ctx, next) => {
6
+ var _a;
7
+ if (ctx.action !== 'publish')
8
+ return next();
9
+ const model = strapi.getModel(ctx.uid);
10
+ if (!(model === null || model === void 0 ? void 0 : model.attributes))
11
+ return next();
12
+ const requiredMediaFields = Object.entries(model.attributes)
13
+ .filter(([, attr]) => attr.type === 'media' && attr.required === true)
14
+ .map(([key]) => key);
15
+ if (requiredMediaFields.length === 0)
16
+ return next();
17
+ const documentId = (_a = ctx.params) === null || _a === void 0 ? void 0 : _a.documentId;
18
+ if (!documentId)
19
+ return next();
20
+ const populate = Object.fromEntries(requiredMediaFields.map((f) => [f, true]));
21
+ const doc = await strapi.documents(ctx.uid).findOne({
22
+ documentId,
23
+ populate,
24
+ });
25
+ const missing = requiredMediaFields.filter((f) => !(doc === null || doc === void 0 ? void 0 : doc[f]));
26
+ if (missing.length > 0) {
27
+ const labels = missing
28
+ .map((f) => f.charAt(0).toUpperCase() + f.slice(1).replace(/([A-Z])/g, ' $1'))
29
+ .join(', ');
30
+ throw new utils_1.errors.ValidationError(`The following required media fields are empty: ${labels}`);
31
+ }
32
+ return next();
33
+ });
34
+ };
35
+ const bootstrap = (_args) => { };
36
+ const destroy = (_args) => { };
37
+ exports.default = { register, bootstrap, destroy };
38
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":";;AACA,yCAAuC;AAEvC,MAAM,QAAQ,GAAG,CAAC,EAAE,MAAM,EAA2B,EAAE,EAAE;IACvD,MAAM,CAAC,SAAS,CAAC,GAAG,CAAC,KAAK,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;;QACvC,IAAI,GAAG,CAAC,MAAM,KAAK,SAAS;YAAE,OAAO,IAAI,EAAE,CAAC;QAE5C,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,GAAsB,CAAC,CAAC;QAC1D,IAAI,CAAC,CAAA,KAAK,aAAL,KAAK,uBAAL,KAAK,CAAE,UAAU,CAAA;YAAE,OAAO,IAAI,EAAE,CAAC;QAEtC,MAAM,mBAAmB,GAAG,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,UAAU,CAAC;aACzD,MAAM,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,EAAE,EAAE,CAAE,IAAY,CAAC,IAAI,KAAK,OAAO,IAAK,IAAY,CAAC,QAAQ,KAAK,IAAI,CAAC;aACvF,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC;QAEvB,IAAI,mBAAmB,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,IAAI,EAAE,CAAC;QAEpD,MAAM,UAAU,GAAG,MAAC,GAAG,CAAC,MAAkC,0CAAE,UAAU,CAAC;QACvE,IAAI,CAAC,UAAU;YAAE,OAAO,IAAI,EAAE,CAAC;QAE/B,MAAM,QAAQ,GAAG,MAAM,CAAC,WAAW,CAAC,mBAAmB,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC;QAC/E,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,SAAS,CAAC,GAAG,CAAC,GAAsB,CAAC,CAAC,OAAO,CAAC;YACrE,UAAU;YACV,QAAQ;SACT,CAAC,CAAC;QAEH,MAAM,OAAO,GAAG,mBAAmB,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAW,aAAX,GAAG,uBAAH,GAAG,CAAW,CAAC,CAAC,CAAA,CAAC,CAAC;QAEtE,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACvB,MAAM,MAAM,GAAG,OAAO;iBACnB,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,UAAU,EAAE,KAAK,CAAC,CAAC;iBAC7E,IAAI,CAAC,IAAI,CAAC,CAAC;YACd,MAAM,IAAI,cAAM,CAAC,eAAe,CAC9B,kDAAkD,MAAM,EAAE,CAC3D,CAAC;QACJ,CAAC;QAED,OAAO,IAAI,EAAE,CAAC;IAChB,CAAC,CAAC,CAAC;AACL,CAAC,CAAC;AAEF,MAAM,SAAS,GAAG,CAAC,KAA8B,EAAE,EAAE,GAAE,CAAC,CAAC;AACzD,MAAM,OAAO,GAAG,CAAC,KAA8B,EAAE,EAAE,GAAE,CAAC,CAAC;AAEvD,kBAAe,EAAE,QAAQ,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC"}
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "strapi-plugin-publish-media-validation",
3
+ "version": "1.0.0",
4
+ "description": "Strapi v5 plugin that enforces required media fields at publish time — works around the known limitation where required: true on media fields is not validated on publish.",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "strapi",
8
+ "plugin",
9
+ "media",
10
+ "validation",
11
+ "publish",
12
+ "required"
13
+ ],
14
+ "main": "./dist/server/index.js",
15
+ "exports": {
16
+ "./strapi-server": "./dist/server/index.js"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src"
21
+ ],
22
+ "strapi": {
23
+ "kind": "plugin",
24
+ "name": "publish-media-validation",
25
+ "displayName": "Publish Media Validation",
26
+ "description": "Enforces required media fields when publishing content types in Strapi v5."
27
+ },
28
+ "scripts": {
29
+ "build": "tsc",
30
+ "dev": "tsc --watch",
31
+ "prepublishOnly": "npm run build",
32
+ "test": "jest",
33
+ "test:watch": "jest --watch"
34
+ },
35
+ "jest": {
36
+ "testEnvironment": "node",
37
+ "testMatch": ["**/__tests__/**/*.test.ts"],
38
+ "transform": {
39
+ "^.+\\.tsx?$": ["ts-jest", { "tsconfig": "tsconfig.test.json" }]
40
+ }
41
+ },
42
+ "peerDependencies": {
43
+ "@strapi/strapi": ">=5.0.0"
44
+ },
45
+ "devDependencies": {
46
+ "@strapi/strapi": "5.44.0",
47
+ "@strapi/utils": "5.44.0",
48
+ "@types/jest": "^30.0.0",
49
+ "jest": "^30.4.2",
50
+ "ts-jest": "^29.4.11",
51
+ "typescript": "^5.0.0"
52
+ }
53
+ }
@@ -0,0 +1,153 @@
1
+ import { errors } from '@strapi/utils';
2
+ import plugin from '../index';
3
+
4
+ // Capture the middleware function registered via strapi.documents.use()
5
+ function buildStrapi(
6
+ modelAttributes: Record<string, unknown> | null,
7
+ docFields: Record<string, unknown> | null = {}
8
+ ) {
9
+ let capturedMiddleware: Function | null = null;
10
+
11
+ const strapi = {
12
+ documents: Object.assign(
13
+ (uid: string) => ({
14
+ findOne: jest.fn().mockResolvedValue(docFields),
15
+ }),
16
+ {
17
+ use: jest.fn((fn: Function) => {
18
+ capturedMiddleware = fn;
19
+ }),
20
+ }
21
+ ),
22
+ getModel: jest.fn().mockReturnValue(
23
+ modelAttributes !== null ? { attributes: modelAttributes } : null
24
+ ),
25
+ };
26
+
27
+ plugin.register({ strapi: strapi as any });
28
+
29
+ return {
30
+ strapi,
31
+ runMiddleware: (ctx: Record<string, unknown>) => {
32
+ if (!capturedMiddleware) throw new Error('middleware not registered');
33
+ const next = jest.fn().mockResolvedValue(undefined);
34
+ return { result: capturedMiddleware(ctx, next), next };
35
+ },
36
+ };
37
+ }
38
+
39
+ const baseCtx = {
40
+ action: 'publish',
41
+ uid: 'api::article.article',
42
+ params: { documentId: 'abc123' },
43
+ };
44
+
45
+ describe('register', () => {
46
+ it('calls strapi.documents.use once', () => {
47
+ const { strapi } = buildStrapi({});
48
+ expect(strapi.documents.use).toHaveBeenCalledTimes(1);
49
+ });
50
+ });
51
+
52
+ describe('middleware', () => {
53
+ it('passes through when action is not publish', async () => {
54
+ const { runMiddleware } = buildStrapi({});
55
+ const { next } = runMiddleware({ ...baseCtx, action: 'create' });
56
+ await next;
57
+ expect(next).toHaveBeenCalled();
58
+ });
59
+
60
+ it('passes through when model is null', async () => {
61
+ const { runMiddleware } = buildStrapi(null);
62
+ const { next } = runMiddleware(baseCtx);
63
+ await next;
64
+ expect(next).toHaveBeenCalled();
65
+ });
66
+
67
+ it('passes through when model has no required media fields', async () => {
68
+ const { runMiddleware } = buildStrapi({
69
+ title: { type: 'string', required: true },
70
+ cover: { type: 'media', required: false },
71
+ });
72
+ const { next } = runMiddleware(baseCtx);
73
+ await next;
74
+ expect(next).toHaveBeenCalled();
75
+ });
76
+
77
+ it('passes through when there is no documentId', async () => {
78
+ const { runMiddleware } = buildStrapi({
79
+ cover: { type: 'media', required: true },
80
+ });
81
+ const { next } = runMiddleware({ ...baseCtx, params: {} });
82
+ await next;
83
+ expect(next).toHaveBeenCalled();
84
+ });
85
+
86
+ it('passes through when all required media fields are populated', async () => {
87
+ const { runMiddleware } = buildStrapi(
88
+ { cover: { type: 'media', required: true } },
89
+ { cover: { id: 1, url: '/uploads/img.jpg' } }
90
+ );
91
+ const { result, next } = runMiddleware(baseCtx);
92
+ await result;
93
+ expect(next).toHaveBeenCalled();
94
+ });
95
+
96
+ it('throws ValidationError when a required media field is empty', async () => {
97
+ const { runMiddleware } = buildStrapi(
98
+ { cover: { type: 'media', required: true } },
99
+ { cover: null }
100
+ );
101
+ const { result } = runMiddleware(baseCtx);
102
+ await expect(result).rejects.toThrow(errors.ValidationError);
103
+ await expect(result).rejects.toThrow('Cover');
104
+ });
105
+
106
+ it('lists all missing fields in the error message', async () => {
107
+ const { runMiddleware } = buildStrapi(
108
+ {
109
+ cover: { type: 'media', required: true },
110
+ heroImage: { type: 'media', required: true },
111
+ },
112
+ { cover: null, heroImage: null }
113
+ );
114
+ const { result } = runMiddleware(baseCtx);
115
+ await expect(result).rejects.toThrow('Cover');
116
+ await expect(result).rejects.toThrow('Hero Image');
117
+ });
118
+
119
+ it('only validates required media fields, not optional ones', async () => {
120
+ const { runMiddleware } = buildStrapi(
121
+ {
122
+ cover: { type: 'media', required: true },
123
+ gallery: { type: 'media', required: false },
124
+ },
125
+ { cover: { id: 1 }, gallery: null }
126
+ );
127
+ const { result, next } = runMiddleware(baseCtx);
128
+ await result;
129
+ expect(next).toHaveBeenCalled();
130
+ });
131
+
132
+ it('populates only required media fields when fetching the document', async () => {
133
+ const findOne = jest.fn().mockResolvedValue({ cover: { id: 1 } });
134
+ const strapi = {
135
+ documents: Object.assign(() => ({ findOne }), {
136
+ use: jest.fn((fn: Function) => fn(baseCtx, jest.fn().mockResolvedValue(undefined))),
137
+ }),
138
+ getModel: jest.fn().mockReturnValue({
139
+ attributes: {
140
+ cover: { type: 'media', required: true },
141
+ title: { type: 'string', required: true },
142
+ },
143
+ }),
144
+ };
145
+
146
+ plugin.register({ strapi: strapi as any });
147
+
148
+ expect(findOne).toHaveBeenCalledWith({
149
+ documentId: 'abc123',
150
+ populate: { cover: true },
151
+ });
152
+ });
153
+ });
@@ -0,0 +1,44 @@
1
+ import type { Core, UID } from '@strapi/strapi';
2
+ import { errors } from '@strapi/utils';
3
+
4
+ const register = ({ strapi }: { strapi: Core.Strapi }) => {
5
+ strapi.documents.use(async (ctx, next) => {
6
+ if (ctx.action !== 'publish') return next();
7
+
8
+ const model = strapi.getModel(ctx.uid as UID.ContentType);
9
+ if (!model?.attributes) return next();
10
+
11
+ const requiredMediaFields = Object.entries(model.attributes)
12
+ .filter(([, attr]) => (attr as any).type === 'media' && (attr as any).required === true)
13
+ .map(([key]) => key);
14
+
15
+ if (requiredMediaFields.length === 0) return next();
16
+
17
+ const documentId = (ctx.params as { documentId?: string })?.documentId;
18
+ if (!documentId) return next();
19
+
20
+ const populate = Object.fromEntries(requiredMediaFields.map((f) => [f, true]));
21
+ const doc = await strapi.documents(ctx.uid as UID.ContentType).findOne({
22
+ documentId,
23
+ populate,
24
+ });
25
+
26
+ const missing = requiredMediaFields.filter((f) => !(doc as any)?.[f]);
27
+
28
+ if (missing.length > 0) {
29
+ const labels = missing
30
+ .map((f) => f.charAt(0).toUpperCase() + f.slice(1).replace(/([A-Z])/g, ' $1'))
31
+ .join(', ');
32
+ throw new errors.ValidationError(
33
+ `The following required media fields are empty: ${labels}`
34
+ );
35
+ }
36
+
37
+ return next();
38
+ });
39
+ };
40
+
41
+ const bootstrap = (_args: { strapi: Core.Strapi }) => {};
42
+ const destroy = (_args: { strapi: Core.Strapi }) => {};
43
+
44
+ export default { register, bootstrap, destroy };