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.
@@ -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