strapi-plugin-bulk-updater 1.2.3 → 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",
@@ -376,6 +400,39 @@ const bulkUpdaterController = ({ strapi: strapi2 }) => ({
376
400
  ctx.status = 400;
377
401
  ctx.body = { success: false, error: err.message };
378
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
+ }
379
436
  }
380
437
  });
381
438
  const auditController = ({ strapi: strapi2 }) => ({
@@ -548,7 +605,7 @@ const csvController = ({ strapi: strapi2 }) => ({
548
605
  }
549
606
  const csvService2 = strapi2.plugin("bulk-updater").service("csv");
550
607
  const result = await csvService2.importCsv(uid, csvText, {
551
- mode: mode2 === "create_or_override" ? "create_or_override" : "update",
608
+ mode: mode2 === "update" ? "update" : mode2 === "create_or_override" ? "create_or_override" : "auto",
552
609
  publish: publish !== false,
553
610
  user: ctx.state.user
554
611
  });
@@ -621,6 +678,7 @@ function checkBusinessHours(config, testDate) {
621
678
  };
622
679
  }
623
680
  const pendingBatches = /* @__PURE__ */ new Map();
681
+ const batchJobsMap = /* @__PURE__ */ new Map();
624
682
  let isRunnerRunning = false;
625
683
  let runnerInterval = null;
626
684
  function invalidateCollectionCache(uid) {
@@ -720,6 +778,31 @@ async function executeBatchInternal(strapi2, {
720
778
  const results = [];
721
779
  let updatedCount = 0;
722
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);
723
806
  let regexObj = null;
724
807
  if (isRegex && regexPattern) {
725
808
  regexObj = new RegExp(regexPattern, "g");
@@ -954,11 +1037,24 @@ async function executeBatchInternal(strapi2, {
954
1037
  else if (res.status === "skipped") skippedCount++;
955
1038
  results.push(res);
956
1039
  }
1040
+ jobState.processedCount = results.length;
1041
+ jobState.updatedCount = updatedCount;
1042
+ jobState.skippedCount = skippedCount;
1043
+ jobState.failedCount = results.filter((r) => r.status === "error").length;
957
1044
  }
958
1045
  const affectedUids = new Set(targets.map((t) => t.uid));
959
1046
  affectedUids.forEach((uid) => {
960
1047
  invalidateCollectionCache(uid);
961
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
+ }));
962
1058
  const batchSummary = {
963
1059
  batchId,
964
1060
  attribute,
@@ -980,7 +1076,10 @@ async function executeBatchInternal(strapi2, {
980
1076
  value,
981
1077
  updatedCount,
982
1078
  skippedCount,
1079
+ failedCount,
983
1080
  totalTargets: targetsMap.size,
1081
+ isLive: jobState.isLive,
1082
+ status: jobState.status,
984
1083
  results
985
1084
  };
986
1085
  }
@@ -1108,69 +1207,87 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1108
1207
  }
1109
1208
  const collections = [];
1110
1209
  const signatures = [];
1111
- for (const uid of staticSchemaCache.targetUids) {
1112
- const meta = staticSchemaCache.metaByUid[uid];
1113
- if (!meta) continue;
1114
- const signature = await getCollectionSignature(strapi2, uid);
1115
- signatures.push(`${uid}=${signature}`);
1116
- const cached = modelsCache.get(uid);
1117
- if (cached && cached.signature === signature) {
1118
- collections.push({
1119
- ...meta,
1120
- totalEntries: cached.totalCount,
1121
- entries: cached.entries
1122
- });
1123
- } else {
1124
- let entries = [];
1125
- let totalCount = 0;
1126
- try {
1127
- const ctAttrs = allContentTypes[uid]?.attributes || {};
1128
- const relKeys = Object.keys(meta.relations || {});
1129
- const compKeys = Object.keys(ctAttrs).filter((k) => ctAttrs[k].type === "component");
1130
- const populateOptionObj = {};
1131
- relKeys.forEach((k) => {
1132
- populateOptionObj[k] = true;
1133
- });
1134
- compKeys.forEach((k) => {
1135
- populateOptionObj[k] = true;
1136
- });
1137
- const populateOption = Object.keys(populateOptionObj).length > 0 ? populateOptionObj : void 0;
1138
- if (meta.isSingleType) {
1139
- const singleDoc = await strapi2.documents(uid).findFirst({
1140
- 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;
1141
1239
  });
1142
- if (singleDoc) {
1143
- entries = [singleDoc];
1144
- totalCount = 1;
1145
- }
1146
- } else {
1147
- totalCount = await strapi2.documents(uid).count();
1148
- entries = await strapi2.documents(uid).findMany({
1149
- limit: 10,
1150
- sort: { createdAt: "desc" },
1151
- populate: populateOption
1240
+ compKeys.forEach((k) => {
1241
+ populateOptionObj[k] = true;
1152
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;
1153
1264
  }
1154
- } catch (err) {
1155
- strapi2.log.error(`[bulk-updater] Failed to fetch entries for ${uid}:`, err);
1156
- entries = [];
1157
- totalCount = 0;
1158
- }
1159
- const formattedEntries = (entries || []).map((entry) => ({
1160
- id: entry.id,
1161
- documentId: entry.documentId,
1162
- ...entry
1163
- }));
1164
- modelsCache.set(uid, {
1165
- signature,
1166
- totalCount,
1167
- entries: formattedEntries
1168
- });
1169
- collections.push({
1170
- ...meta,
1171
- totalEntries: totalCount,
1172
- entries: formattedEntries
1173
- });
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);
1174
1291
  }
1175
1292
  }
1176
1293
  const executionConfig = getExecutionWindowConfig(strapi2);
@@ -1489,6 +1606,24 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1489
1606
  makerNotes
1490
1607
  };
1491
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
+ });
1492
1627
  return {
1493
1628
  success: true,
1494
1629
  pending: true,
@@ -1528,6 +1663,25 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1528
1663
  makerNotes
1529
1664
  };
1530
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
+ });
1531
1685
  return {
1532
1686
  success: true,
1533
1687
  pending: true,
@@ -1568,6 +1722,26 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1568
1722
  makerNotes: "Automatically queued due to peak business hours restriction."
1569
1723
  };
1570
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
+ });
1571
1745
  return {
1572
1746
  success: true,
1573
1747
  pending: true,
@@ -1700,6 +1874,226 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1700
1874
  result
1701
1875
  };
