strapi-plugin-bulk-updater 1.4.10 → 1.5.0

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.
@@ -111,6 +111,22 @@ const routes = {
111
111
  policies: []
112
112
  }
113
113
  },
114
+ {
115
+ method: "GET",
116
+ path: "/batches/:batchId/diffs",
117
+ handler: "bulkUpdater.getPendingBatchDiffs",
118
+ config: {
119
+ policies: []
120
+ }
121
+ },
122
+ {
123
+ method: "GET",
124
+ path: "/batches/:batchId/diffs/export",
125
+ handler: "bulkUpdater.exportPendingBatchDiffs",
126
+ config: {
127
+ policies: []
128
+ }
129
+ },
114
130
  {
115
131
  method: "GET",
116
132
  path: "/audit/history",
@@ -474,6 +490,28 @@ const bulkUpdaterController = ({ strapi }) => ({
474
490
  ctx.body = { success: false, error: err.message };
475
491
  }
476
492
  },
493
+ async getPendingBatchDiffs(ctx) {
494
+ try {
495
+ const { batchId } = ctx.params;
496
+ const service = strapi.plugin("bulk-updater").service("bulkUpdater");
497
+ const result = await service.getPendingBatchDiffs(batchId);
498
+ ctx.body = result;
499
+ } catch (err) {
500
+ ctx.status = err.message?.includes("not found") ? 404 : 500;
501
+ ctx.body = { success: false, error: err.message };
502
+ }
503
+ },
504
+ async exportPendingBatchDiffs(ctx) {
505
+ try {
506
+ const { batchId } = ctx.params;
507
+ const service = strapi.plugin("bulk-updater").service("bulkUpdater");
508
+ const result = await service.getPendingBatchDiffsCsv(batchId);
509
+ ctx.body = result;
510
+ } catch (err) {
511
+ ctx.status = err.message?.includes("not found") ? 404 : 500;
512
+ ctx.body = { success: false, error: err.message };
513
+ }
514
+ },
477
515
  async getRecentBatches(ctx) {
478
516
  try {
479
517
  const service = strapi.plugin("bulk-updater").service("bulkUpdater");
@@ -2374,7 +2412,7 @@ const bulkUpdaterService = ({ strapi }) => {
2374
2412
  } else if (normOp === "endswith") {
2375
2413
  orConditions.push({ [k]: { $endsWithi: q } });
2376
2414
  } else if (normOp === "notequals") {
2377
- orConditions.push({ [k]: { $ne: q } });
2415
+ orConditions.push({ [k]: { $nei: q } });
2378
2416
  } else if (normOp === "notcontains") {
2379
2417
  orConditions.push({ [k]: { $notContainsi: q } });
2380
2418
  } else {
@@ -2392,12 +2430,21 @@ const bulkUpdaterService = ({ strapi }) => {
2392
2430
  } else if (normOp === "notempty") {
2393
2431
  condition = isStringLike ? { $and: [{ $notNull: true }, { $ne: "" }] } : { $notNull: true };
2394
2432
  } else if (q !== "") {
2433
+ let typedVal = q;
2434
+ const isNumeric = ["integer", "biginteger", "float", "decimal", "number"].includes(targetAttrSchema?.type);
2435
+ const isBoolean = targetAttrSchema?.type === "boolean";
2436
+ if (isNumeric && /^-?\d+(\.\d+)?$/.test(q)) {
2437
+ typedVal = Number(q);
2438
+ } else if (isBoolean) {
2439
+ if (q.toLowerCase() === "true") typedVal = true;
2440
+ else if (q.toLowerCase() === "false") typedVal = false;
2441
+ }
2395
2442
  switch (normOp) {
2396
2443
  case "equals":
2397
- condition = { $containsi: q };
2444
+ condition = isStringLike ? { $eqi: q } : { $eq: typedVal };
2398
2445
  break;
2399
2446
  case "notequals":
2400
- condition = { $notContainsi: q };
2447
+ condition = isStringLike ? { $nei: q } : { $ne: typedVal };
2401
2448
  break;
2402
2449
  case "contains":
2403
2450
  condition = { $containsi: q };
@@ -2406,10 +2453,10 @@ const bulkUpdaterService = ({ strapi }) => {
2406
2453
  condition = { $notContainsi: q };
2407
2454
  break;
2408
2455
  case "startswith":
2409
- condition = { $startsWith: q };
2456
+ condition = { $startsWithi: q };
2410
2457
  break;
2411
2458
  case "endswith":
2412
- condition = { $endsWith: q };
2459
+ condition = { $endsWithi: q };
2413
2460
  break;
2414
2461
  }
2415
2462
  }
@@ -2535,7 +2582,7 @@ const bulkUpdaterService = ({ strapi }) => {
2535
2582
  if (typeof v === "object") {
2536
2583
  return testSingleValue(v.name) || testSingleValue(v.title) || testSingleValue(v.email) || testSingleValue(v.username) || testSingleValue(v.documentId) || testSingleValue(v.id) || testSingleValue(v.slug);
2537
2584
  }
2538
- const valStr = String(v).toLowerCase();
2585
+ const valStr = String(v).trim().toLowerCase();
2539
2586
  switch (normOp) {
2540
2587
  case "equals":
2541
2588
  return valStr === qVal;
@@ -3619,6 +3666,198 @@ const bulkUpdaterService = ({ strapi }) => {
3619
3666
  batch
3620
3667
  };
3621
3668
  },
3669
+ /**
3670
+ * Inspect batch changes item-by-item for Maker-Checker 4-Eyes review
3671
+ */
3672
+ async getPendingBatchDiffs(batchId) {
3673
+ if (!batchId) throw new Error("batchId is required");
3674
+ const batch = pendingBatches.get(batchId);
3675
+ if (!batch) {
3676
+ throw new Error(`Pending batch "${batchId}" not found.`);
3677
+ }
3678
+ let previewRes = null;
3679
+ if (batch.payload?.action === "csv_import" || batch.id.startsWith("csv_")) {
3680
+ const csvService2 = strapi.plugin("bulk-updater").service("csv");
3681
+ previewRes = await csvService2.previewCsvBatch(
3682
+ batch.payload.uid,
3683
+ batch.payload.rows || [],
3684
+ batch.payload.options || {}
3685
+ );
3686
+ } else if (batch.payload?.action === "publish") {
3687
+ const selectedEntries = batch.payload.selectedEntries || [];
3688
+ const selectedCollections = batch.payload.selectedCollections || [];
3689
+ const targetsMap = /* @__PURE__ */ new Map();
3690
+ for (const colUid of selectedCollections) {
3691
+ if (!strapi.contentTypes[colUid]) continue;
3692
+ const entries = await strapi.documents(colUid).findMany();
3693
+ for (const entry of entries || []) {
3694
+ targetsMap.set(`${colUid}@@@${entry.documentId}`, { uid: colUid, documentId: entry.documentId });
3695
+ }
3696
+ }
3697
+ for (const item of selectedEntries) {
3698
+ if (!item.uid || !item.documentId) continue;
3699
+ targetsMap.set(`${item.uid}@@@${item.documentId}`, { uid: item.uid, documentId: item.documentId });
3700
+ }
3701
+ const diffs = [];
3702
+ let willUpdate = 0;
3703
+ let unchanged = 0;
3704
+ for (const target of targetsMap.values()) {
3705
+ const ct = strapi.contentTypes[target.uid];
3706
+ const displayName = ct?.info?.displayName || target.uid;
3707
+ try {
3708
+ const doc = await strapi.documents(target.uid).findOne({
3709
+ documentId: target.documentId
3710
+ });
3711
+ const label = doc ? doc.title || doc.name || doc.slug || doc.documentId : target.documentId;
3712
+ const isLive = Boolean(doc?.publishedAt) && doc?.status !== "modified";
3713
+ const currentStatus = isLive ? "published" : doc?.status === "modified" ? "modified draft" : "draft";
3714
+ const hasChange = !isLive;
3715
+ if (hasChange) willUpdate++;
3716
+ else unchanged++;
3717
+ diffs.push({
3718
+ uid: target.uid,
3719
+ collectionName: displayName,
3720
+ documentId: target.documentId,
3721
+ label,
3722
+ attribute: "status",
3723
+ oldValue: currentStatus,
3724
+ newValue: "published",
3725
+ oldDisplay: currentStatus,
3726
+ newDisplay: "published live",
3727
+ hasChange,
3728
+ status: hasChange ? "will_update" : "unchanged",
3729
+ action: "publish",
3730
+ message: hasChange ? "Record will be published live" : "Already published live"
3731
+ });
3732
+ } catch (err) {
3733
+ diffs.push({
3734
+ uid: target.uid,
3735
+ collectionName: displayName,
3736
+ documentId: target.documentId,
3737
+ label: target.documentId,
3738
+ attribute: "status",
3739
+ oldValue: null,
3740
+ newValue: null,
3741
+ oldDisplay: "—",
3742
+ newDisplay: "—",
3743
+ hasChange: false,
3744
+ status: "will_skip",
3745
+ action: "skip",
3746
+ message: `Error inspecting document: ${err.message}`
3747
+ });
3748
+ }
3749
+ }
3750
+ previewRes = {
3751
+ success: true,
3752
+ summary: {
3753
+ total: targetsMap.size,
3754
+ willUpdate,
3755
+ willCreate: 0,
3756
+ unchanged,
3757
+ skipped: 0
3758
+ },
3759
+ diffs
3760
+ };
3761
+ } else {
3762
+ const preview = await this.previewBatch({
3763
+ attribute: batch.payload.attribute,
3764
+ value: batch.payload.value,
3765
+ selectedEntries: batch.payload.selectedEntries,
3766
+ selectedCollections: batch.payload.selectedCollections,
3767
+ isRegex: batch.payload.isRegex,
3768
+ regexPattern: batch.payload.regexPattern
3769
+ });
3770
+ previewRes = {
3771
+ success: true,
3772
+ summary: {
3773
+ total: preview.summary?.total ?? 0,
3774
+ willUpdate: preview.summary?.willUpdate ?? 0,
3775
+ willCreate: 0,
3776
+ unchanged: preview.summary?.unchanged ?? 0,
3777
+ skipped: preview.summary?.skipped ?? 0
3778
+ },
3779
+ diffs: (preview.diffs || []).map((d) => ({
3780
+ ...d,
3781
+ action: "update"
3782
+ }))
3783
+ };
3784
+ }
3785
+ return {
3786
+ success: true,
3787
+ batchId: batch.id,
3788
+ createdAt: batch.createdAt,
3789
+ createdBy: batch.createdBy,
3790
+ makerNotes: batch.makerNotes,
3791
+ batchSummary: batch.summary,
3792
+ payload: {
3793
+ action: batch.payload?.action || "bulk_update",
3794
+ attribute: batch.payload?.attribute,
3795
+ value: batch.payload?.value,
3796
+ publishOnApproval: batch.summary?.publishOnApproval,
3797
+ publishStrategy: batch.summary?.publishStrategy
3798
+ },
3799
+ summary: previewRes.summary,
3800
+ diffs: previewRes.diffs
3801
+ };
3802
+ },
3803
+ /**
3804
+ * Export batch diffs to CSV for offline audit review
3805
+ */
3806
+ async getPendingBatchDiffsCsv(batchId) {
3807
+ const data = await this.getPendingBatchDiffs(batchId);
3808
+ function escapeVal(val) {
3809
+ if (val === null || val === void 0) return "";
3810
+ let str = typeof val === "object" ? JSON.stringify(val) : String(val);
3811
+ str = str.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
3812
+ if (/^[=+@-]$/.test(str.charAt(0))) {
3813
+ str = `'${str}`;
3814
+ }
3815
+ if (str.includes(",") || str.includes('"') || str.includes("\n")) {
3816
+ return `"${str.replace(/"/g, '""')}"`;
3817
+ }
3818
+ return str;
3819
+ }
3820
+ const headers = [
3821
+ "Batch ID",
3822
+ "Action",
3823
+ "Collection UID",
3824
+ "Collection Name",
3825
+ "Document ID",
3826
+ "Record Identifier",
3827
+ "Attribute Name",
3828
+ "Original Value (Before)",
3829
+ "Proposed Value (After)",
3830
+ "Change Status",
3831
+ "Details / Message"
3832
+ ];
3833
+ const rows = [headers.join(",")];
3834
+ for (const diff of data.diffs || []) {
3835
+ const row = [
3836
+ escapeVal(data.batchId),
3837
+ escapeVal(diff.action || "update"),
3838
+ escapeVal(diff.uid),
3839
+ escapeVal(diff.collectionName),
3840
+ escapeVal(diff.documentId),
3841
+ escapeVal(diff.label),
3842
+ escapeVal(diff.attribute),
3843
+ escapeVal(diff.oldDisplay ?? diff.oldValue),
3844
+ escapeVal(diff.newDisplay ?? diff.newValue),
3845
+ escapeVal(
3846
+ diff.status === "will_update" ? "Will Update" : diff.status === "will_create" ? "Will Create" : diff.status === "unchanged" ? "Unchanged" : "Skipped"
3847
+ ),
3848
+ escapeVal(diff.message)
3849
+ ];
3850
+ rows.push(row.join(","));
3851
+ }
3852
+ const safeBatchId = batchId.replace(/[^a-zA-Z0-9_-]/g, "_");
3853
+ const filename = `pending_batch_review_${safeBatchId}_${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.csv`;
3854
+ return {
3855
+ success: true,
3856
+ filename,
3857
+ csvContent: "\uFEFF" + rows.join("\r\n") + "\r\n",
3858
+ count: data.diffs?.length || 0
3859
+ };
3860
+ },
3622
3861
  /**
3623
3862
  * Get recent batch jobs for the processing dashboard
3624
3863
  */
@@ -5007,230 +5246,246 @@ function deepMergeComponent(existingVal, newVal) {
5007
5246
  }
5008
5247
  return merged;
5009
5248
  }
5010
- async function processCsvRows(strapi, ct, uid, rows, startRowNumber, options) {
5011
- const attrs = ct.attributes || {};
5012
- const { mode = "auto", publish = true, publishStrategy = "safe", user } = options;
5013
- const isDraftAndPublish = Boolean(ct.options?.draftAndPublish);
5014
- const stats = {
5015
- total: rows.length,
5016
- created: 0,
5017
- updated: 0,
5018
- skipped: 0,
5019
- newWithoutDocId: 0,
5020
- errors: [],
5021
- diffs: []
5022
- };
5023
- for (let i = 0; i < rows.length; i++) {
5024
- const rowNumber = startRowNumber + i;
5025
- const row = rows[i];
5026
- let docId = extractDocId(row);
5027
- const rowLocale = extractLocale(row);
5028
- if (ct.kind === "singleType" && !docId) {
5029
- try {
5030
- const findSingleArgs = {};
5031
- if (rowLocale) findSingleArgs.locale = rowLocale;
5032
- const singleDoc = await strapi.documents(uid).findFirst(findSingleArgs);
5033
- if (singleDoc?.documentId) {
5034
- docId = singleDoc.documentId;
5035
- }
5036
- } catch {
5037
- }
5038
- }
5039
- const payload = {};
5040
- for (const [key, rawVal] of Object.entries(row)) {
5041
- const normalizedKey = key.toLowerCase().replace(/[\s_-]/g, "");
5042
- if (SYSTEM_FIELD_KEYS.has(normalizedKey)) continue;
5043
- if (key.includes(".")) {
5044
- const parts = key.split(".");
5045
- let currentAttrs = attrs;
5046
- let currentTarget = payload;
5047
- for (let pIdx = 0; pIdx < parts.length; pIdx++) {
5048
- const part = parts[pIdx];
5049
- const isLeaf = pIdx === parts.length - 1;
5050
- if (isLeaf) {
5051
- let leafDef = currentAttrs[part];
5052
- if (!leafDef) {
5053
- const match = Object.keys(currentAttrs).find(
5054
- (k) => k.toLowerCase() === part.toLowerCase() || k.toLowerCase() === part.toLowerCase().replace(/\s+/g, "")
5055
- );
5056
- if (match) leafDef = currentAttrs[match];
5249
+ function parseRowToPayload(strapi, attrs, row) {
5250
+ const payload = {};
5251
+ for (const [key, rawVal] of Object.entries(row)) {
5252
+ const normalizedKey = key.toLowerCase().replace(/[\s_-]/g, "");
5253
+ if (SYSTEM_FIELD_KEYS.has(normalizedKey)) continue;
5254
+ if (key.includes(".")) {
5255
+ const parts = key.split(".");
5256
+ let currentAttrs = attrs;
5257
+ let currentTarget = payload;
5258
+ for (let pIdx = 0; pIdx < parts.length; pIdx++) {
5259
+ const part = parts[pIdx];
5260
+ const isLeaf = pIdx === parts.length - 1;
5261
+ if (isLeaf) {
5262
+ let leafDef = currentAttrs[part];
5263
+ if (!leafDef) {
5264
+ const match = Object.keys(currentAttrs).find(
5265
+ (k) => k.toLowerCase() === part.toLowerCase() || k.toLowerCase() === part.toLowerCase().replace(/\s+/g, "")
5266
+ );
5267
+ if (match) leafDef = currentAttrs[match];
5268
+ }
5269
+ if (!leafDef) {
5270
+ break;
5271
+ }
5272
+ if (rawVal === "" || rawVal === null || rawVal === void 0) {
5273
+ if (leafDef.type !== "dynamiczone" && !(leafDef.type === "component" && leafDef.repeatable) && leafDef.type !== "relation") {
5274
+ currentTarget[part] = null;
5057
5275
  }
5058
- if (!leafDef) {
5059
- break;
5276
+ } else if (leafDef.type === "integer" || leafDef.type === "biginteger") {
5277
+ const parsedInt = parseInt(rawVal, 10);
5278
+ currentTarget[part] = isNaN(parsedInt) ? null : parsedInt;
5279
+ } else if (leafDef.type === "float" || leafDef.type === "decimal") {
5280
+ const parsedFloat = parseFloat(rawVal);
5281
+ currentTarget[part] = isNaN(parsedFloat) ? null : parsedFloat;
5282
+ } else if (leafDef.type === "boolean") {
5283
+ currentTarget[part] = rawVal.toLowerCase() === "true" || rawVal === "1";
5284
+ } else if (leafDef.type === "json") {
5285
+ try {
5286
+ currentTarget[part] = JSON.parse(rawVal);
5287
+ } catch {
5288
+ currentTarget[part] = rawVal;
5060
5289
  }
5061
- if (rawVal === "" || rawVal === null || rawVal === void 0) {
5062
- if (leafDef.type !== "dynamiczone" && !(leafDef.type === "component" && leafDef.repeatable) && leafDef.type !== "relation") {
5063
- currentTarget[part] = null;
5290
+ } else if (leafDef.type === "component" && leafDef.repeatable) {
5291
+ try {
5292
+ const parsed = JSON.parse(rawVal);
5293
+ if (Array.isArray(parsed)) {
5294
+ currentTarget[part] = parsed;
5064
5295
  }
5065
- } else if (leafDef.type === "integer" || leafDef.type === "biginteger") {
5066
- const parsedInt = parseInt(rawVal, 10);
5067
- currentTarget[part] = isNaN(parsedInt) ? null : parsedInt;
5068
- } else if (leafDef.type === "float" || leafDef.type === "decimal") {
5069
- const parsedFloat = parseFloat(rawVal);
5070
- currentTarget[part] = isNaN(parsedFloat) ? null : parsedFloat;
5071
- } else if (leafDef.type === "boolean") {
5072
- currentTarget[part] = rawVal.toLowerCase() === "true" || rawVal === "1";
5073
- } else if (leafDef.type === "json") {
5074
- try {
5075
- currentTarget[part] = JSON.parse(rawVal);
5076
- } catch {
5296
+ } catch {
5297
+ }
5298
+ } else if (leafDef.type === "relation") {
5299
+ try {
5300
+ const parsed = JSON.parse(rawVal);
5301
+ if (Array.isArray(parsed) || typeof parsed === "object" && parsed !== null) {
5302
+ currentTarget[part] = parsed;
5303
+ } else if (isValidDocId(rawVal)) {
5077
5304
  currentTarget[part] = rawVal;
5078
5305
  }
5079
- } else if (leafDef.type === "component" && leafDef.repeatable) {
5080
- try {
5081
- const parsed = JSON.parse(rawVal);
5082
- if (Array.isArray(parsed)) {
5083
- currentTarget[part] = parsed;
5084
- }
5085
- } catch {
5086
- }
5087
- } else if (leafDef.type === "relation") {
5088
- try {
5089
- const parsed = JSON.parse(rawVal);
5090
- if (Array.isArray(parsed) || typeof parsed === "object" && parsed !== null) {
5091
- currentTarget[part] = parsed;
5092
- } else if (isValidDocId(rawVal)) {
5093
- currentTarget[part] = rawVal;
5094
- }
5095
- } catch {
5096
- if (isValidDocId(rawVal)) {
5097
- currentTarget[part] = rawVal;
5098
- }
5306
+ } catch {
5307
+ if (isValidDocId(rawVal)) {
5308
+ currentTarget[part] = rawVal;
5099
5309
  }
5100
- } else {
5101
- currentTarget[part] = rawVal;
5102
5310
  }
5103
5311
  } else {
5104
- let compDef = currentAttrs[part];
5105
- if (!compDef) {
5106
- const match = Object.keys(currentAttrs).find(
5107
- (k) => k.toLowerCase() === part.toLowerCase() || k.toLowerCase() === part.toLowerCase().replace(/\s+/g, "")
5108
- );
5109
- if (match) compDef = currentAttrs[match];
5110
- }
5111
- if (compDef?.type === "component" && !compDef.repeatable && compDef.component) {
5112
- const comp = strapi.components[compDef.component];
5113
- if (!comp?.attributes) {
5114
- break;
5115
- }
5116
- if (!currentTarget[part] || typeof currentTarget[part] !== "object" || Array.isArray(currentTarget[part])) {
5117
- currentTarget[part] = {};
5118
- }
5119
- currentTarget = currentTarget[part];
5120
- currentAttrs = comp.attributes;
5121
- } else {
5312
+ currentTarget[part] = rawVal;
5313
+ }
5314
+ } else {
5315
+ let compDef = currentAttrs[part];
5316
+ if (!compDef) {
5317
+ const match = Object.keys(currentAttrs).find(
5318
+ (k) => k.toLowerCase() === part.toLowerCase() || k.toLowerCase() === part.toLowerCase().replace(/\s+/g, "")
5319
+ );
5320
+ if (match) compDef = currentAttrs[match];
5321
+ }
5322
+ if (compDef?.type === "component" && !compDef.repeatable && compDef.component) {
5323
+ const comp = strapi.components[compDef.component];
5324
+ if (!comp?.attributes) {
5122
5325
  break;
5123
5326
  }
5327
+ if (!currentTarget[part] || typeof currentTarget[part] !== "object" || Array.isArray(currentTarget[part])) {
5328
+ currentTarget[part] = {};
5329
+ }
5330
+ currentTarget = currentTarget[part];
5331
+ currentAttrs = comp.attributes;
5332
+ } else {
5333
+ break;
5124
5334
  }
5125
5335
  }
5126
- continue;
5127
5336
  }
5128
- let attrDef = attrs[key];
5129
- if (!attrDef) {
5130
- const matchKey = Object.keys(attrs).find(
5131
- (k) => k.toLowerCase() === key.toLowerCase() || k.toLowerCase() === key.toLowerCase().replace(/\s+/g, "")
5132
- );
5133
- if (matchKey) {
5134
- attrDef = attrs[matchKey];
5135
- }
5337
+ continue;
5338
+ }
5339
+ let attrDef = attrs[key];
5340
+ if (!attrDef) {
5341
+ const matchKey = Object.keys(attrs).find(
5342
+ (k) => k.toLowerCase() === key.toLowerCase() || k.toLowerCase() === key.toLowerCase().replace(/\s+/g, "")
5343
+ );
5344
+ if (matchKey) {
5345
+ attrDef = attrs[matchKey];
5136
5346
  }
5137
- if (!attrDef) continue;
5138
- if (rawVal === "" || rawVal === null || rawVal === void 0) {
5139
- if (attrDef.type !== "dynamiczone" && !(attrDef.type === "component" && attrDef.repeatable)) {
5140
- payload[key] = null;
5141
- }
5142
- continue;
5347
+ }
5348
+ if (!attrDef) continue;
5349
+ if (rawVal === "" || rawVal === null || rawVal === void 0) {
5350
+ if (attrDef.type !== "dynamiczone" && !(attrDef.type === "component" && attrDef.repeatable)) {
5351
+ payload[key] = null;
5143
5352
  }
5144
- switch (attrDef.type) {
5145
- case "integer":
5146
- case "biginteger": {
5147
- const parsedInt = parseInt(rawVal, 10);
5148
- payload[key] = isNaN(parsedInt) ? null : parsedInt;
5149
- break;
5353
+ continue;
5354
+ }
5355
+ switch (attrDef.type) {
5356
+ case "integer":
5357
+ case "biginteger": {
5358
+ const parsedInt = parseInt(rawVal, 10);
5359
+ payload[key] = isNaN(parsedInt) ? null : parsedInt;
5360
+ break;
5361
+ }
5362
+ case "float":
5363
+ case "decimal": {
5364
+ const parsedFloat = parseFloat(rawVal);
5365
+ payload[key] = isNaN(parsedFloat) ? null : parsedFloat;
5366
+ break;
5367
+ }
5368
+ case "boolean":
5369
+ payload[key] = rawVal.toLowerCase() === "true" || rawVal === "1";
5370
+ break;
5371
+ case "json":
5372
+ try {
5373
+ payload[key] = JSON.parse(rawVal);
5374
+ } catch {
5375
+ payload[key] = rawVal;
5150
5376
  }
5151
- case "float":
5152
- case "decimal": {
5153
- const parsedFloat = parseFloat(rawVal);
5154
- payload[key] = isNaN(parsedFloat) ? null : parsedFloat;
5155
- break;
5377
+ break;
5378
+ case "dynamiczone": {
5379
+ try {
5380
+ const parsed = JSON.parse(rawVal);
5381
+ if (Array.isArray(parsed) && parsed.every((item) => item && typeof item === "object" && typeof item.__component === "string")) {
5382
+ payload[key] = parsed;
5383
+ }
5384
+ } catch {
5156
5385
  }
5157
- case "boolean":
5158
- payload[key] = rawVal.toLowerCase() === "true" || rawVal === "1";
5159
- break;
5160
- case "json":
5386
+ break;
5387
+ }
5388
+ case "component": {
5389
+ if (attrDef.repeatable) {
5161
5390
  try {
5162
- payload[key] = JSON.parse(rawVal);
5391
+ const parsed = JSON.parse(rawVal);
5392
+ if (Array.isArray(parsed) && parsed.every((item) => item && typeof item === "object")) {
5393
+ payload[key] = parsed;
5394
+ }
5163
5395
  } catch {
5164
- payload[key] = rawVal;
5165
5396
  }
5166
- break;
5167
- case "dynamiczone": {
5397
+ } else {
5168
5398
  try {
5169
5399
  const parsed = JSON.parse(rawVal);
5170
- if (Array.isArray(parsed) && parsed.every((item) => item && typeof item === "object" && typeof item.__component === "string")) {
5400
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
5171
5401
  payload[key] = parsed;
5172
5402
  }
5173
5403
  } catch {
5174
5404
  }
5175
- break;
5176
5405
  }
5177
- case "component": {
5178
- if (attrDef.repeatable) {
5179
- try {
5180
- const parsed = JSON.parse(rawVal);
5181
- if (Array.isArray(parsed) && parsed.every((item) => item && typeof item === "object")) {
5182
- payload[key] = parsed;
5183
- }
5184
- } catch {
5185
- }
5186
- } else {
5187
- try {
5188
- const parsed = JSON.parse(rawVal);
5189
- if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
5190
- payload[key] = parsed;
5191
- }
5192
- } catch {
5193
- }
5406
+ break;
5407
+ }
5408
+ case "relation": {
5409
+ try {
5410
+ const parsed = JSON.parse(rawVal);
5411
+ if (Array.isArray(parsed) || typeof parsed === "object" && parsed !== null) {
5412
+ payload[key] = parsed;
5413
+ break;
5414
+ }
5415
+ } catch {
5416
+ }
5417
+ if (isValidDocId(rawVal)) {
5418
+ payload[key] = rawVal;
5419
+ } else if (rawVal.includes(";") || rawVal.includes(",")) {
5420
+ const parts = rawVal.split(/[;,]/).map((s) => s.trim()).filter(Boolean);
5421
+ if (parts.length > 0 && parts.every((p) => isValidDocId(p))) {
5422
+ payload[key] = parts;
5194
5423
  }
5195
- break;
5196
5424
  }
5197
- case "relation": {
5425
+ break;
5426
+ }
5427
+ case "media": {
5428
+ if (/^\d+$/.test(rawVal.trim())) {
5429
+ payload[key] = parseInt(rawVal.trim(), 10);
5430
+ } else {
5198
5431
  try {
5199
5432
  const parsed = JSON.parse(rawVal);
5200
- if (Array.isArray(parsed) || typeof parsed === "object" && parsed !== null) {
5433
+ if (typeof parsed === "number" || typeof parsed === "object") {
5201
5434
  payload[key] = parsed;
5202
- break;
5203
5435
  }
5204
5436
  } catch {
5205
5437
  }
5206
- if (isValidDocId(rawVal)) {
5207
- payload[key] = rawVal;
5208
- } else if (rawVal.includes(";") || rawVal.includes(",")) {
5209
- const parts = rawVal.split(/[;,]/).map((s) => s.trim()).filter(Boolean);
5210
- if (parts.length > 0 && parts.every((p) => isValidDocId(p))) {
5211
- payload[key] = parts;
5212
- }
5213
- }
5214
- break;
5215
5438
  }
5216
- case "media": {
5217
- if (/^\d+$/.test(rawVal.trim())) {
5218
- payload[key] = parseInt(rawVal.trim(), 10);
5219
- } else {
5220
- try {
5221
- const parsed = JSON.parse(rawVal);
5222
- if (typeof parsed === "number" || typeof parsed === "object") {
5223
- payload[key] = parsed;
5224
- }
5225
- } catch {
5226
- }
5227
- }
5228
- break;
5439
+ break;
5440
+ }
5441
+ default:
5442
+ payload[key] = rawVal;
5443
+ }
5444
+ }
5445
+ return payload;
5446
+ }
5447
+ function formatValueDisplay(val) {
5448
+ if (val === null || val === void 0) return "(empty)";
5449
+ if (typeof val === "object") {
5450
+ try {
5451
+ return JSON.stringify(val);
5452
+ } catch {
5453
+ return String(val);
5454
+ }
5455
+ }
5456
+ const s = String(val).trim();
5457
+ return s === "" ? "(empty)" : s;
5458
+ }
5459
+ async function processCsvRows(strapi, ct, uid, rows, startRowNumber, options) {
5460
+ const attrs = ct.attributes || {};
5461
+ const { mode = "auto", publish = true, publishStrategy = "safe", user } = options;
5462
+ const isDraftAndPublish = Boolean(ct.options?.draftAndPublish);
5463
+ const stats = {
5464
+ total: rows.length,
5465
+ created: 0,
5466
+ updated: 0,
5467
+ skipped: 0,
5468
+ newWithoutDocId: 0,
5469
+ errors: [],
5470
+ diffs: []
5471
+ };
5472
+ for (let i = 0; i < rows.length; i++) {
5473
+ const rowNumber = startRowNumber + i;
5474
+ const row = rows[i];
5475
+ let docId = extractDocId(row);
5476
+ const rowLocale = extractLocale(row);
5477
+ if (ct.kind === "singleType" && !docId) {
5478
+ try {
5479
+ const findSingleArgs = {};
5480
+ if (rowLocale) findSingleArgs.locale = rowLocale;
5481
+ const singleDoc = await strapi.documents(uid).findFirst(findSingleArgs);
5482
+ if (singleDoc?.documentId) {
5483
+ docId = singleDoc.documentId;
5229
5484
  }
5230
- default:
5231
- payload[key] = rawVal;
5485
+ } catch {
5232
5486
  }
5233
5487
  }
5488
+ const payload = parseRowToPayload(strapi, attrs, row);
5234
5489
  try {
5235
5490
  if (mode === "update") {
5236
5491
  if (!docId) {
@@ -5280,7 +5535,7 @@ async function processCsvRows(strapi, ct, uid, rows, startRowNumber, options) {
5280
5535
  if (rowLocale) updateArgs.locale = rowLocale;
5281
5536
  await strapi.documents(uid).update(updateArgs);
5282
5537
  const isApproved = isDraftAndPublish && Boolean(existing?.publishedAt) && existing?.status !== "modified";
5283
- const shouldPublish = isDraftAndPublish && Boolean(publish) && (publishStrategy === "all" ? true : isApproved);
5538
+ const shouldPublish = isDraftAndPublish && Boolean(publish) && (publishStrategy === "all" || options.unreviewedConsent ? true : isApproved);
5284
5539
  if (shouldPublish) {
5285
5540
  try {
5286
5541
  const pubArgs = { documentId: resolvedDocId };
@@ -5335,7 +5590,7 @@ async function processCsvRows(strapi, ct, uid, rows, startRowNumber, options) {
5335
5590
  if (rowLocale) updateArgs.locale = rowLocale;
5336
5591
  await strapi.documents(uid).update(updateArgs);
5337
5592
  const isApproved = isDraftAndPublish && Boolean(existing?.publishedAt) && existing?.status !== "modified";
5338
- const shouldPublish = isDraftAndPublish && Boolean(publish) && (publishStrategy === "all" ? true : isApproved);
5593
+ const shouldPublish = isDraftAndPublish && Boolean(publish) && (publishStrategy === "all" || options.unreviewedConsent ? true : isApproved);
5339
5594
  if (shouldPublish) {
5340
5595
  try {
5341
5596
  const pubArgs = { documentId: resolvedDocId };
@@ -5376,7 +5631,7 @@ async function processCsvRows(strapi, ct, uid, rows, startRowNumber, options) {
5376
5631
  if (rowLocale) createArgs.locale = rowLocale;
5377
5632
  const created = await strapi.documents(uid).create(createArgs);
5378
5633
  const newDocId = created?.documentId || docId;
5379
- const shouldPublishNew = isDraftAndPublish && Boolean(publish) && (publishStrategy === "all" ? true : false);
5634
+ const shouldPublishNew = isDraftAndPublish && Boolean(publish) && (publishStrategy === "all" || options.unreviewedConsent ? true : false);
5380
5635
  if (shouldPublishNew && newDocId) {
5381
5636
  try {
5382
5637
  const pubArgs = { documentId: newDocId };
@@ -5432,7 +5687,7 @@ async function processCsvRows(strapi, ct, uid, rows, startRowNumber, options) {
5432
5687
  if (rowLocale) updateArgs.locale = rowLocale;
5433
5688
  await strapi.documents(uid).update(updateArgs);
5434
5689
  const isApproved = isDraftAndPublish && Boolean(existing?.publishedAt) && existing?.status !== "modified";
5435
- const shouldPublish = isDraftAndPublish && Boolean(publish) && (publishStrategy === "all" ? true : isApproved);
5690
+ const shouldPublish = isDraftAndPublish && Boolean(publish) && (publishStrategy === "all" || options.unreviewedConsent ? true : isApproved);
5436
5691
  if (shouldPublish) {
5437
5692
  try {
5438
5693
  const pubArgs = { documentId: resolvedDocId };
@@ -5473,7 +5728,7 @@ async function processCsvRows(strapi, ct, uid, rows, startRowNumber, options) {
5473
5728
  if (rowLocale) createArgs.locale = rowLocale;
5474
5729
  const created = await strapi.documents(uid).create(createArgs);
5475
5730
  const newDocId = created?.documentId || docId;
5476
- const shouldPublishNew = isDraftAndPublish && Boolean(publish) && (publishStrategy === "all" ? true : false);
5731
+ const shouldPublishNew = isDraftAndPublish && Boolean(publish) && (publishStrategy === "all" || options.unreviewedConsent ? true : false);
5477
5732
  if (shouldPublishNew && newDocId) {
5478
5733
  try {
5479
5734
  const pubArgs = { documentId: newDocId };
@@ -6217,6 +6472,174 @@ const csvService = ({ strapi }) => ({
6217
6472
  totalWarningCount: warnings.length
6218
6473
  };
6219
6474
  },
6475
+ /**
6476
+ * Previews CSV batch changes item-by-item without mutating the database.
6477
+ * Compares proposed CSV rows against existing documents to compute exact diffs for Maker-Checker review.
6478
+ */
6479
+ async previewCsvBatch(uid, rows, options = {}) {
6480
+ const ct = strapi.contentTypes[uid];
6481
+ if (!ct) {
6482
+ throw new Error(`Content type "${uid}" not found`);
6483
+ }
6484
+ const attrs = ct.attributes || {};
6485
+ const { mode = "auto" } = options;
6486
+ const diffs = [];
6487
+ let willUpdateCount = 0;
6488
+ let willCreateCount = 0;
6489
+ let unchangedCount = 0;
6490
+ let skippedCount = 0;
6491
+ for (let i = 0; i < rows.length; i++) {
6492
+ const rowNumber = i + 2;
6493
+ const row = rows[i];
6494
+ let docId = extractDocId(row);
6495
+ const rowLocale = extractLocale(row);
6496
+ if (ct.kind === "singleType" && !docId) {
6497
+ try {
6498
+ const findSingleArgs = {};
6499
+ if (rowLocale) findSingleArgs.locale = rowLocale;
6500
+ const singleDoc = await strapi.documents(uid).findFirst(findSingleArgs);
6501
+ if (singleDoc?.documentId) {
6502
+ docId = singleDoc.documentId;
6503
+ }
6504
+ } catch {
6505
+ }
6506
+ }
6507
+ let existing = null;
6508
+ if (docId) {
6509
+ existing = await findExistingDocument(strapi, uid, docId, rowLocale);
6510
+ }
6511
+ if (mode === "update" && (!docId || !existing)) {
6512
+ skippedCount++;
6513
+ diffs.push({
6514
+ uid,
6515
+ collectionName: ct.info?.displayName || uid,
6516
+ documentId: docId || `(Row #${rowNumber})`,
6517
+ label: `Row #${rowNumber}`,
6518
+ attribute: "(record)",
6519
+ oldValue: null,
6520
+ newValue: null,
6521
+ oldDisplay: "—",
6522
+ newDisplay: "—",
6523
+ hasChange: false,
6524
+ status: "will_skip",
6525
+ action: "skip",
6526
+ message: !docId ? "Missing documentId in update mode" : `Document "${docId}" not found in database`
6527
+ });
6528
+ continue;
6529
+ }
6530
+ const payload = parseRowToPayload(strapi, attrs, row);
6531
+ if (existing) {
6532
+ const resolvedDocId = existing.documentId || docId;
6533
+ const entryLabel = existing.title || existing.name || existing.slug || `Entry #${resolvedDocId}`;
6534
+ let rowHasChanges = false;
6535
+ const payloadKeys = Object.keys(payload);
6536
+ if (payloadKeys.length === 0) {
6537
+ unchangedCount++;
6538
+ diffs.push({
6539
+ uid,
6540
+ collectionName: ct.info?.displayName || uid,
6541
+ documentId: resolvedDocId,
6542
+ label: entryLabel,
6543
+ attribute: "(no fields)",
6544
+ oldValue: null,
6545
+ newValue: null,
6546
+ oldDisplay: "—",
6547
+ newDisplay: "—",
6548
+ hasChange: false,
6549
+ status: "unchanged",
6550
+ action: "update",
6551
+ message: "No modified attributes in CSV row"
6552
+ });
6553
+ } else {
6554
+ for (const attrKey of payloadKeys) {
6555
+ const oldVal = existing?.[attrKey] ?? null;
6556
+ let newVal = payload[attrKey];
6557
+ if (attrs[attrKey]?.type === "component" && typeof newVal === "object" && newVal !== null) {
6558
+ newVal = deepMergeComponent(existing[attrKey], newVal);
6559
+ }
6560
+ const oldDisplay = formatValueDisplay(oldVal);
6561
+ const newDisplay = formatValueDisplay(newVal);
6562
+ const hasChange = String(oldVal ?? "") !== String(newVal ?? "");
6563
+ if (hasChange) {
6564
+ rowHasChanges = true;
6565
+ }
6566
+ diffs.push({
6567
+ uid,
6568
+ collectionName: ct.info?.displayName || uid,
6569
+ documentId: resolvedDocId,
6570
+ label: entryLabel,
6571
+ attribute: attrKey,
6572
+ oldValue: oldVal,
6573
+ newValue: newVal,
6574
+ oldDisplay,
6575
+ newDisplay,
6576
+ hasChange,
6577
+ status: hasChange ? "will_update" : "unchanged",
6578
+ action: "update",
6579
+ message: hasChange ? `Will update "${attrKey}" from CSV` : `Already matches CSV value`
6580
+ });
6581
+ }
6582
+ if (rowHasChanges) {
6583
+ willUpdateCount++;
6584
+ } else {
6585
+ unchangedCount++;
6586
+ }
6587
+ }
6588
+ } else {
6589
+ willCreateCount++;
6590
+ const resolvedDocId = docId || `(Auto-generated)`;
6591
+ const entryLabel = payload.title || payload.name || payload.slug || `New Entry (Row #${rowNumber})`;
6592
+ const payloadKeys = Object.keys(payload);
6593
+ if (payloadKeys.length === 0) {
6594
+ diffs.push({
6595
+ uid,
6596
+ collectionName: ct.info?.displayName || uid,
6597
+ documentId: resolvedDocId,
6598
+ label: entryLabel,
6599
+ attribute: "(all fields)",
6600
+ oldValue: null,
6601
+ newValue: null,
6602
+ oldDisplay: "(new entry)",
6603
+ newDisplay: "(empty record)",
6604
+ hasChange: true,
6605
+ status: "will_create",
6606
+ action: "create",
6607
+ message: "Will create new record"
6608
+ });
6609
+ } else {
6610
+ for (const attrKey of payloadKeys) {
6611
+ const newVal = payload[attrKey];
6612
+ diffs.push({
6613
+ uid,
6614
+ collectionName: ct.info?.displayName || uid,
6615
+ documentId: resolvedDocId,
6616
+ label: entryLabel,
6617
+ attribute: attrKey,
6618
+ oldValue: null,
6619
+ newValue: newVal,
6620
+ oldDisplay: "(new entry)",
6621
+ newDisplay: formatValueDisplay(newVal),
6622
+ hasChange: true,
6623
+ status: "will_create",
6624
+ action: "create",
6625
+ message: `Will create new record with attribute "${attrKey}"`
6626
+ });
6627
+ }
6628
+ }
6629
+ }
6630
+ }
6631
+ return {
6632
+ success: true,
6633
+ summary: {
6634
+ total: rows.length,
6635
+ willUpdate: willUpdateCount,
6636
+ willCreate: willCreateCount,
6637
+ unchanged: unchangedCount,
6638
+ skipped: skippedCount
6639
+ },
6640
+ diffs
6641
+ };
6642
+ },
6220
6643
  /**
6221
6644
  * Submits a CSV batch with Guardrails: Maker-Checker (4-Eyes), Execution Windows, Off-Peak Scheduling
6222
6645
  */