strapi-plugin-bulk-updater 1.1.0 → 1.2.3

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.
@@ -182,6 +182,78 @@ const routes = {
182
182
  ]
183
183
  }
184
184
  };
185
+ const auditLog = {
186
+ collectionName: "bulk_updater_audit_logs",
187
+ info: {
188
+ singularName: "audit-log",
189
+ pluralName: "audit-logs",
190
+ displayName: "Bulk Updater Audit Log",
191
+ description: "Internal audit trail for bulk attribute and relation updates"
192
+ },
193
+ options: {
194
+ draftAndPublish: false
195
+ },
196
+ pluginOptions: {
197
+ "content-manager": {
198
+ visible: true
199
+ },
200
+ "content-type-builder": {
201
+ visible: false
202
+ }
203
+ },
204
+ attributes: {
205
+ batchId: {
206
+ type: "string",
207
+ required: true
208
+ },
209
+ attribute: {
210
+ type: "string",
211
+ required: true
212
+ },
213
+ value: {
214
+ type: "text"
215
+ },
216
+ userEmail: {
217
+ type: "string"
218
+ },
219
+ userName: {
220
+ type: "string"
221
+ },
222
+ updatedCount: {
223
+ type: "integer",
224
+ default: 0
225
+ },
226
+ skippedCount: {
227
+ type: "integer",
228
+ default: 0
229
+ },
230
+ totalTargets: {
231
+ type: "integer",
232
+ default: 0
233
+ },
234
+ diffs: {
235
+ type: "json"
236
+ },
237
+ isRolledBack: {
238
+ type: "boolean",
239
+ default: false
240
+ },
241
+ rolledBackAt: {
242
+ type: "datetime"
243
+ },
244
+ rolledBackBy: {
245
+ type: "string"
246
+ },
247
+ executedAt: {
248
+ type: "datetime"
249
+ }
250
+ }
251
+ };
252
+ const contentTypes = {
253
+ "audit-log": {
254
+ schema: auditLog
255
+ }
256
+ };
185
257
  const bulkUpdaterController = ({ strapi: strapi2 }) => ({
186
258
  async getModels(ctx) {
187
259
  try {
@@ -224,7 +296,7 @@ const bulkUpdaterController = ({ strapi: strapi2 }) => ({
224
296
  return;
225
297
  }
226
298
  }
227
- ctx.body = data;
299
+ ctx.body = { success: true, ...data };
228
300
  } catch (err) {
229
301
  ctx.status = 500;
230
302
  ctx.body = { success: false, error: err.message };
@@ -673,9 +745,69 @@ async function executeBatchInternal(strapi2, {
673
745
  try {
674
746
  const currentEntry = await getDocumentDeduplicated(target.uid, target.documentId);
675
747
  const entryLabel = currentEntry?.name || currentEntry?.title || currentEntry?.email || `Entry #${target.documentId}`;
748
+ const isNested = attribute.includes(".");
749
+ let isComponentNested = false;
750
+ let parentKey = "";
751
+ let childKey = "";
752
+ if (isNested) {
753
+ const parts = attribute.split(".");
754
+ parentKey = parts[0];
755
+ childKey = parts[1];
756
+ const parentAttrDef = contentType?.attributes?.[parentKey];
757
+ if (parentAttrDef?.type === "component" && !parentAttrDef.repeatable && parentAttrDef.component) {
758
+ const compSchema = strapi2.components[parentAttrDef.component];
759
+ if (compSchema?.attributes?.[childKey]) {
760
+ isComponentNested = true;
761
+ }
762
+ }
763
+ }
676
764
  const attrDef = contentType?.attributes?.[attribute];
677
- const hasAttribute = Boolean(attrDef);
765
+ const hasAttribute = Boolean(attrDef) || isComponentNested;
678
766
  if (hasAttribute) {
767
+ if (isComponentNested) {
768
+ const currentEntryWithPopulate = await strapi2.documents(target.uid).findOne({
769
+ documentId: target.documentId,
770
+ populate: [parentKey]
771
+ });
772
+ const existingParent = currentEntryWithPopulate?.[parentKey] || {};
773
+ const currentVal2 = existingParent?.[childKey];
774
+ let newValue2 = value;
775
+ if (regexObj && typeof currentVal2 === "string") {
776
+ newValue2 = currentVal2.replace(regexObj, value);
777
+ }
778
+ const updatedParent = {
779
+ ...existingParent,
780
+ [childKey]: newValue2
781
+ };
782
+ await strapi2.documents(target.uid).update({
783
+ documentId: target.documentId,
784
+ data: {
785
+ [parentKey]: updatedParent
786
+ }
787
+ });
788
+ const isDraftAndPublishComp = Boolean(contentType?.options?.draftAndPublish);
789
+ if (publishOnApproval && isDraftAndPublishComp) {
790
+ try {
791
+ await strapi2.documents(target.uid).publish({
792
+ documentId: target.documentId
793
+ });
794
+ } catch (publishErr) {
795
+ strapi2.log.warn(`[bulk-updater] Failed to auto-publish ${target.uid}/${target.documentId}:`, publishErr);
796
+ }
797
+ }
798
+ invalidateCollectionCache(target.uid);
799
+ return {
800
+ uid: target.uid,
801
+ collectionName: displayName,
802
+ documentId: target.documentId,
803
+ label: entryLabel,
804
+ attribute,
805
+ oldValue: currentVal2 ?? null,
806
+ newValue: newValue2,
807
+ status: "updated",
808
+ message: `Successfully updated nested field ${attribute}`
809
+ };
810
+ }
679
811
  if (attrDef.type === "relation") {
680
812
  const isMulti = attrDef.relation === "oneToMany" || attrDef.relation === "manyToMany";
681
813
  const currentEntryWithPopulate = await strapi2.documents(target.uid).findOne({
@@ -917,9 +1049,24 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
917
1049
  const isSingleType = contentType.kind === "singleType";
918
1050
  const rawAttrs = contentType.attributes || {};
919
1051
  const relationsByAttr = {};
1052
+ const nestedComponentAttrs = [];
920
1053
  const validAttrs = Object.keys(rawAttrs).filter((attrKey) => {
921
1054
  if (systemFields.includes(attrKey)) return false;
922
- if (rawAttrs[attrKey].type === "component" || rawAttrs[attrKey].type === "dynamiczone") return false;
1055
+ if (rawAttrs[attrKey].type === "dynamiczone") return false;
1056
+ if (rawAttrs[attrKey].type === "component") {
1057
+ const compDef = rawAttrs[attrKey];
1058
+ if (!compDef.repeatable && compDef.component) {
1059
+ const componentSchema = strapi2.components[compDef.component];
1060
+ if (componentSchema && componentSchema.attributes) {
1061
+ for (const [subKey, subAttr] of Object.entries(componentSchema.attributes)) {
1062
+ if (systemFields.includes(subKey)) continue;
1063
+ if (["component", "dynamiczone", "relation"].includes(subAttr.type)) continue;
1064
+ nestedComponentAttrs.push(`${attrKey}.${subKey}`);
1065
+ }
1066
+ }
1067
+ }
1068
+ return false;
1069
+ }
923
1070
  if (rawAttrs[attrKey].type === "relation") {
924
1071
  const rel = rawAttrs[attrKey];
925
1072
  const targetUid = rel.target;
@@ -939,7 +1086,8 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
939
1086
  }
940
1087
  return true;
941
1088
  });
942
- validAttrs.forEach((attr) => allAttributesSet.add(attr));
1089
+ const allColAttrs = [...validAttrs, ...nestedComponentAttrs];
1090
+ allColAttrs.forEach((attr) => allAttributesSet.add(attr));
943
1091
  metaByUid[uid] = {
944
1092
  uid,
945
1093
  kind: contentType.kind,
@@ -947,7 +1095,7 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
947
1095
  singularName: contentType.info?.singularName || uid,
948
1096
  pluralName: contentType.info?.pluralName || uid,
949
1097
  displayName: contentType.info?.displayName || uid,
950
- attributes: validAttrs,
1098
+ attributes: allColAttrs,
951
1099
  relations: relationsByAttr
952
1100
  };
953
1101
  }
@@ -976,8 +1124,17 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
976
1124
  let entries = [];
977
1125
  let totalCount = 0;
978
1126
  try {
1127
+ const ctAttrs = allContentTypes[uid]?.attributes || {};
979
1128
  const relKeys = Object.keys(meta.relations || {});
980
- const populateOption = relKeys.length > 0 ? relKeys.reduce((acc, k) => ({ ...acc, [k]: true }), {}) : void 0;
1129
+ const compKeys = Object.keys(ctAttrs).filter((k) => ctAttrs[k].type === "component");
1130
+ const populateOptionObj = {};
1131
+ relKeys.forEach((k) => {
1132
+ populateOptionObj[k] = true;
1133
+ });
1134
+ compKeys.forEach((k) => {
1135
+ populateOptionObj[k] = true;
1136
+ });
1137
+ const populateOption = Object.keys(populateOptionObj).length > 0 ? populateOptionObj : void 0;
981
1138
  if (meta.isSingleType) {
982
1139
  const singleDoc = await strapi2.documents(uid).findFirst({
983
1140
  populate: populateOption
@@ -1055,7 +1212,7 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1055
1212
  if (!contentType) {
1056
1213
  throw new Error(`Collection with UID "${uid}" does not exist.`);
1057
1214
  }
1058
- const limit = Math.min(Math.max(Number(pageSize) || 10, 1), 100);
1215
+ const limit = Math.min(Math.max(Number(pageSize) || 10, 1), 500);
1059
1216
  const currentPage = Math.max(Number(page) || 1, 1);
1060
1217
  const start = (currentPage - 1) * limit;
1061
1218
  const isSingleType = contentType.kind === "singleType";
@@ -1071,8 +1228,118 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1071
1228
  };
1072
1229
  }
1073
1230
  let result;
1231
+ const filters2 = {};
1232
+ const attrs = contentType.attributes || {};
1233
+ let colHasAttribute = false;
1234
+ let isNestedAttr = false;
1235
+ let parentKey = "";
1236
+ let childKey = "";
1237
+ if (attribute) {
1238
+ if (attribute.includes(".")) {
1239
+ [parentKey, childKey] = attribute.split(".");
1240
+ if (attrs[parentKey]?.type === "component" && !attrs[parentKey].repeatable && attrs[parentKey].component) {
1241
+ const compSchema = strapi2.components[attrs[parentKey].component];
1242
+ if (compSchema?.attributes?.[childKey]) {
1243
+ colHasAttribute = true;
1244
+ isNestedAttr = true;
1245
+ }
1246
+ }
1247
+ } else if (attrs[attribute]) {
1248
+ colHasAttribute = true;
1249
+ }
1250
+ }
1251
+ const normOp = (filterOperator || "").toLowerCase().replace(/_/g, "");
1252
+ if (normOp && normOp !== "none") {
1253
+ if (!colHasAttribute) {
1254
+ result = {
1255
+ uid,
1256
+ displayName: contentType.info?.displayName || uid,
1257
+ isSingleType,
1258
+ entries: [],
1259
+ pagination: {
1260
+ page: 1,
1261
+ pageSize: limit,
1262
+ total: 0,
1263
+ pageCount: 0
1264
+ }
1265
+ };
1266
+ queryCache.set(cacheKey, { signature, data: result, timestamp: Date.now() });
1267
+ return { ...result, cached: false, etag };
1268
+ }
1269
+ let condition = void 0;
1270
+ const q = (filterQuery || "").trim();
1271
+ if (normOp === "empty") {
1272
+ condition = { $or: [{ $null: true }, { $eq: "" }] };
1273
+ } else if (normOp === "notempty") {
1274
+ condition = { $and: [{ $notNull: true }, { $ne: "" }] };
1275
+ } else if (q !== "") {
1276
+ switch (normOp) {
1277
+ case "equals":
1278
+ condition = { $containsi: q };
1279
+ break;
1280
+ case "notequals":
1281
+ condition = { $notContainsi: q };
1282
+ break;
1283
+ case "contains":
1284
+ condition = { $containsi: q };
1285
+ break;
1286
+ case "notcontains":
1287
+ condition = { $notContainsi: q };
1288
+ break;
1289
+ case "startswith":
1290
+ condition = { $startsWith: q };
1291
+ break;
1292
+ case "endswith":
1293
+ condition = { $endsWith: q };
1294
+ break;
1295
+ }
1296
+ }
1297
+ if (condition !== void 0) {
1298
+ if (isNestedAttr) {
1299
+ filters2[parentKey] = {
1300
+ [childKey]: condition
1301
+ };
1302
+ } else {
1303
+ if (normOp === "empty") {
1304
+ filters2.$or = [
1305
+ { [attribute]: { $null: true } },
1306
+ { [attribute]: { $eq: "" } }
1307
+ ];
1308
+ } else if (normOp === "notempty") {
1309
+ filters2[attribute] = { $notNull: true, $ne: "" };
1310
+ } else {
1311
+ filters2[attribute] = condition;
1312
+ }
1313
+ }
1314
+ }
1315
+ }
1316
+ if (search && search.trim() !== "") {
1317
+ const q = search.trim();
1318
+ const searchableAttrs = Object.keys(attrs).filter(
1319
+ (k) => ["string", "text", "email", "uid"].includes(attrs[k]?.type)
1320
+ );
1321
+ if (searchableAttrs.length > 0) {
1322
+ const orList = searchableAttrs.slice(0, 6).map((k) => ({
1323
+ [k]: { $containsi: q }
1324
+ }));
1325
+ if (Object.keys(filters2).length > 0) {
1326
+ const existingFilters = { ...filters2 };
1327
+ Object.keys(filters2).forEach((k) => delete filters2[k]);
1328
+ filters2.$and = [existingFilters, { $or: orList }];
1329
+ } else {
1330
+ filters2.$or = orList;
1331
+ }
1332
+ }
1333
+ }
1074
1334
  if (isSingleType) {
1075
- const singleDoc = await strapi2.documents(uid).findFirst();
1335
+ let singleDoc = null;
1336
+ try {
1337
+ singleDoc = await strapi2.documents(uid).findFirst({
1338
+ filters: Object.keys(filters2).length > 0 ? filters2 : void 0
1339
+ });
1340
+ } catch {
1341
+ singleDoc = null;
1342
+ }
1076
1343
  result = {
1077
1344
  uid,
1078
1345
  displayName: contentType.info?.displayName || uid,
@@ -1086,55 +1353,23 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1086
1353
  }
1087
1354
  };
1088
1355
  } else {
1089
- const filters2 = {};
1090
- if (attribute && filterOperator && filterQuery !== void 0 && filterQuery !== "") {
1091
- switch (filterOperator) {
1092
- case "equals":
1093
- filters2[attribute] = { $eq: filterQuery };
1094
- break;
1095
- case "notEquals":
1096
- filters2[attribute] = { $ne: filterQuery };
1097
- break;
1098
- case "contains":
1099
- filters2[attribute] = { $containsi: filterQuery };
1100
- break;
1101
- case "notContains":
1102
- filters2[attribute] = { $notContainsi: filterQuery };
1103
- break;
1104
- case "startsWith":
1105
- filters2[attribute] = { $startsWith: filterQuery };
1106
- break;
1107
- case "endsWith":
1108
- filters2[attribute] = { $endsWith: filterQuery };
1109
- break;
1110
- case "empty":
1111
- filters2[attribute] = { $null: true };
1112
- break;
1113
- case "notEmpty":
1114
- filters2[attribute] = { $notNull: true };
1115
- break;
1116
- }
1117
- }
1118
- if (search && search.trim() !== "") {
1119
- const stringAttrs = Object.keys(contentType.attributes || {}).filter(
1120
- (k) => contentType.attributes[k].type === "string" || contentType.attributes[k].type === "text" || contentType.attributes[k].type === "email"
1121
- );
1122
- if (stringAttrs.length > 0) {
1123
- filters2.$or = stringAttrs.slice(0, 5).map((attr) => ({
1124
- [attr]: { $containsi: search.trim() }
1125
- }));
1126
- }
1127
- }
1128
1356
  const sortClause = {};
1129
- if (sortField && contentType.attributes?.[sortField]) {
1357
+ if (sortField && sortField.includes(".")) {
1358
+ const [parent, child] = sortField.split(".");
1359
+ if (contentType.attributes?.[parent]?.type === "component") {
1360
+ sortClause[parent] = { [child]: sortOrder };
1361
+ } else {
1362
+ sortClause["createdAt"] = "desc";
1363
+ }
1364
+ } else if (sortField && contentType.attributes?.[sortField]) {
1130
1365
  sortClause[sortField] = sortOrder;
1131
1366
  } else {
1132
1367
  sortClause["createdAt"] = "desc";
1133
1368
  }
1134
- const relationAttrs = Object.keys(contentType.attributes || {}).filter(
1135
- (k) => contentType.attributes[k].type === "relation"
1369
+ const populateAttrs = Object.keys(contentType.attributes || {}).filter(
1370
+ (k) => contentType.attributes[k].type === "relation" || contentType.attributes[k].type === "component"
1136
1371
  );
1137
- const populateOption = relationAttrs.length > 0 ? relationAttrs.reduce((acc, k) => ({ ...acc, [k]: true }), {}) : void 0;
1372
+ const populateOption = populateAttrs.length > 0 ? populateAttrs.reduce((acc, k) => ({ ...acc, [k]: true }), {}) : void 0;
1138
1373
  const [entries, totalEntries] = await Promise.all([
1139
1374
  strapi2.documents(uid).findMany({
1140
1375
  start,
@@ -1473,6 +1708,9 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1473
1708
  modelsCache.clear();
1474
1709
  staticSchemaCache = null;
1475
1710
  }
1711
+ },
1712
+ invalidateCollectionCache(uid) {
1713
+ invalidateCollectionCache(uid);
1476
1714
  }
1477
1715
  };
1478
1716
  };
@@ -1492,6 +1730,7 @@ const DEFAULT_FIELDS = {
1492
1730
  executedAt: "executedAt"
1493
1731
  };
1494
1732
  const DEFAULT_CANDIDATE_TYPES = [
1733
+ "plugin::bulk-updater.audit-log",
1495
1734
  "api::audit-log.audit-log",
1496
1735
  "api::audit.audit",
1497
1736
  "api::bulk-audit-log.bulk-audit-log"
@@ -1513,6 +1752,7 @@ const auditService = ({ strapi: strapi2 }) => ({
1513
1752
  * 1. Explicit consumer custom schema (full declarative control)
1514
1753
  * 2. Zero-config convention auto-detection (api::audit-log.audit-log)
1515
1754
  * 3. Strapi Enterprise native audit log bridge
1755
+ * 4. Zero-config plugin core store (works out of the box in 100% of Strapi installations)
1516
1756
  */
1517
1757
  getAuditConfig() {
1518
1758
  const rawConfig = strapi2.config.get("plugin::bulk-updater.audit");
@@ -1566,10 +1806,9 @@ const auditService = ({ strapi: strapi2 }) => ({
1566
1806
  };
1567
1807
  }
1568
1808
  return {
1569
- enabled: false,
1570
- mode: "disabled",
1571
- isEnterpriseNativeAvailable: false,
1572
- error: "No audit collection configured or detected. To enable rollback, provide a collection or use Strapi Enterprise."
1809
+ enabled: true,
1810
+ mode: "store",
1811
+ isEnterpriseNativeAvailable: false
1573
1812
  };
1574
1813
  },
1575
1814
  /**
@@ -1614,7 +1853,40 @@ const auditService = ({ strapi: strapi2 }) => ({
1614
1853
  },
1615
1854
  user
1616
1855
  );
1617
- if (!config.enabled || !config.contentType || !config.fields) {
1856
+ if (!config.enabled) {
1857
+ return null;
1858
+ }
1859
+ if (config.mode === "store") {
1860
+ try {
1861
+ const store = strapi2.store({ type: "plugin", name: "bulk-updater", key: "audit-history" });
1862
+ const stored = await store.get({}) || { history: [] };
1863
+ const history = Array.isArray(stored.history) ? stored.history : [];
1864
+ const newRecord = {
1865
+ id: batchSummary.batchId,
1866
+ documentId: batchSummary.batchId,
1867
+ batchId: batchSummary.batchId,
1868
+ attribute: batchSummary.attribute,
1869
+ value: batchSummary.value,
1870
+ userEmail: user?.email || "admin@strapi.io",
1871
+ userName: user?.firstname ? `${user.firstname} ${user.lastname || ""}`.trim() : user?.email || "Admin",
1872
+ updatedCount: batchSummary.updatedCount,
1873
+ skippedCount: batchSummary.skippedCount,
1874
+ totalTargets: batchSummary.totalTargets,
1875
+ diffs: results,
1876
+ isRolledBack: false,
1877
+ rolledBackAt: null,
1878
+ rolledBackBy: null,
1879
+ executedAt: (/* @__PURE__ */ new Date()).toISOString()
1880
+ };
1881
+ const updatedHistory = [newRecord, ...history].slice(0, 200);
1882
+ await store.set({ value: { history: updatedHistory } });
1883
+ return newRecord;
1884
+ } catch (err) {
1885
+ strapi2.log.error(`[bulk-updater] Failed to record audit log in core store: ${err.message}`);
1886
+ return null;
1887
+ }
1888
+ }
1889
+ if (!config.contentType || !config.fields) {
1618
1890
  return null;
1619
1891
  }
1620
1892
  const f = config.fields;
@@ -1655,6 +1927,25 @@ const auditService = ({ strapi: strapi2 }) => ({
1655
1927
  history: []
1656
1928
  };
1657
1929
  }
1930
+ if (config.mode === "store") {
1931
+ try {
1932
+ const store = strapi2.store({ type: "plugin", name: "bulk-updater", key: "audit-history" });
1933
+ const stored = await store.get({}) || { history: [] };
1934
+ const history = Array.isArray(stored.history) ? stored.history : [];
1935
+ return {
1936
+ enabled: true,
1937
+ mode: "store",
1938
+ isEnterpriseNativeAvailable: config.isEnterpriseNativeAvailable,
1939
+ history
1940
+ };
1941
+ } catch (err) {
1942
+ return {
1943
+ enabled: false,
1944
+ message: `Failed to read audit history from core store: ${err.message}`,
1945
+ history: []
1946
+ };
1947
+ }
1948
+ }
1658
1949
  if (!config.enabled || !config.contentType || !config.fields) {
1659
1950
  return {
1660
1951
  enabled: false,
@@ -1700,27 +1991,9 @@ const auditService = ({ strapi: strapi2 }) => ({
1700
1991
  }
1701
1992
  },
1702
1993
  /**
1703
- * Performs deterministic rollback of a batch
1994
+ * Helper to revert diff items back to original values
1704
1995
  */
1705
- async rollbackBatch(batchId, currentUser) {
1706
- const config = this.getAuditConfig();
1707
- if (!config.enabled || !config.contentType || !config.fields) {
1708
- throw new Error("Audit trail is not configured or disabled.");
1709
- }
1710
- const f = config.fields;
1711
- const records = await strapi2.documents(config.contentType).findMany({
1712
- filters: {
1713
- [f.batchId]: { $eq: batchId }
1714
- }
1715
- });
1716
- const record = records?.[0];
1717
- if (!record) {
1718
- throw new Error(`Batch record with ID "${batchId}" was not found in audit trail.`);
1719
- }
1720
- if (record[f.isRolledBack]) {
1721
- throw new Error(`Batch "${batchId}" has already been rolled back.`);
1722
- }
1723
- const diffs = typeof record[f.diffs] === "string" ? JSON.parse(record[f.diffs]) : record[f.diffs] || [];
1996
+ async revertDiffItems(diffs) {
1724
1997
  let revertedCount = 0;
1725
1998
  const rollbackDetails = [];
1726
1999
  for (const item of diffs) {
@@ -1734,12 +2007,41 @@ const auditService = ({ strapi: strapi2 }) => ({
1734
2007
  } else {
1735
2008
  rollbackPayload = item.oldValue;
1736
2009
  }
1737
- await strapi2.documents(item.uid).update({
1738
- documentId: item.documentId,
1739
- data: {
1740
- [item.attribute]: rollbackPayload
2010
+ if (item.attribute.includes(".")) {
2011
+ const [parentKey, childKey] = item.attribute.split(".");
2012
+ const ct2 = strapi2.contentTypes[item.uid];
2013
+ if (ct2?.attributes?.[parentKey]?.type === "component") {
2014
+ const currentDoc = await strapi2.documents(item.uid).findOne({
2015
+ documentId: item.documentId,
2016
+ populate: [parentKey]
2017
+ });
2018
+ const currentParent = currentDoc?.[parentKey] || {};
2019
+ const restoredParent = {
2020
+ ...currentParent,
2021
+ [childKey]: rollbackPayload
2022
+ };
2023
+ await strapi2.documents(item.uid).update({
2024
+ documentId: item.documentId,
2025
+ data: {
2026
+ [parentKey]: restoredParent
2027
+ }
2028
+ });
2029
+ } else {
2030
+ await strapi2.documents(item.uid).update({
2031
+ documentId: item.documentId,
2032
+ data: {
2033
+ [item.attribute]: rollbackPayload
2034
+ }
2035
+ });
1741
2036
  }
1742
- });
2037
+ } else {
2038
+ await strapi2.documents(item.uid).update({
2039
+ documentId: item.documentId,
2040
+ data: {
2041
+ [item.attribute]: rollbackPayload
2042
+ }
2043
+ });
2044
+ }
1743
2045
  const ct = strapi2.contentTypes[item.uid];
1744
2046
  if (ct?.options?.draftAndPublish) {
1745
2047
  try {
@@ -1750,7 +2052,10 @@ const auditService = ({ strapi: strapi2 }) => ({
1750
2052
  }
1751
2053
  }
1752
2054
  try {
1753
- strapi2.plugin("bulk-updater").service("bulkUpdater").invalidateCollectionCache(item.uid);
2055
+ const bulkService = strapi2.plugin("bulk-updater").service("bulkUpdater");
2056
+ if (bulkService?.invalidateCache) {
2057
+ bulkService.invalidateCache(item.uid);
2058
+ }
1754
2059
  } catch {
1755
2060
  }
1756
2061
  revertedCount++;
@@ -1773,6 +2078,78 @@ const auditService = ({ strapi: strapi2 }) => ({
1773
2078
  }
1774
2079
  }
1775
2080
  }
2081
+ return { revertedCount, rollbackDetails };
2082
+ },
2083
+ /**
2084
+ * Performs deterministic rollback of a batch
2085
+ */
2086
+ async rollbackBatch(batchId, currentUser) {
2087
+ const config = this.getAuditConfig();
2088
+ if (!config.enabled) {
2089
+ throw new Error("Audit trail is not configured or disabled.");
2090
+ }
2091
+ if (config.mode === "store") {
2092
+ const store = strapi2.store({ type: "plugin", name: "bulk-updater", key: "audit-history" });
2093
+ const stored = await store.get({}) || { history: [] };
2094
+ const history = Array.isArray(stored.history) ? stored.history : [];
2095
+ const recordIndex = history.findIndex((r) => r.batchId === batchId);
2096
+ if (recordIndex === -1) {
2097
+ throw new Error(`Batch record with ID "${batchId}" was not found in audit trail.`);
2098
+ }
2099
+ const record2 = history[recordIndex];
2100
+ if (record2.isRolledBack) {
2101
+ throw new Error(`Batch "${batchId}" has already been rolled back.`);
2102
+ }
2103
+ const diffs2 = Array.isArray(record2.diffs) ? record2.diffs : [];
2104
+ const { revertedCount: revertedCount2, rollbackDetails: rollbackDetails2 } = await this.revertDiffItems(diffs2);
2105
+ record2.isRolledBack = true;
2106
+ record2.rolledBackAt = (/* @__PURE__ */ new Date()).toISOString();
2107
+ record2.rolledBackBy = currentUser?.email || "Admin";
2108
+ history[recordIndex] = record2;
2109
+ await store.set({ value: { history } });
2110
+ try {
2111
+ const affectedUids = new Set(rollbackDetails2.map((d) => d.uid));
2112
+ const bulkService = strapi2.plugin("bulk-updater").service("bulkUpdater");
2113
+ affectedUids.forEach((uid) => {
2114
+ if (uid && bulkService?.invalidateCache) {
2115
+ bulkService.invalidateCache(uid);
2116
+ }
2117
+ });
2118
+ } catch {
2119
+ }
2120
+ await this.recordToEnterpriseNative(
2121
+ "bulk-updater.rollback",
2122
+ {
2123
+ batchId,
2124
+ revertedCount: revertedCount2
2125
+ },
2126
+ currentUser
2127
+ );
2128
+ return {
2129
+ success: true,
2130
+ batchId,
2131
+ revertedCount: revertedCount2,
2132
+ details: rollbackDetails2
2133
+ };
2134
+ }
2135
+ if (!config.contentType || !config.fields) {
2136
+ throw new Error("Audit trail is not configured or disabled.");
2137
+ }
2138
+ const f = config.fields;
2139
+ const records = await strapi2.documents(config.contentType).findMany({
2140
+ filters: {
2141
+ [f.batchId]: { $eq: batchId }
2142
+ }
2143
+ });
2144
+ const record = records?.[0];
2145
+ if (!record) {
2146
+ throw new Error(`Batch record with ID "${batchId}" was not found in audit trail.`);
2147
+ }
2148
+ if (record[f.isRolledBack]) {
2149
+ throw new Error(`Batch "${batchId}" has already been rolled back.`);
2150
+ }
2151
+ const diffs = typeof record[f.diffs] === "string" ? JSON.parse(record[f.diffs]) : record[f.diffs] || [];
2152
+ const { revertedCount, rollbackDetails } = await this.revertDiffItems(diffs);
1776
2153
  const updateData = {};
1777
2154
  if (f.isRolledBack) updateData[f.isRolledBack] = true;
1778
2155
  if (f.rolledBackAt) updateData[f.rolledBackAt] = (/* @__PURE__ */ new Date()).toISOString();
@@ -2130,10 +2507,24 @@ ${sampleRow.map(escapeCsvValue).join(",")}
2130
2507
  const attrs = ct.attributes || {};
2131
2508
  const excluded = ["createdBy", "updatedBy", "localizations"];
2132
2509
  const exportFields = ["documentId"];
2510
+ const compFieldsToPopulate = [];
2133
2511
  for (const [attrName, attrDef] of Object.entries(attrs)) {
2134
2512
  if (excluded.includes(attrName)) continue;
2135
- if (["component", "dynamiczone", "media"].includes(attrDef.type)) continue;
2136
- if (attrDef.type === "relation") continue;
2513
+ if (["dynamiczone", "media", "relation"].includes(attrDef.type)) continue;
2514
+ if (attrDef.type === "component") {
2515
+ if (!attrDef.repeatable && attrDef.component) {
2516
+ const comp = strapi2.components[attrDef.component];
2517
+ if (comp?.attributes) {
2518
+ compFieldsToPopulate.push(attrName);
2519
+ for (const [subKey, subDef] of Object.entries(comp.attributes)) {
2520
+ if (excluded.includes(subKey)) continue;
2521
+ if (["component", "dynamiczone", "media", "relation"].includes(subDef.type)) continue;
2522
+ exportFields.push(`${attrName}.${subKey}`);
2523
+ }
2524
+ }
2525
+ }
2526
+ continue;
2527
+ }
2137
2528
  exportFields.push(attrName);
2138
2529
  }
2139
2530
  const filters2 = {};
@@ -2177,16 +2568,25 @@ ${sampleRow.map(escapeCsvValue).join(",")}
2177
2568
  }
2178
2569
  }
2179
2570
  }
2571
+ const populateOption = compFieldsToPopulate.length > 0 ? compFieldsToPopulate.reduce((acc, k) => ({ ...acc, [k]: true }), {}) : void 0;
2180
2572
  const entries = await strapi2.documents(uid).findMany({
2181
2573
  filters: Object.keys(filters2).length > 0 ? filters2 : void 0,
2182
2574
  limit: options.limit || 5e3,
2183
- sort: { createdAt: "desc" }
2575
+ sort: { createdAt: "desc" },
2576
+ populate: populateOption
2184
2577
  });
2185
2578
  const lines = [];
2186
2579
  lines.push(exportFields.map(escapeCsvValue).join(","));
2187
2580
  for (const entry of entries || []) {
2188
2581
  const rowValues = exportFields.map((field) => {
2189
- return escapeCsvValue(entry[field]);
2582
+ let val;
2583
+ if (field.includes(".")) {
2584
+ const [parent, child] = field.split(".");
2585
+ val = entry[parent]?.[child];
2586
+ } else {
2587
+ val = entry[field];
2588
+ }
2589
+ return escapeCsvValue(val);
2190
2590
  });
2191
2591
  lines.push(rowValues.join(","));
2192
2592
  }
@@ -2272,6 +2672,29 @@ ${sampleRow.map(escapeCsvValue).join(",")}
2272
2672
  const payload = {};
2273
2673
  for (const [key, rawVal] of Object.entries(row)) {
2274
2674
  if (key === "documentId" || key === "id") continue;
2675
+ if (key.includes(".")) {
2676
+ const [parent, child] = key.split(".");
2677
+ const parentDef = attrs[parent];
2678
+ if (parentDef?.type === "component" && !parentDef.repeatable && parentDef.component) {
2679
+ const comp = strapi2.components[parentDef.component];
2680
+ const childDef = comp?.attributes?.[child];
2681
+ if (childDef) {
2682
+ if (!payload[parent]) payload[parent] = {};
2683
+ let val = rawVal;
2684
+ if (rawVal === "" || rawVal === null || rawVal === void 0) {
2685
+ val = null;
2686
+ } else if (childDef.type === "integer" || childDef.type === "biginteger") {
2687
+ val = parseInt(rawVal, 10);
2688
+ } else if (childDef.type === "float" || childDef.type === "decimal") {
2689
+ val = parseFloat(rawVal);
2690
+ } else if (childDef.type === "boolean") {
2691
+ val = rawVal.toLowerCase() === "true" || rawVal === "1";
2692
+ }
2693
+ payload[parent][child] = val;
2694
+ }
2695
+ }
2696
+ continue;
2697
+ }
2275
2698
  const attrDef = attrs[key];
2276
2699
  if (!attrDef) continue;
2277
2700
  if (rawVal === "" || rawVal === null || rawVal === void 0) {
@@ -2323,9 +2746,15 @@ ${sampleRow.map(escapeCsvValue).join(",")}
2323
2746
  });
2324
2747
  continue;
2325
2748
  }
2749
+ const mergedPayload = { ...payload };
2750
+ for (const key of Object.keys(payload)) {
2751
+ if (attrs[key]?.type === "component" && typeof payload[key] === "object" && payload[key] !== null) {
2752
+ mergedPayload[key] = { ...existing[key] || {}, ...payload[key] };
2753
+ }
2754
+ }
2326
2755
  await strapi2.documents(uid).update({
2327
2756
  documentId: docId,
2328
- data: payload
2757
+ data: mergedPayload
2329
2758
  });
2330
2759
  if (publish && ct.options?.draftAndPublish) {
2331
2760
  try {
@@ -2352,9 +2781,15 @@ ${sampleRow.map(escapeCsvValue).join(",")}
2352
2781
  documentId: docId
2353
2782
  });
2354
2783
  if (existing) {
2784
+ const mergedPayload = { ...payload };
2785
+ for (const key of Object.keys(payload)) {
2786
+ if (attrs[key]?.type === "component" && typeof payload[key] === "object" && payload[key] !== null) {
2787
+ mergedPayload[key] = { ...existing[key] || {}, ...payload[key] };
2788
+ }
2789
+ }
2355
2790
  await strapi2.documents(uid).update({
2356
2791
  documentId: docId,
2357
- data: payload
2792
+ data: mergedPayload
2358
2793
  });
2359
2794
  if (publish && ct.options?.draftAndPublish) {
2360
2795
  try {
@@ -31957,6 +32392,7 @@ const index = {
31957
32392
  });
31958
32393
  },
31959
32394
  routes,
32395
+ contentTypes,
31960
32396
  controllers: {
31961
32397
  bulkUpdater: bulkUpdaterController,
31962
32398
  audit: auditController,