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