strapi-plugin-bulk-updater 1.2.3 → 1.2.8

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",
@@ -363,6 +387,39 @@ const bulkUpdaterController = ({ strapi: strapi2 }) => ({
363
387
  ctx.status = 400;
364
388
  ctx.body = { success: false, error: err.message };
365
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
+ }
366
423
  }
367
424
  });
368
425
  const auditController = ({ strapi: strapi2 }) => ({
@@ -535,7 +592,7 @@ const csvController = ({ strapi: strapi2 }) => ({
535
592
  }
536
593
  const csvService2 = strapi2.plugin("bulk-updater").service("csv");
537
594
  const result = await csvService2.importCsv(uid, csvText, {
538
- mode: mode2 === "create_or_override" ? "create_or_override" : "update",
595
+ mode: mode2 === "update" ? "update" : mode2 === "create_or_override" ? "create_or_override" : "auto",
539
596
  publish: publish !== false,
540
597
  user: ctx.state.user
541
598
  });
@@ -608,6 +665,7 @@ function checkBusinessHours(config, testDate) {
608
665
  };
609
666
  }
610
667
  const pendingBatches = /* @__PURE__ */ new Map();
668
+ const batchJobsMap = /* @__PURE__ */ new Map();
611
669
  let isRunnerRunning = false;
612
670
  let runnerInterval = null;
613
671
  function invalidateCollectionCache(uid) {
@@ -707,6 +765,31 @@ async function executeBatchInternal(strapi2, {
707
765
  const results = [];
708
766
  let updatedCount = 0;
709
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);
710
793
  let regexObj = null;
711
794
  if (isRegex && regexPattern) {
712
795
  regexObj = new RegExp(regexPattern, "g");
@@ -941,11 +1024,24 @@ async function executeBatchInternal(strapi2, {
941
1024
  else if (res.status === "skipped") skippedCount++;
942
1025
  results.push(res);
943
1026
  }
1027
+ jobState.processedCount = results.length;
1028
+ jobState.updatedCount = updatedCount;
1029
+ jobState.skippedCount = skippedCount;
1030
+ jobState.failedCount = results.filter((r) => r.status === "error").length;
944
1031
  }
945
1032
  const affectedUids = new Set(targets.map((t) => t.uid));
946
1033
  affectedUids.forEach((uid) => {
947
1034
  invalidateCollectionCache(uid);
948
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
+ }));
949
1045
  const batchSummary = {
950
1046
  batchId,
951
1047
  attribute,
@@ -967,7 +1063,10 @@ async function executeBatchInternal(strapi2, {
967
1063
  value,
968
1064
  updatedCount,
969
1065
  skippedCount,
1066
+ failedCount,
970
1067
  totalTargets: targetsMap.size,
1068
+ isLive: jobState.isLive,
1069
+ status: jobState.status,
971
1070
  results
972
1071
  };
973
1072
  }
@@ -1095,69 +1194,87 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1095
1194
  }
1096
1195
  const collections = [];
1097
1196
  const signatures = [];
1098
- for (const uid of staticSchemaCache.targetUids) {
1099
- const meta = staticSchemaCache.metaByUid[uid];
1100
- if (!meta) continue;
1101
- const signature = await getCollectionSignature(strapi2, uid);
1102
- signatures.push(`${uid}=${signature}`);
1103
- const cached = modelsCache.get(uid);
1104
- if (cached && cached.signature === signature) {
1105
- collections.push({
1106
- ...meta,
1107
- totalEntries: cached.totalCount,
1108
- entries: cached.entries
1109
- });
1110
- } else {
1111
- let entries = [];
1112
- let totalCount = 0;
1113
- try {
1114
- const ctAttrs = allContentTypes[uid]?.attributes || {};
1115
- const relKeys = Object.keys(meta.relations || {});
1116
- const compKeys = Object.keys(ctAttrs).filter((k) => ctAttrs[k].type === "component");
1117
- const populateOptionObj = {};
1118
- relKeys.forEach((k) => {
1119
- populateOptionObj[k] = true;
1120
- });
1121
- compKeys.forEach((k) => {
1122
- populateOptionObj[k] = true;
1123
- });
1124
- const populateOption = Object.keys(populateOptionObj).length > 0 ? populateOptionObj : void 0;
1125
- if (meta.isSingleType) {
1126
- const singleDoc = await strapi2.documents(uid).findFirst({
1127
- 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;
1128
1226
  });
1129
- if (singleDoc) {
1130
- entries = [singleDoc];
1131
- totalCount = 1;
1132
- }
1133
- } else {
1134
- totalCount = await strapi2.documents(uid).count();
1135
- entries = await strapi2.documents(uid).findMany({
1136
- limit: 10,
1137
- sort: { createdAt: "desc" },
1138
- populate: populateOption
1227
+ compKeys.forEach((k) => {
1228
+ populateOptionObj[k] = true;
1139
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;
1140
1251
  }
1141
- } catch (err) {
1142
- strapi2.log.error(`[bulk-updater] Failed to fetch entries for ${uid}:`, err);
1143
- entries = [];
1144
- totalCount = 0;
1145
- }
1146
- const formattedEntries = (entries || []).map((entry) => ({
1147
- id: entry.id,
1148
- documentId: entry.documentId,
1149
- ...entry
1150
- }));
1151
- modelsCache.set(uid, {
1152
- signature,
1153
- totalCount,
1154
- entries: formattedEntries
1155
- });
1156
- collections.push({
1157
- ...meta,
1158
- totalEntries: totalCount,
1159
- entries: formattedEntries
1160
- });
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);
1161
1278
  }
1162
1279
  }
1163
1280
  const executionConfig = getExecutionWindowConfig(strapi2);
@@ -1476,6 +1593,24 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1476
1593
  makerNotes
1477
1594
  };
1478
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
+ });
1479
1614
  return {
1480
1615
  success: true,
1481
1616
  pending: true,
@@ -1515,6 +1650,25 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1515
1650
  makerNotes
1516
1651
  };
1517
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
+ });
1518
1672
  return {
1519
1673
  success: true,
1520
1674
  pending: true,
@@ -1555,6 +1709,26 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1555
1709
  makerNotes: "Automatically queued due to peak business hours restriction."
1556
1710
  };
1557
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
+ });
1558
1732
  return {
1559
1733
  success: true,
1560
1734
  pending: true,
@@ -1687,6 +1861,226 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1687
1861
  result
1688
1862
  };
