strapi-plugin-bulk-updater 1.4.9 → 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
  */
@@ -4990,176 +5229,263 @@ async function findExistingDocument(strapi, uid, targetDocId, docLocale) {
4990
5229
  }
4991
5230
  return null;
4992
5231
  }
4993
- async function processCsvRows(strapi, ct, uid, rows, startRowNumber, options) {
4994
- const attrs = ct.attributes || {};
4995
- const { mode = "auto", publish = true, publishStrategy = "safe", user } = options;
4996
- const isDraftAndPublish = Boolean(ct.options?.draftAndPublish);
4997
- const stats = {
4998
- total: rows.length,
4999
- created: 0,
5000
- updated: 0,
5001
- skipped: 0,
5002
- newWithoutDocId: 0,
5003
- errors: [],
5004
- diffs: []
5005
- };
5006
- for (let i = 0; i < rows.length; i++) {
5007
- const rowNumber = startRowNumber + i;
5008
- const row = rows[i];
5009
- let docId = extractDocId(row);
5010
- const rowLocale = extractLocale(row);
5011
- if (ct.kind === "singleType" && !docId) {
5012
- try {
5013
- const findSingleArgs = {};
5014
- if (rowLocale) findSingleArgs.locale = rowLocale;
5015
- const singleDoc = await strapi.documents(uid).findFirst(findSingleArgs);
5016
- if (singleDoc?.documentId) {
5017
- docId = singleDoc.documentId;
5018
- }
5019
- } catch {
5020
- }
5232
+ function deepMergeComponent(existingVal, newVal) {
5233
+ if (!existingVal || typeof existingVal !== "object" || Array.isArray(existingVal)) {
5234
+ return newVal;
5235
+ }
5236
+ if (!newVal || typeof newVal !== "object" || Array.isArray(newVal)) {
5237
+ return newVal;
5238
+ }
5239
+ const merged = { ...existingVal };
5240
+ for (const k of Object.keys(newVal)) {
5241
+ if (newVal[k] && typeof newVal[k] === "object" && !Array.isArray(newVal[k])) {
5242
+ merged[k] = deepMergeComponent(existingVal[k], newVal[k]);
5243
+ } else if (newVal[k] !== void 0) {
5244
+ merged[k] = newVal[k];
5021
5245
  }
5022
- const payload = {};
5023
- for (const [key, rawVal] of Object.entries(row)) {
5024
- const normalizedKey = key.toLowerCase().replace(/[\s_-]/g, "");
5025
- if (SYSTEM_FIELD_KEYS.has(normalizedKey)) continue;
5026
- if (key.includes(".")) {
5027
- const [parent, child] = key.split(".");
5028
- const parentDef = attrs[parent];
5029
- if (parentDef?.type === "component" && !parentDef.repeatable && parentDef.component) {
5030
- const comp = strapi.components[parentDef.component];
5031
- const childDef = comp?.attributes?.[child];
5032
- if (childDef) {
5033
- if (!payload[parent]) payload[parent] = {};
5034
- let val = rawVal;
5035
- if (rawVal === "" || rawVal === null || rawVal === void 0) {
5036
- val = null;
5037
- } else if (childDef.type === "integer" || childDef.type === "biginteger") {
5038
- const parsedInt = parseInt(rawVal, 10);
5039
- val = isNaN(parsedInt) ? null : parsedInt;
5040
- } else if (childDef.type === "float" || childDef.type === "decimal") {
5041
- const parsedFloat = parseFloat(rawVal);
5042
- val = isNaN(parsedFloat) ? null : parsedFloat;
5043
- } else if (childDef.type === "boolean") {
5044
- val = rawVal.toLowerCase() === "true" || rawVal === "1";
5045
- } else if (childDef.type === "json") {
5046
- try {
5047
- val = JSON.parse(rawVal);
5048
- } catch {
5049
- val = rawVal;
5246
+ }
5247
+ return merged;
5248
+ }
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;
5275
+ }
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;
5289
+ }
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;
5295
+ }
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)) {
5304
+ currentTarget[part] = rawVal;
5305
+ }
5306
+ } catch {
5307
+ if (isValidDocId(rawVal)) {
5308
+ currentTarget[part] = rawVal;
5050
5309
  }
5051
5310
  }
5052
- payload[parent][child] = val;
5311
+ } 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) {
5325
+ break;
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;
5053
5334
  }
5054
5335
  }
5055
- continue;
5056
5336
  }
5057
- let attrDef = attrs[key];
5058
- if (!attrDef) {
5059
- const matchKey = Object.keys(attrs).find(
5060
- (k) => k.toLowerCase() === key.toLowerCase() || k.toLowerCase() === key.toLowerCase().replace(/\s+/g, "")
5061
- );
5062
- if (matchKey) {
5063
- attrDef = attrs[matchKey];
5064
- }
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];
5065
5346
  }
5066
- if (!attrDef) continue;
5067
- if (rawVal === "" || rawVal === null || rawVal === void 0) {
5068
- if (attrDef.type !== "dynamiczone" && !(attrDef.type === "component" && attrDef.repeatable)) {
5069
- payload[key] = null;
5070
- }
5071
- 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;
5072
5352
  }
