les-revisions 1.0.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.
Files changed (41) hide show
  1. package/README.md +3 -0
  2. package/dist/_chunks/App-BkgPjE-W.js +726 -0
  3. package/dist/_chunks/App-CYbCF5EG.mjs +726 -0
  4. package/dist/_chunks/en-Cx-m8SNd.mjs +63 -0
  5. package/dist/_chunks/en-Edhi5HYx.js +63 -0
  6. package/dist/_chunks/fr-DXjiOCnX.js +63 -0
  7. package/dist/_chunks/fr-Dbv27Oj8.mjs +63 -0
  8. package/dist/_chunks/index-BYJMXObQ.mjs +70 -0
  9. package/dist/_chunks/index-C7ce1PfW.js +69 -0
  10. package/dist/admin/index.js +3 -0
  11. package/dist/admin/index.mjs +4 -0
  12. package/dist/admin/src/components/Initializer.d.ts +5 -0
  13. package/dist/admin/src/components/PluginIcon.d.ts +2 -0
  14. package/dist/admin/src/index.d.ts +10 -0
  15. package/dist/admin/src/pages/App.d.ts +2 -0
  16. package/dist/admin/src/pages/ComparePage.d.ts +2 -0
  17. package/dist/admin/src/pages/EntriesPage.d.ts +2 -0
  18. package/dist/admin/src/pages/HomePage.d.ts +2 -0
  19. package/dist/admin/src/pages/RevisionHistoryPage.d.ts +2 -0
  20. package/dist/admin/src/pluginId.d.ts +1 -0
  21. package/dist/admin/src/utils/api.d.ts +14 -0
  22. package/dist/admin/src/utils/getTranslation.d.ts +2 -0
  23. package/dist/server/index.js +900 -0
  24. package/dist/server/index.mjs +901 -0
  25. package/dist/server/src/bootstrap.d.ts +5 -0
  26. package/dist/server/src/config/index.d.ts +12 -0
  27. package/dist/server/src/content-types/index.d.ts +76 -0
  28. package/dist/server/src/content-types/revision/index.d.ts +74 -0
  29. package/dist/server/src/controllers/index.d.ts +15 -0
  30. package/dist/server/src/controllers/revision.d.ts +41 -0
  31. package/dist/server/src/destroy.d.ts +5 -0
  32. package/dist/server/src/index.d.ts +189 -0
  33. package/dist/server/src/middlewares/index.d.ts +2 -0
  34. package/dist/server/src/policies/index.d.ts +2 -0
  35. package/dist/server/src/register.d.ts +5 -0
  36. package/dist/server/src/routes/admin/index.d.ts +12 -0
  37. package/dist/server/src/routes/content-api/index.d.ts +5 -0
  38. package/dist/server/src/routes/index.d.ts +18 -0
  39. package/dist/server/src/services/index.d.ts +59 -0
  40. package/dist/server/src/services/revision.d.ts +84 -0
  41. package/package.json +69 -0
