strapi-plugin-bulk-updater 1.4.2 → 1.4.6

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.
@@ -214,6 +214,30 @@ const routes = {
214
214
  config: {
215
215
  policies: []
216
216
  }
217
+ },
218
+ {
219
+ method: "POST",
220
+ path: "/csv/import-chunk",
221
+ handler: "csv.importChunk",
222
+ config: {
223
+ policies: []
224
+ }
225
+ },
226
+ {
227
+ method: "POST",
228
+ path: "/csv/validate-preflight",
229
+ handler: "csv.validatePreflight",
230
+ config: {
231
+ policies: []
232
+ }
233
+ },
234
+ {
235
+ method: "POST",
236
+ path: "/csv/submit-batch",
237
+ handler: "csv.submitBatch",
238
+ config: {
239
+ policies: []
240
+ }
217
241
  }
218
242
  ]
219
243
  }
@@ -666,6 +690,86 @@ const csvController = ({ strapi }) => ({
666
690
  ctx.status = 500;
667
691
  ctx.body = { success: false, error: err.message };
668
692
  }
693
+ },
694
+ async importChunk(ctx) {
695
+ try {
696
+ const { uid, rows, batchId, isFirstChunk, isLastChunk, startRowNumber, mode, publish } = ctx.request.body;
697
+ if (!uid) {
698
+ ctx.status = 400;
699
+ ctx.body = { success: false, error: "Target collection UID is required." };
700
+ return;
701
+ }
702
+ if (!Array.isArray(rows)) {
703
+ ctx.status = 400;
704
+ ctx.body = { success: false, error: "Rows array is required." };
705
+ return;
706
+ }
707
+ const csvService2 = strapi.plugin("bulk-updater").service("csv");
708
+ const result = await csvService2.importChunk(uid, rows, {
709
+ batchId: batchId ? String(batchId) : void 0,
710
+ isFirstChunk: Boolean(isFirstChunk),
711
+ isLastChunk: Boolean(isLastChunk),
712
+ startRowNumber: typeof startRowNumber === "number" ? startRowNumber : void 0,
713
+ mode: mode === "update" ? "update" : mode === "create_or_override" ? "create_or_override" : "auto",
714
+ publish: publish !== false,
715
+ user: ctx.state.user
716
+ });
717
+ ctx.body = result;
718
+ } catch (err) {
719
+ ctx.status = 500;
720
+ ctx.body = { success: false, error: err.message };
721
+ }
722
+ },
723
+ async validatePreflight(ctx) {
724
+ try {
725
+ const { uid, rows, mode } = ctx.request.body;
726
+ if (!uid) {
727
+ ctx.status = 400;
728
+ ctx.body = { success: false, error: "Target collection UID is required." };
729
+ return;
730
+ }
731
+ if (!Array.isArray(rows)) {
732
+ ctx.status = 400;
733
+ ctx.body = { success: false, error: "Rows array is required." };
734
+ return;
735
+ }
736
+ const csvService2 = strapi.plugin("bulk-updater").service("csv");
737
+ const result = await csvService2.validatePreflight(uid, rows, {
738
+ mode: mode === "update" ? "update" : mode === "create_or_override" ? "create_or_override" : "auto"
739
+ });
740
+ ctx.body = result;
741
+ } catch (err) {
742
+ ctx.status = 500;
743
+ ctx.body = { success: false, error: err.message };
744
+ }
745
+ },
746
+ async submitBatch(ctx) {
747
+ try {
748
+ const { uid, rows, executionMode, makerNotes, forceImmediate, mode, publish } = ctx.request.body;
749
+ if (!uid) {
750
+ ctx.status = 400;
751
+ ctx.body = { success: false, error: "Target collection UID is required." };
752
+ return;
753
+ }
754
+ if (!Array.isArray(rows)) {
755
+ ctx.status = 400;
756
+ ctx.body = { success: false, error: "Rows array is required." };
757
+ return;
758
+ }
759
+ const csvService2 = strapi.plugin("bulk-updater").service("csv");
760
+ const result = await csvService2.submitBatch(uid, rows, {
761
+ executionMode,
762
+ makerNotes,
763
+ forceImmediate: Boolean(forceImmediate),
764
+ mode: mode === "update" ? "update" : mode === "create_or_override" ? "create_or_override" : "auto",
765
+ publish: publish !== false,
766
+ user: ctx.state.user
767
+ });
768
+ ctx.body = result;
769
+ } catch (err) {
770
+ ctx.status = 500;
771
+ ctx.body = { success: false, error: err.message };
772
+ }
669
773
  }
670
774
  });
671
775
  const FIELDS_TO_IGNORE = [
@@ -911,9 +1015,74 @@ function discoverComponentAttributes(strapi, compUid, prefix, currentDepth, maxD
911
1015
  }
912
1016
  return Array.from(new Set(discoveredAttrs));
913
1017
  }