5073
- switch (attrDef.type) {
5074
- case "integer":
5075
- case "biginteger": {
5076
- const parsedInt = parseInt(rawVal, 10);
5077
- payload[key] = isNaN(parsedInt) ? null : parsedInt;
5078
- 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;
5079
5376
  }
5080
- case "float":
5081
- case "decimal": {
5082
- const parsedFloat = parseFloat(rawVal);
5083
- payload[key] = isNaN(parsedFloat) ? null : parsedFloat;
5084
- 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 {
5085
5385
  }
5086
- case "boolean":
5087
- payload[key] = rawVal.toLowerCase() === "true" || rawVal === "1";
5088
- break;
5089
- case "json":
5386
+ break;
5387
+ }
5388
+ case "component": {
5389
+ if (attrDef.repeatable) {
5090
5390
  try {
5091
- 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
+ }
5092
5395
  } catch {
5093
- payload[key] = rawVal;
5094
5396
  }
5095
- break;
5096
- case "dynamiczone": {
5397
+ } else {
5097
5398
  try {
5098
5399
  const parsed = JSON.parse(rawVal);
5099
- 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)) {
5100
5401
  payload[key] = parsed;
5101
5402
  }
5102
5403
  } catch {
5103
5404
  }
5104
- break;
5105
5405
  }
5106
- case "component": {
5107
- if (attrDef.repeatable) {
5108
- try {
5109
- const parsed = JSON.parse(rawVal);
5110
- if (Array.isArray(parsed) && parsed.every((item) => item && typeof item === "object")) {
5111
- payload[key] = parsed;
5112
- }
5113
- } catch {
5114
- }
5115
- } else {
5116
- try {
5117
- const parsed = JSON.parse(rawVal);
5118
- if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
5119
- payload[key] = parsed;
5120
- }
5121
- } catch {
5122
- }
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;
5123
5414
  }
5124
- break;
5415
+ } catch {
5125
5416
  }
5126
- case "relation": {
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;
5423
+ }
5424
+ }
5425
+ break;
5426
+ }
5427
+ case "media": {
5428
+ if (/^\d+$/.test(rawVal.trim())) {
5429
+ payload[key] = parseInt(rawVal.trim(), 10);
5430
+ } else {
5127
5431
  try {
5128
5432
  const parsed = JSON.parse(rawVal);
5129
- if (Array.isArray(parsed) || typeof parsed === "object" && parsed !== null) {
5433
+ if (typeof parsed === "number" || typeof parsed === "object") {
5130
5434
  payload[key] = parsed;
5131
- break;
5132
5435
  }
5133
5436
  } catch {
5134
5437
  }
5135
- if (isValidDocId(rawVal)) {
5136
- payload[key] = rawVal;
5137
- } else if (rawVal.includes(";") || rawVal.includes(",")) {
5138
- const parts = rawVal.split(/[;,]/).map((s) => s.trim()).filter(Boolean);
5139
- if (parts.length > 0 && parts.every((p) => isValidDocId(p))) {
5140
- payload[key] = parts;
5141
- }
5142
- }
5143
- break;
5144
5438
  }
5145
- case "media": {
5146
- if (/^\d+$/.test(rawVal.trim())) {
5147
- payload[key] = parseInt(rawVal.trim(), 10);
5148
- } else {
5149
- try {
5150
- const parsed = JSON.parse(rawVal);
5151
- if (typeof parsed === "number" || typeof parsed === "object") {
5152
- payload[key] = parsed;
5153
- }
5154
- } catch {
5155
- }
5156
- }
5157
- 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;
5158
5484
  }
5159
- default:
5160
- payload[key] = rawVal;
5485
+ } catch {
5161
5486
  }
5162
5487
  }
