strapi-plugin-bulk-updater 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/LICENSE +21 -0
- package/README.md +250 -0
- package/dist/_chunks/BulkUpdater-CooMo-Hc.js +2274 -0
- package/dist/_chunks/BulkUpdater-CooMo-Hc.js.map +1 -0
- package/dist/_chunks/BulkUpdater-R78pX-om.mjs +2272 -0
- package/dist/_chunks/BulkUpdater-R78pX-om.mjs.map +1 -0
- package/dist/_chunks/SettingsPage-C6QyloJe.js +176 -0
- package/dist/_chunks/SettingsPage-C6QyloJe.js.map +1 -0
- package/dist/_chunks/SettingsPage-Drb7s719.mjs +176 -0
- package/dist/_chunks/SettingsPage-Drb7s719.mjs.map +1 -0
- package/dist/admin/index.js +36 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/index.mjs +37 -0
- package/dist/admin/index.mjs.map +1 -0
- package/dist/server/index.js +1752 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/index.mjs +1751 -0
- package/dist/server/index.mjs.map +1 -0
- package/package.json +95 -0
- package/strapi-admin.js +3 -0
- package/strapi-server.js +3 -0
|
@@ -0,0 +1,1751 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
const routes = {
|
|
3
|
+
admin: {
|
|
4
|
+
type: "admin",
|
|
5
|
+
routes: [
|
|
6
|
+
{
|
|
7
|
+
method: "GET",
|
|
8
|
+
path: "/models",
|
|
9
|
+
handler: "bulkUpdater.getModels",
|
|
10
|
+
config: {
|
|
11
|
+
policies: []
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
method: "GET",
|
|
16
|
+
path: "/collections/:uid/entries",
|
|
17
|
+
handler: "bulkUpdater.getCollectionEntries",
|
|
18
|
+
config: {
|
|
19
|
+
policies: []
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
method: "GET",
|
|
24
|
+
path: "/relation-options",
|
|
25
|
+
handler: "bulkUpdater.getRelationOptions",
|
|
26
|
+
config: {
|
|
27
|
+
policies: []
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
method: "POST",
|
|
32
|
+
path: "/batch-update",
|
|
33
|
+
handler: "bulkUpdater.batchUpdate",
|
|
34
|
+
config: {
|
|
35
|
+
policies: []
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
method: "GET",
|
|
40
|
+
path: "/pending-batches",
|
|
41
|
+
handler: "bulkUpdater.getPendingBatches",
|
|
42
|
+
config: {
|
|
43
|
+
policies: []
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
method: "POST",
|
|
48
|
+
path: "/batches/:batchId/approve",
|
|
49
|
+
handler: "bulkUpdater.approveBatch",
|
|
50
|
+
config: {
|
|
51
|
+
policies: []
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
method: "POST",
|
|
56
|
+
path: "/batches/:batchId/reject",
|
|
57
|
+
handler: "bulkUpdater.rejectBatch",
|
|
58
|
+
config: {
|
|
59
|
+
policies: []
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
method: "POST",
|
|
64
|
+
path: "/batches/:batchId/execute-now",
|
|
65
|
+
handler: "bulkUpdater.executeNow",
|
|
66
|
+
config: {
|
|
67
|
+
policies: []
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
method: "GET",
|
|
72
|
+
path: "/license",
|
|
73
|
+
handler: "license.getStatus",
|
|
74
|
+
config: {
|
|
75
|
+
policies: []
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
method: "POST",
|
|
80
|
+
path: "/license/verify",
|
|
81
|
+
handler: "license.verify",
|
|
82
|
+
config: {
|
|
83
|
+
policies: []
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
method: "POST",
|
|
88
|
+
path: "/license/deactivate",
|
|
89
|
+
handler: "license.deactivate",
|
|
90
|
+
config: {
|
|
91
|
+
policies: []
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
method: "GET",
|
|
96
|
+
path: "/audit/history",
|
|
97
|
+
handler: "audit.getHistory",
|
|
98
|
+
config: {
|
|
99
|
+
policies: []
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
method: "POST",
|
|
104
|
+
path: "/audit/:batchId/rollback",
|
|
105
|
+
handler: "audit.rollback",
|
|
106
|
+
config: {
|
|
107
|
+
policies: []
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const bulkUpdaterController = ({ strapi }) => ({
|
|
114
|
+
async getModels(ctx) {
|
|
115
|
+
try {
|
|
116
|
+
const service = strapi.plugin("bulk-updater").service("bulkUpdater");
|
|
117
|
+
const data = await service.getModels();
|
|
118
|
+
if (data?.etag) {
|
|
119
|
+
ctx.set("ETag", data.etag);
|
|
120
|
+
ctx.set("Cache-Control", "private, no-cache");
|
|
121
|
+
if (ctx.request.headers["if-none-match"] === data.etag) {
|
|
122
|
+
ctx.status = 304;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
ctx.body = data;
|
|
127
|
+
} catch (err) {
|
|
128
|
+
ctx.status = 500;
|
|
129
|
+
ctx.body = { success: false, error: err.message };
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
async getCollectionEntries(ctx) {
|
|
133
|
+
try {
|
|
134
|
+
const { uid } = ctx.params;
|
|
135
|
+
const { page, pageSize, attribute, filterOperator, filterQuery, search, sortField, sortOrder } = ctx.query;
|
|
136
|
+
const service = strapi.plugin("bulk-updater").service("bulkUpdater");
|
|
137
|
+
const data = await service.getCollectionEntries(uid, {
|
|
138
|
+
page: page ? Number(page) : 1,
|
|
139
|
+
pageSize: pageSize ? Number(pageSize) : 10,
|
|
140
|
+
attribute: attribute ? String(attribute) : void 0,
|
|
141
|
+
filterOperator: filterOperator ? String(filterOperator) : void 0,
|
|
142
|
+
filterQuery: filterQuery !== void 0 ? String(filterQuery) : void 0,
|
|
143
|
+
search: search ? String(search) : void 0,
|
|
144
|
+
sortField: sortField ? String(sortField) : void 0,
|
|
145
|
+
sortOrder: sortOrder === "asc" ? "asc" : "desc"
|
|
146
|
+
});
|
|
147
|
+
if (data?.etag) {
|
|
148
|
+
ctx.set("ETag", data.etag);
|
|
149
|
+
ctx.set("Cache-Control", "private, no-cache");
|
|
150
|
+
if (ctx.request.headers["if-none-match"] === data.etag) {
|
|
151
|
+
ctx.status = 304;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
ctx.body = data;
|
|
156
|
+
} catch (err) {
|
|
157
|
+
ctx.status = 500;
|
|
158
|
+
ctx.body = { success: false, error: err.message };
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
async getRelationOptions(ctx) {
|
|
162
|
+
try {
|
|
163
|
+
const { targetUid } = ctx.query;
|
|
164
|
+
if (!targetUid) {
|
|
165
|
+
ctx.status = 400;
|
|
166
|
+
ctx.body = { success: false, error: "targetUid is required" };
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const service = strapi.plugin("bulk-updater").service("bulkUpdater");
|
|
170
|
+
const data = await service.getRelationOptions(String(targetUid));
|
|
171
|
+
ctx.body = { success: true, ...data };
|
|
172
|
+
} catch (err) {
|
|
173
|
+
ctx.status = 500;
|
|
174
|
+
ctx.body = { success: false, error: err.message };
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
async batchUpdate(ctx) {
|
|
178
|
+
try {
|
|
179
|
+
const service = strapi.plugin("bulk-updater").service("bulkUpdater");
|
|
180
|
+
const currentUser = ctx.state.user;
|
|
181
|
+
const result = await service.batchUpdate(ctx.request.body || {}, currentUser);
|
|
182
|
+
ctx.body = result;
|
|
183
|
+
} catch (err) {
|
|
184
|
+
ctx.status = 400;
|
|
185
|
+
ctx.body = { success: false, error: err.message };
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
async getPendingBatches(ctx) {
|
|
189
|
+
try {
|
|
190
|
+
const service = strapi.plugin("bulk-updater").service("bulkUpdater");
|
|
191
|
+
const result = await service.getPendingBatches();
|
|
192
|
+
ctx.body = result;
|
|
193
|
+
} catch (err) {
|
|
194
|
+
ctx.status = 500;
|
|
195
|
+
ctx.body = { success: false, error: err.message };
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
async approveBatch(ctx) {
|
|
199
|
+
try {
|
|
200
|
+
const { batchId } = ctx.params;
|
|
201
|
+
const { executeImmediately = true } = ctx.request.body || {};
|
|
202
|
+
const service = strapi.plugin("bulk-updater").service("bulkUpdater");
|
|
203
|
+
const currentUser = ctx.state.user;
|
|
204
|
+
const result = await service.approveBatch(batchId, currentUser, { executeImmediately });
|
|
205
|
+
ctx.body = result;
|
|
206
|
+
} catch (err) {
|
|
207
|
+
ctx.status = 400;
|
|
208
|
+
ctx.body = { success: false, error: err.message };
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
async rejectBatch(ctx) {
|
|
212
|
+
try {
|
|
213
|
+
const { batchId } = ctx.params;
|
|
214
|
+
const { reason } = ctx.request.body || {};
|
|
215
|
+
const service = strapi.plugin("bulk-updater").service("bulkUpdater");
|
|
216
|
+
const currentUser = ctx.state.user;
|
|
217
|
+
const result = await service.rejectBatch(batchId, currentUser, reason);
|
|
218
|
+
ctx.body = result;
|
|
219
|
+
} catch (err) {
|
|
220
|
+
ctx.status = 400;
|
|
221
|
+
ctx.body = { success: false, error: err.message };
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
async executeNow(ctx) {
|
|
225
|
+
try {
|
|
226
|
+
const { batchId } = ctx.params;
|
|
227
|
+
const service = strapi.plugin("bulk-updater").service("bulkUpdater");
|
|
228
|
+
const currentUser = ctx.state.user;
|
|
229
|
+
const result = await service.executeNow(batchId, currentUser);
|
|
230
|
+
ctx.body = result;
|
|
231
|
+
} catch (err) {
|
|
232
|
+
ctx.status = 400;
|
|
233
|
+
ctx.body = { success: false, error: err.message };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
const licenseController = ({ strapi }) => ({
|
|
238
|
+
async getStatus(ctx) {
|
|
239
|
+
try {
|
|
240
|
+
const service = strapi.plugin("bulk-updater").service("license");
|
|
241
|
+
const license = await service.getLicenseStatus();
|
|
242
|
+
ctx.body = { success: true, license };
|
|
243
|
+
} catch (err) {
|
|
244
|
+
ctx.status = 500;
|
|
245
|
+
ctx.body = { success: false, error: err.message };
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
async verify(ctx) {
|
|
249
|
+
try {
|
|
250
|
+
const { licenseKey } = ctx.request.body || {};
|
|
251
|
+
const service = strapi.plugin("bulk-updater").service("license");
|
|
252
|
+
const result = await service.verifyAndActivateLicense(licenseKey);
|
|
253
|
+
if (!result.success) {
|
|
254
|
+
ctx.status = 400;
|
|
255
|
+
ctx.body = result;
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
ctx.body = result;
|
|
259
|
+
} catch (err) {
|
|
260
|
+
ctx.status = 500;
|
|
261
|
+
ctx.body = { success: false, error: err.message };
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
async deactivate(ctx) {
|
|
265
|
+
try {
|
|
266
|
+
const service = strapi.plugin("bulk-updater").service("license");
|
|
267
|
+
const license = await service.deactivateLicense();
|
|
268
|
+
ctx.body = { success: true, license };
|
|
269
|
+
} catch (err) {
|
|
270
|
+
ctx.status = 500;
|
|
271
|
+
ctx.body = { success: false, error: err.message };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
const auditController = ({ strapi }) => ({
|
|
276
|
+
async getHistory(ctx) {
|
|
277
|
+
try {
|
|
278
|
+
const service = strapi.plugin("bulk-updater").service("audit");
|
|
279
|
+
const data = await service.getHistory();
|
|
280
|
+
ctx.body = data;
|
|
281
|
+
} catch (err) {
|
|
282
|
+
ctx.status = 500;
|
|
283
|
+
ctx.body = { success: false, error: err.message };
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
async rollback(ctx) {
|
|
287
|
+
try {
|
|
288
|
+
const { batchId } = ctx.params;
|
|
289
|
+
const service = strapi.plugin("bulk-updater").service("audit");
|
|
290
|
+
const currentUser = ctx.state.user;
|
|
291
|
+
const result = await service.rollbackBatch(batchId, currentUser);
|
|
292
|
+
ctx.body = result;
|
|
293
|
+
} catch (err) {
|
|
294
|
+
ctx.status = 400;
|
|
295
|
+
ctx.body = { success: false, error: err.message };
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
const queryCache = /* @__PURE__ */ new Map();
|
|
300
|
+
const modelsCache = /* @__PURE__ */ new Map();
|
|
301
|
+
const MAX_QUERY_CACHE_SIZE = 500;
|
|
302
|
+
const CACHE_TTL_MS = 60 * 1e3;
|
|
303
|
+
let staticSchemaCache = null;
|
|
304
|
+
function getSafeTimezone(timeZone) {
|
|
305
|
+
if (!timeZone || typeof timeZone !== "string") {
|
|
306
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
Intl.DateTimeFormat(void 0, { timeZone: timeZone.trim() });
|
|
310
|
+
return timeZone.trim();
|
|
311
|
+
} catch {
|
|
312
|
+
return "UTC";
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
function getTimeInTimezone(timeZone, date = /* @__PURE__ */ new Date()) {
|
|
316
|
+
const safeTz = getSafeTimezone(timeZone);
|
|
317
|
+
const formatter = new Intl.DateTimeFormat("en-GB", {
|
|
318
|
+
timeZone: safeTz,
|
|
319
|
+
hour: "2-digit",
|
|
320
|
+
minute: "2-digit",
|
|
321
|
+
hourCycle: "h23"
|
|
322
|
+
});
|
|
323
|
+
const timeString = formatter.format(date);
|
|
324
|
+
const [h, m] = timeString.split(":").map(Number);
|
|
325
|
+
return { hours: h, minutes: m, timeString };
|
|
326
|
+
}
|
|
327
|
+
function getExecutionWindowConfig(strapi) {
|
|
328
|
+
const pluginConfig = strapi.config.get("plugin::bulk-updater.executionWindow");
|
|
329
|
+
const configuredTz = pluginConfig?.timeZone || strapi.config.get("server.timeZone") || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
330
|
+
const timeZone = getSafeTimezone(configuredTz);
|
|
331
|
+
return {
|
|
332
|
+
enabled: pluginConfig?.enabled ?? true,
|
|
333
|
+
timeZone,
|
|
334
|
+
businessHours: {
|
|
335
|
+
start: pluginConfig?.businessHours?.start || "09:00",
|
|
336
|
+
end: pluginConfig?.businessHours?.end || "18:00"
|
|
337
|
+
},
|
|
338
|
+
offPeakStartTime: pluginConfig?.offPeakStartTime || "18:00",
|
|
339
|
+
autoQueueDuringBusinessHours: pluginConfig?.autoQueueDuringBusinessHours ?? true
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
function checkBusinessHours(config, testDate) {
|
|
343
|
+
const timeZone = getSafeTimezone(config.timeZone);
|
|
344
|
+
const { hours, minutes, timeString } = getTimeInTimezone(timeZone, /* @__PURE__ */ new Date());
|
|
345
|
+
const currentMinutes = hours * 60 + minutes;
|
|
346
|
+
const [startH, startM] = (config.businessHours.start || "09:00").split(":").map(Number);
|
|
347
|
+
const [endH, endM] = (config.businessHours.end || "18:00").split(":").map(Number);
|
|
348
|
+
const startMinutes = startH * 60 + startM;
|
|
349
|
+
const endMinutes = endH * 60 + endM;
|
|
350
|
+
const isBusinessHours = currentMinutes >= startMinutes && currentMinutes < endMinutes;
|
|
351
|
+
return {
|
|
352
|
+
isBusinessHours,
|
|
353
|
+
currentServerTime: timeString,
|
|
354
|
+
timeZone,
|
|
355
|
+
businessHours: config.businessHours,
|
|
356
|
+
offPeakStartTime: config.offPeakStartTime,
|
|
357
|
+
policyMessage: isBusinessHours ? `Server policy restricts bulk updates during peak business hours (${config.businessHours.start} - ${config.businessHours.end} ${timeZone}). Current time: ${timeString} (${timeZone}). Updates will be queued for off-peak execution at ${config.offPeakStartTime} ${timeZone}.` : `Currently in off-peak execution window (outside ${config.businessHours.start} - ${config.businessHours.end} ${timeZone}). Current time: ${timeString} (${timeZone}). Direct execution is permitted.`
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
const pendingBatches = /* @__PURE__ */ new Map();
|
|
361
|
+
let isRunnerRunning = false;
|
|
362
|
+
let runnerInterval = null;
|
|
363
|
+
function invalidateCollectionCache(uid) {
|
|
364
|
+
modelsCache.delete(uid);
|
|
365
|
+
for (const key of queryCache.keys()) {
|
|
366
|
+
if (key.startsWith(`${uid}:`)) {
|
|
367
|
+
queryCache.delete(key);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
async function getCollectionSignature(strapi, uid) {
|
|
372
|
+
const contentType = strapi.contentTypes[uid];
|
|
373
|
+
if (!contentType) return "invalid";
|
|
374
|
+
const isSingleType = contentType.kind === "singleType";
|
|
375
|
+
try {
|
|
376
|
+
if (isSingleType) {
|
|
377
|
+
const doc = await strapi.documents(uid).findFirst({
|
|
378
|
+
fields: ["updatedAt", "id"]
|
|
379
|
+
});
|
|
380
|
+
return doc ? `single:${doc.id}:${doc.updatedAt}` : "single:empty";
|
|
381
|
+
}
|
|
382
|
+
const [total, latest] = await Promise.all([
|
|
383
|
+
strapi.documents(uid).count(),
|
|
384
|
+
strapi.documents(uid).findMany({
|
|
385
|
+
limit: 1,
|
|
386
|
+
sort: { updatedAt: "desc" },
|
|
387
|
+
fields: ["updatedAt", "id"]
|
|
388
|
+
})
|
|
389
|
+
]);
|
|
390
|
+
const latestDoc = latest && latest.length > 0 ? latest[0] : null;
|
|
391
|
+
return `${total}:${latestDoc?.updatedAt || "0"}:${latestDoc?.id || "0"}`;
|
|
392
|
+
} catch {
|
|
393
|
+
return `${Date.now()}`;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
async function computeTargetsSummary(strapi, { selectedCollections = [], selectedEntries = [] }) {
|
|
397
|
+
const targetsMap = /* @__PURE__ */ new Map();
|
|
398
|
+
const breakdown = {};
|
|
399
|
+
for (const colUid of selectedCollections) {
|
|
400
|
+
const contentType = strapi.contentTypes[colUid];
|
|
401
|
+
const displayName = contentType?.info?.displayName || colUid;
|
|
402
|
+
try {
|
|
403
|
+
const entries = await strapi.documents(colUid).findMany();
|
|
404
|
+
const count = entries ? entries.length : 0;
|
|
405
|
+
breakdown[displayName] = (breakdown[displayName] || 0) + count;
|
|
406
|
+
for (const entry of entries || []) {
|
|
407
|
+
targetsMap.set(`${colUid}@@@${entry.documentId}`, { uid: colUid, documentId: entry.documentId });
|
|
408
|
+
}
|
|
409
|
+
} catch {
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
for (const item of selectedEntries) {
|
|
413
|
+
if (!item.uid || !item.documentId) continue;
|
|
414
|
+
const key = `${item.uid}@@@${item.documentId}`;
|
|
415
|
+
if (!targetsMap.has(key)) {
|
|
416
|
+
targetsMap.set(key, { uid: item.uid, documentId: item.documentId });
|
|
417
|
+
const contentType = strapi.contentTypes[item.uid];
|
|
418
|
+
const displayName = contentType?.info?.displayName || item.uid;
|
|
419
|
+
breakdown[displayName] = (breakdown[displayName] || 0) + 1;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return {
|
|
423
|
+
totalTargets: targetsMap.size,
|
|
424
|
+
collectionBreakdown: breakdown
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
async function executeBatchInternal(strapi, {
|
|
428
|
+
attribute,
|
|
429
|
+
value,
|
|
430
|
+
selectedEntries = [],
|
|
431
|
+
selectedCollections = [],
|
|
432
|
+
isRegex = false,
|
|
433
|
+
regexPattern = "",
|
|
434
|
+
publishOnApproval = true
|
|
435
|
+
}, currentUser, existingBatchId) {
|
|
436
|
+
if (!attribute) {
|
|
437
|
+
throw new Error("Attribute name is required.");
|
|
438
|
+
}
|
|
439
|
+
const licenseService2 = strapi.plugin("bulk-updater").service("license");
|
|
440
|
+
const license = await licenseService2.getLicenseStatus();
|
|
441
|
+
if (isRegex && !license.isPro) {
|
|
442
|
+
throw new Error(
|
|
443
|
+
"Regex find & replace is a Pro feature. Please activate a Pro license in Plugin Settings."
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
const targetsMap = /* @__PURE__ */ new Map();
|
|
447
|
+
for (const colUid of selectedCollections) {
|
|
448
|
+
if (!strapi.contentTypes[colUid]) continue;
|
|
449
|
+
const entries = await strapi.documents(colUid).findMany();
|
|
450
|
+
for (const entry of entries || []) {
|
|
451
|
+
const key = `${colUid}@@@${entry.documentId}`;
|
|
452
|
+
targetsMap.set(key, { uid: colUid, documentId: entry.documentId });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
for (const item of selectedEntries) {
|
|
456
|
+
if (!item.uid || !item.documentId) continue;
|
|
457
|
+
const key = `${item.uid}@@@${item.documentId}`;
|
|
458
|
+
targetsMap.set(key, { uid: item.uid, documentId: item.documentId });
|
|
459
|
+
}
|
|
460
|
+
if (targetsMap.size === 0) {
|
|
461
|
+
throw new Error("No collections or entries were selected for update.");
|
|
462
|
+
}
|
|
463
|
+
const batchId = existingBatchId || `batch_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
|
|
464
|
+
const results = [];
|
|
465
|
+
let updatedCount = 0;
|
|
466
|
+
let skippedCount = 0;
|
|
467
|
+
let regexObj = null;
|
|
468
|
+
if (isRegex && regexPattern) {
|
|
469
|
+
regexObj = new RegExp(regexPattern, "g");
|
|
470
|
+
}
|
|
471
|
+
const targets = Array.from(targetsMap.values());
|
|
472
|
+
const CHUNK_SIZE = 25;
|
|
473
|
+
const documentLoader = /* @__PURE__ */ new Map();
|
|
474
|
+
const getDocumentDeduplicated = (uid, documentId) => {
|
|
475
|
+
const loaderKey = `${uid}@@@${documentId}`;
|
|
476
|
+
if (!documentLoader.has(loaderKey)) {
|
|
477
|
+
documentLoader.set(
|
|
478
|
+
loaderKey,
|
|
479
|
+
strapi.documents(uid).findOne({ documentId })
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
return documentLoader.get(loaderKey);
|
|
483
|
+
};
|
|
484
|
+
for (let i = 0; i < targets.length; i += CHUNK_SIZE) {
|
|
485
|
+
const chunk = targets.slice(i, i + CHUNK_SIZE);
|
|
486
|
+
const chunkPromises = chunk.map(async (target) => {
|
|
487
|
+
const contentType = strapi.contentTypes[target.uid];
|
|
488
|
+
const displayName = contentType?.info?.displayName || target.uid;
|
|
489
|
+
try {
|
|
490
|
+
const currentEntry = await getDocumentDeduplicated(target.uid, target.documentId);
|
|
491
|
+
const entryLabel = currentEntry?.name || currentEntry?.title || currentEntry?.email || `Entry #${target.documentId}`;
|
|
492
|
+
const attrDef = contentType?.attributes?.[attribute];
|
|
493
|
+
const hasAttribute = Boolean(attrDef);
|
|
494
|
+
if (hasAttribute) {
|
|
495
|
+
if (attrDef.type === "relation") {
|
|
496
|
+
const isMulti = attrDef.relation === "oneToMany" || attrDef.relation === "manyToMany";
|
|
497
|
+
const currentEntryWithPopulate = await strapi.documents(target.uid).findOne({
|
|
498
|
+
documentId: target.documentId,
|
|
499
|
+
populate: [attribute]
|
|
500
|
+
});
|
|
501
|
+
const currentRel = currentEntryWithPopulate?.[attribute];
|
|
502
|
+
let oldDocIds = [];
|
|
503
|
+
let oldDisplay = "";
|
|
504
|
+
if (isMulti && Array.isArray(currentRel)) {
|
|
505
|
+
oldDocIds = currentRel.map((r) => r.documentId).filter(Boolean);
|
|
506
|
+
oldDisplay = currentRel.map((r) => r.name || r.title || r.email || r.documentId).join(", ") || "(empty)";
|
|
507
|
+
} else if (currentRel) {
|
|
508
|
+
oldDocIds = currentRel.documentId ? [currentRel.documentId] : [];
|
|
509
|
+
oldDisplay = currentRel.name || currentRel.title || currentRel.email || currentRel.documentId || "(empty)";
|
|
510
|
+
} else {
|
|
511
|
+
oldDisplay = "(unlinked / none)";
|
|
512
|
+
}
|
|
513
|
+
let mode = "set";
|
|
514
|
+
let targetDocIds = [];
|
|
515
|
+
let newDisplay = "";
|
|
516
|
+
if (value === "__clear__" || value === "") {
|
|
517
|
+
mode = "clear";
|
|
518
|
+
newDisplay = "(cleared / unlinked)";
|
|
519
|
+
} else {
|
|
520
|
+
try {
|
|
521
|
+
const parsed = JSON.parse(value);
|
|
522
|
+
if (parsed && typeof parsed === "object" && parsed.mode) {
|
|
523
|
+
mode = parsed.mode;
|
|
524
|
+
targetDocIds = Array.isArray(parsed.documentIds) ? parsed.documentIds : parsed.documentId ? [parsed.documentId] : [];
|
|
525
|
+
newDisplay = parsed.label || targetDocIds.join(", ") || `[${mode}]`;
|
|
526
|
+
} else if (Array.isArray(parsed)) {
|
|
527
|
+
targetDocIds = parsed;
|
|
528
|
+
newDisplay = targetDocIds.join(", ");
|
|
529
|
+
} else {
|
|
530
|
+
targetDocIds = [value];
|
|
531
|
+
newDisplay = value;
|
|
532
|
+
}
|
|
533
|
+
} catch {
|
|
534
|
+
targetDocIds = [value];
|
|
535
|
+
newDisplay = value;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
let relationPayload;
|
|
539
|
+
if (mode === "clear") {
|
|
540
|
+
relationPayload = isMulti ? { set: [] } : null;
|
|
541
|
+
} else if (mode === "set") {
|
|
542
|
+
relationPayload = isMulti ? { set: targetDocIds } : targetDocIds[0] || null;
|
|
543
|
+
} else if (mode === "connect") {
|
|
544
|
+
relationPayload = { connect: targetDocIds };
|
|
545
|
+
} else if (mode === "disconnect") {
|
|
546
|
+
relationPayload = { disconnect: targetDocIds };
|
|
547
|
+
}
|
|
548
|
+
await strapi.documents(target.uid).update({
|
|
549
|
+
documentId: target.documentId,
|
|
550
|
+
data: {
|
|
551
|
+
[attribute]: relationPayload
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
const isDraftAndPublishRel = Boolean(contentType?.options?.draftAndPublish);
|
|
555
|
+
if (publishOnApproval && isDraftAndPublishRel) {
|
|
556
|
+
try {
|
|
557
|
+
await strapi.documents(target.uid).publish({
|
|
558
|
+
documentId: target.documentId
|
|
559
|
+
});
|
|
560
|
+
} catch (publishErr) {
|
|
561
|
+
strapi.log.warn(`[bulk-updater] Failed to auto-publish ${target.uid}/${target.documentId}:`, publishErr);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
invalidateCollectionCache(target.uid);
|
|
565
|
+
if (attrDef.target) {
|
|
566
|
+
invalidateCollectionCache(attrDef.target);
|
|
567
|
+
}
|
|
568
|
+
return {
|
|
569
|
+
uid: target.uid,
|
|
570
|
+
collectionName: displayName,
|
|
571
|
+
documentId: target.documentId,
|
|
572
|
+
label: entryLabel,
|
|
573
|
+
attribute,
|
|
574
|
+
oldValue: { isRelation: true, documentIds: oldDocIds, display: oldDisplay },
|
|
575
|
+
newValue: { isRelation: true, mode, documentIds: targetDocIds, display: newDisplay },
|
|
576
|
+
status: "updated",
|
|
577
|
+
message: `Successfully updated relation "${attribute}"`
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
let newValue = value;
|
|
581
|
+
const currentVal = currentEntry?.[attribute];
|
|
582
|
+
if (regexObj && typeof currentVal === "string") {
|
|
583
|
+
newValue = currentVal.replace(regexObj, value);
|
|
584
|
+
}
|
|
585
|
+
await strapi.documents(target.uid).update({
|
|
586
|
+
documentId: target.documentId,
|
|
587
|
+
data: {
|
|
588
|
+
[attribute]: newValue
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
const isDraftAndPublishScalar = Boolean(contentType?.options?.draftAndPublish);
|
|
592
|
+
if (publishOnApproval && isDraftAndPublishScalar) {
|
|
593
|
+
try {
|
|
594
|
+
await strapi.documents(target.uid).publish({
|
|
595
|
+
documentId: target.documentId
|
|
596
|
+
});
|
|
597
|
+
} catch (publishErr) {
|
|
598
|
+
strapi.log.warn(`[bulk-updater] Failed to auto-publish ${target.uid}/${target.documentId}:`, publishErr);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return {
|
|
602
|
+
uid: target.uid,
|
|
603
|
+
collectionName: displayName,
|
|
604
|
+
documentId: target.documentId,
|
|
605
|
+
label: entryLabel,
|
|
606
|
+
attribute,
|
|
607
|
+
oldValue: currentVal ?? null,
|
|
608
|
+
newValue,
|
|
609
|
+
status: "updated",
|
|
610
|
+
message: `Successfully updated ${attribute}`
|
|
611
|
+
};
|
|
612
|
+
} else {
|
|
613
|
+
return {
|
|
614
|
+
uid: target.uid,
|
|
615
|
+
collectionName: displayName,
|
|
616
|
+
documentId: target.documentId,
|
|
617
|
+
label: entryLabel,
|
|
618
|
+
attribute,
|
|
619
|
+
status: "skipped",
|
|
620
|
+
message: `Skipped: Attribute "${attribute}" does not exist in schema for ${displayName}`
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
} catch (updateErr) {
|
|
624
|
+
return {
|
|
625
|
+
uid: target.uid,
|
|
626
|
+
collectionName: displayName,
|
|
627
|
+
documentId: target.documentId,
|
|
628
|
+
label: `Entry #${target.documentId}`,
|
|
629
|
+
attribute,
|
|
630
|
+
status: "error",
|
|
631
|
+
message: updateErr.message
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
const chunkResults = await Promise.all(chunkPromises);
|
|
636
|
+
for (const res of chunkResults) {
|
|
637
|
+
if (res.status === "updated") updatedCount++;
|
|
638
|
+
else if (res.status === "skipped") skippedCount++;
|
|
639
|
+
results.push(res);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
const affectedUids = new Set(targets.map((t) => t.uid));
|
|
643
|
+
affectedUids.forEach((uid) => {
|
|
644
|
+
invalidateCollectionCache(uid);
|
|
645
|
+
});
|
|
646
|
+
const batchSummary = {
|
|
647
|
+
batchId,
|
|
648
|
+
attribute,
|
|
649
|
+
value,
|
|
650
|
+
updatedCount,
|
|
651
|
+
skippedCount,
|
|
652
|
+
totalTargets: targetsMap.size,
|
|
653
|
+
publishOnApproval
|
|
654
|
+
};
|
|
655
|
+
await strapi.plugin("bulk-updater").service("audit").recordBatch(
|
|
656
|
+
batchSummary,
|
|
657
|
+
results,
|
|
658
|
+
currentUser
|
|
659
|
+
);
|
|
660
|
+
return {
|
|
661
|
+
success: true,
|
|
662
|
+
batchId,
|
|
663
|
+
attribute,
|
|
664
|
+
value,
|
|
665
|
+
updatedCount,
|
|
666
|
+
skippedCount,
|
|
667
|
+
totalTargets: targetsMap.size,
|
|
668
|
+
results
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
const bulkUpdaterService = ({ strapi }) => {
|
|
672
|
+
if (!runnerInterval) {
|
|
673
|
+
runnerInterval = setInterval(async () => {
|
|
674
|
+
if (isRunnerRunning) return;
|
|
675
|
+
isRunnerRunning = true;
|
|
676
|
+
try {
|
|
677
|
+
const config = getExecutionWindowConfig(strapi);
|
|
678
|
+
const { isBusinessHours } = checkBusinessHours(config);
|
|
679
|
+
for (const [id, batch] of pendingBatches.entries()) {
|
|
680
|
+
if (batch.status === "queued_offpeak" && !isBusinessHours) {
|
|
681
|
+
strapi.log.info(`[bulk-updater] Executing off-peak queued batch: ${id}`);
|
|
682
|
+
const result = await executeBatchInternal(strapi, batch.payload, batch.createdBy, id);
|
|
683
|
+
batch.status = "executed";
|
|
684
|
+
batch.executionResult = result;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
} catch (err) {
|
|
688
|
+
strapi.log.error("[bulk-updater] Scheduled runner error:", err);
|
|
689
|
+
} finally {
|
|
690
|
+
isRunnerRunning = false;
|
|
691
|
+
}
|
|
692
|
+
}, 3e4);
|
|
693
|
+
}
|
|
694
|
+
return {
|
|
695
|
+
/**
|
|
696
|
+
* Lightweight Schema Discovery:
|
|
697
|
+
* Returns schema definitions, valid attributes, and total counts (<15KB payload)
|
|
698
|
+
* Previews up to 10 entries per collection to ensure instantaneous initial paint.
|
|
699
|
+
* Utilizes in-memory schema memoization and database high-watermark signature caching.
|
|
700
|
+
*/
|
|
701
|
+
async getModels() {
|
|
702
|
+
const allContentTypes = strapi.contentTypes || {};
|
|
703
|
+
const auditConfig = strapi.plugin("bulk-updater").service("audit").getAuditConfig();
|
|
704
|
+
const auditContentType = auditConfig?.contentType;
|
|
705
|
+
if (!staticSchemaCache) {
|
|
706
|
+
const allAttributesSet = /* @__PURE__ */ new Set();
|
|
707
|
+
const systemFields = [
|
|
708
|
+
"createdAt",
|
|
709
|
+
"updatedAt",
|
|
710
|
+
"publishedAt",
|
|
711
|
+
"createdBy",
|
|
712
|
+
"updatedBy",
|
|
713
|
+
"locale",
|
|
714
|
+
"localizations"
|
|
715
|
+
];
|
|
716
|
+
const configuredCollections = strapi.config.get(
|
|
717
|
+
"plugin::bulk-updater.collections"
|
|
718
|
+
);
|
|
719
|
+
let targetUids;
|
|
720
|
+
if (configuredCollections && Array.isArray(configuredCollections) && configuredCollections.length > 0) {
|
|
721
|
+
targetUids = configuredCollections.filter((uid) => uid in allContentTypes);
|
|
722
|
+
} else {
|
|
723
|
+
targetUids = Object.keys(allContentTypes).filter(
|
|
724
|
+
(uid) => uid.startsWith("api::") && !uid.includes("bulk-updater") && uid !== auditContentType
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
const metaByUid = {};
|
|
728
|
+
const allRelationsMeta = {};
|
|
729
|
+
for (const uid of targetUids) {
|
|
730
|
+
const contentType = allContentTypes[uid];
|
|
731
|
+
if (!contentType || contentType.kind !== "collectionType" && contentType.kind !== "singleType") continue;
|
|
732
|
+
const isSingleType = contentType.kind === "singleType";
|
|
733
|
+
const rawAttrs = contentType.attributes || {};
|
|
734
|
+
const relationsByAttr = {};
|
|
735
|
+
const validAttrs = Object.keys(rawAttrs).filter((attrKey) => {
|
|
736
|
+
if (systemFields.includes(attrKey)) return false;
|
|
737
|
+
if (rawAttrs[attrKey].type === "component" || rawAttrs[attrKey].type === "dynamiczone") return false;
|
|
738
|
+
if (rawAttrs[attrKey].type === "relation") {
|
|
739
|
+
const rel = rawAttrs[attrKey];
|
|
740
|
+
const targetUid = rel.target;
|
|
741
|
+
if (!targetUid || !allContentTypes[targetUid]) return false;
|
|
742
|
+
const targetInfo = allContentTypes[targetUid]?.info;
|
|
743
|
+
const isMulti = rel.relation === "oneToMany" || rel.relation === "manyToMany";
|
|
744
|
+
const relMeta = {
|
|
745
|
+
isRelation: true,
|
|
746
|
+
relationType: rel.relation,
|
|
747
|
+
targetUid,
|
|
748
|
+
targetDisplayName: targetInfo?.displayName || targetUid,
|
|
749
|
+
isMulti
|
|
750
|
+
};
|
|
751
|
+
relationsByAttr[attrKey] = relMeta;
|
|
752
|
+
allRelationsMeta[attrKey] = relMeta;
|
|
753
|
+
return true;
|
|
754
|
+
}
|
|
755
|
+
return true;
|
|
756
|
+
});
|
|
757
|
+
validAttrs.forEach((attr) => allAttributesSet.add(attr));
|
|
758
|
+
metaByUid[uid] = {
|
|
759
|
+
uid,
|
|
760
|
+
kind: contentType.kind,
|
|
761
|
+
isSingleType,
|
|
762
|
+
singularName: contentType.info?.singularName || uid,
|
|
763
|
+
pluralName: contentType.info?.pluralName || uid,
|
|
764
|
+
displayName: contentType.info?.displayName || uid,
|
|
765
|
+
attributes: validAttrs,
|
|
766
|
+
relations: relationsByAttr
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
staticSchemaCache = {
|
|
770
|
+
attributes: Array.from(allAttributesSet),
|
|
771
|
+
targetUids: Object.keys(metaByUid),
|
|
772
|
+
metaByUid,
|
|
773
|
+
relationsMeta: allRelationsMeta
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
const collections = [];
|
|
777
|
+
const signatures = [];
|
|
778
|
+
for (const uid of staticSchemaCache.targetUids) {
|
|
779
|
+
const meta = staticSchemaCache.metaByUid[uid];
|
|
780
|
+
if (!meta) continue;
|
|
781
|
+
const signature = await getCollectionSignature(strapi, uid);
|
|
782
|
+
signatures.push(`${uid}=${signature}`);
|
|
783
|
+
const cached = modelsCache.get(uid);
|
|
784
|
+
if (cached && cached.signature === signature) {
|
|
785
|
+
collections.push({
|
|
786
|
+
...meta,
|
|
787
|
+
totalEntries: cached.totalCount,
|
|
788
|
+
entries: cached.entries
|
|
789
|
+
});
|
|
790
|
+
} else {
|
|
791
|
+
let entries = [];
|
|
792
|
+
let totalCount = 0;
|
|
793
|
+
try {
|
|
794
|
+
const relKeys = Object.keys(meta.relations || {});
|
|
795
|
+
const populateOption = relKeys.length > 0 ? relKeys.reduce((acc, k) => ({ ...acc, [k]: true }), {}) : void 0;
|
|
796
|
+
if (meta.isSingleType) {
|
|
797
|
+
const singleDoc = await strapi.documents(uid).findFirst({
|
|
798
|
+
populate: populateOption
|
|
799
|
+
});
|
|
800
|
+
if (singleDoc) {
|
|
801
|
+
entries = [singleDoc];
|
|
802
|
+
totalCount = 1;
|
|
803
|
+
}
|
|
804
|
+
} else {
|
|
805
|
+
totalCount = await strapi.documents(uid).count();
|
|
806
|
+
entries = await strapi.documents(uid).findMany({
|
|
807
|
+
limit: 10,
|
|
808
|
+
sort: { createdAt: "desc" },
|
|
809
|
+
populate: populateOption
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
} catch (err) {
|
|
813
|
+
strapi.log.error(`[bulk-updater] Failed to fetch entries for ${uid}:`, err);
|
|
814
|
+
entries = [];
|
|
815
|
+
totalCount = 0;
|
|
816
|
+
}
|
|
817
|
+
const formattedEntries = (entries || []).map((entry) => ({
|
|
818
|
+
id: entry.id,
|
|
819
|
+
documentId: entry.documentId,
|
|
820
|
+
...entry
|
|
821
|
+
}));
|
|
822
|
+
modelsCache.set(uid, {
|
|
823
|
+
signature,
|
|
824
|
+
totalCount,
|
|
825
|
+
entries: formattedEntries
|
|
826
|
+
});
|
|
827
|
+
collections.push({
|
|
828
|
+
...meta,
|
|
829
|
+
totalEntries: totalCount,
|
|
830
|
+
entries: formattedEntries
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
const licenseService2 = strapi.plugin("bulk-updater").service("license");
|
|
835
|
+
const license = await licenseService2.getLicenseStatus();
|
|
836
|
+
const executionConfig = getExecutionWindowConfig(strapi);
|
|
837
|
+
const windowStatus = checkBusinessHours(executionConfig);
|
|
838
|
+
const pendingCount = Array.from(pendingBatches.values()).filter(
|
|
839
|
+
(b) => b.status === "pending_approval" || b.status === "queued_offpeak"
|
|
840
|
+
).length;
|
|
841
|
+
const etag = `W/"models-${crypto.createHash("md5").update(`${signatures.join("|")}:${license.tier}:${pendingCount}:${windowStatus.isBusinessHours}:${windowStatus.currentServerTime}:${windowStatus.timeZone}`).digest("hex")}"`;
|
|
842
|
+
return {
|
|
843
|
+
success: true,
|
|
844
|
+
attributes: staticSchemaCache.attributes,
|
|
845
|
+
collections,
|
|
846
|
+
relationsMeta: staticSchemaCache.relationsMeta,
|
|
847
|
+
license,
|
|
848
|
+
executionWindow: {
|
|
849
|
+
...executionConfig,
|
|
850
|
+
...windowStatus
|
|
851
|
+
},
|
|
852
|
+
pendingBatchesCount: pendingCount,
|
|
853
|
+
auditEnabled: Boolean(auditConfig?.enabled),
|
|
854
|
+
etag
|
|
855
|
+
};
|
|
856
|
+
},
|
|
857
|
+
/**
|
|
858
|
+
* On-Demand Paginated Collection Entries:
|
|
859
|
+
* Supports server-side pagination (limit / start) and database query pushdown for filters.
|
|
860
|
+
* Cached in-process with cross-pod high-watermark signature validation.
|
|
861
|
+
*/
|
|
862
|
+
async getCollectionEntries(uid, {
|
|
863
|
+
page = 1,
|
|
864
|
+
pageSize = 10,
|
|
865
|
+
attribute,
|
|
866
|
+
filterOperator,
|
|
867
|
+
filterQuery,
|
|
868
|
+
search,
|
|
869
|
+
sortField,
|
|
870
|
+
sortOrder = "desc"
|
|
871
|
+
}) {
|
|
872
|
+
const contentType = strapi.contentTypes[uid];
|
|
873
|
+
if (!contentType) {
|
|
874
|
+
throw new Error(`Collection with UID "${uid}" does not exist.`);
|
|
875
|
+
}
|
|
876
|
+
const limit = Math.min(Math.max(Number(pageSize) || 10, 1), 100);
|
|
877
|
+
const currentPage = Math.max(Number(page) || 1, 1);
|
|
878
|
+
const start = (currentPage - 1) * limit;
|
|
879
|
+
const isSingleType = contentType.kind === "singleType";
|
|
880
|
+
const signature = await getCollectionSignature(strapi, uid);
|
|
881
|
+
const cacheKey = `${uid}:${limit}:${start}:${attribute || ""}:${filterOperator || ""}:${filterQuery || ""}:${search || ""}:${sortField || ""}:${sortOrder}`;
|
|
882
|
+
const cached = queryCache.get(cacheKey);
|
|
883
|
+
const etag = `W/"entry-${crypto.createHash("md5").update(`${cacheKey}:${signature}`).digest("hex")}"`;
|
|
884
|
+
if (cached && cached.signature === signature && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
|
885
|
+
return {
|
|
886
|
+
...cached.data,
|
|
887
|
+
cached: true,
|
|
888
|
+
etag
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
let result;
|
|
892
|
+
if (isSingleType) {
|
|
893
|
+
const singleDoc = await strapi.documents(uid).findFirst();
|
|
894
|
+
result = {
|
|
895
|
+
uid,
|
|
896
|
+
displayName: contentType.info?.displayName || uid,
|
|
897
|
+
isSingleType: true,
|
|
898
|
+
entries: singleDoc ? [singleDoc] : [],
|
|
899
|
+
pagination: {
|
|
900
|
+
page: 1,
|
|
901
|
+
pageSize: 1,
|
|
902
|
+
total: singleDoc ? 1 : 0,
|
|
903
|
+
pageCount: singleDoc ? 1 : 0
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
} else {
|
|
907
|
+
const filters = {};
|
|
908
|
+
if (attribute && filterOperator && filterQuery !== void 0 && filterQuery !== "") {
|
|
909
|
+
switch (filterOperator) {
|
|
910
|
+
case "equals":
|
|
911
|
+
filters[attribute] = { $eq: filterQuery };
|
|
912
|
+
break;
|
|
913
|
+
case "notEquals":
|
|
914
|
+
filters[attribute] = { $ne: filterQuery };
|
|
915
|
+
break;
|
|
916
|
+
case "contains":
|
|
917
|
+
filters[attribute] = { $containsi: filterQuery };
|
|
918
|
+
break;
|
|
919
|
+
case "notContains":
|
|
920
|
+
filters[attribute] = { $notContainsi: filterQuery };
|
|
921
|
+
break;
|
|
922
|
+
case "startsWith":
|
|
923
|
+
filters[attribute] = { $startsWith: filterQuery };
|
|
924
|
+
break;
|
|
925
|
+
case "endsWith":
|
|
926
|
+
filters[attribute] = { $endsWith: filterQuery };
|
|
927
|
+
break;
|
|
928
|
+
case "empty":
|
|
929
|
+
filters[attribute] = { $null: true };
|
|
930
|
+
break;
|
|
931
|
+
case "notEmpty":
|
|
932
|
+
filters[attribute] = { $notNull: true };
|
|
933
|
+
break;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
if (search && search.trim() !== "") {
|
|
937
|
+
const stringAttrs = Object.keys(contentType.attributes || {}).filter(
|
|
938
|
+
(k) => contentType.attributes[k].type === "string" || contentType.attributes[k].type === "text" || contentType.attributes[k].type === "email"
|
|
939
|
+
);
|
|
940
|
+
if (stringAttrs.length > 0) {
|
|
941
|
+
filters.$or = stringAttrs.slice(0, 5).map((attr) => ({
|
|
942
|
+
[attr]: { $containsi: search.trim() }
|
|
943
|
+
}));
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
const sortClause = {};
|
|
947
|
+
if (sortField && contentType.attributes?.[sortField]) {
|
|
948
|
+
sortClause[sortField] = sortOrder;
|
|
949
|
+
} else {
|
|
950
|
+
sortClause["createdAt"] = "desc";
|
|
951
|
+
}
|
|
952
|
+
const relationAttrs = Object.keys(contentType.attributes || {}).filter(
|
|
953
|
+
(k) => contentType.attributes[k].type === "relation"
|
|
954
|
+
);
|
|
955
|
+
const populateOption = relationAttrs.length > 0 ? relationAttrs.reduce((acc, k) => ({ ...acc, [k]: true }), {}) : void 0;
|
|
956
|
+
const [entries, totalEntries] = await Promise.all([
|
|
957
|
+
strapi.documents(uid).findMany({
|
|
958
|
+
start,
|
|
959
|
+
limit,
|
|
960
|
+
filters: Object.keys(filters).length > 0 ? filters : void 0,
|
|
961
|
+
sort: sortClause,
|
|
962
|
+
populate: populateOption
|
|
963
|
+
}),
|
|
964
|
+
strapi.documents(uid).count({
|
|
965
|
+
filters: Object.keys(filters).length > 0 ? filters : void 0
|
|
966
|
+
})
|
|
967
|
+
]);
|
|
968
|
+
const totalPages = Math.ceil(totalEntries / limit);
|
|
969
|
+
result = {
|
|
970
|
+
uid,
|
|
971
|
+
displayName: contentType.info?.displayName || uid,
|
|
972
|
+
isSingleType: false,
|
|
973
|
+
entries: (entries || []).map((entry) => ({
|
|
974
|
+
id: entry.id,
|
|
975
|
+
documentId: entry.documentId,
|
|
976
|
+
...entry
|
|
977
|
+
})),
|
|
978
|
+
pagination: {
|
|
979
|
+
page: currentPage,
|
|
980
|
+
pageSize: limit,
|
|
981
|
+
total: totalEntries,
|
|
982
|
+
pageCount: totalPages
|
|
983
|
+
}
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
if (queryCache.size >= MAX_QUERY_CACHE_SIZE) {
|
|
987
|
+
const toDelete = Array.from(queryCache.keys()).slice(0, 100);
|
|
988
|
+
toDelete.forEach((k) => queryCache.delete(k));
|
|
989
|
+
}
|
|
990
|
+
queryCache.set(cacheKey, { signature, data: result, timestamp: Date.now() });
|
|
991
|
+
return {
|
|
992
|
+
...result,
|
|
993
|
+
cached: false,
|
|
994
|
+
etag
|
|
995
|
+
};
|
|
996
|
+
},
|
|
997
|
+
/**
|
|
998
|
+
* Get available target entities for a relation field:
|
|
999
|
+
* Cached in-process with cross-pod signature validation.
|
|
1000
|
+
*/
|
|
1001
|
+
async getRelationOptions(targetUid) {
|
|
1002
|
+
if (!targetUid || !strapi.contentTypes[targetUid]) {
|
|
1003
|
+
throw new Error(`Target content type "${targetUid}" does not exist.`);
|
|
1004
|
+
}
|
|
1005
|
+
const cacheKey = `rel_options:${targetUid}`;
|
|
1006
|
+
const signature = await getCollectionSignature(strapi, targetUid);
|
|
1007
|
+
const cached = queryCache.get(cacheKey);
|
|
1008
|
+
if (cached && cached.signature === signature) {
|
|
1009
|
+
return cached.data;
|
|
1010
|
+
}
|
|
1011
|
+
const docs = await strapi.documents(targetUid).findMany({
|
|
1012
|
+
limit: 100,
|
|
1013
|
+
sort: { createdAt: "desc" }
|
|
1014
|
+
});
|
|
1015
|
+
const options = (docs || []).map((doc) => {
|
|
1016
|
+
const label = doc.name || doc.title || doc.email || doc.username || `Entry #${doc.id || doc.documentId}`;
|
|
1017
|
+
return {
|
|
1018
|
+
documentId: doc.documentId,
|
|
1019
|
+
label
|
|
1020
|
+
};
|
|
1021
|
+
});
|
|
1022
|
+
const result = { targetUid, options };
|
|
1023
|
+
queryCache.set(cacheKey, { signature, data: result, timestamp: Date.now() });
|
|
1024
|
+
return result;
|
|
1025
|
+
},
|
|
1026
|
+
/**
|
|
1027
|
+
* Batch Update API with Maker-Checker and Off-Peak Scheduling:
|
|
1028
|
+
*/
|
|
1029
|
+
async batchUpdate({
|
|
1030
|
+
attribute,
|
|
1031
|
+
value,
|
|
1032
|
+
selectedEntries = [],
|
|
1033
|
+
selectedCollections = [],
|
|
1034
|
+
isRegex = false,
|
|
1035
|
+
regexPattern = "",
|
|
1036
|
+
executionMode = "immediate",
|
|
1037
|
+
makerNotes = "",
|
|
1038
|
+
forceImmediate = false,
|
|
1039
|
+
publishOnApproval = true
|
|
1040
|
+
}, currentUser) {
|
|
1041
|
+
if (!attribute) {
|
|
1042
|
+
throw new Error("Attribute name is required.");
|
|
1043
|
+
}
|
|
1044
|
+
const licenseService2 = strapi.plugin("bulk-updater").service("license");
|
|
1045
|
+
const license = await licenseService2.getLicenseStatus();
|
|
1046
|
+
license.tier === "enterprise";
|
|
1047
|
+
const executionConfig = getExecutionWindowConfig(strapi);
|
|
1048
|
+
const { isBusinessHours, policyMessage } = checkBusinessHours(executionConfig);
|
|
1049
|
+
if (executionMode === "maker_checker") {
|
|
1050
|
+
const summary = await computeTargetsSummary(strapi, { selectedCollections, selectedEntries });
|
|
1051
|
+
if (summary.totalTargets === 0) {
|
|
1052
|
+
throw new Error("No collections or entries were selected for update.");
|
|
1053
|
+
}
|
|
1054
|
+
const batchId = `batch_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
|
|
1055
|
+
const batch = {
|
|
1056
|
+
id: batchId,
|
|
1057
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1058
|
+
createdBy: currentUser ? {
|
|
1059
|
+
id: currentUser.id,
|
|
1060
|
+
email: currentUser.email,
|
|
1061
|
+
firstname: currentUser.firstname,
|
|
1062
|
+
lastname: currentUser.lastname,
|
|
1063
|
+
username: currentUser.username
|
|
1064
|
+
} : { email: "admin@system.local" },
|
|
1065
|
+
payload: { attribute, value, selectedEntries, selectedCollections, isRegex, regexPattern, publishOnApproval },
|
|
1066
|
+
summary: {
|
|
1067
|
+
attribute,
|
|
1068
|
+
value,
|
|
1069
|
+
totalTargets: summary.totalTargets,
|
|
1070
|
+
collectionBreakdown: summary.collectionBreakdown,
|
|
1071
|
+
publishOnApproval
|
|
1072
|
+
},
|
|
1073
|
+
status: "pending_approval",
|
|
1074
|
+
executionMode: "maker_checker",
|
|
1075
|
+
makerNotes
|
|
1076
|
+
};
|
|
1077
|
+
pendingBatches.set(batchId, batch);
|
|
1078
|
+
return {
|
|
1079
|
+
success: true,
|
|
1080
|
+
pending: true,
|
|
1081
|
+
batchId,
|
|
1082
|
+
status: "pending_approval",
|
|
1083
|
+
message: "Batch submitted for Checker authorization (4-Eyes Maker-Checker Workflow).",
|
|
1084
|
+
batch
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
if (executionMode === "queue_offpeak") {
|
|
1088
|
+
const summary = await computeTargetsSummary(strapi, { selectedCollections, selectedEntries });
|
|
1089
|
+
if (summary.totalTargets === 0) {
|
|
1090
|
+
throw new Error("No collections or entries were selected for update.");
|
|
1091
|
+
}
|
|
1092
|
+
const batchId = `batch_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
|
|
1093
|
+
const batch = {
|
|
1094
|
+
id: batchId,
|
|
1095
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1096
|
+
createdBy: currentUser ? {
|
|
1097
|
+
id: currentUser.id,
|
|
1098
|
+
email: currentUser.email,
|
|
1099
|
+
firstname: currentUser.firstname,
|
|
1100
|
+
lastname: currentUser.lastname,
|
|
1101
|
+
username: currentUser.username
|
|
1102
|
+
} : { email: "admin@system.local" },
|
|
1103
|
+
payload: { attribute, value, selectedEntries, selectedCollections, isRegex, regexPattern, publishOnApproval },
|
|
1104
|
+
summary: {
|
|
1105
|
+
attribute,
|
|
1106
|
+
value,
|
|
1107
|
+
totalTargets: summary.totalTargets,
|
|
1108
|
+
collectionBreakdown: summary.collectionBreakdown,
|
|
1109
|
+
publishOnApproval
|
|
1110
|
+
},
|
|
1111
|
+
status: "queued_offpeak",
|
|
1112
|
+
executionMode: "queue_offpeak",
|
|
1113
|
+
scheduledFor: executionConfig.offPeakStartTime,
|
|
1114
|
+
makerNotes
|
|
1115
|
+
};
|
|
1116
|
+
pendingBatches.set(batchId, batch);
|
|
1117
|
+
return {
|
|
1118
|
+
success: true,
|
|
1119
|
+
pending: true,
|
|
1120
|
+
batchId,
|
|
1121
|
+
status: "queued_offpeak",
|
|
1122
|
+
scheduledFor: executionConfig.offPeakStartTime,
|
|
1123
|
+
message: `Batch queued for server off-peak maintenance window (${executionConfig.offPeakStartTime}).`,
|
|
1124
|
+
batch
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
if (executionConfig.enabled && executionConfig.autoQueueDuringBusinessHours && isBusinessHours && !forceImmediate) {
|
|
1128
|
+
const summary = await computeTargetsSummary(strapi, { selectedCollections, selectedEntries });
|
|
1129
|
+
if (summary.totalTargets === 0) {
|
|
1130
|
+
throw new Error("No collections or entries were selected for update.");
|
|
1131
|
+
}
|
|
1132
|
+
const batchId = `batch_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
|
|
1133
|
+
const batch = {
|
|
1134
|
+
id: batchId,
|
|
1135
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1136
|
+
createdBy: currentUser ? {
|
|
1137
|
+
id: currentUser.id,
|
|
1138
|
+
email: currentUser.email,
|
|
1139
|
+
firstname: currentUser.firstname,
|
|
1140
|
+
lastname: currentUser.lastname,
|
|
1141
|
+
username: currentUser.username
|
|
1142
|
+
} : { email: "admin@system.local" },
|
|
1143
|
+
payload: { attribute, value, selectedEntries, selectedCollections, isRegex, regexPattern, publishOnApproval },
|
|
1144
|
+
summary: {
|
|
1145
|
+
attribute,
|
|
1146
|
+
value,
|
|
1147
|
+
totalTargets: summary.totalTargets,
|
|
1148
|
+
collectionBreakdown: summary.collectionBreakdown,
|
|
1149
|
+
publishOnApproval
|
|
1150
|
+
},
|
|
1151
|
+
status: "queued_offpeak",
|
|
1152
|
+
executionMode: "queue_offpeak",
|
|
1153
|
+
scheduledFor: executionConfig.offPeakStartTime,
|
|
1154
|
+
makerNotes: "Automatically queued due to peak business hours restriction."
|
|
1155
|
+
};
|
|
1156
|
+
pendingBatches.set(batchId, batch);
|
|
1157
|
+
return {
|
|
1158
|
+
success: true,
|
|
1159
|
+
pending: true,
|
|
1160
|
+
batchId,
|
|
1161
|
+
status: "queued_offpeak",
|
|
1162
|
+
scheduledFor: executionConfig.offPeakStartTime,
|
|
1163
|
+
message: policyMessage,
|
|
1164
|
+
batch
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
return await executeBatchInternal(
|
|
1168
|
+
strapi,
|
|
1169
|
+
{ attribute, value, selectedEntries, selectedCollections, isRegex, regexPattern, publishOnApproval },
|
|
1170
|
+
currentUser
|
|
1171
|
+
);
|
|
1172
|
+
},
|
|
1173
|
+
/**
|
|
1174
|
+
* Get Pending & Queued Batches
|
|
1175
|
+
*/
|
|
1176
|
+
getPendingBatches() {
|
|
1177
|
+
const executionConfig = getExecutionWindowConfig(strapi);
|
|
1178
|
+
const windowStatus = checkBusinessHours(executionConfig);
|
|
1179
|
+
const batches = Array.from(pendingBatches.values()).sort(
|
|
1180
|
+
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
|
1181
|
+
);
|
|
1182
|
+
return {
|
|
1183
|
+
success: true,
|
|
1184
|
+
batches,
|
|
1185
|
+
executionWindow: {
|
|
1186
|
+
...executionConfig,
|
|
1187
|
+
...windowStatus
|
|
1188
|
+
}
|
|
1189
|
+
};
|
|
1190
|
+
},
|
|
1191
|
+
/**
|
|
1192
|
+
* Checker Approves Batch
|
|
1193
|
+
*/
|
|
1194
|
+
async approveBatch(batchId, checkerUser, options = { executeImmediately: true }) {
|
|
1195
|
+
const batch = pendingBatches.get(batchId);
|
|
1196
|
+
if (!batch) {
|
|
1197
|
+
throw new Error(`Pending batch ${batchId} not found.`);
|
|
1198
|
+
}
|
|
1199
|
+
if (batch.status !== "pending_approval") {
|
|
1200
|
+
throw new Error(`Batch ${batchId} is not pending approval (current status: ${batch.status}).`);
|
|
1201
|
+
}
|
|
1202
|
+
batch.approval = {
|
|
1203
|
+
checkerUser: checkerUser ? {
|
|
1204
|
+
id: checkerUser.id,
|
|
1205
|
+
email: checkerUser.email,
|
|
1206
|
+
firstname: checkerUser.firstname,
|
|
1207
|
+
lastname: checkerUser.lastname,
|
|
1208
|
+
username: checkerUser.username
|
|
1209
|
+
} : { email: "checker@system.local" },
|
|
1210
|
+
decisionDate: (/* @__PURE__ */ new Date()).toISOString()
|
|
1211
|
+
};
|
|
1212
|
+
const config = getExecutionWindowConfig(strapi);
|
|
1213
|
+
const { isBusinessHours } = checkBusinessHours(config);
|
|
1214
|
+
if (options.executeImmediately || !isBusinessHours) {
|
|
1215
|
+
batch.status = "approved";
|
|
1216
|
+
const result = await executeBatchInternal(strapi, batch.payload, checkerUser, batch.id);
|
|
1217
|
+
batch.status = "executed";
|
|
1218
|
+
batch.executionResult = result;
|
|
1219
|
+
return {
|
|
1220
|
+
success: true,
|
|
1221
|
+
batchId,
|
|
1222
|
+
status: "executed",
|
|
1223
|
+
message: "Batch approved and executed successfully by Checker.",
|
|
1224
|
+
batch,
|
|
1225
|
+
result
|
|
1226
|
+
};
|
|
1227
|
+
} else {
|
|
1228
|
+
batch.status = "approved";
|
|
1229
|
+
return {
|
|
1230
|
+
success: true,
|
|
1231
|
+
batchId,
|
|
1232
|
+
status: "approved",
|
|
1233
|
+
message: `Batch approved by Checker. Queued for execution during off-peak window (${config.offPeakStartTime}).`,
|
|
1234
|
+
batch
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
},
|
|
1238
|
+
/**
|
|
1239
|
+
* Checker Rejects Batch
|
|
1240
|
+
*/
|
|
1241
|
+
rejectBatch(batchId, checkerUser, reason = "Rejected by Checker") {
|
|
1242
|
+
const batch = pendingBatches.get(batchId);
|
|
1243
|
+
if (!batch) {
|
|
1244
|
+
throw new Error(`Pending batch ${batchId} not found.`);
|
|
1245
|
+
}
|
|
1246
|
+
batch.status = "rejected";
|
|
1247
|
+
batch.approval = {
|
|
1248
|
+
checkerUser: checkerUser ? {
|
|
1249
|
+
id: checkerUser.id,
|
|
1250
|
+
email: checkerUser.email,
|
|
1251
|
+
firstname: checkerUser.firstname,
|
|
1252
|
+
lastname: checkerUser.lastname,
|
|
1253
|
+
username: checkerUser.username
|
|
1254
|
+
} : { email: "checker@system.local" },
|
|
1255
|
+
decisionDate: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1256
|
+
rejectionReason: reason
|
|
1257
|
+
};
|
|
1258
|
+
return {
|
|
1259
|
+
success: true,
|
|
1260
|
+
batchId,
|
|
1261
|
+
status: "rejected",
|
|
1262
|
+
message: `Batch rejected by Checker: ${reason}`,
|
|
1263
|
+
batch
|
|
1264
|
+
};
|
|
1265
|
+
},
|
|
1266
|
+
/**
|
|
1267
|
+
* Run Batch Immediately
|
|
1268
|
+
*/
|
|
1269
|
+
async executeNow(batchId, currentUser) {
|
|
1270
|
+
const batch = pendingBatches.get(batchId);
|
|
1271
|
+
if (!batch) {
|
|
1272
|
+
throw new Error(`Pending batch ${batchId} not found.`);
|
|
1273
|
+
}
|
|
1274
|
+
if (batch.status === "executed") {
|
|
1275
|
+
throw new Error(`Batch ${batchId} has already been executed.`);
|
|
1276
|
+
}
|
|
1277
|
+
const result = await executeBatchInternal(strapi, batch.payload, currentUser, batch.id);
|
|
1278
|
+
batch.status = "executed";
|
|
1279
|
+
batch.executionResult = result;
|
|
1280
|
+
return {
|
|
1281
|
+
success: true,
|
|
1282
|
+
batchId,
|
|
1283
|
+
status: "executed",
|
|
1284
|
+
message: "Batch executed immediately.",
|
|
1285
|
+
batch,
|
|
1286
|
+
result
|
|
1287
|
+
};
|
|
1288
|
+
},
|
|
1289
|
+
invalidateCache(uid) {
|
|
1290
|
+
if (uid) {
|
|
1291
|
+
invalidateCollectionCache(uid);
|
|
1292
|
+
} else {
|
|
1293
|
+
queryCache.clear();
|
|
1294
|
+
modelsCache.clear();
|
|
1295
|
+
staticSchemaCache = null;
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
};
|
|
1299
|
+
};
|
|
1300
|
+
const DEFAULT_FREE_STATUS = {
|
|
1301
|
+
isPro: false,
|
|
1302
|
+
tier: "free",
|
|
1303
|
+
licenseKey: null,
|
|
1304
|
+
licensedTo: null,
|
|
1305
|
+
activatedAt: null,
|
|
1306
|
+
features: [
|
|
1307
|
+
"Basic attribute batch updates",
|
|
1308
|
+
"Schema compatibility checks",
|
|
1309
|
+
"Interactive target selection"
|
|
1310
|
+
]
|
|
1311
|
+
};
|
|
1312
|
+
const PRO_FEATURES = [
|
|
1313
|
+
"Basic attribute batch updates",
|
|
1314
|
+
"Schema compatibility checks",
|
|
1315
|
+
"Interactive target selection",
|
|
1316
|
+
"Regex find & replace within attributes",
|
|
1317
|
+
"Cross-relation and dynamic zone updates",
|
|
1318
|
+
"Audit log export (CSV / JSON)",
|
|
1319
|
+
"Unlimited collection batch operations",
|
|
1320
|
+
"Priority enterprise support"
|
|
1321
|
+
];
|
|
1322
|
+
const ENTERPRISE_FEATURES = [
|
|
1323
|
+
...PRO_FEATURES,
|
|
1324
|
+
"Maker-Checker Approval Workflow (4-Eyes Principle)",
|
|
1325
|
+
"Server-Enforced Off-Peak Maintenance Window",
|
|
1326
|
+
"Scheduled batch queuing during business hours"
|
|
1327
|
+
];
|
|
1328
|
+
const licenseService = ({ strapi }) => ({
|
|
1329
|
+
async getStore() {
|
|
1330
|
+
return strapi.store({
|
|
1331
|
+
type: "plugin",
|
|
1332
|
+
name: "bulk-updater",
|
|
1333
|
+
key: "license"
|
|
1334
|
+
});
|
|
1335
|
+
},
|
|
1336
|
+
async getLicenseStatus() {
|
|
1337
|
+
const isConfigEnterprise = strapi.config.get("plugin::bulk-updater.makerChecker.enabled");
|
|
1338
|
+
const store = await this.getStore();
|
|
1339
|
+
const stored = await store.get({});
|
|
1340
|
+
if (!stored || !stored.licenseKey) {
|
|
1341
|
+
if (isConfigEnterprise) {
|
|
1342
|
+
return {
|
|
1343
|
+
isPro: true,
|
|
1344
|
+
tier: "enterprise",
|
|
1345
|
+
licenseKey: "CONFIG-OVERRIDE",
|
|
1346
|
+
licensedTo: "Enterprise Developer Config",
|
|
1347
|
+
activatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1348
|
+
features: ENTERPRISE_FEATURES
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
return DEFAULT_FREE_STATUS;
|
|
1352
|
+
}
|
|
1353
|
+
if (isConfigEnterprise) {
|
|
1354
|
+
return {
|
|
1355
|
+
...stored,
|
|
1356
|
+
isPro: true,
|
|
1357
|
+
tier: "enterprise",
|
|
1358
|
+
features: Array.from(/* @__PURE__ */ new Set([...stored.features || [], ...ENTERPRISE_FEATURES]))
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
return stored;
|
|
1362
|
+
},
|
|
1363
|
+
async verifyAndActivateLicense(licenseKey) {
|
|
1364
|
+
if (!licenseKey || typeof licenseKey !== "string") {
|
|
1365
|
+
return { success: false, error: "A valid license key is required." };
|
|
1366
|
+
}
|
|
1367
|
+
const trimmed = licenseKey.trim();
|
|
1368
|
+
const isEnterprise = trimmed.toUpperCase().startsWith("ENT-") || trimmed.toUpperCase().startsWith("ENTERPRISE-");
|
|
1369
|
+
const isPro = trimmed.toUpperCase().startsWith("PRO-") || isEnterprise;
|
|
1370
|
+
if (!isPro) {
|
|
1371
|
+
return {
|
|
1372
|
+
success: false,
|
|
1373
|
+
error: "Invalid license key. License keys must start with PRO- or ENTERPRISE-."
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
const tier = isEnterprise ? "enterprise" : "pro";
|
|
1377
|
+
const status = {
|
|
1378
|
+
isPro: true,
|
|
1379
|
+
tier,
|
|
1380
|
+
licenseKey: trimmed,
|
|
1381
|
+
licensedTo: `Licensed Customer (${tier.toUpperCase()})`,
|
|
1382
|
+
activatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1383
|
+
features: isEnterprise ? ENTERPRISE_FEATURES : PRO_FEATURES
|
|
1384
|
+
};
|
|
1385
|
+
const store = await this.getStore();
|
|
1386
|
+
await store.set({ value: status });
|
|
1387
|
+
return {
|
|
1388
|
+
success: true,
|
|
1389
|
+
license: status
|
|
1390
|
+
};
|
|
1391
|
+
},
|
|
1392
|
+
async deactivateLicense() {
|
|
1393
|
+
const store = await this.getStore();
|
|
1394
|
+
await store.set({ value: DEFAULT_FREE_STATUS });
|
|
1395
|
+
return DEFAULT_FREE_STATUS;
|
|
1396
|
+
}
|
|
1397
|
+
});
|
|
1398
|
+
const DEFAULT_FIELDS = {
|
|
1399
|
+
batchId: "batchId",
|
|
1400
|
+
attribute: "attribute",
|
|
1401
|
+
value: "value",
|
|
1402
|
+
userEmail: "userEmail",
|
|
1403
|
+
userName: "userName",
|
|
1404
|
+
updatedCount: "updatedCount",
|
|
1405
|
+
skippedCount: "skippedCount",
|
|
1406
|
+
totalTargets: "totalTargets",
|
|
1407
|
+
diffs: "diffs",
|
|
1408
|
+
isRolledBack: "isRolledBack",
|
|
1409
|
+
rolledBackAt: "rolledBackAt",
|
|
1410
|
+
rolledBackBy: "rolledBackBy",
|
|
1411
|
+
executedAt: "executedAt"
|
|
1412
|
+
};
|
|
1413
|
+
const DEFAULT_CANDIDATE_TYPES = [
|
|
1414
|
+
"api::audit-log.audit-log",
|
|
1415
|
+
"api::audit.audit",
|
|
1416
|
+
"api::bulk-audit-log.bulk-audit-log"
|
|
1417
|
+
];
|
|
1418
|
+
const auditService = ({ strapi }) => ({
|
|
1419
|
+
/**
|
|
1420
|
+
* Detects whether Strapi Enterprise Native Audit Log service is available.
|
|
1421
|
+
*/
|
|
1422
|
+
getEnterpriseAuditService() {
|
|
1423
|
+
try {
|
|
1424
|
+
const svc = strapi.service?.("admin::audit-logs") || strapi.admin?.services?.["audit-logs"];
|
|
1425
|
+
return svc || null;
|
|
1426
|
+
} catch {
|
|
1427
|
+
return null;
|
|
1428
|
+
}
|
|
1429
|
+
},
|
|
1430
|
+
/**
|
|
1431
|
+
* Resolves the audit configuration:
|
|
1432
|
+
* 1. Explicit consumer custom schema (full declarative control)
|
|
1433
|
+
* 2. Zero-config convention auto-detection (api::audit-log.audit-log)
|
|
1434
|
+
* 3. Strapi Enterprise native audit log bridge
|
|
1435
|
+
*/
|
|
1436
|
+
getAuditConfig() {
|
|
1437
|
+
const rawConfig = strapi.config.get("plugin::bulk-updater.audit");
|
|
1438
|
+
const eeService = this.getEnterpriseAuditService();
|
|
1439
|
+
const isEnterpriseNativeAvailable = Boolean(
|
|
1440
|
+
eeService && typeof eeService.save === "function"
|
|
1441
|
+
);
|
|
1442
|
+
if (rawConfig && rawConfig.enabled === false) {
|
|
1443
|
+
return {
|
|
1444
|
+
enabled: false,
|
|
1445
|
+
mode: "disabled",
|
|
1446
|
+
isEnterpriseNativeAvailable
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
if (rawConfig?.contentType) {
|
|
1450
|
+
const { contentType, fields } = rawConfig;
|
|
1451
|
+
const schema = strapi.contentTypes[contentType];
|
|
1452
|
+
if (!schema) {
|
|
1453
|
+
return {
|
|
1454
|
+
enabled: false,
|
|
1455
|
+
mode: "disabled",
|
|
1456
|
+
isEnterpriseNativeAvailable,
|
|
1457
|
+
error: `Declared audit contentType "${contentType}" does not exist in Strapi schema registry.`
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
const resolvedFields = { ...DEFAULT_FIELDS, ...fields || {} };
|
|
1461
|
+
return {
|
|
1462
|
+
enabled: true,
|
|
1463
|
+
mode: "custom",
|
|
1464
|
+
contentType,
|
|
1465
|
+
fields: resolvedFields,
|
|
1466
|
+
isEnterpriseNativeAvailable
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
for (const candidate of DEFAULT_CANDIDATE_TYPES) {
|
|
1470
|
+
if (strapi.contentTypes[candidate]) {
|
|
1471
|
+
return {
|
|
1472
|
+
enabled: true,
|
|
1473
|
+
mode: "default",
|
|
1474
|
+
contentType: candidate,
|
|
1475
|
+
fields: DEFAULT_FIELDS,
|
|
1476
|
+
isEnterpriseNativeAvailable
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
if (isEnterpriseNativeAvailable) {
|
|
1481
|
+
return {
|
|
1482
|
+
enabled: true,
|
|
1483
|
+
mode: "enterprise_native",
|
|
1484
|
+
isEnterpriseNativeAvailable: true
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
return {
|
|
1488
|
+
enabled: false,
|
|
1489
|
+
mode: "disabled",
|
|
1490
|
+
isEnterpriseNativeAvailable: false,
|
|
1491
|
+
error: "No audit collection configured or detected. To enable rollback, provide a collection or use Strapi Enterprise."
|
|
1492
|
+
};
|
|
1493
|
+
},
|
|
1494
|
+
/**
|
|
1495
|
+
* Optionally writes an audit event directly to Strapi Enterprise's native Audit Logs table.
|
|
1496
|
+
*/
|
|
1497
|
+
async recordToEnterpriseNative(action, payload, user) {
|
|
1498
|
+
try {
|
|
1499
|
+
const eeService = this.getEnterpriseAuditService();
|
|
1500
|
+
if (eeService && typeof eeService.save === "function") {
|
|
1501
|
+
await eeService.save({
|
|
1502
|
+
action,
|
|
1503
|
+
date: /* @__PURE__ */ new Date(),
|
|
1504
|
+
userId: user?.id,
|
|
1505
|
+
payload
|
|
1506
|
+
});
|
|
1507
|
+
strapi.log.info(
|
|
1508
|
+
`[bulk-updater] Successfully dispatched event to Strapi Enterprise Audit Log: ${action}`
|
|
1509
|
+
);
|
|
1510
|
+
}
|
|
1511
|
+
} catch (err) {
|
|
1512
|
+
strapi.log.warn(
|
|
1513
|
+
`[bulk-updater] Note: Could not dispatch to Strapi Enterprise native audit log: ${err.message}`
|
|
1514
|
+
);
|
|
1515
|
+
}
|
|
1516
|
+
},
|
|
1517
|
+
/**
|
|
1518
|
+
* Records a batch execution:
|
|
1519
|
+
* - Dispatches to Strapi Enterprise native audit log if available
|
|
1520
|
+
* - Records to consumer or auto-detected collection type for one-click rollback
|
|
1521
|
+
*/
|
|
1522
|
+
async recordBatch(batchSummary, results, user) {
|
|
1523
|
+
const config = this.getAuditConfig();
|
|
1524
|
+
await this.recordToEnterpriseNative(
|
|
1525
|
+
"bulk-updater.batch-update",
|
|
1526
|
+
{
|
|
1527
|
+
batchId: batchSummary.batchId,
|
|
1528
|
+
attribute: batchSummary.attribute,
|
|
1529
|
+
value: batchSummary.value,
|
|
1530
|
+
updatedCount: batchSummary.updatedCount,
|
|
1531
|
+
skippedCount: batchSummary.skippedCount,
|
|
1532
|
+
totalTargets: batchSummary.totalTargets
|
|
1533
|
+
},
|
|
1534
|
+
user
|
|
1535
|
+
);
|
|
1536
|
+
if (!config.enabled || !config.contentType || !config.fields) {
|
|
1537
|
+
return null;
|
|
1538
|
+
}
|
|
1539
|
+
const f = config.fields;
|
|
1540
|
+
const mappedData = {};
|
|
1541
|
+
if (f.batchId) mappedData[f.batchId] = batchSummary.batchId;
|
|
1542
|
+
if (f.attribute) mappedData[f.attribute] = batchSummary.attribute;
|
|
1543
|
+
if (f.value) mappedData[f.value] = batchSummary.value;
|
|
1544
|
+
if (f.userEmail) mappedData[f.userEmail] = user?.email || "admin@strapi.io";
|
|
1545
|
+
if (f.userName) {
|
|
1546
|
+
mappedData[f.userName] = user?.firstname ? `${user.firstname} ${user.lastname || ""}`.trim() : user?.email || "Admin";
|
|
1547
|
+
}
|
|
1548
|
+
if (f.updatedCount) mappedData[f.updatedCount] = batchSummary.updatedCount;
|
|
1549
|
+
if (f.skippedCount) mappedData[f.skippedCount] = batchSummary.skippedCount;
|
|
1550
|
+
if (f.totalTargets) mappedData[f.totalTargets] = batchSummary.totalTargets;
|
|
1551
|
+
if (f.diffs) mappedData[f.diffs] = results;
|
|
1552
|
+
if (f.isRolledBack) mappedData[f.isRolledBack] = false;
|
|
1553
|
+
if (f.executedAt) mappedData[f.executedAt] = (/* @__PURE__ */ new Date()).toISOString();
|
|
1554
|
+
try {
|
|
1555
|
+
const created = await strapi.documents(config.contentType).create({
|
|
1556
|
+
data: mappedData
|
|
1557
|
+
});
|
|
1558
|
+
return created;
|
|
1559
|
+
} catch (err) {
|
|
1560
|
+
strapi.log.error(`[bulk-updater] Failed to record audit log: ${err.message}`);
|
|
1561
|
+
return null;
|
|
1562
|
+
}
|
|
1563
|
+
},
|
|
1564
|
+
/**
|
|
1565
|
+
* Fetches audit history for the UI
|
|
1566
|
+
*/
|
|
1567
|
+
async getHistory() {
|
|
1568
|
+
const config = this.getAuditConfig();
|
|
1569
|
+
if (config.mode === "enterprise_native" && !config.contentType) {
|
|
1570
|
+
return {
|
|
1571
|
+
enabled: true,
|
|
1572
|
+
mode: "enterprise_native",
|
|
1573
|
+
message: "Strapi Enterprise Native Audit Logs active. Batch events are recorded directly to Strapi Settings > Audit Logs. (To enable one-click document rollback, declare or add an audit-log collection).",
|
|
1574
|
+
history: []
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
if (!config.enabled || !config.contentType || !config.fields) {
|
|
1578
|
+
return {
|
|
1579
|
+
enabled: false,
|
|
1580
|
+
message: config.error || "Audit trail is disabled. To enable audit logs & rollback, declare an audit schema in config/plugins.ts or create api::audit-log.audit-log.",
|
|
1581
|
+
history: []
|
|
1582
|
+
};
|
|
1583
|
+
}
|
|
1584
|
+
try {
|
|
1585
|
+
const records = await strapi.documents(config.contentType).findMany({
|
|
1586
|
+
sort: { createdAt: "desc" }
|
|
1587
|
+
});
|
|
1588
|
+
const f = config.fields;
|
|
1589
|
+
const normalized = (records || []).map((r) => ({
|
|
1590
|
+
id: r.id,
|
|
1591
|
+
documentId: r.documentId,
|
|
1592
|
+
batchId: r[f.batchId] || r.documentId,
|
|
1593
|
+
attribute: r[f.attribute],
|
|
1594
|
+
value: r[f.value],
|
|
1595
|
+
userEmail: r[f.userEmail] || "admin@strapi.io",
|
|
1596
|
+
userName: r[f.userName] || "Admin",
|
|
1597
|
+
updatedCount: r[f.updatedCount] || 0,
|
|
1598
|
+
skippedCount: r[f.skippedCount] || 0,
|
|
1599
|
+
totalTargets: r[f.totalTargets] || 0,
|
|
1600
|
+
diffs: typeof r[f.diffs] === "string" ? JSON.parse(r[f.diffs]) : r[f.diffs] || [],
|
|
1601
|
+
isRolledBack: Boolean(r[f.isRolledBack]),
|
|
1602
|
+
rolledBackAt: r[f.rolledBackAt] || null,
|
|
1603
|
+
rolledBackBy: r[f.rolledBackBy] || null,
|
|
1604
|
+
executedAt: r[f.executedAt] || r.createdAt
|
|
1605
|
+
}));
|
|
1606
|
+
return {
|
|
1607
|
+
enabled: true,
|
|
1608
|
+
mode: config.mode,
|
|
1609
|
+
contentType: config.contentType,
|
|
1610
|
+
isEnterpriseNativeAvailable: config.isEnterpriseNativeAvailable,
|
|
1611
|
+
history: normalized
|
|
1612
|
+
};
|
|
1613
|
+
} catch (err) {
|
|
1614
|
+
return {
|
|
1615
|
+
enabled: false,
|
|
1616
|
+
message: `Failed to read audit history: ${err.message}`,
|
|
1617
|
+
history: []
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
},
|
|
1621
|
+
/**
|
|
1622
|
+
* Performs deterministic rollback of a batch
|
|
1623
|
+
*/
|
|
1624
|
+
async rollbackBatch(batchId, currentUser) {
|
|
1625
|
+
const config = this.getAuditConfig();
|
|
1626
|
+
if (!config.enabled || !config.contentType || !config.fields) {
|
|
1627
|
+
throw new Error("Audit trail is not configured or disabled.");
|
|
1628
|
+
}
|
|
1629
|
+
const f = config.fields;
|
|
1630
|
+
const records = await strapi.documents(config.contentType).findMany({
|
|
1631
|
+
filters: {
|
|
1632
|
+
[f.batchId]: { $eq: batchId }
|
|
1633
|
+
}
|
|
1634
|
+
});
|
|
1635
|
+
const record = records?.[0];
|
|
1636
|
+
if (!record) {
|
|
1637
|
+
throw new Error(`Batch record with ID "${batchId}" was not found in audit trail.`);
|
|
1638
|
+
}
|
|
1639
|
+
if (record[f.isRolledBack]) {
|
|
1640
|
+
throw new Error(`Batch "${batchId}" has already been rolled back.`);
|
|
1641
|
+
}
|
|
1642
|
+
const diffs = typeof record[f.diffs] === "string" ? JSON.parse(record[f.diffs]) : record[f.diffs] || [];
|
|
1643
|
+
let revertedCount = 0;
|
|
1644
|
+
const rollbackDetails = [];
|
|
1645
|
+
for (const item of diffs) {
|
|
1646
|
+
if (item.status === "updated" && item.uid && item.documentId && item.attribute) {
|
|
1647
|
+
try {
|
|
1648
|
+
const isRel = item.oldValue && typeof item.oldValue === "object" && item.oldValue.isRelation;
|
|
1649
|
+
let rollbackPayload;
|
|
1650
|
+
if (isRel) {
|
|
1651
|
+
const oldDocIds = Array.isArray(item.oldValue.documentIds) ? item.oldValue.documentIds : [];
|
|
1652
|
+
rollbackPayload = { set: oldDocIds };
|
|
1653
|
+
} else {
|
|
1654
|
+
rollbackPayload = item.oldValue;
|
|
1655
|
+
}
|
|
1656
|
+
await strapi.documents(item.uid).update({
|
|
1657
|
+
documentId: item.documentId,
|
|
1658
|
+
data: {
|
|
1659
|
+
[item.attribute]: rollbackPayload
|
|
1660
|
+
}
|
|
1661
|
+
});
|
|
1662
|
+
const ct = strapi.contentTypes[item.uid];
|
|
1663
|
+
if (ct?.options?.draftAndPublish) {
|
|
1664
|
+
try {
|
|
1665
|
+
await strapi.documents(item.uid).publish({
|
|
1666
|
+
documentId: item.documentId
|
|
1667
|
+
});
|
|
1668
|
+
} catch {
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
try {
|
|
1672
|
+
strapi.plugin("bulk-updater").service("bulkUpdater").invalidateCollectionCache(item.uid);
|
|
1673
|
+
} catch {
|
|
1674
|
+
}
|
|
1675
|
+
revertedCount++;
|
|
1676
|
+
rollbackDetails.push({
|
|
1677
|
+
uid: item.uid,
|
|
1678
|
+
documentId: item.documentId,
|
|
1679
|
+
label: item.label,
|
|
1680
|
+
attribute: item.attribute,
|
|
1681
|
+
revertedTo: item.oldValue,
|
|
1682
|
+
status: "reverted"
|
|
1683
|
+
});
|
|
1684
|
+
} catch (err) {
|
|
1685
|
+
rollbackDetails.push({
|
|
1686
|
+
uid: item.uid,
|
|
1687
|
+
documentId: item.documentId,
|
|
1688
|
+
label: item.label,
|
|
1689
|
+
status: "error",
|
|
1690
|
+
message: err.message
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
const updateData = {};
|
|
1696
|
+
if (f.isRolledBack) updateData[f.isRolledBack] = true;
|
|
1697
|
+
if (f.rolledBackAt) updateData[f.rolledBackAt] = (/* @__PURE__ */ new Date()).toISOString();
|
|
1698
|
+
if (f.rolledBackBy) {
|
|
1699
|
+
updateData[f.rolledBackBy] = currentUser?.email || "Admin";
|
|
1700
|
+
}
|
|
1701
|
+
await strapi.documents(config.contentType).update({
|
|
1702
|
+
documentId: record.documentId,
|
|
1703
|
+
data: updateData
|
|
1704
|
+
});
|
|
1705
|
+
try {
|
|
1706
|
+
const affectedUids = new Set(rollbackDetails.map((d) => d.uid));
|
|
1707
|
+
const bulkService = strapi.plugin("bulk-updater").service("bulkUpdater");
|
|
1708
|
+
affectedUids.forEach((uid) => {
|
|
1709
|
+
if (uid && bulkService?.invalidateCache) {
|
|
1710
|
+
bulkService.invalidateCache(uid);
|
|
1711
|
+
}
|
|
1712
|
+
});
|
|
1713
|
+
} catch {
|
|
1714
|
+
}
|
|
1715
|
+
await this.recordToEnterpriseNative(
|
|
1716
|
+
"bulk-updater.rollback",
|
|
1717
|
+
{
|
|
1718
|
+
batchId,
|
|
1719
|
+
revertedCount
|
|
1720
|
+
},
|
|
1721
|
+
currentUser
|
|
1722
|
+
);
|
|
1723
|
+
return {
|
|
1724
|
+
success: true,
|
|
1725
|
+
batchId,
|
|
1726
|
+
revertedCount,
|
|
1727
|
+
details: rollbackDetails
|
|
1728
|
+
};
|
|
1729
|
+
}
|
|
1730
|
+
});
|
|
1731
|
+
const index = {
|
|
1732
|
+
register({ strapi }) {
|
|
1733
|
+
},
|
|
1734
|
+
bootstrap({ strapi }) {
|
|
1735
|
+
},
|
|
1736
|
+
routes,
|
|
1737
|
+
controllers: {
|
|
1738
|
+
bulkUpdater: bulkUpdaterController,
|
|
1739
|
+
license: licenseController,
|
|
1740
|
+
audit: auditController
|
|
1741
|
+
},
|
|
1742
|
+
services: {
|
|
1743
|
+
bulkUpdater: bulkUpdaterService,
|
|
1744
|
+
license: licenseService,
|
|
1745
|
+
audit: auditService
|
|
1746
|
+
}
|
|
1747
|
+
};
|
|
1748
|
+
export {
|
|
1749
|
+
index as default
|
|
1750
|
+
};
|
|
1751
|
+
//# sourceMappingURL=index.mjs.map
|