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.
@@ -169,6 +169,78 @@ const routes = {
169
169
  ]
170
170
  }
171
171
  };
172
+ const auditLog = {
173
+ collectionName: "bulk_updater_audit_logs",
174
+ info: {
175
+ singularName: "audit-log",
176
+ pluralName: "audit-logs",
177
+ displayName: "Bulk Updater Audit Log",
178
+ description: "Internal audit trail for bulk attribute and relation updates"
179
+ },
180
+ options: {
181
+ draftAndPublish: false
182
+ },
183
+ pluginOptions: {
184
+ "content-manager": {
185
+ visible: true
186
+ },
187
+ "content-type-builder": {
188
+ visible: false
189
+ }
190
+ },
191
+ attributes: {
192
+ batchId: {
193
+ type: "string",
194
+ required: true
195
+ },
196
+ attribute: {
197
+ type: "string",
198
+ required: true
199
+ },
200
+ value: {
201
+ type: "text"
202
+ },
203
+ userEmail: {
204
+ type: "string"
205
+ },
206
+ userName: {
207
+ type: "string"
208
+ },
209
+ updatedCount: {
210
+ type: "integer",
211
+ default: 0
212
+ },
213
+ skippedCount: {
214
+ type: "integer",
215
+ default: 0
216
+ },
217
+ totalTargets: {
218
+ type: "integer",
219
+ default: 0
220
+ },
221
+ diffs: {
222
+ type: "json"
223
+ },
224
+ isRolledBack: {
225
+ type: "boolean",
226
+ default: false
227
+ },
228
+ rolledBackAt: {
229
+ type: "datetime"
230
+ },
231
+ rolledBackBy: {
232
+ type: "string"
233
+ },
234
+ executedAt: {
235
+ type: "datetime"
236
+ }
237
+ }
238
+ };
239
+ const contentTypes = {
240
+ "audit-log": {
241
+ schema: auditLog
242
+ }
243
+ };
172
244
  const bulkUpdaterController = ({ strapi: strapi2 }) => ({
173
245
  async getModels(ctx) {
174
246
  try {
@@ -211,7 +283,7 @@ const bulkUpdaterController = ({ strapi: strapi2 }) => ({
211
283
  return;
212
284
  }
213
285
  }
214
- ctx.body = data;
286
+ ctx.body = { success: true, ...data };
215
287
  } catch (err) {
216
288
  ctx.status = 500;
217
289
  ctx.body = { success: false, error: err.message };
@@ -660,9 +732,69 @@ async function executeBatchInternal(strapi2, {
660
732
  try {
661
733
  const currentEntry = await getDocumentDeduplicated(target.uid, target.documentId);
662
734
  const entryLabel = currentEntry?.name || currentEntry?.title || currentEntry?.email || `Entry #${target.documentId}`;
735
+ const isNested = attribute.includes(".");
736
+ let isComponentNested = false;
737
+ let parentKey = "";
738
+ let childKey = "";
739
+ if (isNested) {
740
+ const parts = attribute.split(".");
741
+ parentKey = parts[0];
742
+ childKey = parts[1];
743
+ const parentAttrDef = contentType?.attributes?.[parentKey];
744
+ if (parentAttrDef?.type === "component" && !parentAttrDef.repeatable && parentAttrDef.component) {
745
+ const compSchema = strapi2.components[parentAttrDef.component];
746
+ if (compSchema?.attributes?.[childKey]) {
747
+ isComponentNested = true;
748
+ }
749
+ }
750
+ }
663
751
  const attrDef = contentType?.attributes?.[attribute];
664
- const hasAttribute = Boolean(attrDef);
752
+ const hasAttribute = Boolean(attrDef) || isComponentNested;
665
753
  if (hasAttribute) {
754
+ if (isComponentNested) {
755
+ const currentEntryWithPopulate = await strapi2.documents(target.uid).findOne({
756
+ documentId: target.documentId,
757
+ populate: [parentKey]
758
+ });
759
+ const existingParent = currentEntryWithPopulate?.[parentKey] || {};
760
+ const currentVal2 = existingParent?.[childKey];
761
+ let newValue2 = value;
762
+ if (regexObj && typeof currentVal2 === "string") {
763
+ newValue2 = currentVal2.replace(regexObj, value);
764
+ }
765
+ const updatedParent = {
766
+ ...existingParent,
767
+ [childKey]: newValue2
768
+ };
769
+ await strapi2.documents(target.uid).update({
770
+ documentId: target.documentId,
771
+ data: {
772
+ [parentKey]: updatedParent
773
+ }
774
+ });
775
+ const isDraftAndPublishComp = Boolean(contentType?.options?.draftAndPublish);
776
+ if (publishOnApproval && isDraftAndPublishComp) {
777
+ try {
778
+ await strapi2.documents(target.uid).publish({
779
+ documentId: target.documentId
780
+ });
781
+ } catch (publishErr) {
782
+ strapi2.log.warn(`[bulk-updater] Failed to auto-publish ${target.uid}/${target.documentId}:`, publishErr);
783
+ }
784
+ }
785
+ invalidateCollectionCache(target.uid);
786
+ return {
787
+ uid: target.uid,
788
+ collectionName: displayName,
789
+ documentId: target.documentId,
790
+ label: entryLabel,
791
+ attribute,
792
+ oldValue: currentVal2 ?? null,
793
+ newValue: newValue2,
794
+ status: "updated",
795
+ message: `Successfully updated nested field ${attribute}`
796
+ };
797
+ }
666
798
  if (attrDef.type === "relation") {
667
799
  const isMulti = attrDef.relation === "oneToMany" || attrDef.relation === "manyToMany";
668
800
  const currentEntryWithPopulate = await strapi2.documents(target.uid).findOne({
@@ -904,9 +1036,24 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
904
1036
  const isSingleType = contentType.kind === "singleType";
905
1037
  const rawAttrs = contentType.attributes || {};
906
1038
  const relationsByAttr = {};
1039
+ const nestedComponentAttrs = [];
907
1040
  const validAttrs = Object.keys(rawAttrs).filter((attrKey) => {
908
1041
  if (systemFields.includes(attrKey)) return false;
909
- if (rawAttrs[attrKey].type === "component" || rawAttrs[attrKey].type === "dynamiczone") return false;
1042
+ if (rawAttrs[attrKey].type === "dynamiczone") return false;
1043
+ if (rawAttrs[attrKey].type === "component") {
1044
+ const compDef = rawAttrs[attrKey];
1045
+ if (!compDef.repeatable && compDef.component) {
1046
+ const componentSchema = strapi2.components[compDef.component];
1047
+ if (componentSchema && componentSchema.attributes) {
1048
+ for (const [subKey, subAttr] of Object.entries(componentSchema.attributes)) {
1049
+ if (systemFields.includes(subKey)) continue;
1050
+ if (["component", "dynamiczone", "relation"].includes(subAttr.type)) continue;
1051
+ nestedComponentAttrs.push(`${attrKey}.${subKey}`);
1052
+ }
1053
+ }
1054
+ }
1055
+ return false;
1056
+ }
910
1057
  if (rawAttrs[attrKey].type === "relation") {
911
1058
  const rel = rawAttrs[attrKey];
912
1059
  const targetUid = rel.target;
@@ -926,7 +1073,8 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
926
1073
  }
927
1074
  return true;
928
1075
  });
929
- validAttrs.forEach((attr) => allAttributesSet.add(attr));
1076
+ const allColAttrs = [...validAttrs, ...nestedComponentAttrs];
1077
+ allColAttrs.forEach((attr) => allAttributesSet.add(attr));
930
1078
  metaByUid[uid] = {
931
1079
  uid,
932
1080
  kind: contentType.kind,
@@ -934,7 +1082,7 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
934
1082
  singularName: contentType.info?.singularName || uid,
935
1083
  pluralName: contentType.info?.pluralName || uid,
936
1084
  displayName: contentType.info?.displayName || uid,
937
- attributes: validAttrs,
1085
+ attributes: allColAttrs,
938
1086
  relations: relationsByAttr
939
1087
  };
940
1088
  }
@@ -963,8 +1111,17 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
963
1111
  let entries = [];
964
1112
  let totalCount = 0;
965
1113
  try {
1114
+ const ctAttrs = allContentTypes[uid]?.attributes || {};
966
1115
  const relKeys = Object.keys(meta.relations || {});
967
- const populateOption = relKeys.length > 0 ? relKeys.reduce((acc, k) => ({ ...acc, [k]: true }), {}) : void 0;
1116
+ const compKeys = Object.keys(ctAttrs).filter((k) => ctAttrs[k].type === "component");
1117
+ const populateOptionObj = {};
1118
+ relKeys.forEach((k) => {
1119
+ populateOptionObj[k] = true;
1120
+ });
1121
+ compKeys.forEach((k) => {
1122
+ populateOptionObj[k] = true;
1123
+ });
1124
+ const populateOption = Object.keys(populateOptionObj).length > 0 ? populateOptionObj : void 0;
968
1125
  if (meta.isSingleType) {
969
1126
  const singleDoc = await strapi2.documents(uid).findFirst({
970
1127
  populate: populateOption
@@ -1042,7 +1199,7 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1042
1199
  if (!contentType) {
1043
1200
  throw new Error(`Collection with UID "${uid}" does not exist.`);
1044
1201
  }
1045
- const limit = Math.min(Math.max(Number(pageSize) || 10, 1), 100);
1202
+ const limit = Math.min(Math.max(Number(pageSize) || 10, 1), 500);
1046
1203
  const currentPage = Math.max(Number(page) || 1, 1);
1047
1204
  const start = (currentPage - 1) * limit;
1048
1205
  const isSingleType = contentType.kind === "singleType";
@@ -1058,8 +1215,118 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1058
1215
  };
1059
1216
  }
1060
1217
  let result;
1218
+ const filters2 = {};
1219
+ const attrs = contentType.attributes || {};
1220
+ let colHasAttribute = false;
1221
+ let isNestedAttr = false;
1222
+ let parentKey = "";
1223
+ let childKey = "";
1224
+ if (attribute) {
1225
+ if (attribute.includes(".")) {
1226
+ [parentKey, childKey] = attribute.split(".");
1227
+ if (attrs[parentKey]?.type === "component" && !attrs[parentKey].repeatable && attrs[parentKey].component) {
1228
+ const compSchema = strapi2.components[attrs[parentKey].component];
1229
+ if (compSchema?.attributes?.[childKey]) {
1230
+ colHasAttribute = true;
1231
+ isNestedAttr = true;
1232
+ }
1233
+ }
1234
+ } else if (attrs[attribute]) {
1235
+ colHasAttribute = true;
1236
+ }
1237
+ }
1238
+ const normOp = (filterOperator || "").toLowerCase().replace(/_/g, "");
1239
+ if (normOp && normOp !== "none") {
1240
+ if (!colHasAttribute) {
1241
+ result = {
1242
+ uid,
1243
+ displayName: contentType.info?.displayName || uid,
1244
+ isSingleType,
1245
+ entries: [],
1246
+ pagination: {
1247
+ page: 1,
1248
+ pageSize: limit,
1249
+ total: 0,
1250
+ pageCount: 0
1251
+ }
1252
+ };
1253
+ queryCache.set(cacheKey, { signature, data: result, timestamp: Date.now() });
1254
+ return { ...result, cached: false, etag };
1255
+ }
1256
+ let condition = void 0;
1257
+ const q = (filterQuery || "").trim();
1258
+ if (normOp === "empty") {
1259
+ condition = { $or: [{ $null: true }, { $eq: "" }] };
1260
+ } else if (normOp === "notempty") {
1261
+ condition = { $and: [{ $notNull: true }, { $ne: "" }] };
1262
+ } else if (q !== "") {
1263
+ switch (normOp) {
1264
+ case "equals":
1265
+ condition = { $containsi: q };
1266
+ break;
1267
+ case "notequals":
1268
+ condition = { $notContainsi: q };
1269
+ break;
1270
+ case "contains":
1271
+ condition = { $containsi: q };
1272
+ break;
1273
+ case "notcontains":
1274
+ condition = { $notContainsi: q };
1275
+ break;
1276
+ case "startswith":
1277
+ condition = { $startsWith: q };
1278
+ break;
1279
+ case "endswith":
1280
+ condition = { $endsWith: q };
1281
+ break;
1282
+ }
1283
+ }
1284
+ if (condition !== void 0) {
1285
+ if (isNestedAttr) {
1286
+ filters2[parentKey] = {
1287
+ [childKey]: condition
1288
+ };
1289
+ } else {
1290
+ if (normOp === "empty") {
1291
+ filters2.$or = [
1292
+ { [attribute]: { $null: true } },
1293
+ { [attribute]: { $eq: "" } }
1294
+ ];
1295
+ } else if (normOp === "notempty") {
1296
+ filters2[attribute] = { $notNull: true, $ne: "" };
1297
+ } else {
1298
+ filters2[attribute] = condition;
1299
+ }
1300
+ }
1301
+ }
1302
+ }
1303
+ if (search && search.trim() !== "") {
1304
+ const q = search.trim();
1305
+ const searchableAttrs = Object.keys(attrs).filter(
1306
+ (k) => ["string", "text", "email", "uid"].includes(attrs[k]?.type)
1307
+ );
1308
+ if (searchableAttrs.length > 0) {
1309
+ const orList = searchableAttrs.slice(0, 6).map((k) => ({
1310
+ [k]: { $containsi: q }
1311
+ }));
1312
+ if (Object.keys(filters2).length > 0) {
1313
+ const existingFilters = { ...filters2 };
1314
+ Object.keys(filters2).forEach((k) => delete filters2[k]);
1315
+ filters2.$and = [existingFilters, { $or: orList }];
1316
+ } else {
1317
+ filters2.$or = orList;
1318
+ }
1319
+ }
1320
+ }
1061
1321
  if (isSingleType) {
1062
- const singleDoc = await strapi2.documents(uid).findFirst();
1322
+ let singleDoc = null;
1323
+ try {
1324
+ singleDoc = await strapi2.documents(uid).findFirst({
1325
+ filters: Object.keys(filters2).length > 0 ? filters2 : void 0
1326
+ });
1327
+ } catch {
1328
+ singleDoc = null;
1329
+ }
1063
1330
  result = {
1064
1331
  uid,
1065
1332
  displayName: contentType.info?.displayName || uid,
@@ -1073,55 +1340,23 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1073
1340
  }
1074
1341
  };
1075
1342
  } else {
1076
- const filters2 = {};
1077
- if (attribute && filterOperator && filterQuery !== void 0 && filterQuery !== "") {
1078
- switch (filterOperator) {
1079
- case "equals":
1080
- filters2[attribute] = { $eq: filterQuery };
1081
- break;
1082
- case "notEquals":
1083
- filters2[attribute] = { $ne: filterQuery };
1084
- break;
1085
- case "contains":
1086
- filters2[attribute] = { $containsi: filterQuery };
1087
- break;
1088
- case "notContains":
1089
- filters2[attribute] = { $notContainsi: filterQuery };
1090
- break;
1091
- case "startsWith":
1092
- filters2[attribute] = { $startsWith: filterQuery };
1093
- break;
1094
- case "endsWith":
1095
- filters2[attribute] = { $endsWith: filterQuery };
1096
- break;
1097
- case "empty":
1098
- filters2[attribute] = { $null: true };
1099
- break;
1100
- case "notEmpty":
1101
- filters2[attribute] = { $notNull: true };
1102
- break;
1103
- }
1104
- }
1105
- if (search && search.trim() !== "") {
1106
- const stringAttrs = Object.keys(contentType.attributes || {}).filter(
1107
- (k) => contentType.attributes[k].type === "string" || contentType.attributes[k].type === "text" || contentType.attributes[k].type === "email"
1108
- );
1109
- if (stringAttrs.length > 0) {
1110
- filters2.$or = stringAttrs.slice(0, 5).map((attr) => ({
1111
- [attr]: { $containsi: search.trim() }
1112
- }));
1113
- }
1114
- }
1115
1343
  const sortClause = {};
1116
- if (sortField && contentType.attributes?.[sortField]) {
1344
+ if (sortField && sortField.includes(".")) {
1345
+ const [parent, child] = sortField.split(".");
1346
+ if (contentType.attributes?.[parent]?.type === "component") {
1347
+ sortClause[parent] = { [child]: sortOrder };
1348
+ } else {
1349
+ sortClause["createdAt"] = "desc";
1350
+ }
1351
+ } else if (sortField && contentType.attributes?.[sortField]) {
1117
1352
  sortClause[sortField] = sortOrder;
1118
1353
  } else {
1119
1354
  sortClause["createdAt"] = "desc";
1120
1355
  }
1121
- const relationAttrs = Object.keys(contentType.attributes || {}).filter(
1122
- (k) => contentType.attributes[k].type === "relation"
1356
+ const populateAttrs = Object.keys(contentType.attributes || {}).filter(
1357
+ (k) => contentType.attributes[k].type === "relation" || contentType.attributes[k].type === "component"
1123
1358
  );
1124
- const populateOption = relationAttrs.length > 0 ? relationAttrs.reduce((acc, k) => ({ ...acc, [k]: true }), {}) : void 0;
1359
+ const populateOption = populateAttrs.length > 0 ? populateAttrs.reduce((acc, k) => ({ ...acc, [k]: true }), {}) : void 0;
1125
1360
  const [entries, totalEntries] = await Promise.all([
1126
1361
  strapi2.documents(uid).findMany({
1127
1362
  start,
@@ -1460,6 +1695,9 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1460
1695
  modelsCache.clear();
1461
1696
  staticSchemaCache = null;
1462
1697
  }
1698
+ },
1699
+ invalidateCollectionCache(uid) {
1700
+ invalidateCollectionCache(uid);
1463
1701
  }
1464
1702
  };
1465
1703
  };
@@ -1479,6 +1717,7 @@ const DEFAULT_FIELDS = {
1479
1717
  executedAt: "executedAt"
1480
1718
  };
1481
1719
  const DEFAULT_CANDIDATE_TYPES = [
1720
+ "plugin::bulk-updater.audit-log",
1482
1721
  "api::audit-log.audit-log",
1483
1722
  "api::audit.audit",
1484
1723
  "api::bulk-audit-log.bulk-audit-log"
@@ -1500,6 +1739,7 @@ const auditService = ({ strapi: strapi2 }) => ({
1500
1739
  * 1. Explicit consumer custom schema (full declarative control)
1501
1740
  * 2. Zero-config convention auto-detection (api::audit-log.audit-log)
1502
1741
  * 3. Strapi Enterprise native audit log bridge
1742
+ * 4. Zero-config plugin core store (works out of the box in 100% of Strapi installations)
1503
1743
  */
1504
1744
  getAuditConfig() {
1505
1745
  const rawConfig = strapi2.config.get("plugin::bulk-updater.audit");
@@ -1553,10 +1793,9 @@ const auditService = ({ strapi: strapi2 }) => ({
1553
1793
  };
1554
1794
  }
1555
1795
  return {
1556
- enabled: false,
1557
- mode: "disabled",
1558
- isEnterpriseNativeAvailable: false,
1559
- error: "No audit collection configured or detected. To enable rollback, provide a collection or use Strapi Enterprise."
1796
+ enabled: true,
1797
+ mode: "store",
1798
+ isEnterpriseNativeAvailable: false
1560
1799
  };
1561
1800
  },
1562
1801
  /**
@@ -1601,7 +1840,40 @@ const auditService = ({ strapi: strapi2 }) => ({
1601
1840
  },
1602
1841
  user
1603
1842
  );
1604
- if (!config.enabled || !config.contentType || !config.fields) {
1843
+ if (!config.enabled) {
1844
+ return null;
1845
+ }
1846
+ if (config.mode === "store") {
1847
+ try {
1848
+ const store = strapi2.store({ type: "plugin", name: "bulk-updater", key: "audit-history" });
1849
+ const stored = await store.get({}) || { history: [] };
1850
+ const history = Array.isArray(stored.history) ? stored.history : [];
1851
+ const newRecord = {
1852
+ id: batchSummary.batchId,
1853
+ documentId: batchSummary.batchId,
1854
+ batchId: batchSummary.batchId,
1855
+ attribute: batchSummary.attribute,
1856
+ value: batchSummary.value,
1857
+ userEmail: user?.email || "admin@strapi.io",
1858
+ userName: user?.firstname ? `${user.firstname} ${user.lastname || ""}`.trim() : user?.email || "Admin",
1859
+ updatedCount: batchSummary.updatedCount,
1860
+ skippedCount: batchSummary.skippedCount,
1861
+ totalTargets: batchSummary.totalTargets,
1862
+ diffs: results,
1863
+ isRolledBack: false,
1864
+ rolledBackAt: null,
1865
+ rolledBackBy: null,
1866
+ executedAt: (/* @__PURE__ */ new Date()).toISOString()
1867
+ };
1868
+ const updatedHistory = [newRecord, ...history].slice(0, 200);
1869
+ await store.set({ value: { history: updatedHistory } });
1870
+ return newRecord;
1871
+ } catch (err) {
1872
+ strapi2.log.error(`[bulk-updater] Failed to record audit log in core store: ${err.message}`);
1873
+ return null;
1874
+ }
1875
+ }
1876
+ if (!config.contentType || !config.fields) {
1605
1877
  return null;
1606
1878
  }
1607
1879
  const f = config.fields;
@@ -1642,6 +1914,25 @@ const auditService = ({ strapi: strapi2 }) => ({
1642
1914
  history: []
1643
1915
  };
1644
1916
  }
1917
+ if (config.mode === "store") {
1918
+ try {
1919
+ const store = strapi2.store({ type: "plugin", name: "bulk-updater", key: "audit-history" });
1920
+ const stored = await store.get({}) || { history: [] };
1921
+ const history = Array.isArray(stored.history) ? stored.history : [];
1922
+ return {
1923
+ enabled: true,
1924
+ mode: "store",
1925
+ isEnterpriseNativeAvailable: config.isEnterpriseNativeAvailable,
1926
+ history
1927
+ };
1928
+ } catch (err) {
1929
+ return {
1930
+ enabled: false,
1931
+ message: `Failed to read audit history from core store: ${err.message}`,
1932
+ history: []
1933
+ };
1934
+ }
1935
+ }
1645
1936
  if (!config.enabled || !config.contentType || !config.fields) {
1646
1937
  return {
1647
1938
  enabled: false,
@@ -1687,27 +1978,9 @@ const auditService = ({ strapi: strapi2 }) => ({
1687
1978
  }
1688
1979
  },
1689
1980
  /**
1690
- * Performs deterministic rollback of a batch
1981
+ * Helper to revert diff items back to original values
1691
1982
  */
1692
- async rollbackBatch(batchId, currentUser) {
1693
- const config = this.getAuditConfig();
1694
- if (!config.enabled || !config.contentType || !config.fields) {
1695
- throw new Error("Audit trail is not configured or disabled.");
1696
- }
1697
- const f = config.fields;
1698
- const records = await strapi2.documents(config.contentType).findMany({
1699
- filters: {
1700
- [f.batchId]: { $eq: batchId }
1701
- }
1702
- });
1703
- const record = records?.[0];
1704
- if (!record) {
1705
- throw new Error(`Batch record with ID "${batchId}" was not found in audit trail.`);
1706
- }
1707
- if (record[f.isRolledBack]) {
1708
- throw new Error(`Batch "${batchId}" has already been rolled back.`);
1709
- }
1710
- const diffs = typeof record[f.diffs] === "string" ? JSON.parse(record[f.diffs]) : record[f.diffs] || [];
1983
+ async revertDiffItems(diffs) {
1711
1984
  let revertedCount = 0;
1712
1985
  const rollbackDetails = [];
1713
1986
  for (const item of diffs) {
@@ -1721,12 +1994,41 @@ const auditService = ({ strapi: strapi2 }) => ({
1721
1994
  } else {
1722
1995
  rollbackPayload = item.oldValue;
1723
1996
  }
1724
- await strapi2.documents(item.uid).update({
1725
- documentId: item.documentId,
1726
- data: {
1727
- [item.attribute]: rollbackPayload
1997
+ if (item.attribute.includes(".")) {
1998
+ const [parentKey, childKey] = item.attribute.split(".");
1999
+ const ct2 = strapi2.contentTypes[item.uid];
2000
+ if (ct2?.attributes?.[parentKey]?.type === "component") {
2001
+ const currentDoc = await strapi2.documents(item.uid).findOne({
2002
+ documentId: item.documentId,
2003
+ populate: [parentKey]
2004
+ });
2005
+ const currentParent = currentDoc?.[parentKey] || {};
2006
+ const restoredParent = {
2007
+ ...currentParent,
2008
+ [childKey]: rollbackPayload
2009
+ };
2010
+ await strapi2.documents(item.uid).update({
2011
+ documentId: item.documentId,
2012
+ data: {
2013
+ [parentKey]: restoredParent
2014
+ }
2015
+ });
2016
+ } else {
2017
+ await strapi2.documents(item.uid).update({
2018
+ documentId: item.documentId,
2019
+ data: {
2020
+ [item.attribute]: rollbackPayload
2021
+ }
2022
+ });
1728
2023
  }
1729
- });
2024
+ } else {
2025
+ await strapi2.documents(item.uid).update({
2026
+ documentId: item.documentId,
2027
+ data: {
2028
+ [item.attribute]: rollbackPayload
2029
+ }
2030
+ });
2031
+ }
1730
2032
  const ct = strapi2.contentTypes[item.uid];
1731
2033
  if (ct?.options?.draftAndPublish) {
1732
2034
  try {
@@ -1737,7 +2039,10 @@ const auditService = ({ strapi: strapi2 }) => ({
1737
2039
  }
1738
2040
  }
1739
2041
  try {
1740
- strapi2.plugin("bulk-updater").service("bulkUpdater").invalidateCollectionCache(item.uid);
2042
+ const bulkService = strapi2.plugin("bulk-updater").service("bulkUpdater");
2043
+ if (bulkService?.invalidateCache) {
2044
+ bulkService.invalidateCache(item.uid);
2045
+ }
1741
2046
  } catch {
1742
2047
  }
1743
2048
  revertedCount++;
@@ -1760,6 +2065,78 @@ const auditService = ({ strapi: strapi2 }) => ({
1760
2065
  }
1761
2066
  }
1762
2067
  }
2068
+ return { revertedCount, rollbackDetails };
2069
+ },
2070
+ /**
2071
+ * Performs deterministic rollback of a batch
2072
+ */
2073
+ async rollbackBatch(batchId, currentUser) {
2074
+ const config = this.getAuditConfig();
2075
+ if (!config.enabled) {
2076
+ throw new Error("Audit trail is not configured or disabled.");
2077
+ }
2078
+ if (config.mode === "store") {
2079
+ const store = strapi2.store({ type: "plugin", name: "bulk-updater", key: "audit-history" });
2080
+ const stored = await store.get({}) || { history: [] };
2081
+ const history = Array.isArray(stored.history) ? stored.history : [];
2082
+ const recordIndex = history.findIndex((r) => r.batchId === batchId);
2083
+ if (recordIndex === -1) {
2084
+ throw new Error(`Batch record with ID "${batchId}" was not found in audit trail.`);
2085
+ }
2086
+ const record2 = history[recordIndex];
2087
+ if (record2.isRolledBack) {
2088
+ throw new Error(`Batch "${batchId}" has already been rolled back.`);
2089
+ }
2090
+ const diffs2 = Array.isArray(record2.diffs) ? record2.diffs : [];
2091
+ const { revertedCount: revertedCount2, rollbackDetails: rollbackDetails2 } = await this.revertDiffItems(diffs2);
2092
+ record2.isRolledBack = true;
2093
+ record2.rolledBackAt = (/* @__PURE__ */ new Date()).toISOString();
2094
+ record2.rolledBackBy = currentUser?.email || "Admin";
2095
+ history[recordIndex] = record2;
2096
+ await store.set({ value: { history } });
2097
+ try {
2098
+ const affectedUids = new Set(rollbackDetails2.map((d) => d.uid));
2099
+ const bulkService = strapi2.plugin("bulk-updater").service("bulkUpdater");
2100
+ affectedUids.forEach((uid) => {
2101
+ if (uid && bulkService?.invalidateCache) {
2102
+ bulkService.invalidateCache(uid);
2103
+ }
2104
+ });
2105
+ } catch {
2106
+ }
2107
+ await this.recordToEnterpriseNative(
2108
+ "bulk-updater.rollback",
2109
+ {
2110
+ batchId,
2111
+ revertedCount: revertedCount2
2112
+ },
2113
+ currentUser
2114
+ );
2115
+ return {
2116
+ success: true,
2117
+ batchId,
2118
+ revertedCount: revertedCount2,
2119
+ details: rollbackDetails2
2120
+ };
2121
+ }
2122
+ if (!config.contentType || !config.fields) {
2123
+ throw new Error("Audit trail is not configured or disabled.");
2124
+ }
2125
+ const f = config.fields;
2126
+ const records = await strapi2.documents(config.contentType).findMany({
2127
+ filters: {
2128
+ [f.batchId]: { $eq: batchId }
2129
+ }
2130
+ });
2131
+ const record = records?.[0];
2132
+ if (!record) {
2133
+ throw new Error(`Batch record with ID "${batchId}" was not found in audit trail.`);
2134
+ }
2135
+ if (record[f.isRolledBack]) {
2136
+ throw new Error(`Batch "${batchId}" has already been rolled back.`);
2137
+ }
2138
+ const diffs = typeof record[f.diffs] === "string" ? JSON.parse(record[f.diffs]) : record[f.diffs] || [];
2139
+ const { revertedCount, rollbackDetails } = await this.revertDiffItems(diffs);
1763
2140
  const updateData = {};
1764
2141
  if (f.isRolledBack) updateData[f.isRolledBack] = true;
1765
2142
  if (f.rolledBackAt) updateData[f.rolledBackAt] = (/* @__PURE__ */ new Date()).toISOString();
@@ -2117,10 +2494,24 @@ ${sampleRow.map(escapeCsvValue).join(",")}
2117
2494
  const attrs = ct.attributes || {};
2118
2495
  const excluded = ["createdBy", "updatedBy", "localizations"];
2119
2496
  const exportFields = ["documentId"];
2497
+ const compFieldsToPopulate = [];
2120
2498
  for (const [attrName, attrDef] of Object.entries(attrs)) {
2121
2499
  if (excluded.includes(attrName)) continue;
2122
- if (["component", "dynamiczone", "media"].includes(attrDef.type)) continue;
2123
- if (attrDef.type === "relation") continue;
2500
+ if (["dynamiczone", "media", "relation"].includes(attrDef.type)) continue;
2501
+ if (attrDef.type === "component") {
2502
+ if (!attrDef.repeatable && attrDef.component) {
2503
+ const comp = strapi2.components[attrDef.component];
2504
+ if (comp?.attributes) {
2505
+ compFieldsToPopulate.push(attrName);
2506
+ for (const [subKey, subDef] of Object.entries(comp.attributes)) {
2507
+ if (excluded.includes(subKey)) continue;
2508
+ if (["component", "dynamiczone", "media", "relation"].includes(subDef.type)) continue;
2509
+ exportFields.push(`${attrName}.${subKey}`);
2510
+ }
2511
+ }
2512
+ }
2513
+ continue;
2514
+ }
2124
2515
  exportFields.push(attrName);
2125
2516
  }
2126
2517
  const filters2 = {};
@@ -2164,16 +2555,25 @@ ${sampleRow.map(escapeCsvValue).join(",")}
2164
2555
  }
2165
2556
  }
2166
2557
  }
2558
+ const populateOption = compFieldsToPopulate.length > 0 ? compFieldsToPopulate.reduce((acc, k) => ({ ...acc, [k]: true }), {}) : void 0;
2167
2559
  const entries = await strapi2.documents(uid).findMany({
2168
2560
  filters: Object.keys(filters2).length > 0 ? filters2 : void 0,
2169
2561
  limit: options.limit || 5e3,
2170
- sort: { createdAt: "desc" }
2562
+ sort: { createdAt: "desc" },
2563
+ populate: populateOption
2171
2564
  });
2172
2565
  const lines = [];
2173
2566
  lines.push(exportFields.map(escapeCsvValue).join(","));
2174
2567
  for (const entry of entries || []) {
2175
2568
  const rowValues = exportFields.map((field) => {
2176
- return escapeCsvValue(entry[field]);
2569
+ let val;
2570
+ if (field.includes(".")) {
2571
+ const [parent, child] = field.split(".");
2572
+ val = entry[parent]?.[child];
2573
+ } else {
2574
+ val = entry[field];
2575
+ }
2576
+ return escapeCsvValue(val);
2177
2577
  });
2178
2578
  lines.push(rowValues.join(","));
2179
2579
  }
@@ -2259,6 +2659,29 @@ ${sampleRow.map(escapeCsvValue).join(",")}
2259
2659
  const payload = {};
2260
2660
  for (const [key, rawVal] of Object.entries(row)) {
2261
2661
  if (key === "documentId" || key === "id") continue;
2662
+ if (key.includes(".")) {
2663
+ const [parent, child] = key.split(".");
2664
+ const parentDef = attrs[parent];
2665
+ if (parentDef?.type === "component" && !parentDef.repeatable && parentDef.component) {
2666
+ const comp = strapi2.components[parentDef.component];
2667
+ const childDef = comp?.attributes?.[child];
2668
+ if (childDef) {
2669
+ if (!payload[parent]) payload[parent] = {};
2670
+ let val = rawVal;
2671
+ if (rawVal === "" || rawVal === null || rawVal === void 0) {
2672
+ val = null;
2673
+ } else if (childDef.type === "integer" || childDef.type === "biginteger") {
2674
+ val = parseInt(rawVal, 10);
2675
+ } else if (childDef.type === "float" || childDef.type === "decimal") {
2676
+ val = parseFloat(rawVal);
2677
+ } else if (childDef.type === "boolean") {
2678
+ val = rawVal.toLowerCase() === "true" || rawVal === "1";
2679
+ }
2680
+ payload[parent][child] = val;
2681
+ }
2682
+ }
2683
+ continue;
2684
+ }
2262
2685
  const attrDef = attrs[key];
2263
2686
  if (!attrDef) continue;
2264
2687
  if (rawVal === "" || rawVal === null || rawVal === void 0) {
@@ -2310,9 +2733,15 @@ ${sampleRow.map(escapeCsvValue).join(",")}
2310
2733
  });
2311
2734
  continue;
2312
2735
  }
2736
+ const mergedPayload = { ...payload };
2737
+ for (const key of Object.keys(payload)) {
2738
+ if (attrs[key]?.type === "component" && typeof payload[key] === "object" && payload[key] !== null) {
2739
+ mergedPayload[key] = { ...existing[key] || {}, ...payload[key] };
2740
+ }
2741
+ }
2313
2742
  await strapi2.documents(uid).update({
2314
2743
  documentId: docId,
2315
- data: payload
2744
+ data: mergedPayload
2316
2745
  });
2317
2746
  if (publish && ct.options?.draftAndPublish) {
2318
2747
  try {
@@ -2339,9 +2768,15 @@ ${sampleRow.map(escapeCsvValue).join(",")}
2339
2768
  documentId: docId
2340
2769
  });
2341
2770
  if (existing) {
2771
+ const mergedPayload = { ...payload };
2772
+ for (const key of Object.keys(payload)) {
2773
+ if (attrs[key]?.type === "component" && typeof payload[key] === "object" && payload[key] !== null) {
2774
+ mergedPayload[key] = { ...existing[key] || {}, ...payload[key] };
2775
+ }
2776
+ }
2342
2777
  await strapi2.documents(uid).update({
2343
2778
  documentId: docId,
2344
- data: payload
2779
+ data: mergedPayload
2345
2780
  });
2346
2781
  if (publish && ct.options?.draftAndPublish) {
2347
2782
  try {
@@ -31944,6 +32379,7 @@ const index = {
31944
32379
  });
31945
32380
  },
31946
32381
  routes,
32382
+ contentTypes,
31947
32383
  controllers: {
31948
32384
  bulkUpdater: bulkUpdaterController,
31949
32385
  audit: auditController,