5488
+ const payload = parseRowToPayload(strapi, attrs, row);
5163
5489
  try {
5164
5490
  if (mode === "update") {
5165
5491
  if (!docId) {
@@ -5184,7 +5510,7 @@ async function processCsvRows(strapi, ct, uid, rows, startRowNumber, options) {
5184
5510
  const mergedPayload = { ...payload };
5185
5511
  for (const key of Object.keys(payload)) {
5186
5512
  if (attrs[key]?.type === "component" && typeof payload[key] === "object" && payload[key] !== null) {
5187
- mergedPayload[key] = { ...existing[key] || {}, ...payload[key] };
5513
+ mergedPayload[key] = deepMergeComponent(existing[key], payload[key]);
5188
5514
  }
5189
5515
  }
5190
5516
  if (Object.keys(mergedPayload).length === 0) {
@@ -5209,7 +5535,7 @@ async function processCsvRows(strapi, ct, uid, rows, startRowNumber, options) {
5209
5535
  if (rowLocale) updateArgs.locale = rowLocale;
5210
5536
  await strapi.documents(uid).update(updateArgs);
5211
5537
  const isApproved = isDraftAndPublish && Boolean(existing?.publishedAt) && existing?.status !== "modified";
5212
- const shouldPublish = isDraftAndPublish && Boolean(publish) && (publishStrategy === "all" ? true : isApproved);
5538
+ const shouldPublish = isDraftAndPublish && Boolean(publish) && (publishStrategy === "all" || options.unreviewedConsent ? true : isApproved);
5213
5539
  if (shouldPublish) {
5214
5540
  try {
5215
5541
  const pubArgs = { documentId: resolvedDocId };
@@ -5254,7 +5580,7 @@ async function processCsvRows(strapi, ct, uid, rows, startRowNumber, options) {
5254
5580
  const mergedPayload = { ...payload };
5255
5581
  for (const key of Object.keys(payload)) {
5256
5582
  if (attrs[key]?.type === "component" && typeof payload[key] === "object" && payload[key] !== null) {
5257
- mergedPayload[key] = { ...existing[key] || {}, ...payload[key] };
5583
+ mergedPayload[key] = deepMergeComponent(existing[key], payload[key]);
5258
5584
  }
5259
5585
  }
5260
5586
  const updateArgs = {
@@ -5264,7 +5590,7 @@ async function processCsvRows(strapi, ct, uid, rows, startRowNumber, options) {
5264
5590
  if (rowLocale) updateArgs.locale = rowLocale;
5265
5591
  await strapi.documents(uid).update(updateArgs);
5266
5592
  const isApproved = isDraftAndPublish && Boolean(existing?.publishedAt) && existing?.status !== "modified";
5267
- const shouldPublish = isDraftAndPublish && Boolean(publish) && (publishStrategy === "all" ? true : isApproved);
5593
+ const shouldPublish = isDraftAndPublish && Boolean(publish) && (publishStrategy === "all" || options.unreviewedConsent ? true : isApproved);
5268
5594
  if (shouldPublish) {
5269
5595
  try {
5270
5596
  const pubArgs = { documentId: resolvedDocId };
@@ -5305,7 +5631,7 @@ async function processCsvRows(strapi, ct, uid, rows, startRowNumber, options) {
5305
5631
  if (rowLocale) createArgs.locale = rowLocale;
5306
5632
  const created = await strapi.documents(uid).create(createArgs);
5307
5633
  const newDocId = created?.documentId || docId;
5308
- const shouldPublishNew = isDraftAndPublish && Boolean(publish) && (publishStrategy === "all" ? true : false);
5634
+ const shouldPublishNew = isDraftAndPublish && Boolean(publish) && (publishStrategy === "all" || options.unreviewedConsent ? true : false);
5309
5635
  if (shouldPublishNew && newDocId) {
5310
5636
  try {
5311
5637
  const pubArgs = { documentId: newDocId };
@@ -5351,7 +5677,7 @@ async function processCsvRows(strapi, ct, uid, rows, startRowNumber, options) {
5351
5677
  const mergedPayload = { ...payload };
5352
5678
  for (const key of Object.keys(payload)) {
5353
5679
  if (attrs[key]?.type === "component" && typeof payload[key] === "object" && payload[key] !== null) {
5354
- mergedPayload[key] = { ...existing[key] || {}, ...payload[key] };
5680
+ mergedPayload[key] = deepMergeComponent(existing[key], payload[key]);
5355
5681
  }
5356
5682
  }
5357
5683
  const updateArgs = {
@@ -5361,7 +5687,7 @@ async function processCsvRows(strapi, ct, uid, rows, startRowNumber, options) {
5361
5687
  if (rowLocale) updateArgs.locale = rowLocale;
5362
5688
  await strapi.documents(uid).update(updateArgs);
5363
5689
  const isApproved = isDraftAndPublish && Boolean(existing?.publishedAt) && existing?.status !== "modified";
5364
- const shouldPublish = isDraftAndPublish && Boolean(publish) && (publishStrategy === "all" ? true : isApproved);
5690
+ const shouldPublish = isDraftAndPublish && Boolean(publish) && (publishStrategy === "all" || options.unreviewedConsent ? true : isApproved);
5365
5691
  if (shouldPublish) {
5366
5692
  try {
5367
5693
  const pubArgs = { documentId: resolvedDocId };
@@ -5402,7 +5728,7 @@ async function processCsvRows(strapi, ct, uid, rows, startRowNumber, options) {
5402
5728
  if (rowLocale) createArgs.locale = rowLocale;
5403
5729
  const created = await strapi.documents(uid).create(createArgs);
5404
5730
  const newDocId = created?.documentId || docId;
5405
- const shouldPublishNew = isDraftAndPublish && Boolean(publish) && (publishStrategy === "all" ? true : false);
5731
+ const shouldPublishNew = isDraftAndPublish && Boolean(publish) && (publishStrategy === "all" || options.unreviewedConsent ? true : false);
5406
5732
  if (shouldPublishNew && newDocId) {
5407
5733
  try {
5408
5734
  const pubArgs = { documentId: newDocId };
@@ -6146,6 +6472,174 @@ const csvService = ({ strapi }) => ({
6146
6472
  totalWarningCount: warnings.length
6147
6473
  };
6148
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
+ },
6149
6643
  /**
6150
6644
  * Submits a CSV batch with Guardrails: Maker-Checker (4-Eyes), Execution Windows, Off-Peak Scheduling
6151
6645
  */