strapi-plugin-bulk-updater 1.2.0 → 1.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/dist/_chunks/{BulkUpdater-DCrCPKSe.js → BulkUpdater-2TTEWMXI.js} +1211 -190
- package/dist/_chunks/BulkUpdater-2TTEWMXI.js.map +1 -0
- package/dist/_chunks/{BulkUpdater-BDszahRM.mjs → BulkUpdater-DR8Z9z95.mjs} +1213 -192
- package/dist/_chunks/BulkUpdater-DR8Z9z95.mjs.map +1 -0
- package/dist/admin/index.js +1 -1
- package/dist/admin/index.mjs +1 -1
- package/dist/server/index.js +817 -129
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +817 -129
- package/dist/server/index.mjs.map +1 -1
- package/package.json +1 -1
- package/dist/_chunks/BulkUpdater-BDszahRM.mjs.map +0 -1
- package/dist/_chunks/BulkUpdater-DCrCPKSe.js.map +0 -1
package/dist/server/index.mjs
CHANGED
|
@@ -78,6 +78,30 @@ const routes = {
|
|
|
78
78
|
policies: []
|
|
79
79
|
}
|
|
80
80
|
},
|
|
81
|
+
{
|
|
82
|
+
method: "GET",
|
|
83
|
+
path: "/batches/recent",
|
|
84
|
+
handler: "bulkUpdater.getRecentBatches",
|
|
85
|
+
config: {
|
|
86
|
+
policies: []
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
method: "GET",
|
|
91
|
+
path: "/batches/:batchId/status",
|
|
92
|
+
handler: "bulkUpdater.getBatchStatus",
|
|
93
|
+
config: {
|
|
94
|
+
policies: []
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
method: "GET",
|
|
99
|
+
path: "/batches/:batchId/report",
|
|
100
|
+
handler: "bulkUpdater.getBatchReport",
|
|
101
|
+
config: {
|
|
102
|
+
policies: []
|
|
103
|
+
}
|
|
104
|
+
},
|
|
81
105
|
{
|
|
82
106
|
method: "GET",
|
|
83
107
|
path: "/audit/history",
|
|
@@ -169,6 +193,78 @@ const routes = {
|
|
|
169
193
|
]
|
|
170
194
|
}
|
|
171
195
|
};
|
|
196
|
+
const auditLog = {
|
|
197
|
+
collectionName: "bulk_updater_audit_logs",
|
|
198
|
+
info: {
|
|
199
|
+
singularName: "audit-log",
|
|
200
|
+
pluralName: "audit-logs",
|
|
201
|
+
displayName: "Bulk Updater Audit Log",
|
|
202
|
+
description: "Internal audit trail for bulk attribute and relation updates"
|
|
203
|
+
},
|
|
204
|
+
options: {
|
|
205
|
+
draftAndPublish: false
|
|
206
|
+
},
|
|
207
|
+
pluginOptions: {
|
|
208
|
+
"content-manager": {
|
|
209
|
+
visible: true
|
|
210
|
+
},
|
|
211
|
+
"content-type-builder": {
|
|
212
|
+
visible: false
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
attributes: {
|
|
216
|
+
batchId: {
|
|
217
|
+
type: "string",
|
|
218
|
+
required: true
|
|
219
|
+
},
|
|
220
|
+
attribute: {
|
|
221
|
+
type: "string",
|
|
222
|
+
required: true
|
|
223
|
+
},
|
|
224
|
+
value: {
|
|
225
|
+
type: "text"
|
|
226
|
+
},
|
|
227
|
+
userEmail: {
|
|
228
|
+
type: "string"
|
|
229
|
+
},
|
|
230
|
+
userName: {
|
|
231
|
+
type: "string"
|
|
232
|
+
},
|
|
233
|
+
updatedCount: {
|
|
234
|
+
type: "integer",
|
|
235
|
+
default: 0
|
|
236
|
+
},
|
|
237
|
+
skippedCount: {
|
|
238
|
+
type: "integer",
|
|
239
|
+
default: 0
|
|
240
|
+
},
|
|
241
|
+
totalTargets: {
|
|
242
|
+
type: "integer",
|
|
243
|
+
default: 0
|
|
244
|
+
},
|
|
245
|
+
diffs: {
|
|
246
|
+
type: "json"
|
|
247
|
+
},
|
|
248
|
+
isRolledBack: {
|
|
249
|
+
type: "boolean",
|
|
250
|
+
default: false
|
|
251
|
+
},
|
|
252
|
+
rolledBackAt: {
|
|
253
|
+
type: "datetime"
|
|
254
|
+
},
|
|
255
|
+
rolledBackBy: {
|
|
256
|
+
type: "string"
|
|
257
|
+
},
|
|
258
|
+
executedAt: {
|
|
259
|
+
type: "datetime"
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
const contentTypes = {
|
|
264
|
+
"audit-log": {
|
|
265
|
+
schema: auditLog
|
|
266
|
+
}
|
|
267
|
+
};
|
|
172
268
|
const bulkUpdaterController = ({ strapi: strapi2 }) => ({
|
|
173
269
|
async getModels(ctx) {
|
|
174
270
|
try {
|
|
@@ -291,6 +387,39 @@ const bulkUpdaterController = ({ strapi: strapi2 }) => ({
|
|
|
291
387
|
ctx.status = 400;
|
|
292
388
|
ctx.body = { success: false, error: err.message };
|
|
293
389
|
}
|
|
390
|
+
},
|
|
391
|
+
async getBatchStatus(ctx) {
|
|
392
|
+
try {
|
|
393
|
+
const { batchId } = ctx.params;
|
|
394
|
+
const service = strapi2.plugin("bulk-updater").service("bulkUpdater");
|
|
395
|
+
const result = await service.getBatchStatus(batchId);
|
|
396
|
+
ctx.body = result;
|
|
397
|
+
} catch (err) {
|
|
398
|
+
ctx.status = 404;
|
|
399
|
+
ctx.body = { success: false, error: err.message };
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
async getBatchReport(ctx) {
|
|
403
|
+
try {
|
|
404
|
+
const { batchId } = ctx.params;
|
|
405
|
+
const { format = "csv" } = ctx.query;
|
|
406
|
+
const service = strapi2.plugin("bulk-updater").service("bulkUpdater");
|
|
407
|
+
const result = await service.getBatchReport(batchId, format);
|
|
408
|
+
ctx.body = result;
|
|
409
|
+
} catch (err) {
|
|
410
|
+
ctx.status = 500;
|
|
411
|
+
ctx.body = { success: false, error: err.message };
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
async getRecentBatches(ctx) {
|
|
415
|
+
try {
|
|
416
|
+
const service = strapi2.plugin("bulk-updater").service("bulkUpdater");
|
|
417
|
+
const result = await service.getRecentBatches();
|
|
418
|
+
ctx.body = result;
|
|
419
|
+
} catch (err) {
|
|
420
|
+
ctx.status = 500;
|
|
421
|
+
ctx.body = { success: false, error: err.message };
|
|
422
|
+
}
|
|
294
423
|
}
|
|
295
424
|
});
|
|
296
425
|
const auditController = ({ strapi: strapi2 }) => ({
|
|
@@ -463,7 +592,7 @@ const csvController = ({ strapi: strapi2 }) => ({
|
|
|
463
592
|
}
|
|
464
593
|
const csvService2 = strapi2.plugin("bulk-updater").service("csv");
|
|
465
594
|
const result = await csvService2.importCsv(uid, csvText, {
|
|
466
|
-
mode: mode2 === "create_or_override" ? "create_or_override" : "
|
|
595
|
+
mode: mode2 === "update" ? "update" : mode2 === "create_or_override" ? "create_or_override" : "auto",
|
|
467
596
|
publish: publish !== false,
|
|
468
597
|
user: ctx.state.user
|
|
469
598
|
});
|
|
@@ -536,6 +665,7 @@ function checkBusinessHours(config, testDate) {
|
|
|
536
665
|
};
|
|
537
666
|
}
|
|
538
667
|
const pendingBatches = /* @__PURE__ */ new Map();
|
|
668
|
+
const batchJobsMap = /* @__PURE__ */ new Map();
|
|
539
669
|
let isRunnerRunning = false;
|
|
540
670
|
let runnerInterval = null;
|
|
541
671
|
function invalidateCollectionCache(uid) {
|
|
@@ -635,6 +765,31 @@ async function executeBatchInternal(strapi2, {
|
|
|
635
765
|
const results = [];
|
|
636
766
|
let updatedCount = 0;
|
|
637
767
|
let skippedCount = 0;
|
|
768
|
+
const jobState = {
|
|
769
|
+
id: batchId,
|
|
770
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
771
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
772
|
+
createdBy: currentUser ? {
|
|
773
|
+
id: currentUser.id,
|
|
774
|
+
email: currentUser.email,
|
|
775
|
+
firstname: currentUser.firstname,
|
|
776
|
+
lastname: currentUser.lastname,
|
|
777
|
+
username: currentUser.username
|
|
778
|
+
} : { email: "admin@system.local" },
|
|
779
|
+
status: "processing",
|
|
780
|
+
executionMode: "immediate",
|
|
781
|
+
attribute,
|
|
782
|
+
value,
|
|
783
|
+
totalTargets: targetsMap.size,
|
|
784
|
+
processedCount: 0,
|
|
785
|
+
updatedCount: 0,
|
|
786
|
+
skippedCount: 0,
|
|
787
|
+
failedCount: 0,
|
|
788
|
+
isLive: false,
|
|
789
|
+
publishOnApproval: Boolean(publishOnApproval),
|
|
790
|
+
results: []
|
|
791
|
+
};
|
|
792
|
+
batchJobsMap.set(batchId, jobState);
|
|
638
793
|
let regexObj = null;
|
|
639
794
|
if (isRegex && regexPattern) {
|
|
640
795
|
regexObj = new RegExp(regexPattern, "g");
|
|
@@ -869,11 +1024,24 @@ async function executeBatchInternal(strapi2, {
|
|
|
869
1024
|
else if (res.status === "skipped") skippedCount++;
|
|
870
1025
|
results.push(res);
|
|
871
1026
|
}
|
|
1027
|
+
jobState.processedCount = results.length;
|
|
1028
|
+
jobState.updatedCount = updatedCount;
|
|
1029
|
+
jobState.skippedCount = skippedCount;
|
|
1030
|
+
jobState.failedCount = results.filter((r) => r.status === "error").length;
|
|
872
1031
|
}
|
|
873
1032
|
const affectedUids = new Set(targets.map((t) => t.uid));
|
|
874
1033
|
affectedUids.forEach((uid) => {
|
|
875
1034
|
invalidateCollectionCache(uid);
|
|
876
1035
|
});
|
|
1036
|
+
const failedCount = results.filter((r) => r.status === "error").length;
|
|
1037
|
+
jobState.failedCount = failedCount;
|
|
1038
|
+
jobState.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1039
|
+
jobState.isLive = updatedCount > 0;
|
|
1040
|
+
jobState.status = failedCount === 0 && updatedCount > 0 ? "successful" : failedCount > 0 && updatedCount > 0 ? "partial_failure" : failedCount > 0 ? "failed" : "successful";
|
|
1041
|
+
jobState.results = results.map((r) => ({
|
|
1042
|
+
...r,
|
|
1043
|
+
isLive: r.status === "updated"
|
|
1044
|
+
}));
|
|
877
1045
|
const batchSummary = {
|
|
878
1046
|
batchId,
|
|
879
1047
|
attribute,
|
|
@@ -895,7 +1063,10 @@ async function executeBatchInternal(strapi2, {
|
|
|
895
1063
|
value,
|
|
896
1064
|
updatedCount,
|
|
897
1065
|
skippedCount,
|
|
1066
|
+
failedCount,
|
|
898
1067
|
totalTargets: targetsMap.size,
|
|
1068
|
+
isLive: jobState.isLive,
|
|
1069
|
+
status: jobState.status,
|
|
899
1070
|
results
|
|
900
1071
|
};
|
|
901
1072
|
}
|
|
@@ -1023,69 +1194,87 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
|
|
|
1023
1194
|
}
|
|
1024
1195
|
const collections = [];
|
|
1025
1196
|
const signatures = [];
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
const
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
populate: populateOption
|
|
1197
|
+
const BATCH_CONCURRENCY = 10;
|
|
1198
|
+
const collectionResults = [];
|
|
1199
|
+
for (let i = 0; i < staticSchemaCache.targetUids.length; i += BATCH_CONCURRENCY) {
|
|
1200
|
+
const batchUids = staticSchemaCache.targetUids.slice(i, i + BATCH_CONCURRENCY);
|
|
1201
|
+
const batchRes = await Promise.all(
|
|
1202
|
+
batchUids.map(async (uid) => {
|
|
1203
|
+
const meta = staticSchemaCache.metaByUid[uid];
|
|
1204
|
+
if (!meta) return null;
|
|
1205
|
+
const signature = await getCollectionSignature(strapi2, uid);
|
|
1206
|
+
const cached = modelsCache.get(uid);
|
|
1207
|
+
if (cached && cached.signature === signature) {
|
|
1208
|
+
return {
|
|
1209
|
+
collection: {
|
|
1210
|
+
...meta,
|
|
1211
|
+
totalEntries: cached.totalCount,
|
|
1212
|
+
entries: cached.entries
|
|
1213
|
+
},
|
|
1214
|
+
signature: `${uid}=${signature}`
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
let entries = [];
|
|
1218
|
+
let totalCount = 0;
|
|
1219
|
+
try {
|
|
1220
|
+
const ctAttrs = allContentTypes[uid]?.attributes || {};
|
|
1221
|
+
const relKeys = Object.keys(meta.relations || {});
|
|
1222
|
+
const compKeys = Object.keys(ctAttrs).filter((k) => ctAttrs[k].type === "component");
|
|
1223
|
+
const populateOptionObj = {};
|
|
1224
|
+
relKeys.forEach((k) => {
|
|
1225
|
+
populateOptionObj[k] = true;
|
|
1056
1226
|
});
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
totalCount = 1;
|
|
1060
|
-
}
|
|
1061
|
-
} else {
|
|
1062
|
-
totalCount = await strapi2.documents(uid).count();
|
|
1063
|
-
entries = await strapi2.documents(uid).findMany({
|
|
1064
|
-
limit: 10,
|
|
1065
|
-
sort: { createdAt: "desc" },
|
|
1066
|
-
populate: populateOption
|
|
1227
|
+
compKeys.forEach((k) => {
|
|
1228
|
+
populateOptionObj[k] = true;
|
|
1067
1229
|
});
|
|
1230
|
+
const populateOption = Object.keys(populateOptionObj).length > 0 ? populateOptionObj : void 0;
|
|
1231
|
+
if (meta.isSingleType) {
|
|
1232
|
+
const singleDoc = await strapi2.documents(uid).findFirst({
|
|
1233
|
+
populate: populateOption
|
|
1234
|
+
});
|
|
1235
|
+
if (singleDoc) {
|
|
1236
|
+
entries = [singleDoc];
|
|
1237
|
+
totalCount = 1;
|
|
1238
|
+
}
|
|
1239
|
+
} else {
|
|
1240
|
+
totalCount = await strapi2.documents(uid).count();
|
|
1241
|
+
entries = await strapi2.documents(uid).findMany({
|
|
1242
|
+
limit: 10,
|
|
1243
|
+
sort: { createdAt: "desc" },
|
|
1244
|
+
populate: populateOption
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
} catch (err) {
|
|
1248
|
+
strapi2.log.error(`[bulk-updater] Failed to fetch entries for ${uid}:`, err);
|
|
1249
|
+
entries = [];
|
|
1250
|
+
totalCount = 0;
|
|
1068
1251
|
}
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1252
|
+
const formattedEntries = (entries || []).map((entry) => ({
|
|
1253
|
+
id: entry.id,
|
|
1254
|
+
documentId: entry.documentId,
|
|
1255
|
+
...entry
|
|
1256
|
+
}));
|
|
1257
|
+
modelsCache.set(uid, {
|
|
1258
|
+
signature,
|
|
1259
|
+
totalCount,
|
|
1260
|
+
entries: formattedEntries
|
|
1261
|
+
});
|
|
1262
|
+
return {
|
|
1263
|
+
collection: {
|
|
1264
|
+
...meta,
|
|
1265
|
+
totalEntries: totalCount,
|
|
1266
|
+
entries: formattedEntries
|
|
1267
|
+
},
|
|
1268
|
+
signature: `${uid}=${signature}`
|
|
1269
|
+
};
|
|
1270
|
+
})
|
|
1271
|
+
);
|
|
1272
|
+
collectionResults.push(...batchRes);
|
|
1273
|
+
}
|
|
1274
|
+
for (const res of collectionResults) {
|
|
1275
|
+
if (res) {
|
|
1276
|
+
collections.push(res.collection);
|
|
1277
|
+
signatures.push(res.signature);
|
|
1089
1278
|
}
|
|
1090
1279
|
}
|
|
1091
1280
|
const executionConfig = getExecutionWindowConfig(strapi2);
|
|
@@ -1404,6 +1593,24 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
|
|
|
1404
1593
|
makerNotes
|
|
1405
1594
|
};
|
|
1406
1595
|
pendingBatches.set(batchId, batch);
|
|
1596
|
+
batchJobsMap.set(batchId, {
|
|
1597
|
+
id: batchId,
|
|
1598
|
+
createdAt: batch.createdAt,
|
|
1599
|
+
createdBy: batch.createdBy,
|
|
1600
|
+
status: "pending_approval",
|
|
1601
|
+
executionMode: "maker_checker",
|
|
1602
|
+
attribute,
|
|
1603
|
+
value,
|
|
1604
|
+
totalTargets: summary.totalTargets,
|
|
1605
|
+
processedCount: 0,
|
|
1606
|
+
updatedCount: 0,
|
|
1607
|
+
skippedCount: 0,
|
|
1608
|
+
failedCount: 0,
|
|
1609
|
+
isLive: false,
|
|
1610
|
+
publishOnApproval: Boolean(publishOnApproval),
|
|
1611
|
+
makerNotes,
|
|
1612
|
+
results: []
|
|
1613
|
+
});
|
|
1407
1614
|
return {
|
|
1408
1615
|
success: true,
|
|
1409
1616
|
pending: true,
|
|
@@ -1443,6 +1650,25 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
|
|
|
1443
1650
|
makerNotes
|
|
1444
1651
|
};
|
|
1445
1652
|
pendingBatches.set(batchId, batch);
|
|
1653
|
+
batchJobsMap.set(batchId, {
|
|
1654
|
+
id: batchId,
|
|
1655
|
+
createdAt: batch.createdAt,
|
|
1656
|
+
createdBy: batch.createdBy,
|
|
1657
|
+
status: "queued_offpeak",
|
|
1658
|
+
executionMode: "queue_offpeak",
|
|
1659
|
+
scheduledFor: executionConfig.offPeakStartTime,
|
|
1660
|
+
attribute,
|
|
1661
|
+
value,
|
|
1662
|
+
totalTargets: summary.totalTargets,
|
|
1663
|
+
processedCount: 0,
|
|
1664
|
+
updatedCount: 0,
|
|
1665
|
+
skippedCount: 0,
|
|
1666
|
+
failedCount: 0,
|
|
1667
|
+
isLive: false,
|
|
1668
|
+
publishOnApproval: Boolean(publishOnApproval),
|
|
1669
|
+
makerNotes,
|
|
1670
|
+
results: []
|
|
1671
|
+
});
|
|
1446
1672
|
return {
|
|
1447
1673
|
success: true,
|
|
1448
1674
|
pending: true,
|
|
@@ -1483,6 +1709,26 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
|
|
|
1483
1709
|
makerNotes: "Automatically queued due to peak business hours restriction."
|
|
1484
1710
|
};
|
|
1485
1711
|
pendingBatches.set(batchId, batch);
|
|
1712
|
+
batchJobsMap.set(batchId, {
|
|
1713
|
+
id: batchId,
|
|
1714
|
+
createdAt: batch.createdAt,
|
|
1715
|
+
createdBy: batch.createdBy,
|
|
1716
|
+
status: "queued_offpeak",
|
|
1717
|
+
executionMode: "queue_offpeak",
|
|
1718
|
+
scheduledFor: executionConfig.offPeakStartTime,
|
|
1719
|
+
attribute,
|
|
1720
|
+
value,
|
|
1721
|
+
totalTargets: summary.totalTargets,
|
|
1722
|
+
processedCount: 0,
|
|
1723
|
+
updatedCount: 0,
|
|
1724
|
+
skippedCount: 0,
|
|
1725
|
+
failedCount: 0,
|
|
1726
|
+
isLive: false,
|
|
1727
|
+
publishOnApproval: Boolean(publishOnApproval),
|
|
1728
|
+
policyMessage,
|
|
1729
|
+
makerNotes: "Automatically queued due to peak business hours restriction.",
|
|
1730
|
+
results: []
|
|
1731
|
+
});
|
|
1486
1732
|
return {
|
|
1487
1733
|
success: true,
|
|
1488
1734
|
pending: true,
|
|
@@ -1615,6 +1861,226 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
|
|
|
1615
1861
|
result
|
|
1616
1862
|
};
|
|
1617
1863
|
},
|
|
1864
|
+
/**
|
|
1865
|
+
* Get real-time or historical processing status of a batch job
|
|
1866
|
+
*/
|
|
1867
|
+
async getBatchStatus(batchId) {
|
|
1868
|
+
if (!batchId) throw new Error("batchId is required");
|
|
1869
|
+
let job = batchJobsMap.get(batchId);
|
|
1870
|
+
if (job) {
|
|
1871
|
+
return { success: true, batch: job };
|
|
1872
|
+
}
|
|
1873
|
+
const pending = pendingBatches.get(batchId);
|
|
1874
|
+
if (pending) {
|
|
1875
|
+
const pendingJob = {
|
|
1876
|
+
id: pending.id,
|
|
1877
|
+
createdAt: pending.createdAt,
|
|
1878
|
+
createdBy: pending.createdBy,
|
|
1879
|
+
status: pending.status,
|
|
1880
|
+
executionMode: pending.executionMode,
|
|
1881
|
+
scheduledFor: pending.scheduledFor,
|
|
1882
|
+
attribute: pending.payload.attribute,
|
|
1883
|
+
value: pending.payload.value,
|
|
1884
|
+
totalTargets: pending.summary.totalTargets,
|
|
1885
|
+
processedCount: 0,
|
|
1886
|
+
updatedCount: 0,
|
|
1887
|
+
skippedCount: 0,
|
|
1888
|
+
failedCount: 0,
|
|
1889
|
+
isLive: false,
|
|
1890
|
+
publishOnApproval: Boolean(pending.payload.publishOnApproval),
|
|
1891
|
+
policyMessage: pending.makerNotes,
|
|
1892
|
+
results: []
|
|
1893
|
+
};
|
|
1894
|
+
return { success: true, batch: pendingJob };
|
|
1895
|
+
}
|
|
1896
|
+
try {
|
|
1897
|
+
const auditService2 = strapi2.plugin("bulk-updater").service("audit");
|
|
1898
|
+
const historyData = await auditService2.getHistory();
|
|
1899
|
+
const records = Array.isArray(historyData?.history) ? historyData.history : [];
|
|
1900
|
+
const found = records.find((r) => r.batchId === batchId);
|
|
1901
|
+
if (found) {
|
|
1902
|
+
const total = found.totalTargets || found.updatedCount + found.skippedCount || 1;
|
|
1903
|
+
const historicalJob = {
|
|
1904
|
+
id: found.batchId,
|
|
1905
|
+
createdAt: found.executedAt || found.createdAt || (/* @__PURE__ */ new Date()).toISOString(),
|
|
1906
|
+
startedAt: found.executedAt || found.createdAt,
|
|
1907
|
+
completedAt: found.executedAt || found.createdAt,
|
|
1908
|
+
createdBy: {
|
|
1909
|
+
email: found.userEmail || "admin@strapi.io",
|
|
1910
|
+
firstname: found.userName || "Admin"
|
|
1911
|
+
},
|
|
1912
|
+
status: found.isRolledBack ? "failed" : "successful",
|
|
1913
|
+
executionMode: "immediate",
|
|
1914
|
+
attribute: found.attribute,
|
|
1915
|
+
value: found.value,
|
|
1916
|
+
totalTargets: total,
|
|
1917
|
+
processedCount: total,
|
|
1918
|
+
updatedCount: found.updatedCount || 0,
|
|
1919
|
+
skippedCount: found.skippedCount || 0,
|
|
1920
|
+
failedCount: 0,
|
|
1921
|
+
isLive: !found.isRolledBack && found.updatedCount > 0,
|
|
1922
|
+
publishOnApproval: true,
|
|
1923
|
+
results: (found.diffs || []).map((d) => ({
|
|
1924
|
+
uid: d.uid,
|
|
1925
|
+
collectionName: d.collectionName || d.uid,
|
|
1926
|
+
documentId: d.documentId,
|
|
1927
|
+
label: d.label || `Entry #${d.documentId}`,
|
|
1928
|
+
attribute: d.attribute || found.attribute,
|
|
1929
|
+
oldValue: d.oldValue,
|
|
1930
|
+
newValue: d.newValue,
|
|
1931
|
+
status: d.status || "updated",
|
|
1932
|
+
isLive: !found.isRolledBack,
|
|
1933
|
+
message: d.message || "Updated via bulk update"
|
|
1934
|
+
}))
|
|
1935
|
+
};
|
|
1936
|
+
batchJobsMap.set(batchId, historicalJob);
|
|
1937
|
+
return { success: true, batch: historicalJob };
|
|
1938
|
+
}
|
|
1939
|
+
} catch (err) {
|
|
1940
|
+
}
|
|
1941
|
+
throw new Error(`Batch with ID "${batchId}" was not found.`);
|
|
1942
|
+
},
|
|
1943
|
+
/**
|
|
1944
|
+
* Downloadable execution report (CSV or JSON)
|
|
1945
|
+
*/
|
|
1946
|
+
async getBatchReport(batchId, format = "csv") {
|
|
1947
|
+
const statusRes = await this.getBatchStatus(batchId);
|
|
1948
|
+
const batch = statusRes.batch;
|
|
1949
|
+
function escapeCsvVal(val) {
|
|
1950
|
+
if (val === null || val === void 0) return "";
|
|
1951
|
+
let str2 = typeof val === "object" ? JSON.stringify(val) : String(val);
|
|
1952
|
+
if (str2.includes(",") || str2.includes('"') || str2.includes("\n") || str2.includes("\r")) {
|
|
1953
|
+
return `"${str2.replace(/"/g, '""')}"`;
|
|
1954
|
+
}
|
|
1955
|
+
return str2;
|
|
1956
|
+
}
|
|
1957
|
+
const headers = [
|
|
1958
|
+
"Batch ID",
|
|
1959
|
+
"Collection UID",
|
|
1960
|
+
"Collection Name",
|
|
1961
|
+
"Document ID",
|
|
1962
|
+
"Record Title / Identifier",
|
|
1963
|
+
"Attribute Name",
|
|
1964
|
+
"Previous Value",
|
|
1965
|
+
"New Live Value",
|
|
1966
|
+
"Processing Status",
|
|
1967
|
+
"Live On Server",
|
|
1968
|
+
"Details / Message",
|
|
1969
|
+
"Executed At",
|
|
1970
|
+
"Operator"
|
|
1971
|
+
];
|
|
1972
|
+
const rows = [headers.join(",")];
|
|
1973
|
+
for (const item of batch.results || []) {
|
|
1974
|
+
const row = [
|
|
1975
|
+
escapeCsvVal(batch.id),
|
|
1976
|
+
escapeCsvVal(item.uid),
|
|
1977
|
+
escapeCsvVal(item.collectionName),
|
|
1978
|
+
escapeCsvVal(item.documentId),
|
|
1979
|
+
escapeCsvVal(item.label),
|
|
1980
|
+
escapeCsvVal(item.attribute),
|
|
1981
|
+
escapeCsvVal(item.oldValue),
|
|
1982
|
+
escapeCsvVal(item.newValue),
|
|
1983
|
+
escapeCsvVal(item.status === "updated" ? "Updated" : item.status === "error" ? "Failed" : "Skipped"),
|
|
1984
|
+
escapeCsvVal(item.isLive ? "YES (Live on Site)" : "NO"),
|
|
1985
|
+
escapeCsvVal(item.message),
|
|
1986
|
+
escapeCsvVal(batch.completedAt || batch.createdAt),
|
|
1987
|
+
escapeCsvVal(batch.createdBy?.email || "admin@strapi.io")
|
|
1988
|
+
];
|
|
1989
|
+
rows.push(row.join(","));
|
|
1990
|
+
}
|
|
1991
|
+
const safeBatchId = batch.id.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
1992
|
+
const filename = `bulk_update_report_${safeBatchId}_${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.csv`;
|
|
1993
|
+
return {
|
|
1994
|
+
success: true,
|
|
1995
|
+
filename,
|
|
1996
|
+
csvContent: rows.join("\n"),
|
|
1997
|
+
summary: {
|
|
1998
|
+
batchId: batch.id,
|
|
1999
|
+
status: batch.status,
|
|
2000
|
+
isLive: batch.isLive,
|
|
2001
|
+
totalTargets: batch.totalTargets,
|
|
2002
|
+
updatedCount: batch.updatedCount,
|
|
2003
|
+
skippedCount: batch.skippedCount,
|
|
2004
|
+
failedCount: batch.failedCount,
|
|
2005
|
+
executedAt: batch.completedAt || batch.createdAt,
|
|
2006
|
+
operator: batch.createdBy?.email || "admin@strapi.io"
|
|
2007
|
+
},
|
|
2008
|
+
batch
|
|
2009
|
+
};
|
|
2010
|
+
},
|
|
2011
|
+
/**
|
|
2012
|
+
* Get recent batch jobs for the processing dashboard
|
|
2013
|
+
*/
|
|
2014
|
+
async getRecentBatches() {
|
|
2015
|
+
const mergedMap = /* @__PURE__ */ new Map();
|
|
2016
|
+
for (const [id, job] of batchJobsMap.entries()) {
|
|
2017
|
+
mergedMap.set(id, {
|
|
2018
|
+
id: job.id,
|
|
2019
|
+
createdAt: job.createdAt,
|
|
2020
|
+
completedAt: job.completedAt,
|
|
2021
|
+
status: job.status,
|
|
2022
|
+
isLive: job.isLive,
|
|
2023
|
+
executionMode: job.executionMode,
|
|
2024
|
+
attribute: job.attribute,
|
|
2025
|
+
value: job.value,
|
|
2026
|
+
totalTargets: job.totalTargets,
|
|
2027
|
+
updatedCount: job.updatedCount,
|
|
2028
|
+
skippedCount: job.skippedCount,
|
|
2029
|
+
failedCount: job.failedCount,
|
|
2030
|
+
operator: job.createdBy?.email || "admin@strapi.io"
|
|
2031
|
+
});
|
|
2032
|
+
}
|
|
2033
|
+
for (const [id, p] of pendingBatches.entries()) {
|
|
2034
|
+
if (!mergedMap.has(id)) {
|
|
2035
|
+
mergedMap.set(id, {
|
|
2036
|
+
id: p.id,
|
|
2037
|
+
createdAt: p.createdAt,
|
|
2038
|
+
status: p.status,
|
|
2039
|
+
isLive: false,
|
|
2040
|
+
executionMode: p.executionMode,
|
|
2041
|
+
attribute: p.payload.attribute,
|
|
2042
|
+
value: p.payload.value,
|
|
2043
|
+
totalTargets: p.summary.totalTargets,
|
|
2044
|
+
updatedCount: 0,
|
|
2045
|
+
skippedCount: 0,
|
|
2046
|
+
failedCount: 0,
|
|
2047
|
+
operator: p.createdBy?.email || "admin@strapi.io"
|
|
2048
|
+
});
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
try {
|
|
2052
|
+
const auditService2 = strapi2.plugin("bulk-updater").service("audit");
|
|
2053
|
+
const historyData = await auditService2.getHistory();
|
|
2054
|
+
const records = Array.isArray(historyData?.history) ? historyData.history : [];
|
|
2055
|
+
for (const r of records) {
|
|
2056
|
+
if (!mergedMap.has(r.batchId)) {
|
|
2057
|
+
mergedMap.set(r.batchId, {
|
|
2058
|
+
id: r.batchId,
|
|
2059
|
+
createdAt: r.executedAt || r.createdAt,
|
|
2060
|
+
completedAt: r.executedAt,
|
|
2061
|
+
status: r.isRolledBack ? "failed" : "successful",
|
|
2062
|
+
isLive: !r.isRolledBack && r.updatedCount > 0,
|
|
2063
|
+
executionMode: "immediate",
|
|
2064
|
+
attribute: r.attribute,
|
|
2065
|
+
value: r.value,
|
|
2066
|
+
totalTargets: r.totalTargets || r.updatedCount + r.skippedCount || 1,
|
|
2067
|
+
updatedCount: r.updatedCount || 0,
|
|
2068
|
+
skippedCount: r.skippedCount || 0,
|
|
2069
|
+
failedCount: 0,
|
|
2070
|
+
operator: r.userEmail || "admin@strapi.io"
|
|
2071
|
+
});
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
} catch {
|
|
2075
|
+
}
|
|
2076
|
+
const batches = Array.from(mergedMap.values()).sort(
|
|
2077
|
+
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
|
2078
|
+
);
|
|
2079
|
+
return {
|
|
2080
|
+
success: true,
|
|
2081
|
+
batches: batches.slice(0, 50)
|
|
2082
|
+
};
|
|
2083
|
+
},
|
|
1618
2084
|
invalidateCache(uid) {
|
|
1619
2085
|
if (uid) {
|
|
1620
2086
|
invalidateCollectionCache(uid);
|
|
@@ -1645,6 +2111,7 @@ const DEFAULT_FIELDS = {
|
|
|
1645
2111
|
executedAt: "executedAt"
|
|
1646
2112
|
};
|
|
1647
2113
|
const DEFAULT_CANDIDATE_TYPES = [
|
|
2114
|
+
"plugin::bulk-updater.audit-log",
|
|
1648
2115
|
"api::audit-log.audit-log",
|
|
1649
2116
|
"api::audit.audit",
|
|
1650
2117
|
"api::bulk-audit-log.bulk-audit-log"
|
|
@@ -1666,6 +2133,7 @@ const auditService = ({ strapi: strapi2 }) => ({
|
|
|
1666
2133
|
* 1. Explicit consumer custom schema (full declarative control)
|
|
1667
2134
|
* 2. Zero-config convention auto-detection (api::audit-log.audit-log)
|
|
1668
2135
|
* 3. Strapi Enterprise native audit log bridge
|
|
2136
|
+
* 4. Zero-config plugin core store (works out of the box in 100% of Strapi installations)
|
|
1669
2137
|
*/
|
|
1670
2138
|
getAuditConfig() {
|
|
1671
2139
|
const rawConfig = strapi2.config.get("plugin::bulk-updater.audit");
|
|
@@ -1719,10 +2187,9 @@ const auditService = ({ strapi: strapi2 }) => ({
|
|
|
1719
2187
|
};
|
|
1720
2188
|
}
|
|
1721
2189
|
return {
|
|
1722
|
-
enabled:
|
|
1723
|
-
mode: "
|
|
1724
|
-
isEnterpriseNativeAvailable: false
|
|
1725
|
-
error: "No audit collection configured or detected. To enable rollback, provide a collection or use Strapi Enterprise."
|
|
2190
|
+
enabled: true,
|
|
2191
|
+
mode: "store",
|
|
2192
|
+
isEnterpriseNativeAvailable: false
|
|
1726
2193
|
};
|
|
1727
2194
|
},
|
|
1728
2195
|
/**
|
|
@@ -1767,7 +2234,40 @@ const auditService = ({ strapi: strapi2 }) => ({
|
|
|
1767
2234
|
},
|
|
1768
2235
|
user
|
|
1769
2236
|
);
|
|
1770
|
-
if (!config.enabled
|
|
2237
|
+
if (!config.enabled) {
|
|
2238
|
+
return null;
|
|
2239
|
+
}
|
|
2240
|
+
if (config.mode === "store") {
|
|
2241
|
+
try {
|
|
2242
|
+
const store = strapi2.store({ type: "plugin", name: "bulk-updater", key: "audit-history" });
|
|
2243
|
+
const stored = await store.get({}) || { history: [] };
|
|
2244
|
+
const history = Array.isArray(stored.history) ? stored.history : [];
|
|
2245
|
+
const newRecord = {
|
|
2246
|
+
id: batchSummary.batchId,
|
|
2247
|
+
documentId: batchSummary.batchId,
|
|
2248
|
+
batchId: batchSummary.batchId,
|
|
2249
|
+
attribute: batchSummary.attribute,
|
|
2250
|
+
value: batchSummary.value,
|
|
2251
|
+
userEmail: user?.email || "admin@strapi.io",
|
|
2252
|
+
userName: user?.firstname ? `${user.firstname} ${user.lastname || ""}`.trim() : user?.email || "Admin",
|
|
2253
|
+
updatedCount: batchSummary.updatedCount,
|
|
2254
|
+
skippedCount: batchSummary.skippedCount,
|
|
2255
|
+
totalTargets: batchSummary.totalTargets,
|
|
2256
|
+
diffs: results,
|
|
2257
|
+
isRolledBack: false,
|
|
2258
|
+
rolledBackAt: null,
|
|
2259
|
+
rolledBackBy: null,
|
|
2260
|
+
executedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2261
|
+
};
|
|
2262
|
+
const updatedHistory = [newRecord, ...history].slice(0, 200);
|
|
2263
|
+
await store.set({ value: { history: updatedHistory } });
|
|
2264
|
+
return newRecord;
|
|
2265
|
+
} catch (err) {
|
|
2266
|
+
strapi2.log.error(`[bulk-updater] Failed to record audit log in core store: ${err.message}`);
|
|
2267
|
+
return null;
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
if (!config.contentType || !config.fields) {
|
|
1771
2271
|
return null;
|
|
1772
2272
|
}
|
|
1773
2273
|
const f = config.fields;
|
|
@@ -1808,6 +2308,25 @@ const auditService = ({ strapi: strapi2 }) => ({
|
|
|
1808
2308
|
history: []
|
|
1809
2309
|
};
|
|
1810
2310
|
}
|
|
2311
|
+
if (config.mode === "store") {
|
|
2312
|
+
try {
|
|
2313
|
+
const store = strapi2.store({ type: "plugin", name: "bulk-updater", key: "audit-history" });
|
|
2314
|
+
const stored = await store.get({}) || { history: [] };
|
|
2315
|
+
const history = Array.isArray(stored.history) ? stored.history : [];
|
|
2316
|
+
return {
|
|
2317
|
+
enabled: true,
|
|
2318
|
+
mode: "store",
|
|
2319
|
+
isEnterpriseNativeAvailable: config.isEnterpriseNativeAvailable,
|
|
2320
|
+
history
|
|
2321
|
+
};
|
|
2322
|
+
} catch (err) {
|
|
2323
|
+
return {
|
|
2324
|
+
enabled: false,
|
|
2325
|
+
message: `Failed to read audit history from core store: ${err.message}`,
|
|
2326
|
+
history: []
|
|
2327
|
+
};
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
1811
2330
|
if (!config.enabled || !config.contentType || !config.fields) {
|
|
1812
2331
|
return {
|
|
1813
2332
|
enabled: false,
|
|
@@ -1853,27 +2372,9 @@ const auditService = ({ strapi: strapi2 }) => ({
|
|
|
1853
2372
|
}
|
|
1854
2373
|
},
|
|
1855
2374
|
/**
|
|
1856
|
-
*
|
|
2375
|
+
* Helper to revert diff items back to original values
|
|
1857
2376
|
*/
|
|
1858
|
-
async
|
|
1859
|
-
const config = this.getAuditConfig();
|
|
1860
|
-
if (!config.enabled || !config.contentType || !config.fields) {
|
|
1861
|
-
throw new Error("Audit trail is not configured or disabled.");
|
|
1862
|
-
}
|
|
1863
|
-
const f = config.fields;
|
|
1864
|
-
const records = await strapi2.documents(config.contentType).findMany({
|
|
1865
|
-
filters: {
|
|
1866
|
-
[f.batchId]: { $eq: batchId }
|
|
1867
|
-
}
|
|
1868
|
-
});
|
|
1869
|
-
const record = records?.[0];
|
|
1870
|
-
if (!record) {
|
|
1871
|
-
throw new Error(`Batch record with ID "${batchId}" was not found in audit trail.`);
|
|
1872
|
-
}
|
|
1873
|
-
if (record[f.isRolledBack]) {
|
|
1874
|
-
throw new Error(`Batch "${batchId}" has already been rolled back.`);
|
|
1875
|
-
}
|
|
1876
|
-
const diffs = typeof record[f.diffs] === "string" ? JSON.parse(record[f.diffs]) : record[f.diffs] || [];
|
|
2377
|
+
async revertDiffItems(diffs) {
|
|
1877
2378
|
let revertedCount = 0;
|
|
1878
2379
|
const rollbackDetails = [];
|
|
1879
2380
|
for (const item of diffs) {
|
|
@@ -1958,6 +2459,78 @@ const auditService = ({ strapi: strapi2 }) => ({
|
|
|
1958
2459
|
}
|
|
1959
2460
|
}
|
|
1960
2461
|
}
|
|
2462
|
+
return { revertedCount, rollbackDetails };
|
|
2463
|
+
},
|
|
2464
|
+
/**
|
|
2465
|
+
* Performs deterministic rollback of a batch
|
|
2466
|
+
*/
|
|
2467
|
+
async rollbackBatch(batchId, currentUser) {
|
|
2468
|
+
const config = this.getAuditConfig();
|
|
2469
|
+
if (!config.enabled) {
|
|
2470
|
+
throw new Error("Audit trail is not configured or disabled.");
|
|
2471
|
+
}
|
|
2472
|
+
if (config.mode === "store") {
|
|
2473
|
+
const store = strapi2.store({ type: "plugin", name: "bulk-updater", key: "audit-history" });
|
|
2474
|
+
const stored = await store.get({}) || { history: [] };
|
|
2475
|
+
const history = Array.isArray(stored.history) ? stored.history : [];
|
|
2476
|
+
const recordIndex = history.findIndex((r) => r.batchId === batchId);
|
|
2477
|
+
if (recordIndex === -1) {
|
|
2478
|
+
throw new Error(`Batch record with ID "${batchId}" was not found in audit trail.`);
|
|
2479
|
+
}
|
|
2480
|
+
const record2 = history[recordIndex];
|
|
2481
|
+
if (record2.isRolledBack) {
|
|
2482
|
+
throw new Error(`Batch "${batchId}" has already been rolled back.`);
|
|
2483
|
+
}
|
|
2484
|
+
const diffs2 = Array.isArray(record2.diffs) ? record2.diffs : [];
|
|
2485
|
+
const { revertedCount: revertedCount2, rollbackDetails: rollbackDetails2 } = await this.revertDiffItems(diffs2);
|
|
2486
|
+
record2.isRolledBack = true;
|
|
2487
|
+
record2.rolledBackAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2488
|
+
record2.rolledBackBy = currentUser?.email || "Admin";
|
|
2489
|
+
history[recordIndex] = record2;
|
|
2490
|
+
await store.set({ value: { history } });
|
|
2491
|
+
try {
|
|
2492
|
+
const affectedUids = new Set(rollbackDetails2.map((d) => d.uid));
|
|
2493
|
+
const bulkService = strapi2.plugin("bulk-updater").service("bulkUpdater");
|
|
2494
|
+
affectedUids.forEach((uid) => {
|
|
2495
|
+
if (uid && bulkService?.invalidateCache) {
|
|
2496
|
+
bulkService.invalidateCache(uid);
|
|
2497
|
+
}
|
|
2498
|
+
});
|
|
2499
|
+
} catch {
|
|
2500
|
+
}
|
|
2501
|
+
await this.recordToEnterpriseNative(
|
|
2502
|
+
"bulk-updater.rollback",
|
|
2503
|
+
{
|
|
2504
|
+
batchId,
|
|
2505
|
+
revertedCount: revertedCount2
|
|
2506
|
+
},
|
|
2507
|
+
currentUser
|
|
2508
|
+
);
|
|
2509
|
+
return {
|
|
2510
|
+
success: true,
|
|
2511
|
+
batchId,
|
|
2512
|
+
revertedCount: revertedCount2,
|
|
2513
|
+
details: rollbackDetails2
|
|
2514
|
+
};
|
|
2515
|
+
}
|
|
2516
|
+
if (!config.contentType || !config.fields) {
|
|
2517
|
+
throw new Error("Audit trail is not configured or disabled.");
|
|
2518
|
+
}
|
|
2519
|
+
const f = config.fields;
|
|
2520
|
+
const records = await strapi2.documents(config.contentType).findMany({
|
|
2521
|
+
filters: {
|
|
2522
|
+
[f.batchId]: { $eq: batchId }
|
|
2523
|
+
}
|
|
2524
|
+
});
|
|
2525
|
+
const record = records?.[0];
|
|
2526
|
+
if (!record) {
|
|
2527
|
+
throw new Error(`Batch record with ID "${batchId}" was not found in audit trail.`);
|
|
2528
|
+
}
|
|
2529
|
+
if (record[f.isRolledBack]) {
|
|
2530
|
+
throw new Error(`Batch "${batchId}" has already been rolled back.`);
|
|
2531
|
+
}
|
|
2532
|
+
const diffs = typeof record[f.diffs] === "string" ? JSON.parse(record[f.diffs]) : record[f.diffs] || [];
|
|
2533
|
+
const { revertedCount, rollbackDetails } = await this.revertDiffItems(diffs);
|
|
1961
2534
|
const updateData = {};
|
|
1962
2535
|
if (f.isRolledBack) updateData[f.isRolledBack] = true;
|
|
1963
2536
|
if (f.rolledBackAt) updateData[f.rolledBackAt] = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -2110,56 +2683,102 @@ const validationRulesService = ({ strapi: strapi2 }) => ({
|
|
|
2110
2683
|
const violations = [];
|
|
2111
2684
|
let totalScanned = 0;
|
|
2112
2685
|
const allContentTypes = strapi2.contentTypes || {};
|
|
2686
|
+
const allComponents = strapi2.components || {};
|
|
2687
|
+
function schemaHasField(ctSchema) {
|
|
2688
|
+
if (!ctSchema || !ctSchema.attributes) return { hasField: false, populateAttrs: {} };
|
|
2689
|
+
const rawAttrs = ctSchema.attributes;
|
|
2690
|
+
const populateAttrs = {};
|
|
2691
|
+
let hasField = Boolean(rawAttrs[targetField]);
|
|
2692
|
+
for (const [attrKey, attrDef] of Object.entries(rawAttrs)) {
|
|
2693
|
+
if (attrDef.type === "component" && attrDef.component) {
|
|
2694
|
+
const comp = allComponents[attrDef.component];
|
|
2695
|
+
if (comp?.attributes?.[targetField]) {
|
|
2696
|
+
hasField = true;
|
|
2697
|
+
populateAttrs[attrKey] = true;
|
|
2698
|
+
}
|
|
2699
|
+
} else if (attrDef.type === "dynamiczone" && Array.isArray(attrDef.components)) {
|
|
2700
|
+
const matchingComp = attrDef.components.some(
|
|
2701
|
+
(compUid) => allComponents[compUid]?.attributes?.[targetField]
|
|
2702
|
+
);
|
|
2703
|
+
if (matchingComp) {
|
|
2704
|
+
hasField = true;
|
|
2705
|
+
populateAttrs[attrKey] = true;
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
return { hasField, populateAttrs };
|
|
2710
|
+
}
|
|
2113
2711
|
const candidateUids = Object.keys(allContentTypes).filter(
|
|
2114
2712
|
(uid) => uid.startsWith("api::") && !uid.includes("bulk-updater")
|
|
2115
2713
|
);
|
|
2714
|
+
const relevantCollections = [];
|
|
2116
2715
|
for (const uid of candidateUids) {
|
|
2117
2716
|
const ct = allContentTypes[uid];
|
|
2118
2717
|
if (!ct) continue;
|
|
2119
|
-
const
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2718
|
+
const { hasField, populateAttrs } = schemaHasField(ct);
|
|
2719
|
+
if (hasField) {
|
|
2720
|
+
relevantCollections.push({
|
|
2721
|
+
uid,
|
|
2722
|
+
displayName: ct.info?.displayName || uid,
|
|
2723
|
+
populateOption: Object.keys(populateAttrs).length > 0 ? populateAttrs : void 0
|
|
2123
2724
|
});
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
const BATCH_SIZE = 250;
|
|
2728
|
+
await Promise.all(
|
|
2729
|
+
relevantCollections.map(async ({ uid, displayName, populateOption }) => {
|
|
2730
|
+
try {
|
|
2731
|
+
let page = 1;
|
|
2732
|
+
while (true) {
|
|
2733
|
+
const entries = await strapi2.documents(uid).findMany({
|
|
2734
|
+
page,
|
|
2735
|
+
pageSize: BATCH_SIZE,
|
|
2736
|
+
populate: populateOption
|
|
2737
|
+
});
|
|
2738
|
+
if (!entries || entries.length === 0) break;
|
|
2739
|
+
for (const entry of entries) {
|
|
2740
|
+
let traverse = function(obj) {
|
|
2741
|
+
if (!obj || typeof obj !== "object") return;
|
|
2742
|
+
for (const key of Object.keys(obj)) {
|
|
2743
|
+
const val = obj[key];
|
|
2744
|
+
if (key === targetField && typeof val === "string" && val) {
|
|
2745
|
+
const lower = val.toLowerCase();
|
|
2746
|
+
counts[lower] = (counts[lower] || 0) + 1;
|
|
2747
|
+
}
|
|
2748
|
+
if (typeof val === "object" && val !== null) {
|
|
2749
|
+
traverse(val);
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
};
|
|
2753
|
+
totalScanned++;
|
|
2754
|
+
const counts = {};
|
|
2755
|
+
traverse(entry);
|
|
2756
|
+
for (const [optKey, max] of Object.entries(quotas)) {
|
|
2757
|
+
const lowerOpt = optKey.toLowerCase();
|
|
2758
|
+
const actualCount = counts[lowerOpt] || 0;
|
|
2759
|
+
if (actualCount > max) {
|
|
2760
|
+
const entryLabel = entry.title || entry.name || entry.email || `Entry #${entry.documentId}`;
|
|
2761
|
+
violations.push({
|
|
2762
|
+
uid,
|
|
2763
|
+
collectionDisplayName: displayName,
|
|
2764
|
+
documentId: entry.documentId,
|
|
2765
|
+
entryLabel,
|
|
2766
|
+
violatingOption: optKey.toUpperCase(),
|
|
2767
|
+
foundCount: actualCount,
|
|
2768
|
+
maxAllowed: max,
|
|
2769
|
+
editUrl: `/admin/content-manager/collection-types/${uid}/${entry.documentId}`
|
|
2770
|
+
});
|
|
2771
|
+
}
|
|
2135
2772
|
}
|
|
2136
2773
|
}
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
const counts = {};
|
|
2140
|
-
traverse(entry);
|
|
2141
|
-
for (const [optKey, max] of Object.entries(quotas)) {
|
|
2142
|
-
const lowerOpt = optKey.toLowerCase();
|
|
2143
|
-
const actualCount = counts[lowerOpt] || 0;
|
|
2144
|
-
if (actualCount > max) {
|
|
2145
|
-
const entryLabel = entry.title || entry.name || entry.email || `Entry #${entry.documentId}`;
|
|
2146
|
-
violations.push({
|
|
2147
|
-
uid,
|
|
2148
|
-
collectionDisplayName: displayName,
|
|
2149
|
-
documentId: entry.documentId,
|
|
2150
|
-
entryLabel,
|
|
2151
|
-
violatingOption: optKey.toUpperCase(),
|
|
2152
|
-
foundCount: actualCount,
|
|
2153
|
-
maxAllowed: max,
|
|
2154
|
-
editUrl: `/admin/content-manager/collection-types/${uid}/${entry.documentId}`
|
|
2155
|
-
});
|
|
2156
|
-
}
|
|
2774
|
+
if (entries.length < BATCH_SIZE) break;
|
|
2775
|
+
page++;
|
|
2157
2776
|
}
|
|
2777
|
+
} catch (err) {
|
|
2778
|
+
strapi2.log.warn(`[bulk-updater] Failed scanning ${uid} for rule ${ruleId}:`, err);
|
|
2158
2779
|
}
|
|
2159
|
-
}
|
|
2160
|
-
|
|
2161
|
-
}
|
|
2162
|
-
}
|
|
2780
|
+
})
|
|
2781
|
+
);
|
|
2163
2782
|
return {
|
|
2164
2783
|
rule,
|
|
2165
2784
|
totalScanned,
|
|
@@ -2461,12 +3080,13 @@ ${sampleRow.map(escapeCsvValue).join(",")}
|
|
|
2461
3080
|
throw new Error("CSV is empty or could not be parsed.");
|
|
2462
3081
|
}
|
|
2463
3082
|
const attrs = ct.attributes || {};
|
|
2464
|
-
const { mode: mode2, publish = true, user } = options;
|
|
3083
|
+
const { mode: mode2 = "auto", publish = true, user } = options;
|
|
2465
3084
|
const stats = {
|
|
2466
3085
|
total: parsedRows.length,
|
|
2467
3086
|
created: 0,
|
|
2468
3087
|
updated: 0,
|
|
2469
3088
|
skipped: 0,
|
|
3089
|
+
newWithoutDocId: 0,
|
|
2470
3090
|
errors: [],
|
|
2471
3091
|
diffs: []
|
|
2472
3092
|
};
|
|
@@ -2538,7 +3158,7 @@ ${sampleRow.map(escapeCsvValue).join(",")}
|
|
|
2538
3158
|
stats.skipped++;
|
|
2539
3159
|
stats.errors.push({
|
|
2540
3160
|
row: rowNumber,
|
|
2541
|
-
error: "Missing documentId for Update mode."
|
|
3161
|
+
error: "Missing documentId for Update mode. (Tip: Use Smart Auto-Detect mode to auto-add entries without document IDs)."
|
|
2542
3162
|
});
|
|
2543
3163
|
continue;
|
|
2544
3164
|
}
|
|
@@ -2583,7 +3203,7 @@ ${sampleRow.map(escapeCsvValue).join(",")}
|
|
|
2583
3203
|
status: "updated",
|
|
2584
3204
|
message: `Updated via CSV import (row ${rowNumber})`
|
|
2585
3205
|
});
|
|
2586
|
-
} else {
|
|
3206
|
+
} else if (mode2 === "create_or_override") {
|
|
2587
3207
|
if (docId) {
|
|
2588
3208
|
const existing = await strapi2.documents(uid).findOne({
|
|
2589
3209
|
documentId: docId
|
|
@@ -2633,6 +3253,73 @@ ${sampleRow.map(escapeCsvValue).join(",")}
|
|
|
2633
3253
|
}
|
|
2634
3254
|
}
|
|
2635
3255
|
stats.created++;
|
|
3256
|
+
if (!docId) {
|
|
3257
|
+
stats.newWithoutDocId++;
|
|
3258
|
+
}
|
|
3259
|
+
stats.diffs.push({
|
|
3260
|
+
uid,
|
|
3261
|
+
collectionName: ct.info?.displayName || uid,
|
|
3262
|
+
documentId: newDocId,
|
|
3263
|
+
label: created?.title || created?.name || `New Entry #${newDocId}`,
|
|
3264
|
+
attribute: Object.keys(payload).join(", "),
|
|
3265
|
+
oldValue: null,
|
|
3266
|
+
newValue: payload,
|
|
3267
|
+
status: "updated",
|
|
3268
|
+
message: docId ? `Created via CSV import (row ${rowNumber})` : `Created as new entry (no documentId provided in CSV - row ${rowNumber})`
|
|
3269
|
+
});
|
|
3270
|
+
} else {
|
|
3271
|
+
if (docId) {
|
|
3272
|
+
const existing = await strapi2.documents(uid).findOne({
|
|
3273
|
+
documentId: docId
|
|
3274
|
+
});
|
|
3275
|
+
if (existing) {
|
|
3276
|
+
const mergedPayload = { ...payload };
|
|
3277
|
+
for (const key of Object.keys(payload)) {
|
|
3278
|
+
if (attrs[key]?.type === "component" && typeof payload[key] === "object" && payload[key] !== null) {
|
|
3279
|
+
mergedPayload[key] = { ...existing[key] || {}, ...payload[key] };
|
|
3280
|
+
}
|
|
3281
|
+
}
|
|
3282
|
+
await strapi2.documents(uid).update({
|
|
3283
|
+
documentId: docId,
|
|
3284
|
+
data: mergedPayload
|
|
3285
|
+
});
|
|
3286
|
+
if (publish && ct.options?.draftAndPublish) {
|
|
3287
|
+
try {
|
|
3288
|
+
await strapi2.documents(uid).publish({ documentId: docId });
|
|
3289
|
+
} catch (pubErr) {
|
|
3290
|
+
strapi2.log.warn(`[bulk-updater] Auto-publish failed for ${docId}:`, pubErr);
|
|
3291
|
+
}
|
|
3292
|
+
}
|
|
3293
|
+
stats.updated++;
|
|
3294
|
+
stats.diffs.push({
|
|
3295
|
+
uid,
|
|
3296
|
+
collectionName: ct.info?.displayName || uid,
|
|
3297
|
+
documentId: docId,
|
|
3298
|
+
label: existing.title || existing.name || `Entry #${docId}`,
|
|
3299
|
+
attribute: Object.keys(payload).join(", "),
|
|
3300
|
+
oldValue: existing,
|
|
3301
|
+
newValue: payload,
|
|
3302
|
+
status: "updated",
|
|
3303
|
+
message: `Updated existing entry via smart match (row ${rowNumber})`
|
|
3304
|
+
});
|
|
3305
|
+
continue;
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3308
|
+
const created = await strapi2.documents(uid).create({
|
|
3309
|
+
data: payload
|
|
3310
|
+
});
|
|
3311
|
+
const newDocId = created?.documentId || docId;
|
|
3312
|
+
if (publish && ct.options?.draftAndPublish && newDocId) {
|
|
3313
|
+
try {
|
|
3314
|
+
await strapi2.documents(uid).publish({ documentId: newDocId });
|
|
3315
|
+
} catch (pubErr) {
|
|
3316
|
+
strapi2.log.warn(`[bulk-updater] Auto-publish failed for ${newDocId}:`, pubErr);
|
|
3317
|
+
}
|
|
3318
|
+
}
|
|
3319
|
+
stats.created++;
|
|
3320
|
+
if (!docId) {
|
|
3321
|
+
stats.newWithoutDocId++;
|
|
3322
|
+
}
|
|
2636
3323
|
stats.diffs.push({
|
|
2637
3324
|
uid,
|
|
2638
3325
|
collectionName: ct.info?.displayName || uid,
|
|
@@ -2642,7 +3329,7 @@ ${sampleRow.map(escapeCsvValue).join(",")}
|
|
|
2642
3329
|
oldValue: null,
|
|
2643
3330
|
newValue: payload,
|
|
2644
3331
|
status: "updated",
|
|
2645
|
-
message: `Created
|
|
3332
|
+
message: docId ? `Created as new entry (documentId "${docId}" not found in database - row ${rowNumber})` : `Created as new entry (no documentId provided in CSV - row ${rowNumber})`
|
|
2646
3333
|
});
|
|
2647
3334
|
}
|
|
2648
3335
|
} catch (err) {
|
|
@@ -32200,6 +32887,7 @@ const index = {
|
|
|
32200
32887
|
});
|
|
32201
32888
|
},
|
|
32202
32889
|
routes,
|
|
32890
|
+
contentTypes,
|
|
32203
32891
|
controllers: {
|
|
32204
32892
|
bulkUpdater: bulkUpdaterController,
|
|
32205
32893
|
audit: auditController,
|