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.
@@ -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" : "update",
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
- for (const uid of staticSchemaCache.targetUids) {
1027
- const meta = staticSchemaCache.metaByUid[uid];
1028
- if (!meta) continue;
1029
- const signature = await getCollectionSignature(strapi2, uid);
1030
- signatures.push(`${uid}=${signature}`);
1031
- const cached = modelsCache.get(uid);
1032
- if (cached && cached.signature === signature) {
1033
- collections.push({
1034
- ...meta,
1035
- totalEntries: cached.totalCount,
1036
- entries: cached.entries
1037
- });
1038
- } else {
1039
- let entries = [];
1040
- let totalCount = 0;
1041
- try {
1042
- const ctAttrs = allContentTypes[uid]?.attributes || {};
1043
- const relKeys = Object.keys(meta.relations || {});
1044
- const compKeys = Object.keys(ctAttrs).filter((k) => ctAttrs[k].type === "component");
1045
- const populateOptionObj = {};
1046
- relKeys.forEach((k) => {
1047
- populateOptionObj[k] = true;
1048
- });
1049
- compKeys.forEach((k) => {
1050
- populateOptionObj[k] = true;
1051
- });
1052
- const populateOption = Object.keys(populateOptionObj).length > 0 ? populateOptionObj : void 0;
1053
- if (meta.isSingleType) {
1054
- const singleDoc = await strapi2.documents(uid).findFirst({
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
- if (singleDoc) {
1058
- entries = [singleDoc];
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
- } catch (err) {
1070
- strapi2.log.error(`[bulk-updater] Failed to fetch entries for ${uid}:`, err);
1071
- entries = [];
1072
- totalCount = 0;
1073
- }
1074
- const formattedEntries = (entries || []).map((entry) => ({
1075
- id: entry.id,
1076
- documentId: entry.documentId,
1077
- ...entry
1078
- }));
1079
- modelsCache.set(uid, {
1080
- signature,
1081
- totalCount,
1082
- entries: formattedEntries
1083
- });
1084
- collections.push({
1085
- ...meta,
1086
- totalEntries: totalCount,
1087
- entries: formattedEntries
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: false,
1723
- mode: "disabled",
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 || !config.contentType || !config.fields) {
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
- * Performs deterministic rollback of a batch
2375
+ * Helper to revert diff items back to original values
1857
2376
  */
1858
- async rollbackBatch(batchId, currentUser) {
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 displayName = ct.info?.displayName || uid;
2120
- try {
2121
- const entries = await strapi2.documents(uid).findMany({
2122
- populate: "*"
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
- for (const entry of entries || []) {
2125
- let traverse = function(obj) {
2126
- if (!obj || typeof obj !== "object") return;
2127
- for (const key of Object.keys(obj)) {
2128
- const val = obj[key];
2129
- if (key === targetField && typeof val === "string" && val) {
2130
- const lower = val.toLowerCase();
2131
- counts[lower] = (counts[lower] || 0) + 1;
2132
- }
2133
- if (typeof val === "object" && val !== null) {
2134
- traverse(val);
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
- totalScanned++;
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
- } catch (err) {
2160
- strapi2.log.warn(`[bulk-updater] Failed scanning ${uid} for rule ${ruleId}:`, err);
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 via CSV import (row ${rowNumber})`
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,