1018
+ function parseDynamicZonePath(path) {
1019
+ if (!path) return { cleanPath: "", rootKey: "", subPath: "" };
1020
+ const bracketMatch = path.match(/^([^.[]+)\[([^\]]+)\]\.(.+)$/);
1021
+ if (bracketMatch) {
1022
+ const rootKey = bracketMatch[1];
1023
+ const selector = bracketMatch[2];
1024
+ const subPath = bracketMatch[3];
1025
+ const cleanPath = `${rootKey}.${subPath}`;
1026
+ if (/^\d+$/.test(selector)) {
1027
+ return { cleanPath, rootKey, subPath, targetIndex: parseInt(selector, 10) };
1028
+ }
1029
+ return { cleanPath, rootKey, subPath, targetComponentUid: selector };
1030
+ }
1031
+ const parts = path.split(".");
1032
+ return {
1033
+ cleanPath: path,
1034
+ rootKey: parts[0] || "",
1035
+ subPath: parts.slice(1).join(".")
1036
+ };
1037
+ }
1038
+ function formatComponentName(compUid) {
1039
+ if (!compUid) return "Component";
1040
+ const name = compUid.includes(".") ? compUid.split(".")[1] : compUid;
1041
+ return name.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
1042
+ }
1043
+ function getAllNestedValues(obj, path) {
1044
+ if (!obj || !path) return [];
1045
+ const parsed = parseDynamicZonePath(path);
1046
+ const parts = parsed.cleanPath.split(".");
1047
+ const [head, ...tail] = parts;
1048
+ const rootTarget = obj[head];
1049
+ if (Array.isArray(rootTarget)) {
1050
+ const results = [];
1051
+ rootTarget.forEach((item, idx) => {
1052
+ if (!item || typeof item !== "object") return;
1053
+ if (parsed.targetIndex !== void 0 && idx !== parsed.targetIndex) return;
1054
+ if (parsed.targetComponentUid && item.__component !== parsed.targetComponentUid) return;
1055
+ const compSchema = global.strapi?.components?.[item.__component];
1056
+ const fieldInSchema = tail.length > 0 && compSchema?.attributes ? tail[0] in compSchema.attributes : false;
1057
+ const subVal = tail.length > 0 ? getDeepProperty(item, tail.join(".")) : item;
1058
+ if (subVal !== void 0 || fieldInSchema) {
1059
+ results.push({
1060
+ index: idx,
1061
+ componentUid: item.__component,
1062
+ componentName: formatComponentName(item.__component),
1063
+ value: subVal !== void 0 ? subVal : null,
1064
+ path: `${head}[#${idx}${item.__component ? ` ${formatComponentName(item.__component)}` : ""}].${tail.join(".")}`
1065
+ });
1066
+ }
1067
+ });
1068
+ return results;
1069
+ }
1070
+ const singleVal = getDeepProperty(obj, parsed.cleanPath);
1071
+ if (singleVal !== void 0) {
1072
+ return [
1073
+ {
1074
+ index: 0,
1075
+ value: singleVal,
1076
+ path: parsed.cleanPath
1077
+ }
1078
+ ];
1079
+ }
1080
+ return [];
1081
+ }
914
1082
  function isDeepComponentField(contentType, allComponents, attribute) {
915
- if (!attribute || !attribute.includes(".")) return false;
916
- const parts = attribute.split(".");
1083
+ if (!attribute || !attribute.includes(".") && !attribute.includes("[")) return false;
1084
+ const parsed = parseDynamicZonePath(attribute);
1085
+ const parts = parsed.cleanPath.split(".");
917
1086
  function checkPath(currentSchema, pathParts) {
918
1087
  if (pathParts.length === 0) return true;
919
1088
  if (!currentSchema || !currentSchema.attributes) return false;
@@ -929,6 +1098,10 @@ function isDeepComponentField(contentType, allComponents, attribute) {
929
1098
  }
930
1099
  if (attrDef.type === "dynamiczone") {
931
1100
  const compUids = Array.isArray(attrDef.components) ? attrDef.components : [];
1101
+ if (parsed.targetComponentUid) {
1102
+ const comp = allComponents[parsed.targetComponentUid];
1103
+ return comp ? checkPath(comp, tail) : false;
1104
+ }
932
1105
  return compUids.some((compUid) => {
933
1106
  const comp = allComponents[compUid];
934
1107
  return checkPath(comp, tail);
@@ -940,8 +1113,9 @@ function isDeepComponentField(contentType, allComponents, attribute) {
940
1113
  }
941
1114
  function getLeafAttributeSchema(contentType, allComponents, attribute) {
942
1115
  if (!attribute) return null;
943
- if (!attribute.includes(".")) return contentType?.attributes?.[attribute] || null;
944
- const parts = attribute.split(".");
1116
+ const parsed = parseDynamicZonePath(attribute);
1117
+ if (!parsed.cleanPath.includes(".")) return contentType?.attributes?.[parsed.cleanPath] || null;
1118
+ const parts = parsed.cleanPath.split(".");
945
1119
  function resolveLeaf(currentSchema, pathParts) {
946
1120
  if (!currentSchema || !currentSchema.attributes) return null;
947
1121
  const [head, ...tail] = pathParts;
@@ -955,6 +1129,9 @@ function getLeafAttributeSchema(contentType, allComponents, attribute) {
955
1129
  }
956
1130
  if (attrDef.type === "dynamiczone") {
957
1131
  const compUids = Array.isArray(attrDef.components) ? attrDef.components : [];
1132
+ if (parsed.targetComponentUid) {
1133
+ return resolveLeaf(allComponents[parsed.targetComponentUid], tail);
1134
+ }
958
1135
  for (const compUid of compUids) {
959
1136
  const found = resolveLeaf(allComponents[compUid], tail);
960
1137
  if (found) return found;
@@ -964,22 +1141,30 @@ function getLeafAttributeSchema(contentType, allComponents, attribute) {
964
1141
  }
965
1142
  return resolveLeaf(contentType, parts);
966
1143
  }
967
- function setDeepProperty(obj, pathParts, value) {
1144
+ function setDeepProperty(obj, pathParts, value, options) {
968
1145
  if (pathParts.length === 0) return value;
969
1146
  const [head, ...tail] = pathParts;
970
1147
  if (Array.isArray(obj)) {
971
1148
  if (/^\d+$/.test(head)) {
972
1149
  const idx = parseInt(head, 10);
973
1150
  const copy = [...obj];
974
- copy[idx] = setDeepProperty(copy[idx] || {}, tail, value);
1151
+ copy[idx] = setDeepProperty(copy[idx] || {}, tail, value, options);
975
1152
  return copy;
976
1153
  }
977
- return obj.map((item) => {
1154
+ return obj.map((item, idx) => {
978
1155
  if (!item || typeof item !== "object") return item;
979
- if (head in item || tail.length > 0 && typeof item[head] === "object") {
1156
+ if (options?.targetIndexes && options.targetIndexes.length > 0) {
1157
+ if (!options.targetIndexes.includes(idx)) return item;
1158
+ }
1159
+ if (options?.targetComponentUid) {
1160
+ if (item.__component !== options.targetComponentUid) return item;
1161
+ }
1162
+ const compSchema = global.strapi?.components?.[item.__component];
1163
+ const fieldExistsInSchema = compSchema?.attributes ? head in compSchema.attributes : true;
1164
+ if (head in item || fieldExistsInSchema || tail.length === 0) {
980
1165
  return {
981
1166
  ...item,
982
- [head]: setDeepProperty(item[head], tail, value)
1167
+ [head]: setDeepProperty(item[head], tail, value, options)
983
1168
  };
984
1169
  }
985
1170
  return item;
@@ -991,16 +1176,17 @@ function setDeepProperty(obj, pathParts, value) {
991
1176
  } else {
992
1177
  const subTarget = current[head];
993
1178
  if (Array.isArray(subTarget)) {
994
- current[head] = setDeepProperty(subTarget, tail, value);
1179
+ current[head] = setDeepProperty(subTarget, tail, value, options);
995
1180
  } else {
996
- current[head] = setDeepProperty(subTarget || {}, tail, value);
1181
+ current[head] = setDeepProperty(subTarget || {}, tail, value, options);
997
1182
  }
998
1183
  }
999
1184
  return current;
1000
1185
  }
1001
1186
  function getDeepProperty(obj, path) {
1002
1187
  if (!obj || !path) return void 0;
1003
- const parts = path.split(".");
1188
+ const parsed = parseDynamicZonePath(path);
1189
+ const parts = parsed.cleanPath.split(".");
1004
1190
  function extract(current, remainingParts) {
1005
1191
  if (current === void 0 || current === null) return void 0;
1006
1192
  if (remainingParts.length === 0) return current;
@@ -1009,7 +1195,10 @@ function getDeepProperty(obj, path) {
1009
1195
  if (/^\d+$/.test(head)) {
1010
1196
  return extract(current[parseInt(head, 10)], tail);
1011
1197
  }
1012
- for (const item of current) {
1198
+ for (let idx = 0; idx < current.length; idx++) {
1199
+ const item = current[idx];
1200
+ if (parsed.targetIndex !== void 0 && idx !== parsed.targetIndex) continue;
1201
+ if (parsed.targetComponentUid && item?.__component !== parsed.targetComponentUid) continue;
1013
1202
  const found = extract(item?.[head], tail);
1014
1203
  if (found !== void 0 && found !== null) return found;
1015
1204
  }
@@ -1086,6 +1275,12 @@ const pendingBatches = /* @__PURE__ */ new Map();
1086
1275
  const batchJobsMap = /* @__PURE__ */ new Map();
1087
1276
  let isRunnerRunning = false;
1088
1277
  let runnerInterval = null;
1278
+ function registerPendingBatch(batch, jobState) {
1279
+ pendingBatches.set(batch.id, batch);
1280
+ if (jobState) {
1281
+ batchJobsMap.set(batch.id, jobState);
1282
+ }
1283
+ }
1089
1284
  function invalidateCollectionCache(uid) {
1090
1285
  modelsCache.delete(uid);
1091
1286
  for (const key of queryCache.keys()) {
@@ -1174,7 +1369,12 @@ async function executeBatchInternal(strapi, {
1174
1369
  for (const item of selectedEntries) {
1175
1370
  if (!item.uid || !item.documentId) continue;
1176
1371
  const key = `${item.uid}@@@${item.documentId}`;
1177
- targetsMap.set(key, { uid: item.uid, documentId: item.documentId });
1372
+ targetsMap.set(key, {
1373
+ uid: item.uid,
1374
+ documentId: item.documentId,
1375
+ dynamicZoneIndexes: item.dynamicZoneIndexes,
1376
+ targetComponentUid: item.targetComponentUid
1377
+ });
1178
1378
  }
1179
1379
  if (targetsMap.size === 0) {
1180
1380
  throw new Error("No collections or entries were selected for update.");
@@ -1233,10 +1433,13 @@ async function executeBatchInternal(strapi, {
1233
1433
  try {
1234
1434
  const currentEntry = await getDocumentDeduplicated(target.uid, target.documentId);
1235
1435
  const entryLabel = currentEntry?.name || currentEntry?.title || currentEntry?.email || `Entry #${target.documentId}`;
1236
- const isNested = attribute.includes(".");
1436
+ const parsed = parseDynamicZonePath(attribute);
1437
+ const isNested = attribute.includes(".") || attribute.includes("[");
1237
1438
  const isComponentNested = isNested && isDeepComponentField(contentType, strapi.components, attribute);
1238
- const parts = isNested ? attribute.split(".") : [];
1239
- const rootKey = isNested ? parts[0] : "";
1439
+ const parts = isNested ? parsed.cleanPath.split(".") : [];
1440
+ const rootKey = parsed.rootKey;
1441
+ const targetComponentUid = target.targetComponentUid || parsed.targetComponentUid;
1442
+ const targetIndexes = target.dynamicZoneIndexes;
1240
1443
  const attrDef = contentType?.attributes?.[attribute];
1241
1444
  const hasAttribute = Boolean(attrDef) || isComponentNested;
1242
1445
  if (hasAttribute) {
@@ -1249,12 +1452,25 @@ async function executeBatchInternal(strapi, {
1249
1452
  const currentEntryWithPopulate = await strapi.documents(target.uid).findOne(findOneArgs);
1250
1453
  const isRootDz = contentType?.attributes?.[rootKey]?.type === "dynamiczone";
1251
1454
  const existingRoot = currentEntryWithPopulate?.[rootKey] || (isRootDz ? [] : {});
1252
- const currentVal2 = getDeepProperty(existingRoot, parts.slice(1).join("."));
1455
+ let affectedMatches = [];
1456
+ if (isRootDz && Array.isArray(existingRoot)) {
1457
+ affectedMatches = getAllNestedValues({ [rootKey]: existingRoot }, attribute);
1458
+ if (targetIndexes && targetIndexes.length > 0) {
1459
+ affectedMatches = affectedMatches.filter((m) => targetIndexes.includes(m.index));
1460
+ }
1461
+ if (targetComponentUid) {
1462
+ affectedMatches = affectedMatches.filter((m) => m.componentUid === targetComponentUid);
1463
+ }
1464
+ }
1465
+ const currentVal2 = isRootDz ? affectedMatches.length > 0 ? affectedMatches.map((m) => m.value).join(", ") : getDeepProperty(existingRoot, parts.slice(1).join(".")) : getDeepProperty(existingRoot, parts.slice(1).join("."));
1253
1466
  let newValue2 = value;
1254
1467
  if (regexObj && typeof currentVal2 === "string") {
1255
1468
  newValue2 = currentVal2.replace(regexObj, value);
1256
1469
  }
1257
- const updatedRoot = setDeepProperty(existingRoot, parts.slice(1), newValue2);
1470
+ const updatedRoot = setDeepProperty(existingRoot, parts.slice(1), newValue2, {
1471
+ targetIndexes,
1472
+ targetComponentUid
1473
+ });
1258
1474
  await strapi.documents(target.uid).update({
1259
1475
  documentId: target.documentId,
1260
1476
  data: {
@@ -1278,6 +1494,14 @@ async function executeBatchInternal(strapi, {
1278
1494
  userId: currentUser?.id,
1279
1495
  statusOverride: publishOnApproval && isDraftAndPublishComp ? "published" : void 0
1280
1496
  });
1497
+ const dzDiffs = isRootDz && affectedMatches.length > 0 ? affectedMatches.map((m) => ({
1498
+ index: m.index,
1499
+ componentUid: m.componentUid,
1500
+ componentName: m.componentName,
1501
+ attribute: `${rootKey}[#${m.index} ${m.componentName}].${parts.slice(1).join(".")}`,
1502
+ oldValue: m.value,
1503
+ newValue: regexObj && typeof m.value === "string" ? m.value.replace(regexObj, value) : value
1504
+ })) : [];
1281
1505
  return {
1282
1506
  uid: target.uid,
1283
1507
  collectionName: displayName,
@@ -1287,7 +1511,8 @@ async function executeBatchInternal(strapi, {
1287
1511
  oldValue: currentVal2 ?? null,
1288
1512
  newValue: newValue2,
1289
1513
  status: "updated",
1290
- message: `Successfully updated nested field ${attribute}`
1514
+ message: isRootDz ? `Successfully updated ${affectedMatches.length} dynamic zone item(s) in ${attribute}` : `Successfully updated nested field ${attribute}`,
1515
+ dynamicZoneDiffs: dzDiffs
1291
1516
  };
1292
1517
  }
1293
1518
  if (attrDef.type === "relation") {
@@ -1316,13 +1541,13 @@ async function executeBatchInternal(strapi, {
1316
1541
  newDisplay = "(cleared / unlinked)";
1317
1542
  } else {
1318
1543
  try {
1319
- const parsed = JSON.parse(value);
1320
- if (parsed && typeof parsed === "object" && parsed.mode) {
1321
- mode = parsed.mode;
1322
- targetDocIds = Array.isArray(parsed.documentIds) ? parsed.documentIds : parsed.documentId ? [parsed.documentId] : [];
1323
- newDisplay = parsed.label || targetDocIds.join(", ") || `[${mode}]`;
1324
- } else if (Array.isArray(parsed)) {
1325
- targetDocIds = parsed;
1544
+ const parsed2 = JSON.parse(value);
1545
+ if (parsed2 && typeof parsed2 === "object" && parsed2.mode) {
1546
+ mode = parsed2.mode;
1547
+ targetDocIds = Array.isArray(parsed2.documentIds) ? parsed2.documentIds : parsed2.documentId ? [parsed2.documentId] : [];
1548
+ newDisplay = parsed2.label || targetDocIds.join(", ") || `[${mode}]`;
1549
+ } else if (Array.isArray(parsed2)) {
1550
+ targetDocIds = parsed2;
1326
1551
  newDisplay = targetDocIds.join(", ");
1327
1552
  } else {
1328
1553
  targetDocIds = [value];
@@ -1466,6 +1691,26 @@ async function executeBatchInternal(strapi, {
1466
1691
  ...r,
1467
1692
  isLive: r.status === "updated"
1468
1693
  }));
1694
+ const flattenedDiffs = [];
1695
+ for (const r of results) {
1696
+ if (r.dynamicZoneDiffs && r.dynamicZoneDiffs.length > 0) {
1697
+ for (const dz of r.dynamicZoneDiffs) {
1698
+ flattenedDiffs.push({
1699
+ uid: r.uid,
1700
+ collectionName: r.collectionName,
1701
+ documentId: r.documentId,
1702
+ label: `${r.label} [Block #${dz.index} (${dz.componentName})]`,
1703
+ attribute: dz.attribute,
1704
+ oldValue: dz.oldValue,
1705
+ newValue: dz.newValue,
1706
+ status: "updated",
1707
+ message: `Updated ${dz.attribute}`
1708
+ });
1709
+ }
1710
+ } else {
1711
+ flattenedDiffs.push(r);
1712
+ }
1713
+ }
1469
1714
  const batchSummary = {
1470
1715
  batchId,
1471
1716
  attribute,
@@ -1477,7 +1722,7 @@ async function executeBatchInternal(strapi, {
1477
1722
  };
1478
1723
  await strapi.plugin("bulk-updater").service("audit").recordBatch(
1479
1724
  batchSummary,
1480
- results,
1725
+ flattenedDiffs,
1481
1726
  currentUser
1482
1727
  );
1483
1728
  return {
@@ -1658,6 +1903,22 @@ async function executePublishBatchInternal(strapi, {
1658
1903
  results
1659
1904
  };
1660
1905
  }
1906
+ async function executeBatchByPayload(strapi, batch, currentUser) {
1907
+ if (batch.payload?.action === "csv_import") {
1908
+ const csvService2 = strapi.plugin("bulk-updater").service("csv");
1909
+ return await csvService2.executeCsvPendingBatch(
1910
+ batch.payload.uid,
1911
+ batch.payload.rows,
1912
+ batch.id,
1913
+ batch.payload.options,
1914
+ currentUser
1915
+ );
1916
+ } else if (batch.payload?.action === "publish") {
1917
+ return await executePublishBatchInternal(strapi, batch.payload, currentUser, batch.id);
1918
+ } else {
1919
+ return await executeBatchInternal(strapi, batch.payload, currentUser, batch.id);
1920
+ }
1921
+ }
1661
1922
  const bulkUpdaterService = ({ strapi }) => {
1662
1923
  if (!runnerInterval) {
1663
1924
  runnerInterval = setInterval(async () => {
@@ -1669,7 +1930,7 @@ const bulkUpdaterService = ({ strapi }) => {
1669
1930
  for (const [id, batch] of pendingBatches.entries()) {
1670
1931
  if (batch.status === "queued_offpeak" && !isBusinessHours) {
1671
1932
  strapi.log.info(`[bulk-updater] Executing off-peak queued batch: ${id}`);
1672
- const result = batch.payload?.action === "publish" ? await executePublishBatchInternal(strapi, batch.payload, batch.createdBy, id) : await executeBatchInternal(strapi, batch.payload, batch.createdBy, id);
1933
+ const result = await executeBatchByPayload(strapi, batch, batch.createdBy);
1673
1934
  batch.status = "executed";
1674
1935
  batch.executionResult = result;
1675
1936
  }
@@ -1745,6 +2006,19 @@ const bulkUpdaterService = ({ strapi }) => {
1745
2006
  colAttributesMeta
1746
2007
  );
1747
2008
  nestedComponentAttrs.push(...compAttrs);
2009
+ const scopedAttrs = discoverComponentAttributes(
2010
+ strapi,
2011
+ compUid,
2012
+ `${attrKey}[${compUid}]`,
2013
+ 1,
2014
+ 5,
2015
+ systemFields,
2016
+ allContentTypes,
2017
+ relationsByAttr,
2018
+ allRelationsMeta,
2019
+ colAttributesMeta
2020
+ );
2021
+ nestedComponentAttrs.push(...scopedAttrs);
1748
2022
  }
1749
2023
  return false;
1750
2024
  }
@@ -1959,13 +2233,20 @@ const bulkUpdaterService = ({ strapi }) => {
1959
2233
  const attrs = contentType.attributes || {};
1960
2234
  let colHasAttribute = false;
1961
2235
  let isNestedAttr = false;
2236
+ let isRootDz = false;
1962
2237
  let parts = [];
1963
2238
  if (targetFilterAttr) {
1964
- if (targetFilterAttr.includes(".")) {
2239
+ const parsed = parseDynamicZonePath(targetFilterAttr);
2240
+ if (parsed.rootKey && contentType.attributes?.[parsed.rootKey]?.type === "dynamiczone") {
2241
+ isRootDz = true;
2242
+ colHasAttribute = true;
2243
+ isNestedAttr = true;
2244
+ parts = parsed.cleanPath.split(".");
2245
+ } else if (targetFilterAttr.includes(".") || targetFilterAttr.includes("[")) {
1965
2246
  isNestedAttr = isDeepComponentField(contentType, strapi.components, targetFilterAttr);
1966
2247
  if (isNestedAttr) {
1967
2248
  colHasAttribute = true;
1968
- parts = targetFilterAttr.split(".");
2249
+ parts = parsed.cleanPath.split(".");
1969
2250
  }
1970
2251
  } else if (attrs[targetFilterAttr]) {
1971
2252
  colHasAttribute = true;
@@ -2022,8 +2303,11 @@ const bulkUpdaterService = ({ strapi }) => {
2022
2303
  }
2023
2304
  if (condition !== void 0) {
2024
2305
  if (isNestedAttr) {
2025
- const deepFilter = buildDeepFilter(parts, condition);
2026
- Object.assign(filters, deepFilter);
2306
+ if (isRootDz) ;
2307
+ else {
2308
+ const deepFilter = buildDeepFilter(parts, condition);
2309
+ Object.assign(filters, deepFilter);
2310
+ }
2027
2311
  } else {
2028
2312
  if (normOp === "empty" && isStringLike) {
2029
2313
  filters.$or = [
@@ -2097,39 +2381,100 @@ const bulkUpdaterService = ({ strapi }) => {
2097
2381
  } else {
2098
2382
  sortClause["createdAt"] = "desc";
2099
2383
  }
2100
- const findManyArgs = {
2101
- start,
2102
- limit,
2103
- filters: Object.keys(filters).length > 0 ? filters : void 0,
2104
- sort: sortClause
2105
- };
2106
- if (populateOption) {
2107
- findManyArgs.populate = populateOption;
2108
- }
2109
- const [entries, totalEntries] = await Promise.all([
2110
- strapi.documents(uid).findMany(findManyArgs),
2111
- strapi.documents(uid).count({
2112
- filters: Object.keys(filters).length > 0 ? filters : void 0
2113
- })
2114
- ]);
2115
- const totalPages = Math.ceil(totalEntries / limit);
2116
- result = {
2117
- uid,
2118
- displayName: contentType.info?.displayName || uid,
2119
- isSingleType: false,
2120
- draftAndPublish: Boolean(contentType.options?.draftAndPublish),
2121
- entries: (entries || []).map((entry) => ({
2122
- id: entry.id,
2123
- documentId: entry.documentId,
2124
- ...entry
2125
- })),
2126
- pagination: {
2127
- page: currentPage,
2128
- pageSize: limit,
2129
- total: totalEntries,
2130
- pageCount: totalPages
2384
+ if (isRootDz && normOp && normOp !== "none") {
2385
+ const findManyArgs = {
2386
+ limit: 500,
2387
+ filters: Object.keys(filters).length > 0 ? filters : void 0,
2388
+ sort: sortClause
2389
+ };
2390
+ if (populateOption) {
2391
+ findManyArgs.populate = populateOption;
2131
2392
  }
2132
- };
2393
+ const rawEntries = await strapi.documents(uid).findMany(findManyArgs);
2394
+ const qVal = (filterQuery || "").trim().toLowerCase();
2395
+ const passesDzFilter = (entry) => {
2396
+ const matches = getAllNestedValues(entry, targetFilterAttr);
2397
+ if (normOp === "empty") {
2398
+ return matches.length === 0 || matches.every((m) => m.value === void 0 || m.value === null || String(m.value).trim() === "");
2399
+ }
2400
+ if (normOp === "notempty") {
2401
+ return matches.some((m) => m.value !== void 0 && m.value !== null && String(m.value).trim() !== "");
2402
+ }
2403
+ return matches.some((m) => {
2404
+ if (m.value === void 0 || m.value === null) return false;
2405
+ const valStr = String(m.value).toLowerCase();
2406
+ switch (normOp) {
2407
+ case "equals":
2408
+ return valStr === qVal;
2409
+ case "notequals":
2410
+ return valStr !== qVal;
2411
+ case "contains":
2412
+ return valStr.includes(qVal);
2413
+ case "notcontains":
2414
+ return !valStr.includes(qVal);
2415
+ case "startswith":
2416
+ return valStr.startsWith(qVal);
2417
+ case "endswith":
2418
+ return valStr.endsWith(qVal);
2419
+ default:
2420
+ return true;
2421
+ }
2422
+ });
2423
+ };
2424
+ const matchedEntries = (rawEntries || []).filter(passesDzFilter);
2425
+ const totalEntries = matchedEntries.length;
2426
+ const paginatedEntries = matchedEntries.slice(start, start + limit);
2427
+ result = {
2428
+ uid,
2429
+ displayName: contentType.info?.displayName || uid,
2430
+ isSingleType: false,
2431
+ draftAndPublish: Boolean(contentType.options?.draftAndPublish),
2432
+ entries: paginatedEntries.map((entry) => ({
2433
+ id: entry.id,
2434
+ documentId: entry.documentId,
2435
+ ...entry
2436
+ })),
2437
+ pagination: {
2438
+ page: currentPage,
2439
+ pageSize: limit,
2440
+ total: totalEntries,
2441
+ pageCount: Math.ceil(totalEntries / limit) || 1
2442
+ }
2443
+ };
2444
+ } else {
2445
+ const findManyArgs = {
2446
+ start,
2447
+ limit,
2448
+ filters: Object.keys(filters).length > 0 ? filters : void 0,
2449
+ sort: sortClause
2450
+ };
2451
+ if (populateOption) {
2452
+ findManyArgs.populate = populateOption;
2453
+ }
2454
+ const [entries, totalEntries] = await Promise.all([
2455
+ strapi.documents(uid).findMany(findManyArgs),
2456
+ strapi.documents(uid).count({
2457
+ filters: Object.keys(filters).length > 0 ? filters : void 0
2458
+ })
2459
+ ]);
2460
+ result = {
2461
+ uid,
2462
+ displayName: contentType.info?.displayName || uid,
2463
+ isSingleType: false,
2464
+ draftAndPublish: Boolean(contentType.options?.draftAndPublish),
2465
+ entries: (entries || []).map((entry) => ({
2466
+ id: entry.id,
2467
+ documentId: entry.documentId,
2468
+ ...entry
2469
+ })),
2470
+ pagination: {
2471
+ page: currentPage,
2472
+ pageSize: limit,
2473
+ total: totalEntries,
2474
+ pageCount: Math.ceil(totalEntries / limit) || 1
2475
+ }
2476
+ };
2477
+ }
2133
2478
  }
2134
2479
  if (queryCache.size >= MAX_QUERY_CACHE_SIZE) {
2135
2480
  const toDelete = Array.from(queryCache.keys()).slice(0, 100);
@@ -2198,7 +2543,12 @@ const bulkUpdaterService = ({ strapi }) => {
2198
2543
  for (const item of selectedEntries) {
2199
2544
  if (!item.uid || !item.documentId) continue;
2200
2545
  const key = `${item.uid}@@@${item.documentId}`;
2201
- targetsMap.set(key, { uid: item.uid, documentId: item.documentId });
2546
+ targetsMap.set(key, {
2547
+ uid: item.uid,
2548
+ documentId: item.documentId,
2549
+ dynamicZoneIndexes: item.dynamicZoneIndexes,
2550
+ targetComponentUid: item.targetComponentUid
2551
+ });
2202
2552
  }
2203
2553
  if (targetsMap.size === 0) {
2204
2554
  throw new Error("No collections or entries were selected for preview.");
@@ -2262,6 +2612,49 @@ const bulkUpdaterService = ({ strapi }) => {
2262
2612
  }
2263
2613
  const entryLabel = currentEntry?.name || currentEntry?.title || currentEntry?.email || `Entry #${target.documentId}`;
2264
2614
  if (isComponentNested) {
2615
+ const parsed = parseDynamicZonePath(attribute);
2616
+ const rootKey = parsed.rootKey;
2617
+ const isRootDz = contentType?.attributes?.[rootKey]?.type === "dynamiczone";
2618
+ const targetIndexes = target.dynamicZoneIndexes;
2619
+ const targetComponentUid = target.targetComponentUid || parsed.targetComponentUid;
2620
+ if (isRootDz && currentEntry?.[rootKey] && Array.isArray(currentEntry[rootKey])) {
2621
+ let affectedMatches = getAllNestedValues({ [rootKey]: currentEntry[rootKey] }, attribute);
2622
+ if (targetIndexes && targetIndexes.length > 0) {
2623
+ affectedMatches = affectedMatches.filter((m) => targetIndexes.includes(m.index));
2624
+ }
2625
+ if (targetComponentUid) {
2626
+ affectedMatches = affectedMatches.filter((m) => m.componentUid === targetComponentUid);
2627
+ }
2628
+ if (affectedMatches.length > 0) {
2629
+ for (const match of affectedMatches) {
2630
+ let blockNewVal = value;
2631
+ if (regexObj && typeof match.value === "string") {
2632
+ blockNewVal = match.value.replace(regexObj, value);
2633
+ }
2634
+ const hasChange3 = String(match.value ?? "") !== String(blockNewVal ?? "");
2635
+ if (hasChange3) {
2636
+ willUpdateCount++;
2637
+ } else {
2638
+ unchangedCount++;
2639
+ }
2640
+ diffs.push({
2641
+ uid: target.uid,
2642
+ collectionName: displayName,
2643
+ documentId: target.documentId,
2644
+ label: `${entryLabel} [Block #${match.index} (${match.componentName})]`,
2645
+ attribute: `${rootKey}[#${match.index} ${match.componentName}].${parsed.subPath}`,
2646
+ oldValue: match.value ?? null,
2647
+ newValue: blockNewVal,
2648
+ oldDisplay: match.value !== void 0 && match.value !== null && String(match.value).trim() !== "" ? String(match.value) : "(empty)",
2649
+ newDisplay: String(blockNewVal ?? ""),
2650
+ hasChange: hasChange3,
2651
+ status: hasChange3 ? "will_update" : "unchanged",
2652
+ message: hasChange3 ? "Block value will be updated" : "Block already has target value"
2653
+ });
2654
+ }
2655
+ continue;
2656
+ }
2657
+ }
2265
2658
  const currentVal2 = getDeepProperty(currentEntry, attribute);
2266
2659
  let newValue2 = value;
2267
2660
  if (regexObj && typeof currentVal2 === "string") {
@@ -2841,7 +3234,7 @@ const bulkUpdaterService = ({ strapi }) => {
2841
3234
  const { isBusinessHours } = checkBusinessHours(config);
2842
3235
  if (options.executeImmediately || !isBusinessHours) {
2843
3236
  batch.status = "approved";
2844
- const result = batch.payload?.action === "publish" ? await executePublishBatchInternal(strapi, batch.payload, checkerUser, batch.id) : await executeBatchInternal(strapi, batch.payload, checkerUser, batch.id);
3237
+ const result = await executeBatchByPayload(strapi, batch, checkerUser);
2845
3238
  batch.status = "executed";
2846
3239
  batch.executionResult = result;
2847
3240
  return {
@@ -2902,7 +3295,7 @@ const bulkUpdaterService = ({ strapi }) => {
2902
3295
  if (batch.status === "executed") {
2903
3296
  throw new Error(`Batch ${batchId} has already been executed.`);
2904
3297
  }
2905
- const result = batch.payload?.action === "publish" ? await executePublishBatchInternal(strapi, batch.payload, currentUser, batch.id) : await executeBatchInternal(strapi, batch.payload, currentUser, batch.id);
3298
+ const result = await executeBatchByPayload(strapi, batch, currentUser);
2906
3299
  batch.status = "executed";
2907
3300
  batch.executionResult = result;
2908
3301
  return {
@@ -3149,6 +3542,20 @@ const bulkUpdaterService = ({ strapi }) => {
3149
3542
  },
3150
3543
  invalidateCollectionCache(uid) {
3151
3544
  invalidateCollectionCache(uid);
3545
+ },
3546
+ registerPendingBatch(batch, jobState) {
3547
+ pendingBatches.set(batch.id, batch);
3548
+ if (jobState) {
3549
+ batchJobsMap.set(batch.id, jobState);
3550
+ }
3551
+ return batch;
3552
+ },
3553
+ getExecutionWindowConfig() {
3554
+ return getExecutionWindowConfig(strapi);
3555
+ },
3556
+ checkBusinessHours(config) {
3557
+ const cfg = config || getExecutionWindowConfig(strapi);
3558
+ return checkBusinessHours(cfg);
3152
3559
  }
3153
3560
  };
3154
3561
  };
@@ -4355,36 +4762,589 @@ function getNestedExportValue(entry, path) {
4355
4762
  }
4356
4763
  return current;
4357
4764
  }
4358
- const csvService = ({ strapi }) => ({
4359
- /**
4360
- * Generates a sample template CSV with schema attribute headers and sample values
4361
- */
4362
- async getTemplateCsv(uid) {
4363
- const ct = strapi.contentTypes[uid];
4364
- if (!ct) {
4365
- throw new Error(`Content type ${uid} not found`);
4765
+ const csvImportSessions = /* @__PURE__ */ new Map();
4766
+ function cleanupStaleSessions() {
4767
+ const oneHourAgo = Date.now() - 36e5;
4768
+ for (const [id, session] of csvImportSessions.entries()) {
4769
+ if (session.createdAt < oneHourAgo) {
4770
+ csvImportSessions.delete(id);
4366
4771
  }
4367
- const attrs = ct.attributes || {};
4368
- const excluded = ["createdAt", "updatedAt", "publishedAt", "createdBy", "updatedBy", "localizations"];
4369
- const headers = ["documentId"];
4370
- const sampleRow = ["(Leave empty to create new, or enter existing docId)"];
4371
- for (const [attrName, attrDef] of Object.entries(attrs)) {
4372
- if (excluded.includes(attrName)) continue;
4373
- if (attrDef.type === "relation") continue;
4374
- if (attrDef.type === "component" || attrDef.type === "dynamiczone") continue;
4375
- headers.push(attrName);
4376
- let sampleVal = "";
4772
+ }
4773
+ }
4774
+ const SYSTEM_FIELD_KEYS = /* @__PURE__ */ new Set([
4775
+ "documentid",
4776
+ "document_id",
4777
+ "docid",
4778
+ "id",
4779
+ "createdat",
4780
+ "updatedat",
4781
+ "publishedat",
4782
+ "createdby",
4783
+ "updatedby",
4784
+ "localizations",
4785
+ "locale",
4786
+ "status"
4787
+ ]);
4788
+ async function findExistingDocument(strapi, uid, targetDocId, docLocale) {
4789
+ if (!targetDocId) return null;
4790
+ try {
4791
+ const queryArgs = { documentId: targetDocId };
4792
+ if (docLocale) queryArgs.locale = docLocale;
4793
+ const found = await strapi.documents(uid).findOne(queryArgs);
4794
+ if (found) return found;
4795
+ } catch {
4796
+ }
4797
+ if (/^\d+$/.test(targetDocId)) {
4798
+ try {
4799
+ const queryArgs = {
4800
+ filters: { id: parseInt(targetDocId, 10) },
4801
+ limit: 1
4802
+ };
4803
+ if (docLocale) queryArgs.locale = docLocale;
4804
+ const list = await strapi.documents(uid).findMany(queryArgs);
4805
+ if (list && list.length > 0) return list[0];
4806
+ } catch {
4807
+ }
4808
+ }
4809
+ return null;
4810
+ }
4811
+ async function processCsvRows(strapi, ct, uid, rows, startRowNumber, options) {
4812
+ const attrs = ct.attributes || {};
4813
+ const { mode = "auto", publish = true, user } = options;
4814
+ const stats = {
4815
+ total: rows.length,
4816
+ created: 0,
4817
+ updated: 0,
4818
+ skipped: 0,
4819
+ newWithoutDocId: 0,
4820
+ errors: [],
4821
+ diffs: []
4822
+ };
4823
+ for (let i = 0; i < rows.length; i++) {
4824
+ const rowNumber = startRowNumber + i;
4825
+ const row = rows[i];
4826
+ let docId = extractDocId(row);
4827
+ const rowLocale = extractLocale(row);
4828
+ if (ct.kind === "singleType" && !docId) {
4829
+ try {
4830
+ const findSingleArgs = {};
4831
+ if (rowLocale) findSingleArgs.locale = rowLocale;
4832
+ const singleDoc = await strapi.documents(uid).findFirst(findSingleArgs);
4833
+ if (singleDoc?.documentId) {
4834
+ docId = singleDoc.documentId;
4835
+ }
4836
+ } catch {
4837
+ }
4838
+ }
4839
+ const payload = {};
4840
+ for (const [key, rawVal] of Object.entries(row)) {
4841
+ const normalizedKey = key.toLowerCase().replace(/[\s_-]/g, "");
4842
+ if (SYSTEM_FIELD_KEYS.has(normalizedKey)) continue;
4843
+ if (key.includes(".")) {
4844
+ const [parent, child] = key.split(".");
4845
+ const parentDef = attrs[parent];
4846
+ if (parentDef?.type === "component" && !parentDef.repeatable && parentDef.component) {
4847
+ const comp = strapi.components[parentDef.component];
4848
+ const childDef = comp?.attributes?.[child];
4849
+ if (childDef) {
4850
+ if (!payload[parent]) payload[parent] = {};
4851
+ let val = rawVal;
4852
+ if (rawVal === "" || rawVal === null || rawVal === void 0) {
4853
+ val = null;
4854
+ } else if (childDef.type === "integer" || childDef.type === "biginteger") {
4855
+ const parsedInt = parseInt(rawVal, 10);
4856
+ val = isNaN(parsedInt) ? null : parsedInt;
4857
+ } else if (childDef.type === "float" || childDef.type === "decimal") {
4858
+ const parsedFloat = parseFloat(rawVal);
4859
+ val = isNaN(parsedFloat) ? null : parsedFloat;
4860
+ } else if (childDef.type === "boolean") {
4861
+ val = rawVal.toLowerCase() === "true" || rawVal === "1";
4862
+ } else if (childDef.type === "json") {
4863
+ try {
4864
+ val = JSON.parse(rawVal);
4865
+ } catch {
4866
+ val = rawVal;
4867
+ }
4868
+ }
4869
+ payload[parent][child] = val;
4870
+ }
4871
+ }
4872
+ continue;
4873
+ }
4874
+ let attrDef = attrs[key];
4875
+ if (!attrDef) {
4876
+ const matchKey = Object.keys(attrs).find(
4877
+ (k) => k.toLowerCase() === key.toLowerCase() || k.toLowerCase() === key.toLowerCase().replace(/\s+/g, "")
4878
+ );
4879
+ if (matchKey) {
4880
+ attrDef = attrs[matchKey];
4881
+ }
4882
+ }
4883
+ if (!attrDef) continue;
4884
+ if (rawVal === "" || rawVal === null || rawVal === void 0) {
4885
+ if (attrDef.type !== "dynamiczone" && !(attrDef.type === "component" && attrDef.repeatable)) {
4886
+ payload[key] = null;
4887
+ }
4888
+ continue;
4889
+ }
4377
4890
  switch (attrDef.type) {
4378
- case "string":
4379
- case "text":
4380
- case "richtext":
4381
- sampleVal = `Sample ${attrName}`;
4382
- break;
4383
4891
  case "integer":
4384
- case "biginteger":
4892
+ case "biginteger": {
4893
+ const parsedInt = parseInt(rawVal, 10);
4894
+ payload[key] = isNaN(parsedInt) ? null : parsedInt;
4895
+ break;
4896
+ }
4385
4897
  case "float":
4386
- case "decimal":
4387
- sampleVal = "10";
4898
+ case "decimal": {
4899
+ const parsedFloat = parseFloat(rawVal);
4900
+ payload[key] = isNaN(parsedFloat) ? null : parsedFloat;
4901
+ break;
4902
+ }
4903
+ case "boolean":
4904
+ payload[key] = rawVal.toLowerCase() === "true" || rawVal === "1";
4905
+ break;
4906
+ case "json":
4907
+ try {
4908
+ payload[key] = JSON.parse(rawVal);
4909
+ } catch {
4910
+ payload[key] = rawVal;
4911
+ }
4912
+ break;
4913
+ case "dynamiczone": {
4914
+ try {
4915
+ const parsed = JSON.parse(rawVal);
4916
+ if (Array.isArray(parsed) && parsed.every((item) => item && typeof item === "object" && typeof item.__component === "string")) {
4917
+ payload[key] = parsed;
4918
+ }
4919
+ } catch {
4920
+ }
4921
+ break;
4922
+ }
4923
+ case "component": {
4924
+ if (attrDef.repeatable) {
4925
+ try {
4926
+ const parsed = JSON.parse(rawVal);
4927
+ if (Array.isArray(parsed) && parsed.every((item) => item && typeof item === "object")) {
4928
+ payload[key] = parsed;
4929
+ }
4930
+ } catch {
4931
+ }
4932
+ } else {
4933
+ try {
4934
+ const parsed = JSON.parse(rawVal);
4935
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
4936
+ payload[key] = parsed;
4937
+ }
4938
+ } catch {
4939
+ }
4940
+ }
4941
+ break;
4942
+ }
4943
+ case "relation": {
4944
+ try {
4945
+ const parsed = JSON.parse(rawVal);
4946
+ if (Array.isArray(parsed) || typeof parsed === "object" && parsed !== null) {
4947
+ payload[key] = parsed;
4948
+ break;
4949
+ }
4950
+ } catch {
4951
+ }
4952
+ if (isValidDocId(rawVal)) {
4953
+ payload[key] = rawVal;
4954
+ } else if (rawVal.includes(";") || rawVal.includes(",")) {
4955
+ const parts = rawVal.split(/[;,]/).map((s) => s.trim()).filter(Boolean);
4956
+ if (parts.length > 0 && parts.every((p) => isValidDocId(p))) {
4957
+ payload[key] = parts;
4958
+ }
4959
+ }
4960
+ break;
4961
+ }
4962
+ case "media": {
4963
+ if (/^\d+$/.test(rawVal.trim())) {
4964
+ payload[key] = parseInt(rawVal.trim(), 10);
4965
+ } else {
4966
+ try {
4967
+ const parsed = JSON.parse(rawVal);
4968
+ if (typeof parsed === "number" || typeof parsed === "object") {
4969
+ payload[key] = parsed;
4970
+ }
4971
+ } catch {
4972
+ }
4973
+ }
4974
+ break;
4975
+ }
4976
+ default:
4977
+ payload[key] = rawVal;
4978
+ }
4979
+ }
4980
+ try {
4981
+ if (mode === "update") {
4982
+ if (!docId) {
4983
+ stats.skipped++;
4984
+ stats.errors.push({
4985
+ row: rowNumber,
4986
+ error: "Missing or invalid documentId for Update mode. (Tip: Use Smart Auto-Detect mode to auto-add entries without document IDs)."
4987
+ });
4988
+ continue;
4989
+ }
4990
+ const existing = await findExistingDocument(strapi, uid, docId, rowLocale);
4991
+ if (!existing) {
4992
+ stats.skipped++;
4993
+ stats.errors.push({
4994
+ row: rowNumber,
4995
+ documentId: docId,
4996
+ error: `Document with ID "${docId}" not found in database.`
4997
+ });
4998
+ continue;
4999
+ }
5000
+ const resolvedDocId = existing.documentId || docId;
5001
+ const mergedPayload = { ...payload };
5002
+ for (const key of Object.keys(payload)) {
5003
+ if (attrs[key]?.type === "component" && typeof payload[key] === "object" && payload[key] !== null) {
5004
+ mergedPayload[key] = { ...existing[key] || {}, ...payload[key] };
5005
+ }
5006
+ }
5007
+ if (Object.keys(mergedPayload).length === 0) {
5008
+ stats.skipped++;
5009
+ stats.diffs.push({
5010
+ uid,
5011
+ collectionName: ct.info?.displayName || uid,
5012
+ documentId: resolvedDocId,
5013
+ label: existing.title || existing.name || `Entry #${resolvedDocId}`,
5014
+ attribute: "",
5015
+ oldValue: existing,
5016
+ newValue: {},
5017
+ status: "skipped",
5018
+ message: `Skipped (no editable attributes in row ${rowNumber})`
5019
+ });
5020
+ continue;
5021
+ }
5022
+ const updateArgs = {
5023
+ documentId: resolvedDocId,
5024
+ data: mergedPayload
5025
+ };
5026
+ if (rowLocale) updateArgs.locale = rowLocale;
5027
+ await strapi.documents(uid).update(updateArgs);
5028
+ if (publish && ct.options?.draftAndPublish) {
5029
+ try {
5030
+ const pubArgs = { documentId: resolvedDocId };
5031
+ if (rowLocale) pubArgs.locale = rowLocale;
5032
+ await strapi.documents(uid).publish(pubArgs);
5033
+ } catch (pubErr) {
5034
+ strapi.log.warn(`[bulk-updater] Auto-publish failed for ${resolvedDocId}:`, pubErr);
5035
+ }
5036
+ }
5037
+ try {
5038
+ await recordContentHistoryVersion(strapi, {
5039
+ uid,
5040
+ documentId: resolvedDocId,
5041
+ userId: user?.id,
5042
+ statusOverride: publish && ct.options?.draftAndPublish ? "published" : void 0
5043
+ });
5044
+ } catch {
5045
+ }
5046
+ stats.updated++;
5047
+ for (const attrKey of Object.keys(payload)) {
5048
+ const oldVal = existing?.[attrKey] ?? null;
5049
+ const newVal = payload[attrKey];
5050
+ stats.diffs.push({
5051
+ uid,
5052
+ collectionName: ct.info?.displayName || uid,
5053
+ documentId: resolvedDocId,
5054
+ label: existing.title || existing.name || `Entry #${resolvedDocId}`,
5055
+ attribute: attrKey,
5056
+ oldValue: oldVal,
5057
+ newValue: newVal,
5058
+ status: "updated",
5059
+ message: `Updated "${attrKey}" via CSV import (row ${rowNumber})`
5060
+ });
5061
+ }
5062
+ } else if (mode === "create_or_override") {
5063
+ let existing = null;
5064
+ if (docId) {
5065
+ existing = await findExistingDocument(strapi, uid, docId, rowLocale);
5066
+ }
5067
+ if (existing) {
5068
+ const resolvedDocId = existing.documentId || docId;
5069
+ const mergedPayload = { ...payload };
5070
+ for (const key of Object.keys(payload)) {
5071
+ if (attrs[key]?.type === "component" && typeof payload[key] === "object" && payload[key] !== null) {
5072
+ mergedPayload[key] = { ...existing[key] || {}, ...payload[key] };
5073
+ }
5074
+ }
5075
+ const updateArgs = {
5076
+ documentId: resolvedDocId,
5077
+ data: mergedPayload
5078
+ };
5079
+ if (rowLocale) updateArgs.locale = rowLocale;
5080
+ await strapi.documents(uid).update(updateArgs);
5081
+ if (publish && ct.options?.draftAndPublish) {
5082
+ try {
5083
+ const pubArgs = { documentId: resolvedDocId };
5084
+ if (rowLocale) pubArgs.locale = rowLocale;
5085
+ await strapi.documents(uid).publish(pubArgs);
5086
+ } catch (pubErr) {
5087
+ strapi.log.warn(`[bulk-updater] Auto-publish failed for ${resolvedDocId}:`, pubErr);
5088
+ }
5089
+ }
5090
+ try {
5091
+ await recordContentHistoryVersion(strapi, {
5092
+ uid,
5093
+ documentId: resolvedDocId,
5094
+ userId: user?.id,
5095
+ statusOverride: publish && ct.options?.draftAndPublish ? "published" : void 0
5096
+ });
5097
+ } catch {
5098
+ }
5099
+ stats.updated++;
5100
+ for (const attrKey of Object.keys(payload)) {
5101
+ const oldVal = existing?.[attrKey] ?? null;
5102
+ const newVal = payload[attrKey];
5103
+ stats.diffs.push({
5104
+ uid,
5105
+ collectionName: ct.info?.displayName || uid,
5106
+ documentId: resolvedDocId,
5107
+ label: existing.title || existing.name || `Entry #${resolvedDocId}`,
5108
+ attribute: attrKey,
5109
+ oldValue: oldVal,
5110
+ newValue: newVal,
5111
+ status: "updated",
5112
+ message: `Overridden "${attrKey}" via CSV import (row ${rowNumber})`
5113
+ });
5114
+ }
5115
+ continue;
5116
+ }
5117
+ const createArgs = { data: payload };
5118
+ if (rowLocale) createArgs.locale = rowLocale;
5119
+ const created = await strapi.documents(uid).create(createArgs);
5120
+ const newDocId = created?.documentId || docId;
5121
+ if (publish && ct.options?.draftAndPublish && newDocId) {
5122
+ try {
5123
+ const pubArgs = { documentId: newDocId };
5124
+ if (rowLocale) pubArgs.locale = rowLocale;
5125
+ await strapi.documents(uid).publish(pubArgs);
5126
+ } catch (pubErr) {
5127
+ strapi.log.warn(`[bulk-updater] Auto-publish failed for ${newDocId}:`, pubErr);
5128
+ }
5129
+ }
5130
+ try {
5131
+ await recordContentHistoryVersion(strapi, {
5132
+ uid,
5133
+ documentId: newDocId,
5134
+ userId: user?.id,
5135
+ statusOverride: publish && ct.options?.draftAndPublish ? "published" : void 0
5136
+ });
5137
+ } catch {
5138
+ }
5139
+ stats.created++;
5140
+ if (!docId) {
5141
+ stats.newWithoutDocId++;
5142
+ }
5143
+ for (const attrKey of Object.keys(payload)) {
5144
+ stats.diffs.push({
5145
+ uid,
5146
+ collectionName: ct.info?.displayName || uid,
5147
+ documentId: newDocId,
5148
+ label: created?.title || created?.name || `New Entry #${newDocId}`,
5149
+ attribute: attrKey,
5150
+ oldValue: null,
5151
+ newValue: payload[attrKey],
5152
+ status: "created",
5153
+ message: docId ? `Created "${attrKey}" via CSV import (row ${rowNumber})` : `Created "${attrKey}" as new entry (row ${rowNumber})`
5154
+ });
5155
+ }
5156
+ } else {
5157
+ let existing = null;
5158
+ if (docId) {
5159
+ existing = await findExistingDocument(strapi, uid, docId, rowLocale);
5160
+ }
5161
+ if (existing) {
5162
+ const resolvedDocId = existing.documentId || docId;
5163
+ const mergedPayload = { ...payload };
5164
+ for (const key of Object.keys(payload)) {
5165
+ if (attrs[key]?.type === "component" && typeof payload[key] === "object" && payload[key] !== null) {
5166
+ mergedPayload[key] = { ...existing[key] || {}, ...payload[key] };
5167
+ }
5168
+ }
5169
+ const updateArgs = {
5170
+ documentId: resolvedDocId,
5171
+ data: mergedPayload
5172
+ };
5173
+ if (rowLocale) updateArgs.locale = rowLocale;
5174
+ await strapi.documents(uid).update(updateArgs);
5175
+ if (publish && ct.options?.draftAndPublish) {
5176
+ try {
5177
+ const pubArgs = { documentId: resolvedDocId };
5178
+ if (rowLocale) pubArgs.locale = rowLocale;
5179
+ await strapi.documents(uid).publish(pubArgs);
5180
+ } catch (pubErr) {
5181
+ strapi.log.warn(`[bulk-updater] Auto-publish failed for ${resolvedDocId}:`, pubErr);
5182
+ }
5183
+ }
5184
+ try {
5185
+ await recordContentHistoryVersion(strapi, {
5186
+ uid,
5187
+ documentId: resolvedDocId,
5188
+ userId: user?.id,
5189
+ statusOverride: publish && ct.options?.draftAndPublish ? "published" : void 0
5190
+ });
5191
+ } catch {
5192
+ }
5193
+ stats.updated++;
5194
+ for (const attrKey of Object.keys(payload)) {
5195
+ const oldVal = existing?.[attrKey] ?? null;
5196
+ const newVal = payload[attrKey];
5197
+ stats.diffs.push({
5198
+ uid,
5199
+ collectionName: ct.info?.displayName || uid,
5200
+ documentId: resolvedDocId,
5201
+ label: existing.title || existing.name || `Entry #${resolvedDocId}`,
5202
+ attribute: attrKey,
5203
+ oldValue: oldVal,
5204
+ newValue: newVal,
5205
+ status: "updated",
5206
+ message: `Updated "${attrKey}" via CSV import (row ${rowNumber})`
5207
+ });
5208
+ }
5209
+ continue;
5210
+ }
5211
+ const createArgs = { data: payload };
5212
+ if (rowLocale) createArgs.locale = rowLocale;
5213
+ const created = await strapi.documents(uid).create(createArgs);
5214
+ const newDocId = created?.documentId || docId;
5215
+ if (publish && ct.options?.draftAndPublish && newDocId) {
5216
+ try {
5217
+ const pubArgs = { documentId: newDocId };
5218
+ if (rowLocale) pubArgs.locale = rowLocale;
5219
+ await strapi.documents(uid).publish(pubArgs);
5220
+ } catch (pubErr) {
5221
+ strapi.log.warn(`[bulk-updater] Auto-publish failed for ${newDocId}:`, pubErr);
5222
+ }
5223
+ }
5224
+ try {
5225
+ await recordContentHistoryVersion(strapi, {
5226
+ uid,
5227
+ documentId: newDocId,
5228
+ userId: user?.id,
5229
+ statusOverride: publish && ct.options?.draftAndPublish ? "published" : void 0
5230
+ });
5231
+ } catch {
5232
+ }
5233
+ stats.created++;
5234
+ if (!docId) {
5235
+ stats.newWithoutDocId++;
5236
+ }
5237
+ for (const attrKey of Object.keys(payload)) {
5238
+ stats.diffs.push({
5239
+ uid,
5240
+ collectionName: ct.info?.displayName || uid,
5241
+ documentId: newDocId,
5242
+ label: created?.title || created?.name || `New Entry #${newDocId}`,
5243
+ attribute: attrKey,
5244
+ oldValue: null,
5245
+ newValue: payload[attrKey],
5246
+ status: "created",
5247
+ message: docId ? `Created "${attrKey}" as new entry (documentId "${docId}" not found in database - row ${rowNumber})` : `Created "${attrKey}" as new entry (no documentId provided in CSV - row ${rowNumber})`
5248
+ });
5249
+ }
5250
+ }
5251
+ } catch (err) {
5252
+ stats.errors.push({
5253
+ row: rowNumber,
5254
+ documentId: docId || void 0,
5255
+ error: extractDetailedErrorMessage(err)
5256
+ });
5257
+ }
5258
+ }
5259
+ return stats;
5260
+ }
5261
+ async function recordCsvAuditLog(strapi, ct, uid, batchId, stats, user) {
5262
+ if (stats.updated === 0 && stats.created === 0) return;
5263
+ try {
5264
+ const auditService2 = strapi.plugin("bulk-updater").service("audit");
5265
+ if (!auditService2) return;
5266
+ const isEnabled = typeof auditService2.isAuditEnabled === "function" ? auditService2.isAuditEnabled() : Boolean(auditService2.getAuditConfig?.().enabled ?? true);
5267
+ if (!isEnabled) return;
5268
+ const userEmail = user?.email || "admin@strapi.io";
5269
+ const userName = user ? `${user.firstname || ""} ${user.lastname || ""}`.trim() : "CSV Importer";
5270
+ const collectionDisplayName = ct.info?.displayName || uid;
5271
+ const updatedDiffs = stats.diffs.filter(
5272
+ (d) => d.status === "updated" || d.status === "created"
5273
+ );
5274
+ const uniqueAttributes = Array.from(
5275
+ new Set(updatedDiffs.map((d) => d.attribute))
5276
+ ).filter(Boolean);
5277
+ const attrListStr = uniqueAttributes.length > 0 ? uniqueAttributes.join(", ") : "fields";
5278
+ const batchAttribute = `CSV [${collectionDisplayName}]: ${attrListStr}`;
5279
+ let batchValue = "";
5280
+ if (updatedDiffs.length === 1) {
5281
+ const d = updatedDiffs[0];
5282
+ const newStr = d.newValue !== null && d.newValue !== void 0 ? String(d.newValue) : "(empty)";
5283
+ batchValue = `"${d.label}": ${d.attribute} → "${newStr}"`;
5284
+ } else if (updatedDiffs.length > 1 && stats.updated === 1 && stats.created === 0) {
5285
+ const d = updatedDiffs[0];
5286
+ const changes = updatedDiffs.map((ud) => `${ud.attribute}: "${ud.newValue ?? ""}"`).join(", ");
5287
+ batchValue = `"${d.label}": ${changes}`;
5288
+ } else {
5289
+ const sampleVal = updatedDiffs[0] ? ` (e.g. ${updatedDiffs[0].attribute}: "${updatedDiffs[0].newValue ?? ""}")` : "";
5290
+ batchValue = `${stats.updated} updated, ${stats.created} created in ${collectionDisplayName}${sampleVal}`;
5291
+ }
5292
+ const batchSummary = {
5293
+ batchId,
5294
+ attribute: batchAttribute,
5295
+ value: batchValue,
5296
+ updatedCount: stats.updated + stats.created,
5297
+ skippedCount: stats.skipped,
5298
+ totalTargets: stats.total
5299
+ };
5300
+ const userObj = {
5301
+ email: userEmail || "admin@strapi.io",
5302
+ firstname: userName || "Admin"
5303
+ };
5304
+ if (typeof auditService2.recordBatch === "function") {
5305
+ await auditService2.recordBatch(batchSummary, stats.diffs, userObj);
5306
+ } else if (typeof auditService2.logOperation === "function") {
5307
+ await auditService2.logOperation({
5308
+ ...batchSummary,
5309
+ userEmail,
5310
+ userName,
5311
+ diffs: stats.diffs
5312
+ });
5313
+ }
5314
+ } catch (auditErr) {
5315
+ strapi.log.warn("[bulk-updater] Failed logging CSV import audit:", auditErr);
5316
+ }
5317
+ }
5318
+ const csvService = ({ strapi }) => ({
5319
+ /**
5320
+ * Generates a sample template CSV with schema attribute headers and sample values
5321
+ */
5322
+ async getTemplateCsv(uid) {
5323
+ const ct = strapi.contentTypes[uid];
5324
+ if (!ct) {
5325
+ throw new Error(`Content type ${uid} not found`);
5326
+ }
5327
+ const attrs = ct.attributes || {};
5328
+ const excluded = ["createdAt", "updatedAt", "publishedAt", "createdBy", "updatedBy", "localizations"];
5329
+ const headers = ["documentId"];
5330
+ const sampleRow = ["(Leave empty to create new, or enter existing docId)"];
5331
+ for (const [attrName, attrDef] of Object.entries(attrs)) {
5332
+ if (excluded.includes(attrName)) continue;
5333
+ if (attrDef.type === "relation") continue;
5334
+ if (attrDef.type === "component" || attrDef.type === "dynamiczone") continue;
5335
+ headers.push(attrName);
5336
+ let sampleVal = "";
5337
+ switch (attrDef.type) {
5338
+ case "string":
5339
+ case "text":
5340
+ case "richtext":
5341
+ sampleVal = `Sample ${attrName}`;
5342
+ break;
5343
+ case "integer":
5344
+ case "biginteger":
5345
+ case "float":
5346
+ case "decimal":
5347
+ sampleVal = "10";
4388
5348
  break;
4389
5349
  case "boolean":
4390
5350
  sampleVal = "true";
@@ -4478,40 +5438,64 @@ const csvService = ({ strapi }) => ({
4478
5438
  }
4479
5439
  }
4480
5440
  const deepPopulate = buildDeepPopulate(ct.attributes || {}, allComponents);
4481
- let entries = [];
5441
+ const lines = [];
5442
+ lines.push(fields.map((f) => escapeCsvValue(f.key)).join(","));
5443
+ let totalExported = 0;
5444
+ const maxLimit = options.limit && options.limit > 0 ? options.limit : Infinity;
5445
+ const CHUNK_SIZE = 1e3;
4482
5446
  if (ct.kind === "singleType") {
4483
5447
  const findFirstArgs = {};
4484
5448
  if (deepPopulate) {
4485
5449
  findFirstArgs.populate = deepPopulate;
4486
5450
  }
4487
5451
  const doc = await strapi.documents(uid).findFirst(findFirstArgs);
4488
- entries = doc ? [doc] : [];
5452
+ if (doc) {
5453
+ const rowValues = fields.map((f) => escapeCsvValue(getNestedExportValue(doc, f.key)));
5454
+ lines.push(rowValues.join(","));
5455
+ totalExported = 1;
5456
+ }
4489
5457
  } else {
4490
- const findManyArgs = {
4491
- filters: Object.keys(filters).length > 0 ? filters : void 0,
4492
- limit: options.limit || 5e3,
4493
- sort: { createdAt: "desc" }
4494
- };
4495
- if (deepPopulate) {
4496
- findManyArgs.populate = deepPopulate;
5458
+ let currentPage = 1;
5459
+ let hasMore = true;
5460
+ while (hasMore && totalExported < maxLimit) {
5461
+ const remaining = maxLimit - totalExported;
5462
+ const currentBatchLimit = Math.min(CHUNK_SIZE, remaining);
5463
+ const findPageArgs = {
5464
+ filters: Object.keys(filters).length > 0 ? filters : void 0,
5465
+ page: currentPage,
5466
+ pageSize: currentBatchLimit,
5467
+ sort: { createdAt: "desc" }
5468
+ };
5469
+ if (deepPopulate) {
5470
+ findPageArgs.populate = deepPopulate;
5471
+ }
5472
+ const chunkEntries = await strapi.documents(uid).findMany(findPageArgs);
5473
+ if (!chunkEntries || chunkEntries.length === 0) {
5474
+ hasMore = false;
5475
+ break;
5476
+ }
5477
+ for (const entry of chunkEntries) {
5478
+ const rowValues = fields.map((f) => escapeCsvValue(getNestedExportValue(entry, f.key)));
5479
+ lines.push(rowValues.join(","));
5480
+ totalExported++;
5481
+ if (totalExported >= maxLimit) {
5482
+ hasMore = false;
5483
+ break;
5484
+ }
5485
+ }
5486
+ if (chunkEntries.length < currentBatchLimit) {
5487
+ hasMore = false;
5488
+ } else {
5489
+ currentPage++;
5490
+ }
4497
5491
  }
4498
- entries = await strapi.documents(uid).findMany(findManyArgs);
4499
- }
4500
- const lines = [];
4501
- lines.push(fields.map((f) => escapeCsvValue(f.key)).join(","));
4502
- for (const entry of entries || []) {
4503
- const rowValues = fields.map((f) => {
4504
- const val = getNestedExportValue(entry, f.key);
4505
- return escapeCsvValue(val);
4506
- });
4507
- lines.push(rowValues.join(","));
4508
5492
  }
4509
5493
  const csvContent = "\uFEFF" + lines.join("\r\n") + "\r\n";
4510
5494
  const filename = `${ct.info?.singularName || uid}_export_${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.csv`;
4511
5495
  return {
4512
5496
  filename,
4513
5497
  csvContent,
4514
- count: entries ? entries.length : 0,
5498
+ count: totalExported,
4515
5499
  fields: fields.map((f) => f.key)
4516
5500
  };
4517
5501
  },
@@ -4572,7 +5556,7 @@ const csvService = ({ strapi }) => ({
4572
5556
  const uids = Object.keys(byUid);
4573
5557
  if (uids.length === 1) {
4574
5558
  const uid = uids[0];
4575
- return await this.exportCollectionCsv(uid, { documentIds: byUid[uid] });
5559
+ return await this.exportCollectionCsv(uid, { documentIds: byUid[uid], limit: 0 });
4576
5560
  }
4577
5561
  const rows = [];
4578
5562
  const headers = [
@@ -4623,7 +5607,77 @@ const csvService = ({ strapi }) => ({
4623
5607
  };
4624
5608
  },
4625
5609
  /**
4626
- * Imports CSV records to update existing records or insert/override records
5610
+ * Imports a single chunk of CSV rows (for large dataset streaming/batching).
5611
+ * Aggregates stats across chunks and writes unified audit trail on the final chunk.
5612
+ */
5613
+ async importChunk(uid, rows, options) {
5614
+ const ct = strapi.contentTypes[uid];
5615
+ if (!ct) {
5616
+ throw new Error(`Content type ${uid} not found`);
5617
+ }
5618
+ cleanupStaleSessions();
5619
+ const batchId = options.batchId || `csv_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
5620
+ let session = csvImportSessions.get(batchId);
5621
+ if (!session || options.isFirstChunk) {
5622
+ session = {
5623
+ batchId,
5624
+ uid,
5625
+ createdAt: Date.now(),
5626
+ total: 0,
5627
+ created: 0,
5628
+ updated: 0,
5629
+ skipped: 0,
5630
+ newWithoutDocId: 0,
5631
+ errors: [],
5632
+ diffs: []
5633
+ };
5634
+ csvImportSessions.set(batchId, session);
5635
+ }
5636
+ const startRowNumber = options.startRowNumber || session.total + 2;
5637
+ const chunkStats = await processCsvRows(strapi, ct, uid, rows || [], startRowNumber, options);
5638
+ session.total += chunkStats.total;
5639
+ session.created += chunkStats.created;
5640
+ session.updated += chunkStats.updated;
5641
+ session.skipped += chunkStats.skipped;
5642
+ session.newWithoutDocId += chunkStats.newWithoutDocId;
5643
+ session.errors.push(...chunkStats.errors);
5644
+ session.diffs.push(...chunkStats.diffs);
5645
+ if (options.isLastChunk) {
5646
+ await recordCsvAuditLog(strapi, ct, uid, batchId, session, options.user);
5647
+ try {
5648
+ const bulkService = strapi.plugin("bulk-updater").service("bulkUpdater");
5649
+ if (bulkService && bulkService.invalidateCollectionCache) {
5650
+ bulkService.invalidateCollectionCache(uid);
5651
+ }
5652
+ } catch {
5653
+ }
5654
+ const aggregateStats = {
5655
+ total: session.total,
5656
+ created: session.created,
5657
+ updated: session.updated,
5658
+ skipped: session.skipped,
5659
+ newWithoutDocId: session.newWithoutDocId,
5660
+ errors: session.errors,
5661
+ diffs: session.diffs
5662
+ };
5663
+ csvImportSessions.delete(batchId);
5664
+ return {
5665
+ success: true,
5666
+ batchId,
5667
+ chunkStats,
5668
+ stats: aggregateStats,
5669
+ isFinished: true
5670
+ };
5671
+ }
5672
+ return {
5673
+ success: true,
5674
+ batchId,
5675
+ chunkStats,
5676
+ isFinished: false
5677
+ };
5678
+ },
5679
+ /**
5680
+ * Imports CSV records to update existing records or insert/override records (single payload).
4627
5681
  */
4628
5682
  async importCsv(uid, csvText, options) {
4629
5683
  const ct = strapi.contentTypes[uid];
@@ -4634,560 +5688,433 @@ const csvService = ({ strapi }) => ({
4634
5688
  if (!parsedRows || parsedRows.length === 0) {
4635
5689
  throw new Error("CSV is empty or could not be parsed.");
4636
5690
  }
4637
- const attrs = ct.attributes || {};
4638
- const { mode = "auto", publish = true, user } = options;
4639
- const stats = {
4640
- total: parsedRows.length,
4641
- created: 0,
4642
- updated: 0,
4643
- skipped: 0,
4644
- newWithoutDocId: 0,
4645
- errors: [],
4646
- diffs: []
4647
- };
4648
5691
  const batchId = `csv_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
4649
- const userEmail = user?.email || "admin@strapi.io";
4650
- const userName = user ? `${user.firstname || ""} ${user.lastname || ""}`.trim() : "CSV Importer";
4651
- const findExistingDocument = async (targetDocId, docLocale) => {
4652
- if (!targetDocId) return null;
4653
- try {
4654
- const queryArgs = { documentId: targetDocId };
4655
- if (docLocale) queryArgs.locale = docLocale;
4656
- const found = await strapi.documents(uid).findOne(queryArgs);
4657
- if (found) return found;
4658
- } catch {
4659
- }
4660
- if (/^\d+$/.test(targetDocId)) {
4661
- try {
4662
- const queryArgs = {
4663
- filters: { id: parseInt(targetDocId, 10) },
4664
- limit: 1
4665
- };
4666
- if (docLocale) queryArgs.locale = docLocale;
4667
- const list = await strapi.documents(uid).findMany(queryArgs);
4668
- if (list && list.length > 0) return list[0];
4669
- } catch {
4670
- }
5692
+ const stats = await processCsvRows(strapi, ct, uid, parsedRows, 2, options);
5693
+ try {
5694
+ const bulkService = strapi.plugin("bulk-updater").service("bulkUpdater");
5695
+ if (bulkService && bulkService.invalidateCollectionCache) {
5696
+ bulkService.invalidateCollectionCache(uid);
4671
5697
  }
4672
- return null;
5698
+ } catch {
5699
+ }
5700
+ await recordCsvAuditLog(strapi, ct, uid, batchId, stats, options.user);
5701
+ return {
5702
+ success: true,
5703
+ batchId,
5704
+ stats
4673
5705
  };
4674
- const SYSTEM_FIELD_KEYS = /* @__PURE__ */ new Set([
4675
- "documentid",
4676
- "document_id",
4677
- "docid",
4678
- "id",
4679
- "createdat",
4680
- "updatedat",
4681
- "publishedat",
4682
- "createdby",
4683
- "updatedby",
4684
- "localizations",
4685
- "locale",
4686
- "status"
4687
- ]);
4688
- for (let i = 0; i < parsedRows.length; i++) {
4689
- const rowNumber = i + 2;
4690
- const row = parsedRows[i];
4691
- let docId = extractDocId(row);
4692
- const rowLocale = extractLocale(row);
4693
- if (ct.kind === "singleType" && !docId) {
4694
- try {
4695
- const findSingleArgs = {};
4696
- if (rowLocale) findSingleArgs.locale = rowLocale;
4697
- const singleDoc = await strapi.documents(uid).findFirst(findSingleArgs);
4698
- if (singleDoc?.documentId) {
4699
- docId = singleDoc.documentId;
4700
- }
4701
- } catch {
4702
- }
4703
- }
4704
- const payload = {};
4705
- for (const [key, rawVal] of Object.entries(row)) {
4706
- const normalizedKey = key.toLowerCase().replace(/[\s_-]/g, "");
4707
- if (SYSTEM_FIELD_KEYS.has(normalizedKey)) continue;
4708
- if (key.includes(".")) {
4709
- const [parent, child] = key.split(".");
4710
- const parentDef = attrs[parent];
4711
- if (parentDef?.type === "component" && !parentDef.repeatable && parentDef.component) {
4712
- const comp = strapi.components[parentDef.component];
4713
- const childDef = comp?.attributes?.[child];
4714
- if (childDef) {
4715
- if (!payload[parent]) payload[parent] = {};
4716
- let val = rawVal;
4717
- if (rawVal === "" || rawVal === null || rawVal === void 0) {
4718
- val = null;
4719
- } else if (childDef.type === "integer" || childDef.type === "biginteger") {
4720
- const parsedInt = parseInt(rawVal, 10);
4721
- val = isNaN(parsedInt) ? null : parsedInt;
4722
- } else if (childDef.type === "float" || childDef.type === "decimal") {
4723
- const parsedFloat = parseFloat(rawVal);
4724
- val = isNaN(parsedFloat) ? null : parsedFloat;
4725
- } else if (childDef.type === "boolean") {
4726
- val = rawVal.toLowerCase() === "true" || rawVal === "1";
4727
- } else if (childDef.type === "json") {
4728
- try {
4729
- val = JSON.parse(rawVal);
4730
- } catch {
4731
- val = rawVal;
4732
- }
4733
- }
4734
- payload[parent][child] = val;
4735
- }
4736
- }
4737
- continue;
4738
- }
4739
- let attrDef = attrs[key];
4740
- if (!attrDef) {
4741
- const matchKey = Object.keys(attrs).find(
4742
- (k) => k.toLowerCase() === key.toLowerCase() || k.toLowerCase() === key.toLowerCase().replace(/\s+/g, "")
4743
- );
4744
- if (matchKey) {
4745
- attrDef = attrs[matchKey];
4746
- }
4747
- }
4748
- if (!attrDef) continue;
4749
- if (rawVal === "" || rawVal === null || rawVal === void 0) {
4750
- if (attrDef.type !== "dynamiczone" && !(attrDef.type === "component" && attrDef.repeatable)) {
4751
- payload[key] = null;
4752
- }
4753
- continue;
4754
- }
4755
- switch (attrDef.type) {
4756
- case "integer":
4757
- case "biginteger": {
4758
- const parsedInt = parseInt(rawVal, 10);
4759
- payload[key] = isNaN(parsedInt) ? null : parsedInt;
4760
- break;
4761
- }
4762
- case "float":
4763
- case "decimal": {
4764
- const parsedFloat = parseFloat(rawVal);
4765
- payload[key] = isNaN(parsedFloat) ? null : parsedFloat;
4766
- break;
4767
- }
4768
- case "boolean":
4769
- payload[key] = rawVal.toLowerCase() === "true" || rawVal === "1";
4770
- break;
4771
- case "json":
4772
- try {
4773
- payload[key] = JSON.parse(rawVal);
4774
- } catch {
4775
- payload[key] = rawVal;
4776
- }
4777
- break;
4778
- case "dynamiczone": {
4779
- try {
4780
- const parsed = JSON.parse(rawVal);
4781
- if (Array.isArray(parsed) && parsed.every((item) => item && typeof item === "object" && typeof item.__component === "string")) {
4782
- payload[key] = parsed;
4783
- }
4784
- } catch {
4785
- }
4786
- break;
4787
- }
4788
- case "component": {
4789
- if (attrDef.repeatable) {
4790
- try {
4791
- const parsed = JSON.parse(rawVal);
4792
- if (Array.isArray(parsed) && parsed.every((item) => item && typeof item === "object")) {
4793
- payload[key] = parsed;
4794
- }
4795
- } catch {
4796
- }
4797
- } else {
4798
- try {
4799
- const parsed = JSON.parse(rawVal);
4800
- if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
4801
- payload[key] = parsed;
4802
- }
4803
- } catch {
4804
- }
4805
- }
4806
- break;
4807
- }
4808
- case "relation": {
4809
- try {
4810
- const parsed = JSON.parse(rawVal);
4811
- if (Array.isArray(parsed) || typeof parsed === "object" && parsed !== null) {
4812
- payload[key] = parsed;
4813
- break;
4814
- }
4815
- } catch {
4816
- }
4817
- if (isValidDocId(rawVal)) {
4818
- payload[key] = rawVal;
4819
- } else if (rawVal.includes(";") || rawVal.includes(",")) {
4820
- const parts = rawVal.split(/[;,]/).map((s) => s.trim()).filter(Boolean);
4821
- if (parts.length > 0 && parts.every((p) => isValidDocId(p))) {
4822
- payload[key] = parts;
4823
- }
4824
- }
4825
- break;
4826
- }
4827
- case "media": {
4828
- if (/^\d+$/.test(rawVal.trim())) {
4829
- payload[key] = parseInt(rawVal.trim(), 10);
4830
- } else {
4831
- try {
4832
- const parsed = JSON.parse(rawVal);
4833
- if (typeof parsed === "number" || typeof parsed === "object") {
4834
- payload[key] = parsed;
4835
- }
4836
- } catch {
4837
- }
4838
- }
4839
- break;
4840
- }
4841
- default:
4842
- payload[key] = rawVal;
4843
- }
5706
+ },
5707
+ /**
5708
+ * Executes a pending CSV batch approved by Checker or queued for off-peak execution
5709
+ */
5710
+ async executeCsvPendingBatch(uid, rows, batchId, options = {}, checkerUser) {
5711
+ const ct = strapi.contentTypes[uid];
5712
+ if (!ct) {
5713
+ throw new Error(`Content type ${uid} not found`);
5714
+ }
5715
+ const effectiveUser = checkerUser || options.user;
5716
+ const stats = await processCsvRows(strapi, ct, uid, rows || [], 2, {
5717
+ ...options,
5718
+ user: effectiveUser
5719
+ });
5720
+ await recordCsvAuditLog(strapi, ct, uid, batchId, stats, effectiveUser);
5721
+ try {
5722
+ const bulkService = strapi.plugin("bulk-updater").service("bulkUpdater");
5723
+ if (bulkService && bulkService.invalidateCollectionCache) {
5724
+ bulkService.invalidateCollectionCache(uid);
4844
5725
  }
4845
- try {
4846
- if (mode === "update") {
4847
- if (!docId) {
4848
- stats.skipped++;
4849
- stats.errors.push({
4850
- row: rowNumber,
4851
- error: "Missing or invalid documentId for Update mode. (Tip: Use Smart Auto-Detect mode to auto-add entries without document IDs)."
4852
- });
4853
- continue;
4854
- }
4855
- const existing = await findExistingDocument(docId, rowLocale);
4856
- if (!existing) {
4857
- stats.skipped++;
4858
- stats.errors.push({
5726
+ } catch {
5727
+ }
5728
+ return {
5729
+ success: true,
5730
+ batchId,
5731
+ stats
5732
+ };
5733
+ },
5734
+ /**
5735
+ * Pre-Flight Dry-Run Validation: Non-mutating analysis verifying document existence,
5736
+ * data types, schema integrity, and quota rule violations before execution.
5737
+ */
5738
+ async validatePreflight(uid, rows, options = {}) {
5739
+ const ct = strapi.contentTypes[uid];
5740
+ if (!ct) {
5741
+ throw new Error(`Content type ${uid} not found`);
5742
+ }
5743
+ const { mode = "auto" } = options;
5744
+ const attrs = ct.attributes || {};
5745
+ let validationRulesService2 = null;
5746
+ let activeRules = [];
5747
+ try {
5748
+ validationRulesService2 = strapi.plugin("bulk-updater").service("validationRules");
5749
+ if (validationRulesService2) {
5750
+ const rulesRes = await validationRulesService2.getRules();
5751
+ activeRules = (rulesRes || []).filter((r) => r.enabled);
5752
+ }
5753
+ } catch {
5754
+ }
5755
+ let validRows = 0;
5756
+ let estimatedUpdates = 0;
5757
+ let estimatedCreates = 0;
5758
+ const errors = [];
5759
+ const warnings = [];
5760
+ for (let i = 0; i < rows.length; i++) {
5761
+ const rowNumber = i + 2;
5762
+ const row = rows[i];
5763
+ const docId = extractDocId(row);
5764
+ const rowLocale = extractLocale(row);
5765
+ let rowHasError = false;
5766
+ let existing = null;
5767
+ if (docId) {
5768
+ existing = await findExistingDocument(strapi, uid, docId, rowLocale);
5769
+ }
5770
+ if (mode === "update") {
5771
+ if (!docId) {
5772
+ errors.push({
5773
+ row: rowNumber,
5774
+ error: "Missing or invalid documentId for Update Only mode."
5775
+ });
5776
+ rowHasError = true;
5777
+ } else if (!existing) {
5778
+ errors.push({
5779
+ row: rowNumber,
5780
+ documentId: docId,
5781
+ error: `Document with ID "${docId}" not found in database.`
5782
+ });
5783
+ rowHasError = true;
5784
+ } else {
5785
+ estimatedUpdates++;
5786
+ }
5787
+ } else if (mode === "create_or_override") {
5788
+ if (existing) {
5789
+ estimatedUpdates++;
5790
+ } else {
5791
+ estimatedCreates++;
5792
+ }
5793
+ } else {
5794
+ if (existing) {
5795
+ estimatedUpdates++;
5796
+ } else {
5797
+ estimatedCreates++;
5798
+ if (docId) {
5799
+ warnings.push({
4859
5800
  row: rowNumber,
4860
5801
  documentId: docId,
4861
- error: `Document with ID "${docId}" not found in database.`
4862
- });
4863
- continue;
4864
- }
4865
- const resolvedDocId = existing.documentId || docId;
4866
- const mergedPayload = { ...payload };
4867
- for (const key of Object.keys(payload)) {
4868
- if (attrs[key]?.type === "component" && typeof payload[key] === "object" && payload[key] !== null) {
4869
- mergedPayload[key] = { ...existing[key] || {}, ...payload[key] };
4870
- }
4871
- }
4872
- if (Object.keys(mergedPayload).length === 0) {
4873
- stats.skipped++;
4874
- stats.diffs.push({
4875
- uid,
4876
- collectionName: ct.info?.displayName || uid,
4877
- documentId: resolvedDocId,
4878
- label: existing.title || existing.name || `Entry #${resolvedDocId}`,
4879
- attribute: "",
4880
- oldValue: existing,
4881
- newValue: {},
4882
- status: "skipped",
4883
- message: `Skipped (no editable attributes in row ${rowNumber})`
4884
- });
4885
- continue;
4886
- }
4887
- const updateArgs = {
4888
- documentId: resolvedDocId,
4889
- data: mergedPayload
4890
- };
4891
- if (rowLocale) updateArgs.locale = rowLocale;
4892
- await strapi.documents(uid).update(updateArgs);
4893
- if (publish && ct.options?.draftAndPublish) {
4894
- try {
4895
- const pubArgs = { documentId: resolvedDocId };
4896
- if (rowLocale) pubArgs.locale = rowLocale;
4897
- await strapi.documents(uid).publish(pubArgs);
4898
- } catch (pubErr) {
4899
- strapi.log.warn(`[bulk-updater] Auto-publish failed for ${resolvedDocId}:`, pubErr);
4900
- }
4901
- }
4902
- try {
4903
- await recordContentHistoryVersion(strapi, {
4904
- uid,
4905
- documentId: resolvedDocId,
4906
- userId: user?.id,
4907
- statusOverride: publish && ct.options?.draftAndPublish ? "published" : void 0
4908
- });
4909
- } catch {
4910
- }
4911
- stats.updated++;
4912
- for (const attrKey of Object.keys(payload)) {
4913
- const oldVal = existing?.[attrKey] ?? null;
4914
- const newVal = payload[attrKey];
4915
- stats.diffs.push({
4916
- uid,
4917
- collectionName: ct.info?.displayName || uid,
4918
- documentId: resolvedDocId,
4919
- label: existing.title || existing.name || `Entry #${resolvedDocId}`,
4920
- attribute: attrKey,
4921
- oldValue: oldVal,
4922
- newValue: newVal,
4923
- status: "updated",
4924
- message: `Updated "${attrKey}" via CSV import (row ${rowNumber})`
5802
+ warning: `Document ID "${docId}" not found in DB; will create as a new entry.`
4925
5803
  });
4926
5804
  }
4927
- } else if (mode === "create_or_override") {
4928
- let existing = null;
4929
- if (docId) {
4930
- existing = await findExistingDocument(docId, rowLocale);
4931
- }
4932
- if (existing) {
4933
- const resolvedDocId = existing.documentId || docId;
4934
- const mergedPayload = { ...payload };
4935
- for (const key of Object.keys(payload)) {
4936
- if (attrs[key]?.type === "component" && typeof payload[key] === "object" && payload[key] !== null) {
4937
- mergedPayload[key] = { ...existing[key] || {}, ...payload[key] };
4938
- }
4939
- }
4940
- const updateArgs = {
4941
- documentId: resolvedDocId,
4942
- data: mergedPayload
4943
- };
4944
- if (rowLocale) updateArgs.locale = rowLocale;
4945
- await strapi.documents(uid).update(updateArgs);
4946
- if (publish && ct.options?.draftAndPublish) {
4947
- try {
4948
- const pubArgs = { documentId: resolvedDocId };
4949
- if (rowLocale) pubArgs.locale = rowLocale;
4950
- await strapi.documents(uid).publish(pubArgs);
4951
- } catch (pubErr) {
4952
- strapi.log.warn(`[bulk-updater] Auto-publish failed for ${resolvedDocId}:`, pubErr);
4953
- }
4954
- }
4955
- try {
4956
- await recordContentHistoryVersion(strapi, {
4957
- uid,
4958
- documentId: resolvedDocId,
4959
- userId: user?.id,
4960
- statusOverride: publish && ct.options?.draftAndPublish ? "published" : void 0
5805
+ }
5806
+ }
5807
+ for (const [colKey, rawVal] of Object.entries(row)) {
5808
+ if (!rawVal || rawVal.trim() === "") continue;
5809
+ const normalizedKey = colKey.toLowerCase().replace(/[\s_-]/g, "");
5810
+ if (SYSTEM_FIELD_KEYS.has(normalizedKey)) continue;
5811
+ let attrDef = attrs[colKey];
5812
+ if (!attrDef && !colKey.includes(".")) {
5813
+ const matchKey = Object.keys(attrs).find(
5814
+ (k) => k.toLowerCase() === colKey.toLowerCase() || k.toLowerCase() === colKey.toLowerCase().replace(/\s+/g, "")
5815
+ );
5816
+ if (matchKey) attrDef = attrs[matchKey];
5817
+ }
5818
+ if (attrDef) {
5819
+ if (attrDef.type === "integer" || attrDef.type === "biginteger") {
5820
+ const num = parseInt(rawVal, 10);
5821
+ if (isNaN(num)) {
5822
+ errors.push({
5823
+ row: rowNumber,
5824
+ documentId: docId || void 0,
5825
+ error: `Column "${colKey}" expects an integer, received: "${rawVal}"`
4961
5826
  });
4962
- } catch {
5827
+ rowHasError = true;
4963
5828
  }
4964
- stats.updated++;
4965
- for (const attrKey of Object.keys(payload)) {
4966
- const oldVal = existing?.[attrKey] ?? null;
4967
- const newVal = payload[attrKey];
4968
- stats.diffs.push({
4969
- uid,
4970
- collectionName: ct.info?.displayName || uid,
4971
- documentId: resolvedDocId,
4972
- label: existing.title || existing.name || `Entry #${resolvedDocId}`,
4973
- attribute: attrKey,
4974
- oldValue: oldVal,
4975
- newValue: newVal,
4976
- status: "updated",
4977
- message: `Overridden "${attrKey}" via CSV import (row ${rowNumber})`
5829
+ } else if (attrDef.type === "float" || attrDef.type === "decimal") {
5830
+ const num = parseFloat(rawVal);
5831
+ if (isNaN(num)) {
5832
+ errors.push({
5833
+ row: rowNumber,
5834
+ documentId: docId || void 0,
5835
+ error: `Column "${colKey}" expects a numeric float/decimal, received: "${rawVal}"`
4978
5836
  });
5837
+ rowHasError = true;
4979
5838
  }
4980
- continue;
4981
- }
4982
- const createArgs = { data: payload };
4983
- if (rowLocale) createArgs.locale = rowLocale;
4984
- const created = await strapi.documents(uid).create(createArgs);
4985
- const newDocId = created?.documentId || docId;
4986
- if (publish && ct.options?.draftAndPublish && newDocId) {
4987
- try {
4988
- const pubArgs = { documentId: newDocId };
4989
- if (rowLocale) pubArgs.locale = rowLocale;
4990
- await strapi.documents(uid).publish(pubArgs);
4991
- } catch (pubErr) {
4992
- strapi.log.warn(`[bulk-updater] Auto-publish failed for ${newDocId}:`, pubErr);
4993
- }
4994
- }
4995
- try {
4996
- await recordContentHistoryVersion(strapi, {
4997
- uid,
4998
- documentId: newDocId,
4999
- userId: user?.id,
5000
- statusOverride: publish && ct.options?.draftAndPublish ? "published" : void 0
5001
- });
5002
- } catch {
5003
- }
5004
- stats.created++;
5005
- if (!docId) {
5006
- stats.newWithoutDocId++;
5007
- }
5008
- for (const attrKey of Object.keys(payload)) {
5009
- stats.diffs.push({
5010
- uid,
5011
- collectionName: ct.info?.displayName || uid,
5012
- documentId: newDocId,
5013
- label: created?.title || created?.name || `New Entry #${newDocId}`,
5014
- attribute: attrKey,
5015
- oldValue: null,
5016
- newValue: payload[attrKey],
5017
- status: "created",
5018
- message: docId ? `Created "${attrKey}" via CSV import (row ${rowNumber})` : `Created "${attrKey}" as new entry (row ${rowNumber})`
5019
- });
5020
- }
5021
- } else {
5022
- let existing = null;
5023
- if (docId) {
5024
- existing = await findExistingDocument(docId, rowLocale);
5025
- }
5026
- if (existing) {
5027
- const resolvedDocId = existing.documentId || docId;
5028
- const mergedPayload = { ...payload };
5029
- for (const key of Object.keys(payload)) {
5030
- if (attrs[key]?.type === "component" && typeof payload[key] === "object" && payload[key] !== null) {
5031
- mergedPayload[key] = { ...existing[key] || {}, ...payload[key] };
5032
- }
5033
- }
5034
- const updateArgs = {
5035
- documentId: resolvedDocId,
5036
- data: mergedPayload
5037
- };
5038
- if (rowLocale) updateArgs.locale = rowLocale;
5039
- await strapi.documents(uid).update(updateArgs);
5040
- if (publish && ct.options?.draftAndPublish) {
5041
- try {
5042
- const pubArgs = { documentId: resolvedDocId };
5043
- if (rowLocale) pubArgs.locale = rowLocale;
5044
- await strapi.documents(uid).publish(pubArgs);
5045
- } catch (pubErr) {
5046
- strapi.log.warn(`[bulk-updater] Auto-publish failed for ${resolvedDocId}:`, pubErr);
5047
- }
5839
+ } else if (attrDef.type === "enumeration" && Array.isArray(attrDef.enum)) {
5840
+ if (!attrDef.enum.includes(rawVal.trim())) {
5841
+ warnings.push({
5842
+ row: rowNumber,
5843
+ documentId: docId || void 0,
5844
+ warning: `Column "${colKey}" value "${rawVal}" is not in defined enum options: [${attrDef.enum.join(", ")}]`
5845
+ });
5048
5846
  }
5847
+ } else if (attrDef.type === "json") {
5049
5848
  try {
5050
- await recordContentHistoryVersion(strapi, {
5051
- uid,
5052
- documentId: resolvedDocId,
5053
- userId: user?.id,
5054
- statusOverride: publish && ct.options?.draftAndPublish ? "published" : void 0
5055
- });
5849
+ JSON.parse(rawVal);
5056
5850
  } catch {
5057
- }
5058
- stats.updated++;
5059
- for (const attrKey of Object.keys(payload)) {
5060
- const oldVal = existing?.[attrKey] ?? null;
5061
- const newVal = payload[attrKey];
5062
- stats.diffs.push({
5063
- uid,
5064
- collectionName: ct.info?.displayName || uid,
5065
- documentId: resolvedDocId,
5066
- label: existing.title || existing.name || `Entry #${resolvedDocId}`,
5067
- attribute: attrKey,
5068
- oldValue: oldVal,
5069
- newValue: newVal,
5070
- status: "updated",
5071
- message: `Updated "${attrKey}" via CSV import (row ${rowNumber})`
5851
+ warnings.push({
5852
+ row: rowNumber,
5853
+ documentId: docId || void 0,
5854
+ warning: `Column "${colKey}" is type JSON but contains non-JSON text.`
5072
5855
  });
5073
5856
  }
5074
- continue;
5075
5857
  }
5076
- const createArgs = { data: payload };
5077
- if (rowLocale) createArgs.locale = rowLocale;
5078
- const created = await strapi.documents(uid).create(createArgs);
5079
- const newDocId = created?.documentId || docId;
5080
- if (publish && ct.options?.draftAndPublish && newDocId) {
5081
- try {
5082
- const pubArgs = { documentId: newDocId };
5083
- if (rowLocale) pubArgs.locale = rowLocale;
5084
- await strapi.documents(uid).publish(pubArgs);
5085
- } catch (pubErr) {
5086
- strapi.log.warn(`[bulk-updater] Auto-publish failed for ${newDocId}:`, pubErr);
5858
+ }
5859
+ if (activeRules.length > 0) {
5860
+ for (const rule of activeRules) {
5861
+ if (rule.collectionUid && rule.collectionUid !== "*" && rule.collectionUid !== uid) continue;
5862
+ if (rule.includeRichTextHtml && (rawVal.includes("<h") || rawVal.includes("# "))) {
5863
+ const headings = extractHeadingsFromRichText(rawVal);
5864
+ for (const [tag, count] of Object.entries(headings)) {
5865
+ const max = rule.quotas?.[tag.toLowerCase()];
5866
+ if (max !== void 0 && count > max) {
5867
+ warnings.push({
5868
+ row: rowNumber,
5869
+ documentId: docId || void 0,
5870
+ warning: `Rule "${rule.name}" quota warning: column "${colKey}" contains ${count} "${tag.toUpperCase()}" headings (maximum allowed: ${max}).`
5871
+ });
5872
+ }
5873
+ }
5087
5874
  }
5088
5875
  }
5089
- try {
5090
- await recordContentHistoryVersion(strapi, {
5091
- uid,
5092
- documentId: newDocId,
5093
- userId: user?.id,
5094
- statusOverride: publish && ct.options?.draftAndPublish ? "published" : void 0
5095
- });
5096
- } catch {
5097
- }
5098
- stats.created++;
5099
- if (!docId) {
5100
- stats.newWithoutDocId++;
5101
- }
5102
- for (const attrKey of Object.keys(payload)) {
5103
- stats.diffs.push({
5104
- uid,
5105
- collectionName: ct.info?.displayName || uid,
5106
- documentId: newDocId,
5107
- label: created?.title || created?.name || `New Entry #${newDocId}`,
5108
- attribute: attrKey,
5109
- oldValue: null,
5110
- newValue: payload[attrKey],
5111
- status: "created",
5112
- message: docId ? `Created "${attrKey}" as new entry (documentId "${docId}" not found in database - row ${rowNumber})` : `Created "${attrKey}" as new entry (no documentId provided in CSV - row ${rowNumber})`
5113
- });
5114
- }
5115
5876
  }
5116
- } catch (err) {
5117
- stats.errors.push({
5118
- row: rowNumber,
5119
- documentId: docId || void 0,
5120
- error: extractDetailedErrorMessage(err)
5121
- });
5122
5877
  }
5123
- }
5124
- try {
5125
- const bulkService = strapi.plugin("bulk-updater").service("bulkUpdater");
5126
- if (bulkService && bulkService.invalidateCollectionCache) {
5127
- bulkService.invalidateCollectionCache(uid);
5878
+ if (!rowHasError) {
5879
+ validRows++;
5128
5880
  }
5129
- } catch {
5130
5881
  }
5131
- if (stats.updated > 0 || stats.created > 0) {
5132
- try {
5133
- const auditService2 = strapi.plugin("bulk-updater").service("audit");
5134
- if (auditService2) {
5135
- const isEnabled = typeof auditService2.isAuditEnabled === "function" ? auditService2.isAuditEnabled() : Boolean(auditService2.getAuditConfig?.().enabled ?? true);
5136
- if (isEnabled) {
5137
- const collectionDisplayName = ct.info?.displayName || uid;
5138
- const updatedDiffs = stats.diffs.filter(
5139
- (d) => d.status === "updated" || d.status === "created"
5140
- );
5141
- const uniqueAttributes = Array.from(
5142
- new Set(updatedDiffs.map((d) => d.attribute))
5143
- ).filter(Boolean);
5144
- const attrListStr = uniqueAttributes.length > 0 ? uniqueAttributes.join(", ") : "fields";
5145
- const batchAttribute = `CSV [${collectionDisplayName}]: ${attrListStr}`;
5146
- let batchValue = "";
5147
- if (updatedDiffs.length === 1) {
5148
- const d = updatedDiffs[0];
5149
- const newStr = d.newValue !== null && d.newValue !== void 0 ? String(d.newValue) : "(empty)";
5150
- batchValue = `"${d.label}": ${d.attribute} → "${newStr}"`;
5151
- } else if (updatedDiffs.length > 1 && stats.updated === 1 && stats.created === 0) {
5152
- const d = updatedDiffs[0];
5153
- const changes = updatedDiffs.map((ud) => `${ud.attribute}: "${ud.newValue ?? ""}"`).join(", ");
5154
- batchValue = `"${d.label}": ${changes}`;
5155
- } else {
5156
- const sampleVal = updatedDiffs[0] ? ` (e.g. ${updatedDiffs[0].attribute}: "${updatedDiffs[0].newValue ?? ""}")` : "";
5157
- batchValue = `${stats.updated} updated, ${stats.created} created in ${collectionDisplayName}${sampleVal}`;
5158
- }
5159
- const batchSummary = {
5160
- batchId,
5161
- attribute: batchAttribute,
5162
- value: batchValue,
5163
- updatedCount: stats.updated + stats.created,
5164
- skippedCount: stats.skipped,
5165
- totalTargets: stats.total
5166
- };
5167
- const userObj = {
5168
- email: userEmail || "admin@strapi.io",
5169
- firstname: userName || "Admin"
5170
- };
5171
- if (typeof auditService2.recordBatch === "function") {
5172
- await auditService2.recordBatch(batchSummary, stats.diffs, userObj);
5173
- } else if (typeof auditService2.logOperation === "function") {
5174
- await auditService2.logOperation({
5175
- ...batchSummary,
5176
- userEmail,
5177
- userName,
5178
- diffs: stats.diffs
5179
- });
5180
- }
5181
- }
5182
- }
5183
- } catch (auditErr) {
5184
- strapi.log.warn("[bulk-updater] Failed logging CSV import audit:", auditErr);
5185
- }
5882
+ return {
5883
+ success: true,
5884
+ totalRows: rows.length,
5885
+ validRows,
5886
+ estimatedUpdates,
5887
+ estimatedCreates,
5888
+ hasErrors: errors.length > 0,
5889
+ hasWarnings: warnings.length > 0,
5890
+ errors: errors.slice(0, 100),
5891
+ warnings: warnings.slice(0, 100),
5892
+ totalErrorCount: errors.length,
5893
+ totalWarningCount: warnings.length
5894
+ };
5895
+ },
5896
+ /**
5897
+ * Submits a CSV batch with Guardrails: Maker-Checker (4-Eyes), Execution Windows, Off-Peak Scheduling
5898
+ */
5899
+ async submitBatch(uid, rows, options = {}) {
5900
+ const ct = strapi.contentTypes[uid];
5901
+ if (!ct) {
5902
+ throw new Error(`Content type ${uid} not found`);
5903
+ }
5904
+ if (!rows || rows.length === 0) {
5905
+ throw new Error("No CSV records provided for batch submission.");
5906
+ }
5907
+ const {
5908
+ executionMode = "immediate",
5909
+ makerNotes = "",
5910
+ forceImmediate = false,
5911
+ mode = "auto",
5912
+ publish = true,
5913
+ user
5914
+ } = options;
5915
+ const executionConfig = getExecutionWindowConfig(strapi);
5916
+ const { isBusinessHours, policyMessage } = checkBusinessHours(executionConfig);
5917
+ const collectionDisplayName = ct.info?.displayName || uid;
5918
+ if (executionMode === "maker_checker") {
5919
+ const batchId = `csv_batch_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
5920
+ const batch = {
5921
+ id: batchId,
5922
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
5923
+ createdBy: user ? {
5924
+ id: user.id,
5925
+ email: user.email,
5926
+ firstname: user.firstname,
5927
+ lastname: user.lastname,
5928
+ username: user.username
5929
+ } : { email: "admin@system.local" },
5930
+ payload: {
5931
+ action: "csv_import",
5932
+ uid,
5933
+ rows,
5934
+ options: {
5935
+ mode,
5936
+ publish,
5937
+ user
5938
+ },
5939
+ attribute: `CSV Import: ${collectionDisplayName}`,
5940
+ value: `${rows.length} rows (${mode} mode)`,
5941
+ publishOnApproval: publish
5942
+ },
5943
+ summary: {
5944
+ attribute: `CSV Import: ${collectionDisplayName}`,
5945
+ value: `${rows.length} rows (${mode} mode)`,
5946
+ totalTargets: rows.length,
5947
+ collectionBreakdown: { [collectionDisplayName]: rows.length },
5948
+ publishOnApproval: publish
5949
+ },
5950
+ status: "pending_approval",
5951
+ executionMode: "maker_checker",
5952
+ makerNotes
5953
+ };
5954
+ registerPendingBatch(batch, {
5955
+ id: batchId,
5956
+ createdAt: batch.createdAt,
5957
+ createdBy: batch.createdBy,
5958
+ status: "pending_approval",
5959
+ executionMode: "maker_checker",
5960
+ attribute: `CSV Import: ${collectionDisplayName}`,
5961
+ value: `${rows.length} rows (${mode} mode)`,
5962
+ totalTargets: rows.length,
5963
+ processedCount: 0,
5964
+ updatedCount: 0,
5965
+ skippedCount: 0,
5966
+ failedCount: 0,
5967
+ isLive: false,
5968
+ publishOnApproval: publish,
5969
+ makerNotes,
5970
+ results: []
5971
+ });
5972
+ return {
5973
+ success: true,
5974
+ pending: true,
5975
+ batchId,
5976
+ status: "pending_approval",
5977
+ message: "CSV batch submitted for Checker authorization (4-Eyes Maker-Checker Workflow).",
5978
+ batch
5979
+ };
5980
+ }
5981
+ if (executionMode === "queue_offpeak") {
5982
+ const batchId = `csv_batch_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
5983
+ const batch = {
5984
+ id: batchId,
5985
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
5986
+ createdBy: user ? {
5987
+ id: user.id,
5988
+ email: user.email,
5989
+ firstname: user.firstname,
5990
+ lastname: user.lastname,
5991
+ username: user.username
5992
+ } : { email: "admin@system.local" },
5993
+ payload: {
5994
+ action: "csv_import",
5995
+ uid,
5996
+ rows,
5997
+ options: {
5998
+ mode,
5999
+ publish,
6000
+ user
6001
+ },
6002
+ attribute: `CSV Import: ${collectionDisplayName}`,
6003
+ value: `${rows.length} rows (${mode} mode)`,
6004
+ publishOnApproval: publish
6005
+ },
6006
+ summary: {
6007
+ attribute: `CSV Import: ${collectionDisplayName}`,
6008
+ value: `${rows.length} rows (${mode} mode)`,
6009
+ totalTargets: rows.length,
6010
+ collectionBreakdown: { [collectionDisplayName]: rows.length },
6011
+ publishOnApproval: publish
6012
+ },
6013
+ status: "queued_offpeak",
6014
+ executionMode: "queue_offpeak",
6015
+ scheduledFor: executionConfig.offPeakStartTime,
6016
+ makerNotes
6017
+ };
6018
+ registerPendingBatch(batch, {
6019
+ id: batchId,
6020
+ createdAt: batch.createdAt,
6021
+ createdBy: batch.createdBy,
6022
+ status: "queued_offpeak",
6023
+ executionMode: "queue_offpeak",
6024
+ scheduledFor: executionConfig.offPeakStartTime,
6025
+ attribute: `CSV Import: ${collectionDisplayName}`,
6026
+ value: `${rows.length} rows (${mode} mode)`,
6027
+ totalTargets: rows.length,
6028
+ processedCount: 0,
6029
+ updatedCount: 0,
6030
+ skippedCount: 0,
6031
+ failedCount: 0,
6032
+ isLive: false,
6033
+ publishOnApproval: publish,
6034
+ makerNotes,
6035
+ results: []
6036
+ });
6037
+ return {
6038
+ success: true,
6039
+ pending: true,
6040
+ batchId,
6041
+ status: "queued_offpeak",
6042
+ scheduledFor: executionConfig.offPeakStartTime,
6043
+ message: `CSV batch queued for server off-peak maintenance window (${executionConfig.offPeakStartTime}).`,
6044
+ batch
6045
+ };
6046
+ }
6047
+ if (executionConfig.enabled && executionConfig.autoQueueDuringBusinessHours && isBusinessHours && !forceImmediate) {
6048
+ const batchId = `csv_batch_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
6049
+ const batch = {
6050
+ id: batchId,
6051
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
6052
+ createdBy: user ? {
6053
+ id: user.id,
6054
+ email: user.email,
6055
+ firstname: user.firstname,
6056
+ lastname: user.lastname,
6057
+ username: user.username
6058
+ } : { email: "admin@system.local" },
6059
+ payload: {
6060
+ action: "csv_import",
6061
+ uid,
6062
+ rows,
6063
+ options: {
6064
+ mode,
6065
+ publish,
6066
+ user
6067
+ },
6068
+ attribute: `CSV Import: ${collectionDisplayName}`,
6069
+ value: `${rows.length} rows (${mode} mode)`,
6070
+ publishOnApproval: publish
6071
+ },
6072
+ summary: {
6073
+ attribute: `CSV Import: ${collectionDisplayName}`,
6074
+ value: `${rows.length} rows (${mode} mode)`,
6075
+ totalTargets: rows.length,
6076
+ collectionBreakdown: { [collectionDisplayName]: rows.length },
6077
+ publishOnApproval: publish
6078
+ },
6079
+ status: "queued_offpeak",
6080
+ executionMode: "queue_offpeak",
6081
+ scheduledFor: executionConfig.offPeakStartTime,
6082
+ makerNotes: "Automatically queued due to peak business hours restriction."
6083
+ };
6084
+ registerPendingBatch(batch, {
6085
+ id: batchId,
6086
+ createdAt: batch.createdAt,
6087
+ createdBy: batch.createdBy,
6088
+ status: "queued_offpeak",
6089
+ executionMode: "queue_offpeak",
6090
+ scheduledFor: executionConfig.offPeakStartTime,
6091
+ attribute: `CSV Import: ${collectionDisplayName}`,
6092
+ value: `${rows.length} rows (${mode} mode)`,
6093
+ totalTargets: rows.length,
6094
+ processedCount: 0,
6095
+ updatedCount: 0,
6096
+ skippedCount: 0,
6097
+ failedCount: 0,
6098
+ isLive: false,
6099
+ publishOnApproval: publish,
6100
+ policyMessage,
6101
+ makerNotes: "Automatically queued due to peak business hours restriction.",
6102
+ results: []
6103
+ });
6104
+ return {
6105
+ success: true,
6106
+ pending: true,
6107
+ batchId,
6108
+ status: "queued_offpeak",
6109
+ scheduledFor: executionConfig.offPeakStartTime,
6110
+ message: policyMessage,
6111
+ batch
6112
+ };
5186
6113
  }
5187
6114
  return {
5188
6115
  success: true,
5189
- batchId,
5190
- stats
6116
+ pending: false,
6117
+ executeNow: true
5191
6118
  };
5192
6119
  }
5193
6120
  });