1702
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
+ },
1703
2097
  invalidateCache(uid) {
1704
2098
  if (uid) {
1705
2099
  invalidateCollectionCache(uid);
@@ -2302,56 +2696,102 @@ const validationRulesService = ({ strapi: strapi2 }) => ({
2302
2696
  const violations = [];
2303
2697
  let totalScanned = 0;
2304
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
+ }
2305
2724
  const candidateUids = Object.keys(allContentTypes).filter(
2306
2725
  (uid) => uid.startsWith("api::") && !uid.includes("bulk-updater")
2307
2726
  );
2727
+ const relevantCollections = [];
2308
2728
  for (const uid of candidateUids) {
2309
2729
  const ct = allContentTypes[uid];
2310
2730
  if (!ct) continue;
2311
- const displayName = ct.info?.displayName || uid;
2312
- try {
2313
- const entries = await strapi2.documents(uid).findMany({
2314
- 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
2315
2737
  });
2316
- for (const entry of entries || []) {
2317
- let traverse = function(obj) {
2318
- if (!obj || typeof obj !== "object") return;
2319
- for (const key of Object.keys(obj)) {
2320
- const val = obj[key];
2321
- if (key === targetField && typeof val === "string" && val) {
2322
- const lower = val.toLowerCase();
2323
- counts[lower] = (counts[lower] || 0) + 1;
2324
- }
2325
- if (typeof val === "object" && val !== null) {
2326
- 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
+ }
2327
2785
  }
2328
2786
  }
2329
- };
2330
- totalScanned++;
2331
- const counts = {};
2332
- traverse(entry);
2333
- for (const [optKey, max] of Object.entries(quotas)) {
2334
- const lowerOpt = optKey.toLowerCase();
2335
- const actualCount = counts[lowerOpt] || 0;
2336
- if (actualCount > max) {
2337
- const entryLabel = entry.title || entry.name || entry.email || `Entry #${entry.documentId}`;
2338
- violations.push({
2339
- uid,
2340
- collectionDisplayName: displayName,
2341
- documentId: entry.documentId,
2342
- entryLabel,
2343
- violatingOption: optKey.toUpperCase(),
2344
- foundCount: actualCount,
2345
- maxAllowed: max,
2346
- editUrl: `/admin/content-manager/collection-types/${uid}/${entry.documentId}`
2347
- });
2348
- }
2787
+ if (entries.length < BATCH_SIZE) break;
2788
+ page++;
2349
2789
  }
2790
+ } catch (err) {
2791
+ strapi2.log.warn(`[bulk-updater] Failed scanning ${uid} for rule ${ruleId}:`, err);
2350
2792
  }
2351
- } catch (err) {
2352
- strapi2.log.warn(`[bulk-updater] Failed scanning ${uid} for rule ${ruleId}:`, err);
2353
- }
2354
- }
2793
+ })
2794
+ );
2355
2795
  return {
2356
2796
  rule,
2357
2797
  totalScanned,
@@ -2653,12 +3093,13 @@ ${sampleRow.map(escapeCsvValue).join(",")}
2653
3093
  throw new Error("CSV is empty or could not be parsed.");
2654
3094
  }
2655
3095
  const attrs = ct.attributes || {};
2656
- const { mode: mode2, publish = true, user } = options;
3096
+ const { mode: mode2 = "auto", publish = true, user } = options;
2657
3097
  const stats = {
2658
3098
  total: parsedRows.length,
2659
3099
  created: 0,
2660
3100
  updated: 0,
2661
3101
  skipped: 0,
3102
+ newWithoutDocId: 0,
2662
3103
  errors: [],
2663
3104
  diffs: []
2664
3105
  };
@@ -2730,7 +3171,7 @@ ${sampleRow.map(escapeCsvValue).join(",")}
2730
3171
  stats.skipped++;
2731
3172
  stats.errors.push({
2732
3173
  row: rowNumber,
2733
- 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)."
2734
3175
  });
2735
3176
  continue;
2736
3177
  }
@@ -2775,7 +3216,7 @@ ${sampleRow.map(escapeCsvValue).join(",")}
2775
3216
  status: "updated",
2776
3217
  message: `Updated via CSV import (row ${rowNumber})`
2777
3218
  });
2778
- } else {
3219
+ } else if (mode2 === "create_or_override") {
2779
3220
  if (docId) {
2780
3221
  const existing = await strapi2.documents(uid).findOne({
2781
3222
  documentId: docId
@@ -2825,6 +3266,73 @@ ${sampleRow.map(escapeCsvValue).join(",")}
2825
3266
  }
2826
3267
  }
2827
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
+ }
2828
3336
  stats.diffs.push({
2829
3337
  uid,
2830
3338
  collectionName: ct.info?.displayName || uid,
@@ -2834,7 +3342,7 @@ ${sampleRow.map(escapeCsvValue).join(",")}
2834
3342
  oldValue: null,
2835
3343
  newValue: payload,
2836
3344
  status: "updated",
2837
- 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})`
2838
3346
  });
2839
3347
  }
2840
3348
  } catch (err) {