1689
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
+ },
1690
2084
  invalidateCache(uid) {
1691
2085
  if (uid) {
1692
2086
  invalidateCollectionCache(uid);
@@ -1798,6 +2192,33 @@ const auditService = ({ strapi: strapi2 }) => ({
1798
2192
  isEnterpriseNativeAvailable: false
1799
2193
  };
1800
2194
  },
2195
+ /**
2196
+ * Checks whether audit logging is enabled.
2197
+ */
2198
+ isAuditEnabled() {
2199
+ const config = this.getAuditConfig();
2200
+ return Boolean(config && config.enabled);
2201
+ },
2202
+ /**
2203
+ * Convenience alias to log an operation into the audit trail.
2204
+ */
2205
+ async logOperation(data) {
2206
+ return this.recordBatch(
2207
+ {
2208
+ batchId: data.batchId,
2209
+ attribute: data.attribute,
2210
+ value: data.value,
2211
+ updatedCount: data.updatedCount,
2212
+ skippedCount: data.skippedCount,
2213
+ totalTargets: data.totalTargets
2214
+ },
2215
+ data.diffs || [],
2216
+ {
2217
+ email: data.userEmail,
2218
+ firstname: data.userName
2219
+ }
2220
+ );
2221
+ },
1801
2222
  /**
1802
2223
  * Optionally writes an audit event directly to Strapi Enterprise's native Audit Logs table.
1803
2224
  */
@@ -2289,56 +2710,102 @@ const validationRulesService = ({ strapi: strapi2 }) => ({
2289
2710
  const violations = [];
2290
2711
  let totalScanned = 0;
2291
2712
  const allContentTypes = strapi2.contentTypes || {};
2713
+ const allComponents = strapi2.components || {};
2714
+ function schemaHasField(ctSchema) {
2715
+ if (!ctSchema || !ctSchema.attributes) return { hasField: false, populateAttrs: {} };
2716
+ const rawAttrs = ctSchema.attributes;
2717
+ const populateAttrs = {};
2718
+ let hasField = Boolean(rawAttrs[targetField]);
2719
+ for (const [attrKey, attrDef] of Object.entries(rawAttrs)) {
2720
+ if (attrDef.type === "component" && attrDef.component) {
2721
+ const comp = allComponents[attrDef.component];
2722
+ if (comp?.attributes?.[targetField]) {
2723
+ hasField = true;
2724
+ populateAttrs[attrKey] = true;
2725
+ }
2726
+ } else if (attrDef.type === "dynamiczone" && Array.isArray(attrDef.components)) {
2727
+ const matchingComp = attrDef.components.some(
2728
+ (compUid) => allComponents[compUid]?.attributes?.[targetField]
2729
+ );
2730
+ if (matchingComp) {
2731
+ hasField = true;
2732
+ populateAttrs[attrKey] = true;
2733
+ }
2734
+ }
2735
+ }
2736
+ return { hasField, populateAttrs };
2737
+ }
2292
2738
  const candidateUids = Object.keys(allContentTypes).filter(
2293
2739
  (uid) => uid.startsWith("api::") && !uid.includes("bulk-updater")
2294
2740
  );
2741
+ const relevantCollections = [];
2295
2742
  for (const uid of candidateUids) {
2296
2743
  const ct = allContentTypes[uid];
2297
2744
  if (!ct) continue;
2298
- const displayName = ct.info?.displayName || uid;
2299
- try {
2300
- const entries = await strapi2.documents(uid).findMany({
2301
- populate: "*"
2745
+ const { hasField, populateAttrs } = schemaHasField(ct);
2746
+ if (hasField) {
2747
+ relevantCollections.push({
2748
+ uid,
2749
+ displayName: ct.info?.displayName || uid,
2750
+ populateOption: Object.keys(populateAttrs).length > 0 ? populateAttrs : void 0
2302
2751
  });
2303
- for (const entry of entries || []) {
2304
- let traverse = function(obj) {
2305
- if (!obj || typeof obj !== "object") return;
2306
- for (const key of Object.keys(obj)) {
2307
- const val = obj[key];
2308
- if (key === targetField && typeof val === "string" && val) {
2309
- const lower = val.toLowerCase();
2310
- counts[lower] = (counts[lower] || 0) + 1;
2311
- }
2312
- if (typeof val === "object" && val !== null) {
2313
- traverse(val);
2752
+ }
2753
+ }
2754
+ const BATCH_SIZE = 250;
2755
+ await Promise.all(
2756
+ relevantCollections.map(async ({ uid, displayName, populateOption }) => {
2757
+ try {
2758
+ let page = 1;
2759
+ while (true) {
2760
+ const entries = await strapi2.documents(uid).findMany({
2761
+ page,
2762
+ pageSize: BATCH_SIZE,
2763
+ populate: populateOption
2764
+ });
2765
+ if (!entries || entries.length === 0) break;
2766
+ for (const entry of entries) {
2767
+ let traverse = function(obj) {
2768
+ if (!obj || typeof obj !== "object") return;
2769
+ for (const key of Object.keys(obj)) {
2770
+ const val = obj[key];
2771
+ if (key === targetField && typeof val === "string" && val) {
2772
+ const lower = val.toLowerCase();
2773
+ counts[lower] = (counts[lower] || 0) + 1;
2774
+ }
2775
+ if (typeof val === "object" && val !== null) {
2776
+ traverse(val);
2777
+ }
2778
+ }
2779
+ };
2780
+ totalScanned++;
2781
+ const counts = {};
2782
+ traverse(entry);
2783
+ for (const [optKey, max] of Object.entries(quotas)) {
2784
+ const lowerOpt = optKey.toLowerCase();
2785
+ const actualCount = counts[lowerOpt] || 0;
2786
+ if (actualCount > max) {
2787
+ const entryLabel = entry.title || entry.name || entry.email || `Entry #${entry.documentId}`;
2788
+ violations.push({
2789
+ uid,
2790
+ collectionDisplayName: displayName,
2791
+ documentId: entry.documentId,
2792
+ entryLabel,
2793
+ violatingOption: optKey.toUpperCase(),
2794
+ foundCount: actualCount,
2795
+ maxAllowed: max,
2796
+ editUrl: `/admin/content-manager/collection-types/${uid}/${entry.documentId}`
2797
+ });
2798
+ }
2314
2799
  }
2315
2800
  }
2316
- };
2317
- totalScanned++;
2318
- const counts = {};
2319
- traverse(entry);
2320
- for (const [optKey, max] of Object.entries(quotas)) {
2321
- const lowerOpt = optKey.toLowerCase();
2322
- const actualCount = counts[lowerOpt] || 0;
2323
- if (actualCount > max) {
2324
- const entryLabel = entry.title || entry.name || entry.email || `Entry #${entry.documentId}`;
2325
- violations.push({
2326
- uid,
2327
- collectionDisplayName: displayName,
2328
- documentId: entry.documentId,
2329
- entryLabel,
2330
- violatingOption: optKey.toUpperCase(),
2331
- foundCount: actualCount,
2332
- maxAllowed: max,
2333
- editUrl: `/admin/content-manager/collection-types/${uid}/${entry.documentId}`
2334
- });
2335
- }
2801
+ if (entries.length < BATCH_SIZE) break;
2802
+ page++;
2336
2803
  }
2804
+ } catch (err) {
2805
+ strapi2.log.warn(`[bulk-updater] Failed scanning ${uid} for rule ${ruleId}:`, err);
2337
2806
  }
2338
- } catch (err) {
2339
- strapi2.log.warn(`[bulk-updater] Failed scanning ${uid} for rule ${ruleId}:`, err);
2340
- }
2341
- }
2807
+ })
2808
+ );
2342
2809
  return {
2343
2810
  rule,
2344
2811
  totalScanned,
@@ -2640,12 +3107,13 @@ ${sampleRow.map(escapeCsvValue).join(",")}
2640
3107
  throw new Error("CSV is empty or could not be parsed.");
2641
3108
  }
2642
3109
  const attrs = ct.attributes || {};
2643
- const { mode: mode2, publish = true, user } = options;
3110
+ const { mode: mode2 = "auto", publish = true, user } = options;
2644
3111
  const stats = {
2645
3112
  total: parsedRows.length,
2646
3113
  created: 0,
2647
3114
  updated: 0,
2648
3115
  skipped: 0,
3116
+ newWithoutDocId: 0,
2649
3117
  errors: [],
2650
3118
  diffs: []
2651
3119
  };
@@ -2717,7 +3185,7 @@ ${sampleRow.map(escapeCsvValue).join(",")}
2717
3185
  stats.skipped++;
2718
3186
  stats.errors.push({
2719
3187
  row: rowNumber,
2720
- error: "Missing documentId for Update mode."
3188
+ error: "Missing documentId for Update mode. (Tip: Use Smart Auto-Detect mode to auto-add entries without document IDs)."
2721
3189
  });
2722
3190
  continue;
2723
3191
  }
@@ -2762,7 +3230,7 @@ ${sampleRow.map(escapeCsvValue).join(",")}
2762
3230
  status: "updated",
2763
3231
  message: `Updated via CSV import (row ${rowNumber})`
2764
3232
  });
2765
- } else {
3233
+ } else if (mode2 === "create_or_override") {
2766
3234
  if (docId) {
2767
3235
  const existing = await strapi2.documents(uid).findOne({
2768
3236
  documentId: docId
@@ -2812,6 +3280,9 @@ ${sampleRow.map(escapeCsvValue).join(",")}
2812
3280
  }
2813
3281
  }
2814
3282
  stats.created++;
3283
+ if (!docId) {
3284
+ stats.newWithoutDocId++;
3285
+ }
2815
3286
  stats.diffs.push({
2816
3287
  uid,
2817
3288
  collectionName: ct.info?.displayName || uid,
@@ -2821,7 +3292,71 @@ ${sampleRow.map(escapeCsvValue).join(",")}
2821
3292
  oldValue: null,
2822
3293
  newValue: payload,
2823
3294
  status: "updated",
2824
- message: `Created via CSV import (row ${rowNumber})`
3295
+ message: docId ? `Created via CSV import (row ${rowNumber})` : `Created as new entry (no documentId provided in CSV - row ${rowNumber})`
3296
+ });
3297
+ } else {
3298
+ if (docId) {
3299
+ const existing = await strapi2.documents(uid).findOne({
3300
+ documentId: docId
3301
+ });
3302
+ if (existing) {
3303
+ const mergedPayload = { ...payload };
3304
+ for (const key of Object.keys(payload)) {
3305
+ if (attrs[key]?.type === "component" && typeof payload[key] === "object" && payload[key] !== null) {
3306
+ mergedPayload[key] = { ...existing[key] || {}, ...payload[key] };
3307
+ }
3308
+ }
3309
+ await strapi2.documents(uid).update({
3310
+ documentId: docId,
3311
+ data: mergedPayload
3312
+ });
3313
+ if (publish && ct.options?.draftAndPublish) {
3314
+ try {
3315
+ await strapi2.documents(uid).publish({ documentId: docId });
3316
+ } catch (pubErr) {
3317
+ strapi2.log.warn(`[bulk-updater] Auto-publish failed for ${docId}:`, pubErr);
3318
+ }
3319
+ }
3320
+ stats.updated++;
3321
+ stats.diffs.push({
3322
+ uid,
3323
+ collectionName: ct.info?.displayName || uid,
3324
+ documentId: docId,
3325
+ label: existing.title || existing.name || `Entry #${docId}`,
3326
+ attribute: Object.keys(payload).join(", "),
3327
+ oldValue: existing,
3328
+ newValue: payload,
3329
+ status: "updated",
3330
+ message: `Updated existing entry via smart match (row ${rowNumber})`
3331
+ });
3332
+ continue;
3333
+ }
3334
+ }
3335
+ const created = await strapi2.documents(uid).create({
3336
+ data: payload
3337
+ });
3338
+ const newDocId = created?.documentId || docId;
3339
+ if (publish && ct.options?.draftAndPublish && newDocId) {
3340
+ try {
3341
+ await strapi2.documents(uid).publish({ documentId: newDocId });
3342
+ } catch (pubErr) {
3343
+ strapi2.log.warn(`[bulk-updater] Auto-publish failed for ${newDocId}:`, pubErr);
3344
+ }
3345
+ }
3346
+ stats.created++;
3347
+ if (!docId) {
3348
+ stats.newWithoutDocId++;
3349
+ }
3350
+ stats.diffs.push({
3351
+ uid,
3352
+ collectionName: ct.info?.displayName || uid,
3353
+ documentId: newDocId,
3354
+ label: created?.title || created?.name || `New Entry #${newDocId}`,
3355
+ attribute: Object.keys(payload).join(", "),
3356
+ oldValue: null,
3357
+ newValue: payload,
3358
+ status: "updated",
3359
+ 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})`
2825
3360
  });
2826
3361
  }
2827
3362
  } catch (err) {
@@ -2842,18 +3377,32 @@ ${sampleRow.map(escapeCsvValue).join(",")}
2842
3377
  if (stats.updated > 0 || stats.created > 0) {
2843
3378
  try {
2844
3379
  const auditService2 = strapi2.plugin("bulk-updater").service("audit");
2845
- if (auditService2 && auditService2.isAuditEnabled()) {
2846
- await auditService2.logOperation({
2847
- batchId,
2848
- attribute: "CSV_IMPORT",
2849
- value: `Mode: ${mode2} (${stats.created} created, ${stats.updated} updated)`,
2850
- userEmail,
2851
- userName,
2852
- updatedCount: stats.updated + stats.created,
2853
- skippedCount: stats.skipped,
2854
- totalTargets: stats.total,
2855
- diffs: stats.diffs
2856
- });
3380
+ if (auditService2) {
3381
+ const isEnabled = typeof auditService2.isAuditEnabled === "function" ? auditService2.isAuditEnabled() : Boolean(auditService2.getAuditConfig?.().enabled ?? true);
3382
+ if (isEnabled) {
3383
+ const batchSummary = {
3384
+ batchId,
3385
+ attribute: "CSV_IMPORT",
3386
+ value: `Mode: ${mode2} (${stats.created} created, ${stats.updated} updated)`,
3387
+ updatedCount: stats.updated + stats.created,
3388
+ skippedCount: stats.skipped,
3389
+ totalTargets: stats.total
3390
+ };
3391
+ const userObj = {
3392
+ email: userEmail || "admin@strapi.io",
3393
+ firstname: userName || "Admin"
3394
+ };
3395
+ if (typeof auditService2.recordBatch === "function") {
3396
+ await auditService2.recordBatch(batchSummary, stats.diffs, userObj);
3397
+ } else if (typeof auditService2.logOperation === "function") {
3398
+ await auditService2.logOperation({
3399
+ ...batchSummary,
3400
+ userEmail,
3401
+ userName,
3402
+ diffs: stats.diffs
3403
+ });
3404
+ }
3405
+ }
2857
3406
  }
2858
3407
  } catch (auditErr) {
2859
3408
  strapi2.log.warn("[bulk-updater] Failed logging CSV import audit:", auditErr);