@@ -0,0 +1,900 @@
1
+ "use strict";
2
+ const REVISION_UID$1 = "plugin::les-revisions.revision";
3
+ const bootstrap = ({ strapi }) => {
4
+ strapi.documents.use(async (context, next) => {
5
+ const { action, uid } = context;
6
+ if (uid === REVISION_UID$1) return next();
7
+ const revisionService2 = strapi.plugin("les-revisions").service("revision");
8
+ if (!revisionService2.isEnabled(uid)) return next();
9
+ const requestCtx = strapi.requestContext?.get?.();
10
+ if (requestCtx?.state?._lesRevisionsSkip) return next();
11
+ const userId = requestCtx?.state?.user?.id ?? null;
12
+ if (action === "create") {
13
+ const result = await next();
14
+ try {
15
+ if (result?.documentId) {
16
+ const populated = await strapi.documents(uid).findOne({
17
+ documentId: result.documentId,
18
+ populate: revisionService2.getPopulate(uid)
19
+ });
20
+ await revisionService2.createRevision({
21
+ contentType: uid,
22
+ documentId: result.documentId,
23
+ snapshot: populated ?? result,
24
+ changeType: "create",
25
+ userId
26
+ });
27
+ }
28
+ } catch (error) {
29
+ strapi.log.error("[les-revisions] Failed to create revision on CREATE:", error);
30
+ }
31
+ return result;
32
+ }
33
+ if (action === "update") {
34
+ let previousState = null;
35
+ const docId = context.params?.documentId;
36
+ if (docId) {
37
+ try {
38
+ previousState = await strapi.documents(uid).findOne({
39
+ documentId: docId,
40
+ populate: revisionService2.getPopulate(uid)
41
+ });
42
+ } catch (error) {
43
+ strapi.log.warn("[les-revisions] Could not capture pre-update state:", error);
44
+ }
45
+ }
46
+ const result = await next();
47
+ if (previousState?.documentId) {
48
+ try {
49
+ await revisionService2.createRevision({
50
+ contentType: uid,
51
+ documentId: previousState.documentId,
52
+ snapshot: previousState,
53
+ changeType: "update",
54
+ userId
55
+ });
56
+ } catch (error) {
57
+ strapi.log.error("[les-revisions] Failed to create revision on UPDATE:", error);
58
+ }
59
+ }
60
+ return result;
61
+ }
62
+ if (action === "delete") {
63
+ let previousState = null;
64
+ const docId = context.params?.documentId;
65
+ if (docId) {
66
+ try {
67
+ previousState = await strapi.documents(uid).findOne({
68
+ documentId: docId,
69
+ populate: revisionService2.getPopulate(uid)
70
+ });
71
+ } catch (error) {
72
+ strapi.log.warn("[les-revisions] Could not capture pre-delete state:", error);
73
+ }
74
+ }
75
+ const result = await next();
76
+ if (previousState?.documentId) {
77
+ try {
78
+ await revisionService2.createRevision({
79
+ contentType: uid,
80
+ documentId: previousState.documentId,
81
+ snapshot: previousState,
82
+ changeType: "delete",
83
+ userId
84
+ });
85
+ } catch (error) {
86
+ strapi.log.error("[les-revisions] Failed to create revision on DELETE:", error);
87
+ }
88
+ }
89
+ return result;
90
+ }
91
+ return next();
92
+ });
93
+ strapi.log.info("[les-revisions] Versioning plugin bootstrapped");
94
+ };
95
+ const destroy = ({ strapi }) => {
96
+ };
97
+ const register = ({ strapi }) => {
98
+ strapi.log.info("[les-revisions] Plugin registered");
99
+ };
100
+ const config = {
101
+ default: {
102
+ maxVersions: 50,
103
+ enabledContentTypes: "*",
104
+ excludeContentTypes: [],
105
+ softDelete: true,
106
+ excludeFields: ["createdBy", "updatedBy"],
107
+ populateDepth: 3
108
+ },
109
+ validator(config2) {
110
+ if (config2.maxVersions !== void 0) {
111
+ if (typeof config2.maxVersions !== "number" || config2.maxVersions < 1) {
112
+ throw new Error("les-revisions: maxVersions must be a positive number");
113
+ }
114
+ }
115
+ if (config2.enabledContentTypes !== void 0 && config2.enabledContentTypes !== "*") {
116
+ if (!Array.isArray(config2.enabledContentTypes)) {
117
+ throw new Error('les-revisions: enabledContentTypes must be an array or "*"');
118
+ }
119
+ }
120
+ if (config2.excludeContentTypes !== void 0) {
121
+ if (!Array.isArray(config2.excludeContentTypes)) {
122
+ throw new Error("les-revisions: excludeContentTypes must be an array");
123
+ }
124
+ }
125
+ if (config2.softDelete !== void 0 && typeof config2.softDelete !== "boolean") {
126
+ throw new Error("les-revisions: softDelete must be a boolean");
127
+ }
128
+ if (config2.populateDepth !== void 0) {
129
+ if (typeof config2.populateDepth !== "number" || config2.populateDepth < 0) {
130
+ throw new Error("les-revisions: populateDepth must be a non-negative number");
131
+ }
132
+ }
133
+ }
134
+ };
135
+ const kind = "collectionType";
136
+ const collectionName = "les_revisions";
137
+ const info = {
138
+ singularName: "revision",
139
+ pluralName: "revisions",
140
+ displayName: "Revision",
141
+ description: "Stores versioned snapshots of content entries"
142
+ };
143
+ const options = {
144
+ draftAndPublish: false
145
+ };
146
+ const pluginOptions = {
147
+ "content-manager": {
148
+ visible: false
149
+ },
150
+ "content-type-builder": {
151
+ visible: false
152
+ }
153
+ };
154
+ const attributes = {
155
+ contentType: {
156
+ type: "string",
157
+ required: true,
158
+ configurable: false
159
+ },
160
+ entryDocumentId: {
161
+ type: "string",
162
+ required: true,
163
+ configurable: false
164
+ },
165
+ version: {
166
+ type: "integer",
167
+ required: true,
168
+ configurable: false,
169
+ "default": 1
170
+ },
171
+ snapshot: {
172
+ type: "json",
173
+ required: true,
174
+ configurable: false
175
+ },
176
+ changeType: {
177
+ type: "enumeration",
178
+ "enum": [
179
+ "create",
180
+ "update",
181
+ "delete"
182
+ ],
183
+ required: true,
184
+ configurable: false
185
+ },
186
+ changedByName: {
187
+ type: "string",
188
+ configurable: false
189
+ },
190
+ changedByEmail: {
191
+ type: "string",
192
+ configurable: false
193
+ },
194
+ changedById: {
195
+ type: "integer",
196
+ configurable: false
197
+ },
198
+ isDeleted: {
199
+ type: "boolean",
200
+ "default": false,
201
+ configurable: false
202
+ },
203
+ metadata: {
204
+ type: "json",
205
+ configurable: false
206
+ }
207
+ };
208
+ const schema = {
209
+ kind,
210
+ collectionName,
211
+ info,
212
+ options,
213
+ pluginOptions,
214
+ attributes
215
+ };
216
+ const revision = {
217
+ schema
218
+ };
219
+ const contentTypes = {
220
+ revision
221
+ };
222
+ const revisionController = ({ strapi }) => ({
223
+ /**
224
+ * GET /revisions
225
+ * Query: contentType, documentId, page?, pageSize?, includeDeleted?
226
+ */
227
+ async find(ctx) {
228
+ const { contentType, documentId, page, pageSize, includeDeleted } = ctx.query;
229
+ if (!contentType || !documentId) {
230
+ return ctx.badRequest("contentType and documentId are required");
231
+ }
232
+ try {
233
+ const result = await strapi.plugin("les-revisions").service("revision").findRevisions(contentType, documentId, {
234
+ page: page ? parseInt(page, 10) : 1,
235
+ pageSize: pageSize ? parseInt(pageSize, 10) : 25,
236
+ includeDeleted: includeDeleted === "true"
237
+ });
238
+ ctx.body = result;
239
+ } catch (error) {
240
+ return ctx.badRequest(error.message);
241
+ }
242
+ },
243
+ /**
244
+ * GET /revisions/:documentId
245
+ */
246
+ async findOne(ctx) {
247
+ const { documentId } = ctx.params;
248
+ try {
249
+ const revision2 = await strapi.plugin("les-revisions").service("revision").findOne(documentId);
250
+ if (!revision2) {
251
+ return ctx.notFound("Revision not found");
252
+ }
253
+ ctx.body = revision2;
254
+ } catch (error) {
255
+ return ctx.badRequest(error.message);
256
+ }
257
+ },
258
+ /**
259
+ * POST /revisions/:documentId/restore
260
+ */
261
+ async restore(ctx) {
262
+ const { documentId } = ctx.params;
263
+ try {
264
+ const restored = await strapi.plugin("les-revisions").service("revision").restore(documentId);
265
+ ctx.body = {
266
+ data: restored,
267
+ message: "Entry restored successfully"
268
+ };
269
+ } catch (error) {
270
+ return ctx.badRequest(error.message);
271
+ }
272
+ },
273
+ /**
274
+ * DELETE /revisions/:documentId
275
+ */
276
+ async delete(ctx) {
277
+ const { documentId } = ctx.params;
278
+ try {
279
+ await strapi.plugin("les-revisions").service("revision").deleteRevision(documentId);
280
+ ctx.body = { message: "Revision deleted successfully" };
281
+ } catch (error) {
282
+ return ctx.badRequest(error.message);
283
+ }
284
+ },
285
+ /**
286
+ * GET /revisions/compare
287
+ * Query: rev1, rev2
288
+ */
289
+ async compare(ctx) {
290
+ const { rev1, rev2 } = ctx.query;
291
+ if (!rev1 || !rev2) {
292
+ return ctx.badRequest("rev1 and rev2 query parameters are required");
293
+ }
294
+ try {
295
+ const result = await strapi.plugin("les-revisions").service("revision").compareRevisions(rev1, rev2);
296
+ ctx.body = result;
297
+ } catch (error) {
298
+ return ctx.badRequest(error.message);
299
+ }
300
+ },
301
+ /**
302
+ * GET /content-types
303
+ */
304
+ async getContentTypes(ctx) {
305
+ try {
306
+ const result = await strapi.plugin("les-revisions").service("revision").getContentTypesWithRevisions();
307
+ ctx.body = result;
308
+ } catch (error) {
309
+ return ctx.badRequest(error.message);
310
+ }
311
+ },
312
+ /**
313
+ * GET /entries
314
+ * Query: contentType, page?, pageSize?
315
+ */
316
+ async getEntries(ctx) {
317
+ const { contentType, page, pageSize } = ctx.query;
318
+ if (!contentType) {
319
+ return ctx.badRequest("contentType is required");
320
+ }
321
+ try {
322
+ const result = await strapi.plugin("les-revisions").service("revision").getEntriesWithRevisions(contentType, {
323
+ page: page ? parseInt(page, 10) : 1,
324
+ pageSize: pageSize ? parseInt(pageSize, 10) : 25
325
+ });
326
+ ctx.body = result;
327
+ } catch (error) {
328
+ return ctx.badRequest(error.message);
329
+ }
330
+ },
331
+ /**
332
+ * GET /config
333
+ */
334
+ async getConfig(ctx) {
335
+ const config2 = strapi.plugin("les-revisions").service("revision").getConfig();
336
+ ctx.body = config2;
337
+ }
338
+ });
339
+ const controllers = {
340
+ revision: revisionController
341
+ };
342
+ const middlewares = {};
343
+ const policies = {};
344
+ const contentAPIRoutes = () => ({
345
+ type: "content-api",
346
+ routes: []
347
+ });
348
+ const adminAPIRoutes = () => ({
349
+ type: "admin",
350
+ routes: [
351
+ {
352
+ method: "GET",
353
+ path: "/content-types",
354
+ handler: "revision.getContentTypes",
355
+ config: { policies: [] }
356
+ },
357
+ {
358
+ method: "GET",
359
+ path: "/entries",
360
+ handler: "revision.getEntries",
361
+ config: { policies: [] }
362
+ },
363
+ {
364
+ method: "GET",
365
+ path: "/revisions/compare",
366
+ handler: "revision.compare",
367
+ config: { policies: [] }
368
+ },
369
+ {
370
+ method: "GET",
371
+ path: "/revisions",
372
+ handler: "revision.find",
373
+ config: { policies: [] }
374
+ },
375
+ {
376
+ method: "GET",
377
+ path: "/revisions/:documentId",
378
+ handler: "revision.findOne",
379
+ config: { policies: [] }
380
+ },
381
+ {
382
+ method: "POST",
383
+ path: "/revisions/:documentId/restore",
384
+ handler: "revision.restore",
385
+ config: { policies: [] }
386
+ },
387
+ {
388
+ method: "DELETE",
389
+ path: "/revisions/:documentId",
390
+ handler: "revision.delete",
391
+ config: { policies: [] }
392
+ },
393
+ {
394
+ method: "GET",
395
+ path: "/config",
396
+ handler: "revision.getConfig",
397
+ config: { policies: [] }
398
+ }
399
+ ]
400
+ });
401
+ const routes = {
402
+ "content-api": contentAPIRoutes,
403
+ admin: adminAPIRoutes
404
+ };
405
+ const REVISION_UID = "plugin::les-revisions.revision";
406
+ function buildDeepPopulate(uid, strapi, depth = 3, visited = /* @__PURE__ */ new Set()) {
407
+ if (depth <= 0 || visited.has(uid)) return true;
408
+ visited.add(uid);
409
+ const model = strapi.contentTypes[uid] || strapi.components?.[uid];
410
+ if (!model) return true;
411
+ const populate = {};
412
+ for (const [key, attribute] of Object.entries(model.attributes)) {
413
+ switch (attribute.type) {
414
+ case "relation": {
415
+ populate[key] = { populate: "*" };
416
+ break;
417
+ }
418
+ case "component": {
419
+ const sub = buildDeepPopulate(attribute.component, strapi, depth - 1, new Set(visited));
420
+ populate[key] = typeof sub === "object" ? { populate: sub } : { populate: "*" };
421
+ break;
422
+ }
423
+ case "dynamiczone": {
424
+ const on = {};
425
+ for (const componentUID of attribute.components || []) {
426
+ const sub = buildDeepPopulate(componentUID, strapi, depth - 1, new Set(visited));
427
+ on[componentUID] = typeof sub === "object" ? { populate: sub } : { populate: "*" };
428
+ }
429
+ populate[key] = { on };
430
+ break;
431
+ }
432
+ case "media": {
433
+ populate[key] = true;
434
+ break;
435
+ }
436
+ }
437
+ }
438
+ return Object.keys(populate).length > 0 ? populate : true;
439
+ }
440
+ function sanitizeSnapshot(data, excludeFields = []) {
441
+ if (!data || typeof data !== "object") return data;
442
+ const skip = /* @__PURE__ */ new Set(["createdBy", "updatedBy", ...excludeFields]);
443
+ const result = {};
444
+ for (const [key, value] of Object.entries(data)) {
445
+ if (skip.has(key)) continue;
446
+ result[key] = value;
447
+ }
448
+ return result;
449
+ }
450
+ function prepareRestoreData(snapshot, uid, strapi) {
451
+ const model = strapi.contentTypes[uid];
452
+ if (!model) return snapshot;
453
+ const internal = /* @__PURE__ */ new Set([
454
+ "id",
455
+ "documentId",
456
+ "createdAt",
457
+ "updatedAt",
458
+ "publishedAt",
459
+ "createdBy",
460
+ "updatedBy",
461
+ "locale",
462
+ "localizations"
463
+ ]);
464
+ const data = {};
465
+ for (const [key, value] of Object.entries(snapshot)) {
466
+ if (internal.has(key)) continue;
467
+ const attr = model.attributes[key];
468
+ if (!attr) continue;
469
+ switch (attr.type) {
470
+ case "relation":
471
+ data[key] = transformRelation(value);
472
+ break;
473
+ case "component":
474
+ data[key] = transformComponent(value, attr, strapi);
475
+ break;
476
+ case "dynamiczone":
477
+ data[key] = transformDynamicZone(value, strapi);
478
+ break;
479
+ case "media":
480
+ data[key] = transformMedia(value);
481
+ break;
482
+ default:
483
+ data[key] = value;
484
+ }
485
+ }
486
+ return data;
487
+ }
488
+ function transformRelation(value) {
489
+ if (value === null || value === void 0) return { set: [] };
490
+ if (Array.isArray(value)) {
491
+ return { set: value.filter((v) => v?.documentId).map((v) => ({ documentId: v.documentId })) };
492
+ }
493
+ if (typeof value === "object" && value.documentId) {
494
+ return { set: [{ documentId: value.documentId }] };
495
+ }
496
+ return { set: [] };
497
+ }
498
+ function transformComponent(value, attr, strapi) {
499
+ if (value === null || value === void 0) return null;
500
+ if (attr.repeatable && Array.isArray(value)) {
501
+ return value.map((v) => cleanComponentData(v, attr.component, strapi));
502
+ }
503
+ return cleanComponentData(value, attr.component, strapi);
504
+ }
505
+ function transformDynamicZone(value, strapi) {
506
+ if (!Array.isArray(value)) return value;
507
+ return value.map((v) => ({
508
+ __component: v.__component,
509
+ ...cleanComponentData(v, v.__component, strapi)
510
+ }));
511
+ }
512
+ function transformMedia(value) {
513
+ if (value === null || value === void 0) return null;
514
+ if (Array.isArray(value)) return value.map((v) => v.id).filter(Boolean);
515
+ if (typeof value === "object") return value.id ?? null;
516
+ return value;
517
+ }
518
+ function cleanComponentData(data, componentUID, strapi) {
519
+ if (!data || typeof data !== "object") return data;
520
+ const component = strapi.components?.[componentUID];
521
+ if (!component) {
522
+ const { id: _id, __component: _c, ...rest } = data;
523
+ return rest;
524
+ }
525
+ const cleaned = {};
526
+ for (const [key, value] of Object.entries(data)) {
527
+ if (key === "id" || key === "__component") continue;
528
+ const attr = component.attributes[key];
529
+ if (!attr) continue;
530
+ switch (attr.type) {
531
+ case "relation":
532
+ cleaned[key] = transformRelation(value);
533
+ break;
534
+ case "component":
535
+ cleaned[key] = transformComponent(value, attr, strapi);
536
+ break;
537
+ case "dynamiczone":
538
+ cleaned[key] = transformDynamicZone(value, strapi);
539
+ break;
540
+ case "media":
541
+ cleaned[key] = transformMedia(value);
542
+ break;
543
+ default:
544
+ cleaned[key] = value;
545
+ }
546
+ }
547
+ return cleaned;
548
+ }
549
+ function computeDiff(oldObj, newObj, path = "") {
550
+ const diffs = [];
551
+ const allKeys = /* @__PURE__ */ new Set([...Object.keys(oldObj ?? {}), ...Object.keys(newObj ?? {})]);
552
+ for (const key of allKeys) {
553
+ const currentPath = path ? `${path}.${key}` : key;
554
+ const oldVal = oldObj?.[key];
555
+ const newVal = newObj?.[key];
556
+ if (oldVal === void 0 && newVal !== void 0) {
557
+ diffs.push({ path: currentPath, type: "added", newValue: newVal });
558
+ } else if (oldVal !== void 0 && newVal === void 0) {
559
+ diffs.push({ path: currentPath, type: "removed", oldValue: oldVal });
560
+ } else if (typeof oldVal === "object" && oldVal !== null && typeof newVal === "object" && newVal !== null && !Array.isArray(oldVal) && !Array.isArray(newVal)) {
561
+ diffs.push(...computeDiff(oldVal, newVal, currentPath));
562
+ } else if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
563
+ diffs.push({ path: currentPath, type: "changed", oldValue: oldVal, newValue: newVal });
564
+ }
565
+ }
566
+ return diffs;
567
+ }
568
+ function getEntryTitle(entry) {
569
+ if (!entry) return "Unknown";
570
+ return entry.title ?? entry.name ?? entry.label ?? entry.slug ?? entry.username ?? entry.email ?? entry.documentId ?? "Untitled";
571
+ }
572
+ const revisionService = ({ strapi }) => ({
573
+ /* ── Configuration ────────────────────────────────────────────────── */
574
+ getConfig() {
575
+ return strapi.config.get("plugin::les-revisions");
576
+ },
577
+ isEnabled(contentTypeUID) {
578
+ if (contentTypeUID === REVISION_UID) return false;
579
+ if (contentTypeUID.startsWith("admin::") || contentTypeUID.startsWith("plugin::") || contentTypeUID.startsWith("strapi::")) {
580
+ return false;
581
+ }
582
+ const config2 = this.getConfig();
583
+ if (config2.excludeContentTypes.includes(contentTypeUID)) return false;
584
+ if (config2.enabledContentTypes === "*") return true;
585
+ return config2.enabledContentTypes.includes(contentTypeUID);
586
+ },
587
+ getPopulate(contentTypeUID) {
588
+ const config2 = this.getConfig();
589
+ return buildDeepPopulate(contentTypeUID, strapi, config2.populateDepth);
590
+ },
591
+ /* ── CRUD ──────────────────────────────────────────────────────────── */
592
+ async createRevision({ contentType, documentId, snapshot, changeType, userId }) {
593
+ const config2 = this.getConfig();
594
+ const existing = await strapi.documents(REVISION_UID).findMany({
595
+ filters: {
596
+ contentType: { $eq: contentType },
597
+ entryDocumentId: { $eq: documentId },
598
+ isDeleted: { $eq: false }
599
+ },
600
+ sort: { version: "desc" },
601
+ limit: 1
602
+ });
603
+ const nextVersion = existing.length > 0 ? (existing[0].version ?? 0) + 1 : 1;
604
+ let changedByName = null;
605
+ let changedByEmail = null;
606
+ let changedById = null;
607
+ if (userId) {
608
+ try {
609
+ const user = await strapi.query("admin::user").findOne({ where: { id: userId } });
610
+ if (user) {
611
+ changedByName = `${user.firstname ?? ""} ${user.lastname ?? ""}`.trim() || null;
612
+ changedByEmail = user.email ?? null;
613
+ changedById = user.id;
614
+ }
615
+ } catch {
616
+ }
617
+ }
618
+ const sanitized = sanitizeSnapshot(snapshot, config2.excludeFields);
619
+ const revision2 = await strapi.documents(REVISION_UID).create({
620
+ data: {
621
+ contentType,
622
+ entryDocumentId: documentId,
623
+ version: nextVersion,
624
+ snapshot: sanitized,
625
+ changeType,
626
+ changedByName,
627
+ changedByEmail,
628
+ changedById,
629
+ isDeleted: false,
630
+ metadata: {
631
+ locale: snapshot?.locale ?? null,
632
+ publishedAt: snapshot?.publishedAt ?? null
633
+ }
634
+ }
635
+ });
636
+ await this.pruneRevisions(contentType, documentId);
637
+ return revision2;
638
+ },
639
+ async findRevisions(contentType, documentId, params = {}) {
640
+ const { page = 1, pageSize = 25, includeDeleted = false } = params;
641
+ const filters = {
642
+ contentType: { $eq: contentType },
643
+ entryDocumentId: { $eq: documentId }
644
+ };
645
+ if (!includeDeleted) {
646
+ filters.isDeleted = { $eq: false };
647
+ }
648
+ const start = (page - 1) * pageSize;
649
+ const [revisions, total] = await Promise.all([
650
+ strapi.documents(REVISION_UID).findMany({
651
+ filters,
652
+ sort: { version: "desc" },
653
+ start,
654
+ limit: pageSize
655
+ }),
656
+ strapi.db.query(REVISION_UID).count({ where: this._filtersToWhere(filters) })
657
+ ]);
658
+ return {
659
+ results: revisions,
660
+ pagination: {
661
+ page,
662
+ pageSize,
663
+ pageCount: Math.ceil(total / pageSize),
664
+ total
665
+ }
666
+ };
667
+ },
668
+ async findOne(revisionDocumentId) {
669
+ return strapi.documents(REVISION_UID).findOne({
670
+ documentId: revisionDocumentId
671
+ });
672
+ },
673
+ /* ── Restore ───────────────────────────────────────────────────────── */
674
+ async restore(revisionDocumentId) {
675
+ const revision2 = await strapi.documents(REVISION_UID).findOne({
676
+ documentId: revisionDocumentId
677
+ });
678
+ if (!revision2) throw new Error("Revision not found");
679
+ const { contentType, entryDocumentId, snapshot } = revision2;
680
+ if (!strapi.contentTypes[contentType]) {
681
+ throw new Error(`Content-type ${contentType} no longer exists`);
682
+ }
683
+ const currentEntry = await strapi.documents(contentType).findOne({
684
+ documentId: entryDocumentId,
685
+ populate: this.getPopulate(contentType)
686
+ });
687
+ if (!currentEntry) {
688
+ throw new Error(`Entry ${entryDocumentId} no longer exists in ${contentType}`);
689
+ }
690
+ const ctx = strapi.requestContext?.get?.();
691
+ const userId = ctx?.state?.user?.id ?? null;
692
+ await this.createRevision({
693
+ contentType,
694
+ documentId: entryDocumentId,
695
+ snapshot: currentEntry,
696
+ changeType: "update",
697
+ userId
698
+ });
699
+ const restoreData = prepareRestoreData(snapshot, contentType, strapi);
700
+ if (ctx) {
701
+ ctx.state = ctx.state || {};
702
+ ctx.state._lesRevisionsSkip = true;
703
+ }
704
+ try {
705
+ const restored = await strapi.documents(contentType).update({
706
+ documentId: entryDocumentId,
707
+ data: restoreData,
708
+ populate: this.getPopulate(contentType)
709
+ });
710
+ return restored;
711
+ } finally {
712
+ if (ctx?.state) {
713
+ ctx.state._lesRevisionsSkip = false;
714
+ }
715
+ }
716
+ },
717
+ /* ── Delete ────────────────────────────────────────────────────────── */
718
+ async deleteRevision(revisionDocumentId) {
719
+ const config2 = this.getConfig();
720
+ if (config2.softDelete) {
721
+ return strapi.documents(REVISION_UID).update({
722
+ documentId: revisionDocumentId,
723
+ data: { isDeleted: true }
724
+ });
725
+ }
726
+ return strapi.documents(REVISION_UID).delete({
727
+ documentId: revisionDocumentId
728
+ });
729
+ },
730
+ /* ── Compare ───────────────────────────────────────────────────────── */
731
+ async compareRevisions(docId1, docId2) {
732
+ const [rev1, rev2] = await Promise.all([
733
+ strapi.documents(REVISION_UID).findOne({ documentId: docId1 }),
734
+ strapi.documents(REVISION_UID).findOne({ documentId: docId2 })
735
+ ]);
736
+ if (!rev1 || !rev2) throw new Error("One or both revisions not found");
737
+ const r1 = rev1;
738
+ const r2 = rev2;
739
+ if (r1.contentType !== r2.contentType || r1.entryDocumentId !== r2.entryDocumentId) {
740
+ throw new Error("Cannot compare revisions from different entries");
741
+ }
742
+ return {
743
+ revision1: rev1,
744
+ revision2: rev2,
745
+ diff: computeDiff(r1.snapshot ?? {}, r2.snapshot ?? {})
746
+ };
747
+ },
748
+ /* ── Pruning ───────────────────────────────────────────────────────── */
749
+ async pruneRevisions(contentType, documentId) {
750
+ const config2 = this.getConfig();
751
+ const { maxVersions, softDelete } = config2;
752
+ const all = await strapi.documents(REVISION_UID).findMany({
753
+ filters: {
754
+ contentType: { $eq: contentType },
755
+ entryDocumentId: { $eq: documentId },
756
+ isDeleted: { $eq: false }
757
+ },
758
+ sort: { version: "desc" }
759
+ });
760
+ if (all.length <= maxVersions) return;
761
+ const toRemove = all.slice(maxVersions);
762
+ for (const rev of toRemove) {
763
+ const docId = rev.documentId;
764
+ if (softDelete) {
765
+ await strapi.documents(REVISION_UID).update({
766
+ documentId: docId,
767
+ data: { isDeleted: true }
768
+ });
769
+ } else {
770
+ await strapi.documents(REVISION_UID).delete({ documentId: docId });
771
+ }
772
+ }
773
+ },
774
+ /* ── Listing helpers (admin UI) ────────────────────────────────────── */
775
+ async getContentTypesWithRevisions() {
776
+ const contentTypes2 = Object.entries(strapi.contentTypes).filter(([uid]) => this.isEnabled(uid)).map(([uid, ct]) => ({ uid, info: ct.info }));
777
+ const result = [];
778
+ for (const ct of contentTypes2) {
779
+ const count = await strapi.db.query(REVISION_UID).count({
780
+ where: { contentType: ct.uid, isDeleted: false }
781
+ });
782
+ result.push({ ...ct, revisionCount: count });
783
+ }
784
+ return result;
785
+ },
786
+ async getEntriesWithRevisions(contentType, params = {}) {
787
+ const { page = 1, pageSize = 25 } = params;
788
+ const revisions = await strapi.documents(REVISION_UID).findMany({
789
+ filters: {
790
+ contentType: { $eq: contentType },
791
+ isDeleted: { $eq: false }
792
+ },
793
+ sort: { createdAt: "desc" }
794
+ });
795
+ const entryMap = /* @__PURE__ */ new Map();
796
+ for (const rev of revisions) {
797
+ const r = rev;
798
+ const entry = entryMap.get(r.entryDocumentId);
799
+ if (!entry) {
800
+ entryMap.set(r.entryDocumentId, {
801
+ documentId: r.entryDocumentId,
802
+ count: 1,
803
+ lastModified: r.createdAt,
804
+ lastVersion: r.version
805
+ });
806
+ } else {
807
+ entry.count++;
808
+ if (r.version > entry.lastVersion) {
809
+ entry.lastModified = r.createdAt;
810
+ entry.lastVersion = r.version;
811
+ }
812
+ }
813
+ }
814
+ const entries = Array.from(entryMap.values()).sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
815
+ const total = entries.length;
816
+ const start = (page - 1) * pageSize;
817
+ const paged = entries.slice(start, start + pageSize);
818
+ const enriched = [];
819
+ for (const entry of paged) {
820
+ try {
821
+ const doc = await strapi.documents(contentType).findOne({
822
+ documentId: entry.documentId
823
+ });
824
+ enriched.push({
825
+ ...entry,
826
+ entryTitle: getEntryTitle(doc),
827
+ exists: !!doc
828
+ });
829
+ } catch {
830
+ enriched.push({
831
+ ...entry,
832
+ entryTitle: `[Supprimé] ${entry.documentId}`,
833
+ exists: false
834
+ });
835
+ }
836
+ }
837
+ return {
838
+ results: enriched,
839
+ pagination: {
840
+ page,
841
+ pageSize,
842
+ pageCount: Math.ceil(total / pageSize),
843
+ total
844
+ }
845
+ };
846
+ },
847
+ /**
848
+ * Soft-delete all revisions for an entry (called on entry hard-delete).
849
+ */
850
+ async deleteAllRevisionsForEntry(contentType, documentId) {
851
+ const config2 = this.getConfig();
852
+ const revisions = await strapi.documents(REVISION_UID).findMany({
853
+ filters: {
854
+ contentType: { $eq: contentType },
855
+ entryDocumentId: { $eq: documentId }
856
+ }
857
+ });
858
+ for (const rev of revisions) {
859
+ const docId = rev.documentId;
860
+ if (config2.softDelete) {
861
+ await strapi.documents(REVISION_UID).update({
862
+ documentId: docId,
863
+ data: { isDeleted: true }
864
+ });
865
+ } else {
866
+ await strapi.documents(REVISION_UID).delete({ documentId: docId });
867
+ }
868
+ }
869
+ },
870
+ /* ── Internal utilities ────────────────────────────────────────────── */
871
+ /** Convert Document Service filters to query-engine where clause */
872
+ _filtersToWhere(filters) {
873
+ const where = {};
874
+ for (const [key, condition] of Object.entries(filters)) {
875
+ if (typeof condition === "object" && condition !== null) {
876
+ if ("$eq" in condition) where[key] = condition.$eq;
877
+ else where[key] = condition;
878
+ } else {
879
+ where[key] = condition;
880
+ }
881
+ }
882
+ return where;
883
+ }
884
+ });
885
+ const services = {
886
+ revision: revisionService
887
+ };
888
+ const index = {
889
+ register,
890
+ bootstrap,
891
+ destroy,
892
+ config,
893
+ controllers,
894
+ routes,
895
+ services,
896
+ contentTypes,
897
+ policies,
898
+ middlewares
899
+ };
900
+ module.exports = index;