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.
@@ -91,6 +91,30 @@ const routes = {
91
91
  policies: []
92
92
  }
93
93
  },
94
+ {
95
+ method: "GET",
96
+ path: "/batches/recent",
97
+ handler: "bulkUpdater.getRecentBatches",
98
+ config: {
99
+ policies: []
100
+ }
101
+ },
102
+ {
103
+ method: "GET",
104
+ path: "/batches/:batchId/status",
105
+ handler: "bulkUpdater.getBatchStatus",
106
+ config: {
107
+ policies: []
108
+ }
109
+ },
110
+ {
111
+ method: "GET",
112
+ path: "/batches/:batchId/report",
113
+ handler: "bulkUpdater.getBatchReport",
114
+ config: {
115
+ policies: []
116
+ }
117
+ },
94
118
  {
95
119
  method: "GET",
96
120
  path: "/audit/history",
@@ -182,6 +206,78 @@ const routes = {
182
206
  ]
183
207
  }
184
208
  };
209
+ const auditLog = {
210
+ collectionName: "bulk_updater_audit_logs",
211
+ info: {
212
+ singularName: "audit-log",
213
+ pluralName: "audit-logs",
214
+ displayName: "Bulk Updater Audit Log",
215
+ description: "Internal audit trail for bulk attribute and relation updates"
216
+ },
217
+ options: {
218
+ draftAndPublish: false
219
+ },
220
+ pluginOptions: {
221
+ "content-manager": {
222
+ visible: true
223
+ },
224
+ "content-type-builder": {
225
+ visible: false
226
+ }
227
+ },
228
+ attributes: {
229
+ batchId: {
230
+ type: "string",
231
+ required: true
232
+ },
233
+ attribute: {
234
+ type: "string",
235
+ required: true
236
+ },
237
+ value: {
238
+ type: "text"
239
+ },
240
+ userEmail: {
241
+ type: "string"
242
+ },
243
+ userName: {
244
+ type: "string"
245
+ },
246
+ updatedCount: {
247
+ type: "integer",
248
+ default: 0
249
+ },
250
+ skippedCount: {
251
+ type: "integer",
252
+ default: 0
253
+ },
254
+ totalTargets: {
255
+ type: "integer",
256
+ default: 0
257
+ },
258
+ diffs: {
259
+ type: "json"
260
+ },
261
+ isRolledBack: {
262
+ type: "boolean",
263
+ default: false
264
+ },
265
+ rolledBackAt: {
266
+ type: "datetime"
267
+ },
268
+ rolledBackBy: {
269
+ type: "string"
270
+ },
271
+ executedAt: {
272
+ type: "datetime"
273
+ }
274
+ }
275
+ };
276
+ const contentTypes = {
277
+ "audit-log": {
278
+ schema: auditLog
279
+ }
280
+ };
185
281
  const bulkUpdaterController = ({ strapi: strapi2 }) => ({
186
282
  async getModels(ctx) {
187
283
  try {
@@ -304,6 +400,39 @@ const bulkUpdaterController = ({ strapi: strapi2 }) => ({
304
400
  ctx.status = 400;
305
401
  ctx.body = { success: false, error: err.message };
306
402
  }
403
+ },
404
+ async getBatchStatus(ctx) {
405
+ try {
406
+ const { batchId } = ctx.params;
407
+ const service = strapi2.plugin("bulk-updater").service("bulkUpdater");
408
+ const result = await service.getBatchStatus(batchId);
409
+ ctx.body = result;
410
+ } catch (err) {
411
+ ctx.status = 404;
412
+ ctx.body = { success: false, error: err.message };
413
+ }
414
+ },
415
+ async getBatchReport(ctx) {
416
+ try {
417
+ const { batchId } = ctx.params;
418
+ const { format = "csv" } = ctx.query;
419
+ const service = strapi2.plugin("bulk-updater").service("bulkUpdater");
420
+ const result = await service.getBatchReport(batchId, format);
421
+ ctx.body = result;
422
+ } catch (err) {
423
+ ctx.status = 500;
424
+ ctx.body = { success: false, error: err.message };
425
+ }
426
+ },
427
+ async getRecentBatches(ctx) {
428
+ try {
429
+ const service = strapi2.plugin("bulk-updater").service("bulkUpdater");
430
+ const result = await service.getRecentBatches();
431
+ ctx.body = result;
432
+ } catch (err) {
433
+ ctx.status = 500;
434
+ ctx.body = { success: false, error: err.message };
435
+ }
307
436
  }
308
437
  });
309
438
  const auditController = ({ strapi: strapi2 }) => ({
@@ -476,7 +605,7 @@ const csvController = ({ strapi: strapi2 }) => ({
476
605
  }
477
606
  const csvService2 = strapi2.plugin("bulk-updater").service("csv");
478
607
  const result = await csvService2.importCsv(uid, csvText, {
479
- mode: mode2 === "create_or_override" ? "create_or_override" : "update",
608
+ mode: mode2 === "update" ? "update" : mode2 === "create_or_override" ? "create_or_override" : "auto",
480
609
  publish: publish !== false,
481
610
  user: ctx.state.user
482
611
  });
@@ -549,6 +678,7 @@ function checkBusinessHours(config, testDate) {
549
678
  };
550
679
  }
551
680
  const pendingBatches = /* @__PURE__ */ new Map();
681
+ const batchJobsMap = /* @__PURE__ */ new Map();
552
682
  let isRunnerRunning = false;
553
683
  let runnerInterval = null;
554
684
  function invalidateCollectionCache(uid) {
@@ -648,6 +778,31 @@ async function executeBatchInternal(strapi2, {
648
778
  const results = [];
649
779
  let updatedCount = 0;
650
780
  let skippedCount = 0;
781
+ const jobState = {
782
+ id: batchId,
783
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
784
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
785
+ createdBy: currentUser ? {
786
+ id: currentUser.id,
787
+ email: currentUser.email,
788
+ firstname: currentUser.firstname,
789
+ lastname: currentUser.lastname,
790
+ username: currentUser.username
791
+ } : { email: "admin@system.local" },
792
+ status: "processing",
793
+ executionMode: "immediate",
794
+ attribute,
795
+ value,
796
+ totalTargets: targetsMap.size,
797
+ processedCount: 0,
798
+ updatedCount: 0,
799
+ skippedCount: 0,
800
+ failedCount: 0,
801
+ isLive: false,
802
+ publishOnApproval: Boolean(publishOnApproval),
803
+ results: []
804
+ };
805
+ batchJobsMap.set(batchId, jobState);
651
806
  let regexObj = null;
652
807
  if (isRegex && regexPattern) {
653
808
  regexObj = new RegExp(regexPattern, "g");
@@ -882,11 +1037,24 @@ async function executeBatchInternal(strapi2, {
882
1037
  else if (res.status === "skipped") skippedCount++;
883
1038
  results.push(res);
884
1039
  }
1040
+ jobState.processedCount = results.length;
1041
+ jobState.updatedCount = updatedCount;
1042
+ jobState.skippedCount = skippedCount;
1043
+ jobState.failedCount = results.filter((r) => r.status === "error").length;
885
1044
  }
886
1045
  const affectedUids = new Set(targets.map((t) => t.uid));
887
1046
  affectedUids.forEach((uid) => {
888
1047
  invalidateCollectionCache(uid);
889
1048
  });
1049
+ const failedCount = results.filter((r) => r.status === "error").length;
1050
+ jobState.failedCount = failedCount;
1051
+ jobState.completedAt = (/* @__PURE__ */ new Date()).toISOString();
1052
+ jobState.isLive = updatedCount > 0;
1053
+ jobState.status = failedCount === 0 && updatedCount > 0 ? "successful" : failedCount > 0 && updatedCount > 0 ? "partial_failure" : failedCount > 0 ? "failed" : "successful";
1054
+ jobState.results = results.map((r) => ({
1055
+ ...r,
1056
+ isLive: r.status === "updated"
1057
+ }));
890
1058
  const batchSummary = {
891
1059
  batchId,
892
1060
  attribute,
@@ -908,7 +1076,10 @@ async function executeBatchInternal(strapi2, {
908
1076
  value,
909
1077
  updatedCount,
910
1078
  skippedCount,
1079
+ failedCount,
911
1080
  totalTargets: targetsMap.size,
1081
+ isLive: jobState.isLive,
1082
+ status: jobState.status,
912
1083
  results
913
1084
  };
914
1085
  }
@@ -1036,69 +1207,87 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1036
1207
  }
1037
1208
  const collections = [];
1038
1209
  const signatures = [];
1039
- for (const uid of staticSchemaCache.targetUids) {
1040
- const meta = staticSchemaCache.metaByUid[uid];
1041
- if (!meta) continue;
1042
- const signature = await getCollectionSignature(strapi2, uid);
1043
- signatures.push(`${uid}=${signature}`);
1044
- const cached = modelsCache.get(uid);
1045
- if (cached && cached.signature === signature) {
1046
- collections.push({
1047
- ...meta,
1048
- totalEntries: cached.totalCount,
1049
- entries: cached.entries
1050
- });
1051
- } else {
1052
- let entries = [];
1053
- let totalCount = 0;
1054
- try {
1055
- const ctAttrs = allContentTypes[uid]?.attributes || {};
1056
- const relKeys = Object.keys(meta.relations || {});
1057
- const compKeys = Object.keys(ctAttrs).filter((k) => ctAttrs[k].type === "component");
1058
- const populateOptionObj = {};
1059
- relKeys.forEach((k) => {
1060
- populateOptionObj[k] = true;
1061
- });
1062
- compKeys.forEach((k) => {
1063
- populateOptionObj[k] = true;
1064
- });
1065
- const populateOption = Object.keys(populateOptionObj).length > 0 ? populateOptionObj : void 0;
1066
- if (meta.isSingleType) {
1067
- const singleDoc = await strapi2.documents(uid).findFirst({
1068
- populate: populateOption
1210
+ const BATCH_CONCURRENCY = 10;
1211
+ const collectionResults = [];
1212
+ for (let i = 0; i < staticSchemaCache.targetUids.length; i += BATCH_CONCURRENCY) {
1213
+ const batchUids = staticSchemaCache.targetUids.slice(i, i + BATCH_CONCURRENCY);
1214
+ const batchRes = await Promise.all(
1215
+ batchUids.map(async (uid) => {
1216
+ const meta = staticSchemaCache.metaByUid[uid];
1217
+ if (!meta) return null;
1218
+ const signature = await getCollectionSignature(strapi2, uid);
1219
+ const cached = modelsCache.get(uid);
1220
+ if (cached && cached.signature === signature) {
1221
+ return {
1222
+ collection: {
1223
+ ...meta,
1224
+ totalEntries: cached.totalCount,
1225
+ entries: cached.entries
1226
+ },
1227
+ signature: `${uid}=${signature}`
1228
+ };
1229
+ }
1230
+ let entries = [];
1231
+ let totalCount = 0;
1232
+ try {
1233
+ const ctAttrs = allContentTypes[uid]?.attributes || {};
1234
+ const relKeys = Object.keys(meta.relations || {});
1235
+ const compKeys = Object.keys(ctAttrs).filter((k) => ctAttrs[k].type === "component");
1236
+ const populateOptionObj = {};
1237
+ relKeys.forEach((k) => {
1238
+ populateOptionObj[k] = true;
1069
1239
  });
1070
- if (singleDoc) {
1071
- entries = [singleDoc];
1072
- totalCount = 1;
1073
- }
1074
- } else {
1075
- totalCount = await strapi2.documents(uid).count();
1076
- entries = await strapi2.documents(uid).findMany({
1077
- limit: 10,
1078
- sort: { createdAt: "desc" },
1079
- populate: populateOption
1240
+ compKeys.forEach((k) => {
1241
+ populateOptionObj[k] = true;
1080
1242
  });
1243
+ const populateOption = Object.keys(populateOptionObj).length > 0 ? populateOptionObj : void 0;
1244
+ if (meta.isSingleType) {
1245
+ const singleDoc = await strapi2.documents(uid).findFirst({
1246
+ populate: populateOption
1247
+ });
1248
+ if (singleDoc) {
1249
+ entries = [singleDoc];
1250
+ totalCount = 1;
1251
+ }
1252
+ } else {
1253
+ totalCount = await strapi2.documents(uid).count();
1254
+ entries = await strapi2.documents(uid).findMany({
1255
+ limit: 10,
1256
+ sort: { createdAt: "desc" },
1257
+ populate: populateOption
1258
+ });
1259
+ }
1260
+ } catch (err) {
1261
+ strapi2.log.error(`[bulk-updater] Failed to fetch entries for ${uid}:`, err);
1262
+ entries = [];
1263
+ totalCount = 0;
1081
1264
  }
1082
- } catch (err) {
1083
- strapi2.log.error(`[bulk-updater] Failed to fetch entries for ${uid}:`, err);
1084
- entries = [];
1085
- totalCount = 0;
1086
- }
1087
- const formattedEntries = (entries || []).map((entry) => ({
1088
- id: entry.id,
1089
- documentId: entry.documentId,
1090
- ...entry
1091
- }));
1092
- modelsCache.set(uid, {
1093
- signature,
1094
- totalCount,
1095
- entries: formattedEntries
1096
- });
1097
- collections.push({
1098
- ...meta,
1099
- totalEntries: totalCount,
1100
- entries: formattedEntries
1101
- });
1265
+ const formattedEntries = (entries || []).map((entry) => ({
1266
+ id: entry.id,
1267
+ documentId: entry.documentId,
1268
+ ...entry
1269
+ }));
1270
+ modelsCache.set(uid, {
1271
+ signature,
1272
+ totalCount,
1273
+ entries: formattedEntries
1274
+ });
1275
+ return {
1276
+ collection: {
1277
+ ...meta,
1278
+ totalEntries: totalCount,
1279
+ entries: formattedEntries
1280
+ },
1281
+ signature: `${uid}=${signature}`
1282
+ };
1283
+ })
1284
+ );
1285
+ collectionResults.push(...batchRes);
1286
+ }
1287
+ for (const res of collectionResults) {
1288
+ if (res) {
1289
+ collections.push(res.collection);
1290
+ signatures.push(res.signature);
1102
1291
  }
1103
1292
  }
1104
1293
  const executionConfig = getExecutionWindowConfig(strapi2);
@@ -1417,6 +1606,24 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1417
1606
  makerNotes
1418
1607
  };
1419
1608
  pendingBatches.set(batchId, batch);
1609
+ batchJobsMap.set(batchId, {
1610
+ id: batchId,
1611
+ createdAt: batch.createdAt,
1612
+ createdBy: batch.createdBy,
1613
+ status: "pending_approval",
1614
+ executionMode: "maker_checker",
1615
+ attribute,
1616
+ value,
1617
+ totalTargets: summary.totalTargets,
1618
+ processedCount: 0,
1619
+ updatedCount: 0,
1620
+ skippedCount: 0,
1621
+ failedCount: 0,
1622
+ isLive: false,
1623
+ publishOnApproval: Boolean(publishOnApproval),
1624
+ makerNotes,
1625
+ results: []
1626
+ });
1420
1627
  return {
1421
1628
  success: true,
1422
1629
  pending: true,
@@ -1456,6 +1663,25 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1456
1663
  makerNotes
1457
1664
  };
1458
1665
  pendingBatches.set(batchId, batch);
1666
+ batchJobsMap.set(batchId, {
1667
+ id: batchId,
1668
+ createdAt: batch.createdAt,
1669
+ createdBy: batch.createdBy,
1670
+ status: "queued_offpeak",
1671
+ executionMode: "queue_offpeak",
1672
+ scheduledFor: executionConfig.offPeakStartTime,
1673
+ attribute,
1674
+ value,
1675
+ totalTargets: summary.totalTargets,
1676
+ processedCount: 0,
1677
+ updatedCount: 0,
1678
+ skippedCount: 0,
1679
+ failedCount: 0,
1680
+ isLive: false,
1681
+ publishOnApproval: Boolean(publishOnApproval),
1682
+ makerNotes,
1683
+ results: []
1684
+ });
1459
1685
  return {
1460
1686
  success: true,
1461
1687
  pending: true,
@@ -1496,6 +1722,26 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1496
1722
  makerNotes: "Automatically queued due to peak business hours restriction."
1497
1723
  };
1498
1724
  pendingBatches.set(batchId, batch);
1725
+ batchJobsMap.set(batchId, {
1726
+ id: batchId,
1727
+ createdAt: batch.createdAt,
1728
+ createdBy: batch.createdBy,
1729
+ status: "queued_offpeak",
1730
+ executionMode: "queue_offpeak",
1731
+ scheduledFor: executionConfig.offPeakStartTime,
1732
+ attribute,
1733
+ value,
1734
+ totalTargets: summary.totalTargets,
1735
+ processedCount: 0,
1736
+ updatedCount: 0,
1737
+ skippedCount: 0,
1738
+ failedCount: 0,
1739
+ isLive: false,
1740
+ publishOnApproval: Boolean(publishOnApproval),
1741
+ policyMessage,
1742
+ makerNotes: "Automatically queued due to peak business hours restriction.",
1743
+ results: []
1744
+ });
1499
1745
  return {
1500
1746
  success: true,
1501
1747
  pending: true,
@@ -1628,6 +1874,226 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1628
1874
  result
1629
1875
  };
1630
1876
  },
1877
+ /**
1878
+ * Get real-time or historical processing status of a batch job
1879
+ */
1880
+ async getBatchStatus(batchId) {
1881
+ if (!batchId) throw new Error("batchId is required");
1882
+ let job = batchJobsMap.get(batchId);
1883
+ if (job) {
1884
+ return { success: true, batch: job };
1885
+ }
1886
+ const pending = pendingBatches.get(batchId);
1887
+ if (pending) {
1888
+ const pendingJob = {
1889
+ id: pending.id,
1890
+ createdAt: pending.createdAt,
1891
+ createdBy: pending.createdBy,
1892
+ status: pending.status,
1893
+ executionMode: pending.executionMode,
1894
+ scheduledFor: pending.scheduledFor,
1895
+ attribute: pending.payload.attribute,
1896
+ value: pending.payload.value,
1897
+ totalTargets: pending.summary.totalTargets,
1898
+ processedCount: 0,
1899
+ updatedCount: 0,
1900
+ skippedCount: 0,
1901
+ failedCount: 0,
1902
+ isLive: false,
1903
+ publishOnApproval: Boolean(pending.payload.publishOnApproval),
1904
+ policyMessage: pending.makerNotes,
1905
+ results: []
1906
+ };
1907
+ return { success: true, batch: pendingJob };
1908
+ }
1909
+ try {
1910
+ const auditService2 = strapi2.plugin("bulk-updater").service("audit");
1911
+ const historyData = await auditService2.getHistory();
1912
+ const records = Array.isArray(historyData?.history) ? historyData.history : [];
1913
+ const found = records.find((r) => r.batchId === batchId);
1914
+ if (found) {
1915
+ const total = found.totalTargets || found.updatedCount + found.skippedCount || 1;
1916
+ const historicalJob = {
1917
+ id: found.batchId,
1918
+ createdAt: found.executedAt || found.createdAt || (/* @__PURE__ */ new Date()).toISOString(),
1919
+ startedAt: found.executedAt || found.createdAt,
1920
+ completedAt: found.executedAt || found.createdAt,
1921
+ createdBy: {
1922
+ email: found.userEmail || "admin@strapi.io",
1923
+ firstname: found.userName || "Admin"
1924
+ },
1925
+ status: found.isRolledBack ? "failed" : "successful",
1926
+ executionMode: "immediate",
1927
+ attribute: found.attribute,
1928
+ value: found.value,
1929
+ totalTargets: total,
1930
+ processedCount: total,
1931
+ updatedCount: found.updatedCount || 0,
1932
+ skippedCount: found.skippedCount || 0,
1933
+ failedCount: 0,
1934
+ isLive: !found.isRolledBack && found.updatedCount > 0,
1935
+ publishOnApproval: true,
1936
+ results: (found.diffs || []).map((d) => ({
1937
+ uid: d.uid,
1938
+ collectionName: d.collectionName || d.uid,
1939
+ documentId: d.documentId,
1940
+ label: d.label || `Entry #${d.documentId}`,
1941
+ attribute: d.attribute || found.attribute,
1942
+ oldValue: d.oldValue,
1943
+ newValue: d.newValue,
1944
+ status: d.status || "updated",
1945
+ isLive: !found.isRolledBack,
1946
+ message: d.message || "Updated via bulk update"
1947
+ }))
1948
+ };
1949
+ batchJobsMap.set(batchId, historicalJob);
1950
+ return { success: true, batch: historicalJob };
1951
+ }
1952
+ } catch (err) {
1953
+ }
1954
+ throw new Error(`Batch with ID "${batchId}" was not found.`);
1955
+ },
1956
+ /**
1957
+ * Downloadable execution report (CSV or JSON)
1958
+ */
1959
+ async getBatchReport(batchId, format = "csv") {
1960
+ const statusRes = await this.getBatchStatus(batchId);
1961
+ const batch = statusRes.batch;
1962
+ function escapeCsvVal(val) {
1963
+ if (val === null || val === void 0) return "";
1964
+ let str2 = typeof val === "object" ? JSON.stringify(val) : String(val);
1965
+ if (str2.includes(",") || str2.includes('"') || str2.includes("\n") || str2.includes("\r")) {
1966
+ return `"${str2.replace(/"/g, '""')}"`;
1967
+ }
1968
+ return str2;
1969
+ }
1970
+ const headers = [
1971
+ "Batch ID",
1972
+ "Collection UID",
1973
+ "Collection Name",
1974
+ "Document ID",
1975
+ "Record Title / Identifier",
1976
+ "Attribute Name",
1977
+ "Previous Value",
1978
+ "New Live Value",
1979
+ "Processing Status",
1980
+ "Live On Server",
1981
+ "Details / Message",
1982
+ "Executed At",
1983
+ "Operator"
1984
+ ];
1985
+ const rows = [headers.join(",")];
1986
+ for (const item of batch.results || []) {
1987
+ const row = [
1988
+ escapeCsvVal(batch.id),
1989
+ escapeCsvVal(item.uid),
1990
+ escapeCsvVal(item.collectionName),
1991
+ escapeCsvVal(item.documentId),
1992
+ escapeCsvVal(item.label),
1993
+ escapeCsvVal(item.attribute),
1994
+ escapeCsvVal(item.oldValue),
1995
+ escapeCsvVal(item.newValue),
1996
+ escapeCsvVal(item.status === "updated" ? "Updated" : item.status === "error" ? "Failed" : "Skipped"),
1997
+ escapeCsvVal(item.isLive ? "YES (Live on Site)" : "NO"),
1998
+ escapeCsvVal(item.message),
1999
+ escapeCsvVal(batch.completedAt || batch.createdAt),
2000
+ escapeCsvVal(batch.createdBy?.email || "admin@strapi.io")
2001
+ ];
2002
+ rows.push(row.join(","));
2003
+ }
2004
+ const safeBatchId = batch.id.replace(/[^a-zA-Z0-9_-]/g, "_");
2005
+ const filename = `bulk_update_report_${safeBatchId}_${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.csv`;
2006
+ return {
2007
+ success: true,
2008
+ filename,
2009
+ csvContent: rows.join("\n"),
2010
+ summary: {
2011
+ batchId: batch.id,
2012
+ status: batch.status,
2013
+ isLive: batch.isLive,
2014
+ totalTargets: batch.totalTargets,
2015
+ updatedCount: batch.updatedCount,
2016
+ skippedCount: batch.skippedCount,
2017
+ failedCount: batch.failedCount,
2018
+ executedAt: batch.completedAt || batch.createdAt,
2019
+ operator: batch.createdBy?.email || "admin@strapi.io"
2020
+ },
2021
+ batch
2022
+ };
2023
+ },
2024
+ /**
2025
+ * Get recent batch jobs for the processing dashboard
2026
+ */
2027
+ async getRecentBatches() {
2028
+ const mergedMap = /* @__PURE__ */ new Map();
2029
+ for (const [id, job] of batchJobsMap.entries()) {
2030
+ mergedMap.set(id, {
2031
+ id: job.id,
2032
+ createdAt: job.createdAt,
2033
+ completedAt: job.completedAt,
2034
+ status: job.status,
2035
+ isLive: job.isLive,
2036
+ executionMode: job.executionMode,
2037
+ attribute: job.attribute,
2038
+ value: job.value,
2039
+ totalTargets: job.totalTargets,
2040
+ updatedCount: job.updatedCount,
2041
+ skippedCount: job.skippedCount,
2042
+ failedCount: job.failedCount,
2043
+ operator: job.createdBy?.email || "admin@strapi.io"
2044
+ });
2045
+ }
2046
+ for (const [id, p] of pendingBatches.entries()) {
2047
+ if (!mergedMap.has(id)) {
2048
+ mergedMap.set(id, {
2049
+ id: p.id,
2050
+ createdAt: p.createdAt,
2051
+ status: p.status,
2052
+ isLive: false,
2053
+ executionMode: p.executionMode,
2054
+ attribute: p.payload.attribute,
2055
+ value: p.payload.value,
2056
+ totalTargets: p.summary.totalTargets,
2057
+ updatedCount: 0,
2058
+ skippedCount: 0,
2059
+ failedCount: 0,
2060
+ operator: p.createdBy?.email || "admin@strapi.io"
2061
+ });
2062
+ }
2063
+ }
2064
+ try {
2065
+ const auditService2 = strapi2.plugin("bulk-updater").service("audit");
2066
+ const historyData = await auditService2.getHistory();
2067
+ const records = Array.isArray(historyData?.history) ? historyData.history : [];
2068
+ for (const r of records) {
2069
+ if (!mergedMap.has(r.batchId)) {
2070
+ mergedMap.set(r.batchId, {
2071
+ id: r.batchId,
2072
+ createdAt: r.executedAt || r.createdAt,
2073
+ completedAt: r.executedAt,
2074
+ status: r.isRolledBack ? "failed" : "successful",
2075
+ isLive: !r.isRolledBack && r.updatedCount > 0,
2076
+ executionMode: "immediate",
2077
+ attribute: r.attribute,
2078
+ value: r.value,
2079
+ totalTargets: r.totalTargets || r.updatedCount + r.skippedCount || 1,
2080
+ updatedCount: r.updatedCount || 0,
2081
+ skippedCount: r.skippedCount || 0,
2082
+ failedCount: 0,
2083
+ operator: r.userEmail || "admin@strapi.io"
2084
+ });
2085
+ }
2086
+ }
2087
+ } catch {
2088
+ }
2089
+ const batches = Array.from(mergedMap.values()).sort(
2090
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
2091
+ );
2092
+ return {
2093
+ success: true,
2094
+ batches: batches.slice(0, 50)
2095
+ };
2096
+ },
1631
2097
  invalidateCache(uid) {
1632
2098
  if (uid) {
1633
2099
  invalidateCollectionCache(uid);
@@ -1658,6 +2124,7 @@ const DEFAULT_FIELDS = {
1658
2124
  executedAt: "executedAt"
1659
2125
  };
1660
2126
  const DEFAULT_CANDIDATE_TYPES = [
2127
+ "plugin::bulk-updater.audit-log",
1661
2128
  "api::audit-log.audit-log",
1662
2129
  "api::audit.audit",
1663
2130
  "api::bulk-audit-log.bulk-audit-log"
@@ -1679,6 +2146,7 @@ const auditService = ({ strapi: strapi2 }) => ({
1679
2146
  * 1. Explicit consumer custom schema (full declarative control)
1680
2147
  * 2. Zero-config convention auto-detection (api::audit-log.audit-log)
1681
2148
  * 3. Strapi Enterprise native audit log bridge
2149
+ * 4. Zero-config plugin core store (works out of the box in 100% of Strapi installations)
1682
2150
  */
1683
2151
  getAuditConfig() {
1684
2152
  const rawConfig = strapi2.config.get("plugin::bulk-updater.audit");
@@ -1732,10 +2200,9 @@ const auditService = ({ strapi: strapi2 }) => ({
1732
2200
  };
1733
2201
  }
1734
2202
  return {
1735
- enabled: false,
1736
- mode: "disabled",
1737
- isEnterpriseNativeAvailable: false,
1738
- error: "No audit collection configured or detected. To enable rollback, provide a collection or use Strapi Enterprise."
2203
+ enabled: true,
2204
+ mode: "store",
2205
+ isEnterpriseNativeAvailable: false
1739
2206
  };
1740
2207
  },
1741
2208
  /**
@@ -1780,7 +2247,40 @@ const auditService = ({ strapi: strapi2 }) => ({
1780
2247
  },
1781
2248
  user
1782
2249
  );
1783
- if (!config.enabled || !config.contentType || !config.fields) {
2250
+ if (!config.enabled) {
2251
+ return null;
2252
+ }
2253
+ if (config.mode === "store") {
2254
+ try {
2255
+ const store = strapi2.store({ type: "plugin", name: "bulk-updater", key: "audit-history" });
2256
+ const stored = await store.get({}) || { history: [] };
2257
+ const history = Array.isArray(stored.history) ? stored.history : [];
2258
+ const newRecord = {
2259
+ id: batchSummary.batchId,
2260
+ documentId: batchSummary.batchId,
2261
+ batchId: batchSummary.batchId,
2262
+ attribute: batchSummary.attribute,
2263
+ value: batchSummary.value,
2264
+ userEmail: user?.email || "admin@strapi.io",
2265
+ userName: user?.firstname ? `${user.firstname} ${user.lastname || ""}`.trim() : user?.email || "Admin",
2266
+ updatedCount: batchSummary.updatedCount,
2267
+ skippedCount: batchSummary.skippedCount,
2268
+ totalTargets: batchSummary.totalTargets,
2269
+ diffs: results,
2270
+ isRolledBack: false,
2271
+ rolledBackAt: null,
2272
+ rolledBackBy: null,
2273
+ executedAt: (/* @__PURE__ */ new Date()).toISOString()
2274
+ };
2275
+ const updatedHistory = [newRecord, ...history].slice(0, 200);
2276
+ await store.set({ value: { history: updatedHistory } });
2277
+ return newRecord;
2278
+ } catch (err) {
2279
+ strapi2.log.error(`[bulk-updater] Failed to record audit log in core store: ${err.message}`);
2280
+ return null;
2281
+ }
2282
+ }
2283
+ if (!config.contentType || !config.fields) {
1784
2284
  return null;
1785
2285
  }
1786
2286
  const f = config.fields;
@@ -1821,6 +2321,25 @@ const auditService = ({ strapi: strapi2 }) => ({
1821
2321
  history: []
1822
2322
  };
1823
2323
  }
2324
+ if (config.mode === "store") {
2325
+ try {
2326
+ const store = strapi2.store({ type: "plugin", name: "bulk-updater", key: "audit-history" });
2327
+ const stored = await store.get({}) || { history: [] };
2328
+ const history = Array.isArray(stored.history) ? stored.history : [];
2329
+ return {
2330
+ enabled: true,
2331
+ mode: "store",
2332
+ isEnterpriseNativeAvailable: config.isEnterpriseNativeAvailable,
2333
+ history
2334
+ };
2335
+ } catch (err) {
2336
+ return {
2337
+ enabled: false,
2338
+ message: `Failed to read audit history from core store: ${err.message}`,
2339
+ history: []
2340
+ };
2341
+ }
2342
+ }
1824
2343
  if (!config.enabled || !config.contentType || !config.fields) {
1825
2344
  return {
1826
2345
  enabled: false,
@@ -1866,27 +2385,9 @@ const auditService = ({ strapi: strapi2 }) => ({
1866
2385
  }
1867
2386
  },
1868
2387
  /**
1869
- * Performs deterministic rollback of a batch
2388
+ * Helper to revert diff items back to original values
1870
2389
  */
1871
- async rollbackBatch(batchId, currentUser) {
1872
- const config = this.getAuditConfig();
1873
- if (!config.enabled || !config.contentType || !config.fields) {
1874
- throw new Error("Audit trail is not configured or disabled.");
1875
- }
1876
- const f = config.fields;
1877
- const records = await strapi2.documents(config.contentType).findMany({
1878
- filters: {
1879
- [f.batchId]: { $eq: batchId }
1880
- }
1881
- });
1882
- const record = records?.[0];
1883
- if (!record) {
1884
- throw new Error(`Batch record with ID "${batchId}" was not found in audit trail.`);
1885
- }
1886
- if (record[f.isRolledBack]) {
1887
- throw new Error(`Batch "${batchId}" has already been rolled back.`);
1888
- }
1889
- const diffs = typeof record[f.diffs] === "string" ? JSON.parse(record[f.diffs]) : record[f.diffs] || [];
2390
+ async revertDiffItems(diffs) {
1890
2391
  let revertedCount = 0;
1891
2392
  const rollbackDetails = [];
1892
2393
  for (const item of diffs) {
@@ -1971,6 +2472,78 @@ const auditService = ({ strapi: strapi2 }) => ({
1971
2472
  }
1972
2473
  }
1973
2474
  }
2475
+ return { revertedCount, rollbackDetails };
2476
+ },
2477
+ /**
2478
+ * Performs deterministic rollback of a batch
2479
+ */
2480
+ async rollbackBatch(batchId, currentUser) {
2481
+ const config = this.getAuditConfig();
2482
+ if (!config.enabled) {
2483
+ throw new Error("Audit trail is not configured or disabled.");
2484
+ }
2485
+ if (config.mode === "store") {
2486
+ const store = strapi2.store({ type: "plugin", name: "bulk-updater", key: "audit-history" });
2487
+ const stored = await store.get({}) || { history: [] };
2488
+ const history = Array.isArray(stored.history) ? stored.history : [];
2489
+ const recordIndex = history.findIndex((r) => r.batchId === batchId);
2490
+ if (recordIndex === -1) {
2491
+ throw new Error(`Batch record with ID "${batchId}" was not found in audit trail.`);
2492
+ }
2493
+ const record2 = history[recordIndex];
2494
+ if (record2.isRolledBack) {
2495
+ throw new Error(`Batch "${batchId}" has already been rolled back.`);
2496
+ }
2497
+ const diffs2 = Array.isArray(record2.diffs) ? record2.diffs : [];
2498
+ const { revertedCount: revertedCount2, rollbackDetails: rollbackDetails2 } = await this.revertDiffItems(diffs2);
2499
+ record2.isRolledBack = true;
2500
+ record2.rolledBackAt = (/* @__PURE__ */ new Date()).toISOString();
2501
+ record2.rolledBackBy = currentUser?.email || "Admin";
2502
+ history[recordIndex] = record2;
2503
+ await store.set({ value: { history } });
2504
+ try {
2505
+ const affectedUids = new Set(rollbackDetails2.map((d) => d.uid));
2506
+ const bulkService = strapi2.plugin("bulk-updater").service("bulkUpdater");
2507
+ affectedUids.forEach((uid) => {
2508
+ if (uid && bulkService?.invalidateCache) {
2509
+ bulkService.invalidateCache(uid);
2510
+ }
2511
+ });
2512
+ } catch {
2513
+ }
2514
+ await this.recordToEnterpriseNative(
2515
+ "bulk-updater.rollback",
2516
+ {
2517
+ batchId,
2518
+ revertedCount: revertedCount2
2519
+ },
2520
+ currentUser
2521
+ );
2522
+ return {
2523
+ success: true,
2524
+ batchId,
2525
+ revertedCount: revertedCount2,
2526
+ details: rollbackDetails2
2527
+ };
2528
+ }
2529
+ if (!config.contentType || !config.fields) {
2530
+ throw new Error("Audit trail is not configured or disabled.");
2531
+ }
2532
+ const f = config.fields;
2533
+ const records = await strapi2.documents(config.contentType).findMany({
2534
+ filters: {
2535
+ [f.batchId]: { $eq: batchId }
2536
+ }
2537
+ });
2538
+ const record = records?.[0];
2539
+ if (!record) {
2540
+ throw new Error(`Batch record with ID "${batchId}" was not found in audit trail.`);
2541
+ }
2542
+ if (record[f.isRolledBack]) {
2543
+ throw new Error(`Batch "${batchId}" has already been rolled back.`);
2544
+ }
2545
+ const diffs = typeof record[f.diffs] === "string" ? JSON.parse(record[f.diffs]) : record[f.diffs] || [];
2546
+ const { revertedCount, rollbackDetails } = await this.revertDiffItems(diffs);
1974
2547
  const updateData = {};
1975
2548
  if (f.isRolledBack) updateData[f.isRolledBack] = true;
1976
2549
  if (f.rolledBackAt) updateData[f.rolledBackAt] = (/* @__PURE__ */ new Date()).toISOString();
@@ -2123,56 +2696,102 @@ const validationRulesService = ({ strapi: strapi2 }) => ({
2123
2696
  const violations = [];
2124
2697
  let totalScanned = 0;
2125
2698
  const allContentTypes = strapi2.contentTypes || {};
2699
+ const allComponents = strapi2.components || {};
2700
+ function schemaHasField(ctSchema) {
2701
+ if (!ctSchema || !ctSchema.attributes) return { hasField: false, populateAttrs: {} };
2702
+ const rawAttrs = ctSchema.attributes;
2703
+ const populateAttrs = {};
2704
+ let hasField = Boolean(rawAttrs[targetField]);
2705
+ for (const [attrKey, attrDef] of Object.entries(rawAttrs)) {
2706
+ if (attrDef.type === "component" && attrDef.component) {
2707
+ const comp = allComponents[attrDef.component];
2708
+ if (comp?.attributes?.[targetField]) {
2709
+ hasField = true;
2710
+ populateAttrs[attrKey] = true;
2711
+ }
2712
+ } else if (attrDef.type === "dynamiczone" && Array.isArray(attrDef.components)) {
2713
+ const matchingComp = attrDef.components.some(
2714
+ (compUid) => allComponents[compUid]?.attributes?.[targetField]
2715
+ );
2716
+ if (matchingComp) {
2717
+ hasField = true;
2718
+ populateAttrs[attrKey] = true;
2719
+ }
2720
+ }
2721
+ }
2722
+ return { hasField, populateAttrs };
2723
+ }
2126
2724
  const candidateUids = Object.keys(allContentTypes).filter(
2127
2725
  (uid) => uid.startsWith("api::") && !uid.includes("bulk-updater")
2128
2726
  );
2727
+ const relevantCollections = [];
2129
2728
  for (const uid of candidateUids) {
2130
2729
  const ct = allContentTypes[uid];
2131
2730
  if (!ct) continue;
2132
- const displayName = ct.info?.displayName || uid;
2133
- try {
2134
- const entries = await strapi2.documents(uid).findMany({
2135
- populate: "*"
2731
+ const { hasField, populateAttrs } = schemaHasField(ct);
2732
+ if (hasField) {
2733
+ relevantCollections.push({
2734
+ uid,
2735
+ displayName: ct.info?.displayName || uid,
2736
+ populateOption: Object.keys(populateAttrs).length > 0 ? populateAttrs : void 0
2136
2737
  });
2137
- for (const entry of entries || []) {
2138
- let traverse = function(obj) {
2139
- if (!obj || typeof obj !== "object") return;
2140
- for (const key of Object.keys(obj)) {
2141
- const val = obj[key];
2142
- if (key === targetField && typeof val === "string" && val) {
2143
- const lower = val.toLowerCase();
2144
- counts[lower] = (counts[lower] || 0) + 1;
2145
- }
2146
- if (typeof val === "object" && val !== null) {
2147
- traverse(val);
2738
+ }
2739
+ }
2740
+ const BATCH_SIZE = 250;
2741
+ await Promise.all(
2742
+ relevantCollections.map(async ({ uid, displayName, populateOption }) => {
2743
+ try {
2744
+ let page = 1;
2745
+ while (true) {
2746
+ const entries = await strapi2.documents(uid).findMany({
2747
+ page,
2748
+ pageSize: BATCH_SIZE,
2749
+ populate: populateOption
2750
+ });
2751
+ if (!entries || entries.length === 0) break;
2752
+ for (const entry of entries) {
2753
+ let traverse = function(obj) {
2754
+ if (!obj || typeof obj !== "object") return;
2755
+ for (const key of Object.keys(obj)) {
2756
+ const val = obj[key];
2757
+ if (key === targetField && typeof val === "string" && val) {
2758
+ const lower = val.toLowerCase();
2759
+ counts[lower] = (counts[lower] || 0) + 1;
2760
+ }
2761
+ if (typeof val === "object" && val !== null) {
2762
+ traverse(val);
2763
+ }
2764
+ }
2765
+ };
2766
+ totalScanned++;
2767
+ const counts = {};
2768
+ traverse(entry);
2769
+ for (const [optKey, max] of Object.entries(quotas)) {
2770
+ const lowerOpt = optKey.toLowerCase();
2771
+ const actualCount = counts[lowerOpt] || 0;
2772
+ if (actualCount > max) {
2773
+ const entryLabel = entry.title || entry.name || entry.email || `Entry #${entry.documentId}`;
2774
+ violations.push({
2775
+ uid,
2776
+ collectionDisplayName: displayName,
2777
+ documentId: entry.documentId,
2778
+ entryLabel,
2779
+ violatingOption: optKey.toUpperCase(),
2780
+ foundCount: actualCount,
2781
+ maxAllowed: max,
2782
+ editUrl: `/admin/content-manager/collection-types/${uid}/${entry.documentId}`
2783
+ });
2784
+ }
2148
2785
  }
2149
2786
  }
2150
- };
2151
- totalScanned++;
2152
- const counts = {};
2153
- traverse(entry);
2154
- for (const [optKey, max] of Object.entries(quotas)) {
2155
- const lowerOpt = optKey.toLowerCase();
2156
- const actualCount = counts[lowerOpt] || 0;
2157
- if (actualCount > max) {
2158
- const entryLabel = entry.title || entry.name || entry.email || `Entry #${entry.documentId}`;
2159
- violations.push({
2160
- uid,
2161
- collectionDisplayName: displayName,
2162
- documentId: entry.documentId,
2163
- entryLabel,
2164
- violatingOption: optKey.toUpperCase(),
2165
- foundCount: actualCount,
2166
- maxAllowed: max,
2167
- editUrl: `/admin/content-manager/collection-types/${uid}/${entry.documentId}`
2168
- });
2169
- }
2787
+ if (entries.length < BATCH_SIZE) break;
2788
+ page++;
2170
2789
  }
2790
+ } catch (err) {
2791
+ strapi2.log.warn(`[bulk-updater] Failed scanning ${uid} for rule ${ruleId}:`, err);
2171
2792
  }
2172
- } catch (err) {
2173
- strapi2.log.warn(`[bulk-updater] Failed scanning ${uid} for rule ${ruleId}:`, err);
2174
- }
2175
- }
2793
+ })
2794
+ );
2176
2795
  return {
2177
2796
  rule,
2178
2797
  totalScanned,
@@ -2474,12 +3093,13 @@ ${sampleRow.map(escapeCsvValue).join(",")}
2474
3093
  throw new Error("CSV is empty or could not be parsed.");
2475
3094
  }
2476
3095
  const attrs = ct.attributes || {};
2477
- const { mode: mode2, publish = true, user } = options;
3096
+ const { mode: mode2 = "auto", publish = true, user } = options;
2478
3097
  const stats = {
2479
3098
  total: parsedRows.length,
2480
3099
  created: 0,
2481
3100
  updated: 0,
2482
3101
  skipped: 0,
3102
+ newWithoutDocId: 0,
2483
3103
  errors: [],
2484
3104
  diffs: []
2485
3105
  };
@@ -2551,7 +3171,7 @@ ${sampleRow.map(escapeCsvValue).join(",")}
2551
3171
  stats.skipped++;
2552
3172
  stats.errors.push({
2553
3173
  row: rowNumber,
2554
- error: "Missing documentId for Update mode."
3174
+ error: "Missing documentId for Update mode. (Tip: Use Smart Auto-Detect mode to auto-add entries without document IDs)."
2555
3175
  });
2556
3176
  continue;
2557
3177
  }
@@ -2596,7 +3216,7 @@ ${sampleRow.map(escapeCsvValue).join(",")}
2596
3216
  status: "updated",
2597
3217
  message: `Updated via CSV import (row ${rowNumber})`
2598
3218
  });
2599
- } else {
3219
+ } else if (mode2 === "create_or_override") {
2600
3220
  if (docId) {
2601
3221
  const existing = await strapi2.documents(uid).findOne({
2602
3222
  documentId: docId
@@ -2646,6 +3266,73 @@ ${sampleRow.map(escapeCsvValue).join(",")}
2646
3266
  }
2647
3267
  }
2648
3268
  stats.created++;
3269
+ if (!docId) {
3270
+ stats.newWithoutDocId++;
3271
+ }
3272
+ stats.diffs.push({
3273
+ uid,
3274
+ collectionName: ct.info?.displayName || uid,
3275
+ documentId: newDocId,
3276
+ label: created?.title || created?.name || `New Entry #${newDocId}`,
3277
+ attribute: Object.keys(payload).join(", "),
3278
+ oldValue: null,
3279
+ newValue: payload,
3280
+ status: "updated",
3281
+ message: docId ? `Created via CSV import (row ${rowNumber})` : `Created as new entry (no documentId provided in CSV - row ${rowNumber})`
3282
+ });
3283
+ } else {
3284
+ if (docId) {
3285
+ const existing = await strapi2.documents(uid).findOne({
3286
+ documentId: docId
3287
+ });
3288
+ if (existing) {
3289
+ const mergedPayload = { ...payload };
3290
+ for (const key of Object.keys(payload)) {
3291
+ if (attrs[key]?.type === "component" && typeof payload[key] === "object" && payload[key] !== null) {
3292
+ mergedPayload[key] = { ...existing[key] || {}, ...payload[key] };
3293
+ }
3294
+ }
3295
+ await strapi2.documents(uid).update({
3296
+ documentId: docId,
3297
+ data: mergedPayload
3298
+ });
3299
+ if (publish && ct.options?.draftAndPublish) {
3300
+ try {
3301
+ await strapi2.documents(uid).publish({ documentId: docId });
3302
+ } catch (pubErr) {
3303
+ strapi2.log.warn(`[bulk-updater] Auto-publish failed for ${docId}:`, pubErr);
3304
+ }
3305
+ }
3306
+ stats.updated++;
3307
+ stats.diffs.push({
3308
+ uid,
3309
+ collectionName: ct.info?.displayName || uid,
3310
+ documentId: docId,
3311
+ label: existing.title || existing.name || `Entry #${docId}`,
3312
+ attribute: Object.keys(payload).join(", "),
3313
+ oldValue: existing,
3314
+ newValue: payload,
3315
+ status: "updated",
3316
+ message: `Updated existing entry via smart match (row ${rowNumber})`
3317
+ });
3318
+ continue;
3319
+ }
3320
+ }
3321
+ const created = await strapi2.documents(uid).create({
3322
+ data: payload
3323
+ });
3324
+ const newDocId = created?.documentId || docId;
3325
+ if (publish && ct.options?.draftAndPublish && newDocId) {
3326
+ try {
3327
+ await strapi2.documents(uid).publish({ documentId: newDocId });
3328
+ } catch (pubErr) {
3329
+ strapi2.log.warn(`[bulk-updater] Auto-publish failed for ${newDocId}:`, pubErr);
3330
+ }
3331
+ }
3332
+ stats.created++;
3333
+ if (!docId) {
3334
+ stats.newWithoutDocId++;
3335
+ }
2649
3336
  stats.diffs.push({
2650
3337
  uid,
2651
3338
  collectionName: ct.info?.displayName || uid,
@@ -2655,7 +3342,7 @@ ${sampleRow.map(escapeCsvValue).join(",")}
2655
3342
  oldValue: null,
2656
3343
  newValue: payload,
2657
3344
  status: "updated",
2658
- message: `Created via CSV import (row ${rowNumber})`
3345
+ 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})`
2659
3346
  });
2660
3347
  }
2661
3348
  } catch (err) {
@@ -32213,6 +32900,7 @@ const index = {
32213
32900
  });
32214
32901
  },
32215
32902
  routes,
32903
+ contentTypes,
32216
32904
  controllers: {
32217
32905
  bulkUpdater: bulkUpdaterController,
32218
32906
  audit: auditController,