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