strapi-plugin-timeline 0.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.
@@ -0,0 +1,1037 @@
1
+ const IGNORED_UIDS = /* @__PURE__ */ new Set([
2
+ "plugin::timeline.timeline-entry",
3
+ "plugin::timeline.timeline-setting"
4
+ ]);
5
+ const restoreInProgress = /* @__PURE__ */ new Set();
6
+ function markRestoreInProgress(key) {
7
+ restoreInProgress.add(key);
8
+ }
9
+ function clearRestoreInProgress(key) {
10
+ restoreInProgress.delete(key);
11
+ }
12
+ function isRestoreInProgress(key) {
13
+ return restoreInProgress.has(key);
14
+ }
15
+ function getRequestUser(strapi) {
16
+ try {
17
+ const ctx = strapi.requestContext?.get?.();
18
+ const user = ctx?.state?.user;
19
+ if (!user?.id) return null;
20
+ const name = [user.firstname, user.lastname].filter(Boolean).join(" ") || user.email || null;
21
+ return { id: user.id, name: name || `User #${user.id}` };
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+ async function isTracked(strapi, uid, action) {
27
+ if (!uid.startsWith("api::")) return false;
28
+ if (IGNORED_UIDS.has(uid)) return false;
29
+ try {
30
+ const settings = await strapi.plugin("timeline").service("settings").getSettings();
31
+ const config2 = settings.contentTypeConfigs?.[uid];
32
+ if (!config2 || !config2.enabled) return false;
33
+ if (action && Array.isArray(config2.actions) && config2.actions.length > 0) {
34
+ return config2.actions.includes(action);
35
+ }
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+ function getRelationAttributeDetails(strapi, uid) {
42
+ const ct = strapi.contentTypes[uid];
43
+ if (!ct?.attributes) return {};
44
+ const result = {};
45
+ for (const [name, attr] of Object.entries(ct.attributes)) {
46
+ const a = attr;
47
+ if (a.type === "relation" && a.target) {
48
+ result[name] = { target: a.target, relation: a.relation || "" };
49
+ }
50
+ }
51
+ return result;
52
+ }
53
+ function extractAttributesSchema(strapi, attributes2, visited = /* @__PURE__ */ new Set()) {
54
+ const schema2 = {};
55
+ for (const [name, attr] of Object.entries(attributes2)) {
56
+ const a = attr;
57
+ const entry = { type: a.type };
58
+ if (a.type === "relation") {
59
+ entry.target = a.target || null;
60
+ entry.relation = a.relation || null;
61
+ }
62
+ if (a.type === "component") {
63
+ entry.component = a.component || null;
64
+ entry.repeatable = a.repeatable || false;
65
+ if (a.component && !visited.has(a.component)) {
66
+ const comp = strapi.components?.[a.component];
67
+ if (comp?.attributes) {
68
+ visited.add(a.component);
69
+ entry.reference = extractAttributesSchema(strapi, comp.attributes, visited);
70
+ }
71
+ }
72
+ }
73
+ if (a.type === "dynamiczone") {
74
+ entry.components = a.components || [];
75
+ const references = {};
76
+ for (const compUid of a.components || []) {
77
+ if (visited.has(compUid)) continue;
78
+ const comp = strapi.components?.[compUid];
79
+ if (comp?.attributes) {
80
+ visited.add(compUid);
81
+ references[compUid] = extractAttributesSchema(strapi, comp.attributes, visited);
82
+ }
83
+ }
84
+ if (Object.keys(references).length > 0) {
85
+ entry.references = references;
86
+ }
87
+ }
88
+ if (a.type === "customField" && a.customField) {
89
+ entry.customField = a.customField;
90
+ try {
91
+ const cfReg = strapi.customFields?.get?.(a.customField);
92
+ if (cfReg?.type) {
93
+ entry.customFieldType = cfReg.type;
94
+ }
95
+ } catch {
96
+ }
97
+ }
98
+ schema2[name] = entry;
99
+ }
100
+ return schema2;
101
+ }
102
+ function extractSchema(strapi, uid) {
103
+ const ct = strapi.contentTypes[uid];
104
+ if (!ct?.attributes) return {};
105
+ return extractAttributesSchema(strapi, ct.attributes, /* @__PURE__ */ new Set([uid]));
106
+ }
107
+ async function resolveRelations(strapi, uid, entryId, rawContent) {
108
+ const relationDetails = getRelationAttributeDetails(strapi, uid);
109
+ const relationNames = Object.keys(relationDetails);
110
+ if (relationNames.length === 0) return rawContent;
111
+ try {
112
+ const populate = {};
113
+ for (const attr of relationNames) {
114
+ const { target } = relationDetails[attr];
115
+ const isUsersPermissions = target.startsWith("plugin::users-permissions.");
116
+ const isAdminUser = target === "admin::user";
117
+ if (isUsersPermissions) {
118
+ populate[attr] = { fields: ["id"] };
119
+ } else if (isAdminUser) {
120
+ populate[attr] = { fields: ["id", "firstname", "lastname", "email"] };
121
+ } else if (attr === "localizations") {
122
+ populate[attr] = { fields: ["documentId", "locale"] };
123
+ } else {
124
+ populate[attr] = { fields: ["documentId"] };
125
+ }
126
+ }
127
+ const populated = await strapi.db.query(uid).findOne({
128
+ where: { id: entryId },
129
+ populate
130
+ });
131
+ if (!populated) return rawContent;
132
+ const content = { ...rawContent };
133
+ for (const attr of relationNames) {
134
+ const val = populated[attr];
135
+ const { target } = relationDetails[attr];
136
+ const isUsersPermissions = target.startsWith("plugin::users-permissions.");
137
+ const isAdminUser = target === "admin::user";
138
+ if (val == null) {
139
+ content[attr] = null;
140
+ } else if (isAdminUser) {
141
+ if (Array.isArray(val)) {
142
+ content[attr] = val.map((r) => ({
143
+ id: r.id,
144
+ firstname: r.firstname,
145
+ lastname: r.lastname,
146
+ email: r.email
147
+ }));
148
+ } else {
149
+ content[attr] = {
150
+ id: val.id,
151
+ firstname: val.firstname,
152
+ lastname: val.lastname,
153
+ email: val.email
154
+ };
155
+ }
156
+ } else if (attr === "localizations") {
157
+ if (Array.isArray(val)) {
158
+ content[attr] = val.map((r) => ({
159
+ documentId: r.documentId,
160
+ locale: r.locale
161
+ })).filter((r) => r.documentId);
162
+ } else if (val.documentId) {
163
+ content[attr] = [{ documentId: val.documentId, locale: val.locale }];
164
+ }
165
+ } else {
166
+ const idField = isUsersPermissions ? "id" : "documentId";
167
+ if (Array.isArray(val)) {
168
+ content[attr] = val.map((r) => r[idField]).filter(Boolean);
169
+ } else if (val[idField]) {
170
+ content[attr] = val[idField];
171
+ }
172
+ }
173
+ }
174
+ return content;
175
+ } catch {
176
+ return rawContent;
177
+ }
178
+ }
179
+ const bootstrap = async ({ strapi }) => {
180
+ strapi.log.info("[Timeline] Registering global lifecycle hooks (collections checked dynamically).");
181
+ strapi.db.lifecycles.subscribe({
182
+ async afterCreate(event) {
183
+ try {
184
+ const { result, model } = event;
185
+ if (!result) return;
186
+ const ct = strapi.contentTypes[model.uid];
187
+ const hasDraftAndPublish = ct?.options?.draftAndPublish !== false;
188
+ const isPublish = hasDraftAndPublish && result.publishedAt != null;
189
+ const action = isPublish ? "publish" : "create";
190
+ if (!await isTracked(strapi, model.uid, action)) return;
191
+ const reqUser = getRequestUser(strapi);
192
+ const content = await resolveRelations(strapi, model.uid, result.id, { ...result });
193
+ const contentTypeSchema = extractSchema(strapi, model.uid);
194
+ await strapi.plugin("timeline").service("timeline").createSnapshot({
195
+ action,
196
+ contentType: model.uid,
197
+ entryDocumentId: result.documentId || String(result.id),
198
+ content,
199
+ contentTypeSchema,
200
+ userId: reqUser?.id || null,
201
+ userName: reqUser?.name || null,
202
+ locale: result.locale || null
203
+ });
204
+ await strapi.plugin("timeline").service("timeline").enforceEntriesLimit(model.uid);
205
+ } catch (error) {
206
+ strapi.log.error(`[Timeline] afterCreate error: ${error}`);
207
+ }
208
+ },
209
+ async afterUpdate(event) {
210
+ try {
211
+ const { result, model } = event;
212
+ if (!result) return;
213
+ const docId = result.documentId || String(result.id);
214
+ const restoreKey = `${model.uid}:${docId}`;
215
+ if (isRestoreInProgress(restoreKey)) return;
216
+ const ct = strapi.contentTypes[model.uid];
217
+ const hasDraftAndPublish = ct?.options?.draftAndPublish !== false;
218
+ if (hasDraftAndPublish && result.publishedAt != null) return;
219
+ if (!await isTracked(strapi, model.uid, "update")) return;
220
+ const reqUser = getRequestUser(strapi);
221
+ const content = await resolveRelations(strapi, model.uid, result.id, { ...result });
222
+ const contentTypeSchema = extractSchema(strapi, model.uid);
223
+ await strapi.plugin("timeline").service("timeline").createSnapshot({
224
+ action: "update",
225
+ contentType: model.uid,
226
+ entryDocumentId: result.documentId || String(result.id),
227
+ content,
228
+ contentTypeSchema,
229
+ userId: reqUser?.id || null,
230
+ userName: reqUser?.name || null,
231
+ locale: result.locale || null
232
+ });
233
+ await strapi.plugin("timeline").service("timeline").enforceEntriesLimit(model.uid);
234
+ } catch (error) {
235
+ strapi.log.error(`[Timeline] afterUpdate error: ${error}`);
236
+ }
237
+ },
238
+ async beforeDelete(event) {
239
+ try {
240
+ const { params, model } = event;
241
+ if (!await isTracked(strapi, model.uid, "delete")) return;
242
+ const where = params.where || {};
243
+ const entries = await strapi.db.query(model.uid).findMany({ where });
244
+ for (const entry of entries) {
245
+ const ct = strapi.contentTypes[model.uid];
246
+ const hasDraftAndPublish = ct?.options?.draftAndPublish !== false;
247
+ if (hasDraftAndPublish && entry.publishedAt != null) continue;
248
+ const reqUser = getRequestUser(strapi);
249
+ const content = await resolveRelations(strapi, model.uid, entry.id, { ...entry });
250
+ const contentTypeSchema = extractSchema(strapi, model.uid);
251
+ await strapi.plugin("timeline").service("timeline").createSnapshot({
252
+ action: "delete",
253
+ contentType: model.uid,
254
+ entryDocumentId: entry.documentId || String(entry.id),
255
+ content,
256
+ contentTypeSchema,
257
+ userId: reqUser?.id || null,
258
+ userName: reqUser?.name || null,
259
+ locale: entry.locale || null
260
+ });
261
+ }
262
+ } catch (error) {
263
+ strapi.log.error(`[Timeline] beforeDelete error: ${error}`);
264
+ }
265
+ }
266
+ });
267
+ try {
268
+ const settings = await strapi.plugin("timeline").service("settings").getSettings();
269
+ if (settings.cleanupEnabled && settings.cleanupCron) {
270
+ strapi.cron.add({
271
+ "timeline-cleanup": {
272
+ task: async ({ strapi: s }) => {
273
+ s.log.info("[Timeline] Running scheduled cleanup...");
274
+ try {
275
+ const results = await s.plugin("timeline").service("schedule").runCleanup();
276
+ s.log.info(`[Timeline] Cleanup complete: ${JSON.stringify(results)}`);
277
+ } catch (err) {
278
+ s.log.error(`[Timeline] Cleanup failed: ${err}`);
279
+ }
280
+ },
281
+ options: settings.cleanupCron
282
+ }
283
+ });
284
+ strapi.log.info(`[Timeline] Registered cleanup cron: ${settings.cleanupCron}`);
285
+ }
286
+ } catch (error) {
287
+ strapi.log.error(`[Timeline] Failed to register cleanup cron: ${error}`);
288
+ }
289
+ };
290
+ const destroy = ({ strapi }) => {
291
+ };
292
+ const register = ({ strapi }) => {
293
+ const actions = [
294
+ {
295
+ uid: "entries.read",
296
+ displayName: "Read timeline entries",
297
+ pluginName: "timeline",
298
+ section: "plugins"
299
+ },
300
+ {
301
+ uid: "entries.delete",
302
+ displayName: "Delete all timeline entries",
303
+ pluginName: "timeline",
304
+ section: "plugins"
305
+ },
306
+ {
307
+ uid: "settings.read",
308
+ displayName: "Read timeline settings",
309
+ pluginName: "timeline",
310
+ section: "plugins"
311
+ },
312
+ {
313
+ uid: "settings.edit",
314
+ displayName: "Edit timeline settings",
315
+ pluginName: "timeline",
316
+ section: "plugins"
317
+ },
318
+ {
319
+ uid: "schedule.read",
320
+ displayName: "Read timeline schedule",
321
+ pluginName: "timeline",
322
+ section: "plugins"
323
+ },
324
+ {
325
+ uid: "schedule.run",
326
+ displayName: "Run timeline cleanup",
327
+ pluginName: "timeline",
328
+ section: "plugins"
329
+ }
330
+ ];
331
+ strapi.admin?.services.permission.actionProvider.registerMany(actions);
332
+ };
333
+ const config = {
334
+ default: {},
335
+ validator() {
336
+ }
337
+ };
338
+ const kind$1 = "singleType";
339
+ const collectionName$1 = "timeline_settings";
340
+ const info$1 = { "singularName": "timeline-setting", "pluralName": "timeline-settings", "displayName": "Timeline Settings" };
341
+ const pluginOptions$1 = { "content-manager": { "visible": false }, "content-type-builder": { "visible": false } };
342
+ const options$1 = { "draftAndPublish": false };
343
+ const attributes$1 = { "trackedContentTypes": { "type": "json", "default": [] }, "contentTypeConfigs": { "type": "json", "default": {} }, "cleanupEnabled": { "type": "boolean", "default": false }, "cleanupCron": { "type": "string", "default": "0 * * * *" }, "lastCleanupAt": { "type": "datetime" }, "lastCleanupResults": { "type": "json" } };
344
+ const schema$1 = {
345
+ kind: kind$1,
346
+ collectionName: collectionName$1,
347
+ info: info$1,
348
+ pluginOptions: pluginOptions$1,
349
+ options: options$1,
350
+ attributes: attributes$1
351
+ };
352
+ const timelineSetting = {
353
+ schema: schema$1
354
+ };
355
+ const kind = "collectionType";
356
+ const collectionName = "timeline_entries";
357
+ const info = { "singularName": "timeline-entry", "pluralName": "timeline-entries", "displayName": "Timeline Entry" };
358
+ const pluginOptions = { "content-manager": { "visible": false }, "content-type-builder": { "visible": false } };
359
+ const options = { "draftAndPublish": false };
360
+ const attributes = { "action": { "type": "string", "required": true }, "contentType": { "type": "string", "required": true }, "entryDocumentId": { "type": "string", "required": true }, "content": { "type": "json" }, "userId": { "type": "integer" }, "userName": { "type": "string" }, "locale": { "type": "string" }, "contentTypeSchema": { "type": "json" } };
361
+ const schema = {
362
+ kind,
363
+ collectionName,
364
+ info,
365
+ pluginOptions,
366
+ options,
367
+ attributes
368
+ };
369
+ const timelineEntry = {
370
+ schema
371
+ };
372
+ const contentTypes = {
373
+ "timeline-setting": timelineSetting,
374
+ "timeline-entry": timelineEntry
375
+ };
376
+ const controller = ({ strapi }) => ({
377
+ index(ctx) {
378
+ ctx.body = strapi.plugin("timeline").service("service").getWelcomeMessage();
379
+ }
380
+ });
381
+ const settingsController = ({ strapi }) => ({
382
+ async getSettings(ctx) {
383
+ const settings = await strapi.plugin("timeline").service("settings").getSettings();
384
+ ctx.body = { data: settings };
385
+ },
386
+ async setSettings(ctx) {
387
+ const { contentTypeConfigs } = ctx.request.body;
388
+ const settings = await strapi.plugin("timeline").service("settings").setSettings({
389
+ contentTypeConfigs: contentTypeConfigs || {}
390
+ });
391
+ ctx.body = { data: settings };
392
+ },
393
+ async getContentTypes(ctx) {
394
+ const contentTypes2 = await strapi.plugin("timeline").service("settings").getContentTypes();
395
+ ctx.body = { data: contentTypes2 };
396
+ }
397
+ });
398
+ const timelineController = ({ strapi }) => ({
399
+ async find(ctx) {
400
+ const {
401
+ contentType,
402
+ entryDocumentId,
403
+ userId,
404
+ action,
405
+ locale,
406
+ dateFrom,
407
+ dateTo,
408
+ sort,
409
+ sortOrder,
410
+ page = "1",
411
+ pageSize = "20"
412
+ } = ctx.query;
413
+ const result = await strapi.plugin("timeline").service("timeline").findEntries({
414
+ contentType: contentType || void 0,
415
+ entryDocumentId: entryDocumentId || void 0,
416
+ userId: userId || void 0,
417
+ action: action || void 0,
418
+ locale: locale || void 0,
419
+ dateFrom: dateFrom || void 0,
420
+ dateTo: dateTo || void 0,
421
+ sort: sort || void 0,
422
+ sortOrder: sortOrder || void 0,
423
+ page: parseInt(page, 10),
424
+ pageSize: parseInt(pageSize, 10)
425
+ });
426
+ ctx.body = { data: result.results, meta: { pagination: result.pagination } };
427
+ },
428
+ async findByDocument(ctx) {
429
+ const contentType = decodeURIComponent(ctx.params.contentType || "");
430
+ const entryDocumentId = decodeURIComponent(ctx.params.entryDocumentId || "");
431
+ if (!contentType || !entryDocumentId) {
432
+ return ctx.badRequest("contentType and entryDocumentId are required");
433
+ }
434
+ const entries = await strapi.plugin("timeline").service("timeline").findByDocumentId(contentType, entryDocumentId);
435
+ ctx.body = { data: entries };
436
+ },
437
+ async getUsers(ctx) {
438
+ const users = await strapi.plugin("timeline").service("timeline").getDistinctUsers();
439
+ ctx.body = { data: users };
440
+ },
441
+ async getLocales(ctx) {
442
+ const locales = await strapi.plugin("timeline").service("timeline").getDistinctLocales();
443
+ ctx.body = { data: locales };
444
+ },
445
+ async deleteAll(ctx) {
446
+ await strapi.plugin("timeline").service("timeline").deleteAll();
447
+ ctx.body = { data: { success: true } };
448
+ },
449
+ async clean(ctx) {
450
+ const { contentTypes: contentTypesToClean } = ctx.request.body || {};
451
+ if (!Array.isArray(contentTypesToClean) || contentTypesToClean.length === 0) {
452
+ return ctx.badRequest("contentTypes array is required");
453
+ }
454
+ const validUids = contentTypesToClean.filter(
455
+ (uid) => typeof uid === "string" && uid.startsWith("api::")
456
+ );
457
+ if (validUids.length === 0) {
458
+ return ctx.badRequest("No valid content type UIDs provided");
459
+ }
460
+ const user = ctx.state?.user;
461
+ const userId = user?.id || null;
462
+ const userName = user ? [user.firstname, user.lastname].filter(Boolean).join(" ") || user.email || `User #${user.id}` : null;
463
+ const deleted = await strapi.plugin("timeline").service("timeline").deleteByContentTypes(validUids);
464
+ for (const uid of validUids) {
465
+ await strapi.plugin("timeline").service("timeline").createSnapshot({
466
+ action: "clean",
467
+ contentType: uid,
468
+ entryDocumentId: "CLEAN",
469
+ content: { cleanedAt: (/* @__PURE__ */ new Date()).toISOString(), deletedCount: deleted },
470
+ userId,
471
+ userName
472
+ });
473
+ }
474
+ ctx.body = { data: { success: true, deleted } };
475
+ },
476
+ async snapshotBeforeRestore(ctx) {
477
+ const { contentType, entryDocumentId, locale, restoreData } = ctx.request.body || {};
478
+ if (!contentType || !entryDocumentId) {
479
+ return ctx.badRequest("contentType and entryDocumentId are required");
480
+ }
481
+ const user = ctx.state?.user;
482
+ const userId = user?.id || null;
483
+ const userName = user ? [user.firstname, user.lastname].filter(Boolean).join(" ") || user.email || `User #${user.id}` : null;
484
+ const restoreKey = `${contentType}:${entryDocumentId}`;
485
+ try {
486
+ const docService = strapi.documents(contentType);
487
+ const findParams = { documentId: entryDocumentId };
488
+ if (locale) findParams.locale = locale;
489
+ const currentDoc = await docService.findOne(findParams);
490
+ if (!currentDoc) {
491
+ ctx.body = { data: { success: false, reason: "Entry not found" } };
492
+ return;
493
+ }
494
+ if (restoreData && typeof restoreData === "object") {
495
+ markRestoreInProgress(restoreKey);
496
+ try {
497
+ const updateParams = {
498
+ documentId: entryDocumentId,
499
+ data: restoreData
500
+ };
501
+ if (locale) updateParams.locale = locale;
502
+ await docService.update(updateParams);
503
+ } finally {
504
+ clearRestoreInProgress(restoreKey);
505
+ }
506
+ }
507
+ const updatedDoc = restoreData ? await docService.findOne(findParams) : null;
508
+ await strapi.plugin("timeline").service("timeline").createSnapshot({
509
+ action: "restore",
510
+ contentType,
511
+ entryDocumentId,
512
+ content: updatedDoc || currentDoc,
513
+ userId,
514
+ userName,
515
+ locale: locale || currentDoc.locale || null
516
+ });
517
+ await strapi.plugin("timeline").service("timeline").enforceEntriesLimit(contentType);
518
+ ctx.body = { data: { success: true } };
519
+ } catch (error) {
520
+ clearRestoreInProgress(restoreKey);
521
+ strapi.log.error(`[Timeline] Failed to restore: ${error}`);
522
+ ctx.internalServerError("Restore failed");
523
+ }
524
+ }
525
+ });
526
+ const scheduleController = ({ strapi }) => ({
527
+ async getStatus(ctx) {
528
+ const status = await strapi.plugin("timeline").service("schedule").getStatus();
529
+ ctx.body = { data: status };
530
+ },
531
+ async runCleanup(ctx) {
532
+ const results = await strapi.plugin("timeline").service("schedule").runCleanup();
533
+ ctx.body = { data: { results, ranAt: (/* @__PURE__ */ new Date()).toISOString() } };
534
+ },
535
+ async updateSchedule(ctx) {
536
+ const { cleanupEnabled, cleanupCron } = ctx.request.body;
537
+ await strapi.plugin("timeline").service("settings").updateScheduleConfig({
538
+ cleanupEnabled,
539
+ cleanupCron
540
+ });
541
+ ctx.body = {
542
+ data: {
543
+ success: true,
544
+ note: "Cron schedule changes take effect after server restart."
545
+ }
546
+ };
547
+ }
548
+ });
549
+ const controllers = {
550
+ controller,
551
+ settings: settingsController,
552
+ timeline: timelineController,
553
+ schedule: scheduleController
554
+ };
555
+ const middlewares = {};
556
+ const policies = {};
557
+ const contentAPIRoutes = () => ({
558
+ type: "content-api",
559
+ routes: [
560
+ {
561
+ method: "GET",
562
+ path: "/",
563
+ // name of the controller file & the method.
564
+ handler: "controller.index",
565
+ config: {
566
+ policies: []
567
+ }
568
+ }
569
+ ]
570
+ });
571
+ const adminAPIRoutes = () => ({
572
+ type: "admin",
573
+ routes: [
574
+ {
575
+ method: "GET",
576
+ path: "/settings",
577
+ handler: "settings.getSettings",
578
+ config: {
579
+ policies: [
580
+ "admin::isAuthenticatedAdmin",
581
+ { name: "admin::hasPermissions", config: { actions: ["plugin::timeline.settings.read"] } }
582
+ ]
583
+ }
584
+ },
585
+ {
586
+ method: "PUT",
587
+ path: "/settings",
588
+ handler: "settings.setSettings",
589
+ config: {
590
+ policies: [
591
+ "admin::isAuthenticatedAdmin",
592
+ { name: "admin::hasPermissions", config: { actions: ["plugin::timeline.settings.edit"] } }
593
+ ]
594
+ }
595
+ },
596
+ {
597
+ method: "GET",
598
+ path: "/content-types",
599
+ handler: "settings.getContentTypes",
600
+ config: {
601
+ policies: ["admin::isAuthenticatedAdmin"]
602
+ }
603
+ },
604
+ {
605
+ method: "GET",
606
+ path: "/entries",
607
+ handler: "timeline.find",
608
+ config: {
609
+ policies: [
610
+ "admin::isAuthenticatedAdmin",
611
+ { name: "admin::hasPermissions", config: { actions: ["plugin::timeline.entries.read"] } }
612
+ ]
613
+ }
614
+ },
615
+ {
616
+ method: "GET",
617
+ path: "/entries/users",
618
+ handler: "timeline.getUsers",
619
+ config: {
620
+ policies: [
621
+ "admin::isAuthenticatedAdmin",
622
+ { name: "admin::hasPermissions", config: { actions: ["plugin::timeline.entries.read"] } }
623
+ ]
624
+ }
625
+ },
626
+ {
627
+ method: "GET",
628
+ path: "/entries/locales",
629
+ handler: "timeline.getLocales",
630
+ config: {
631
+ policies: [
632
+ "admin::isAuthenticatedAdmin",
633
+ { name: "admin::hasPermissions", config: { actions: ["plugin::timeline.entries.read"] } }
634
+ ]
635
+ }
636
+ },
637
+ {
638
+ method: "DELETE",
639
+ path: "/entries",
640
+ handler: "timeline.deleteAll",
641
+ config: {
642
+ policies: [
643
+ "admin::isAuthenticatedAdmin",
644
+ { name: "admin::hasPermissions", config: { actions: ["plugin::timeline.entries.delete"] } }
645
+ ]
646
+ }
647
+ },
648
+ {
649
+ method: "POST",
650
+ path: "/entries/clean",
651
+ handler: "timeline.clean",
652
+ config: {
653
+ policies: [
654
+ "admin::isAuthenticatedAdmin",
655
+ { name: "admin::hasPermissions", config: { actions: ["plugin::timeline.entries.delete"] } }
656
+ ]
657
+ }
658
+ },
659
+ {
660
+ method: "POST",
661
+ path: "/entries/snapshot-before-restore",
662
+ handler: "timeline.snapshotBeforeRestore",
663
+ config: {
664
+ policies: [
665
+ "admin::isAuthenticatedAdmin",
666
+ { name: "admin::hasPermissions", config: { actions: ["plugin::timeline.entries.read"] } }
667
+ ]
668
+ }
669
+ },
670
+ {
671
+ method: "GET",
672
+ path: "/entries/:contentType/:entryDocumentId",
673
+ handler: "timeline.findByDocument",
674
+ config: {
675
+ policies: [
676
+ "admin::isAuthenticatedAdmin",
677
+ { name: "admin::hasPermissions", config: { actions: ["plugin::timeline.entries.read"] } }
678
+ ]
679
+ }
680
+ },
681
+ {
682
+ method: "GET",
683
+ path: "/schedule",
684
+ handler: "schedule.getStatus",
685
+ config: {
686
+ policies: [
687
+ "admin::isAuthenticatedAdmin",
688
+ { name: "admin::hasPermissions", config: { actions: ["plugin::timeline.schedule.read"] } }
689
+ ]
690
+ }
691
+ },
692
+ {
693
+ method: "POST",
694
+ path: "/schedule/run",
695
+ handler: "schedule.runCleanup",
696
+ config: {
697
+ policies: [
698
+ "admin::isAuthenticatedAdmin",
699
+ { name: "admin::hasPermissions", config: { actions: ["plugin::timeline.schedule.run"] } }
700
+ ]
701
+ }
702
+ },
703
+ {
704
+ method: "PUT",
705
+ path: "/schedule",
706
+ handler: "schedule.updateSchedule",
707
+ config: {
708
+ policies: [
709
+ "admin::isAuthenticatedAdmin",
710
+ { name: "admin::hasPermissions", config: { actions: ["plugin::timeline.settings.edit"] } }
711
+ ]
712
+ }
713
+ }
714
+ ]
715
+ });
716
+ const routes = {
717
+ "content-api": contentAPIRoutes,
718
+ admin: adminAPIRoutes
719
+ };
720
+ const service = ({ strapi }) => ({
721
+ getWelcomeMessage() {
722
+ return "Welcome to Strapi 🚀";
723
+ }
724
+ });
725
+ const SETTINGS_UID = "plugin::timeline.timeline-setting";
726
+ const DEFAULT_ACTIONS = ["create", "update", "delete", "publish"];
727
+ const settingsService = ({ strapi }) => ({
728
+ async getSettings() {
729
+ const raw = await strapi.db.query(SETTINGS_UID).findOne({ where: {} });
730
+ if (!raw) {
731
+ return {
732
+ contentTypeConfigs: {},
733
+ cleanupEnabled: false,
734
+ cleanupCron: "0 * * * *",
735
+ lastCleanupAt: null,
736
+ lastCleanupResults: null
737
+ };
738
+ }
739
+ let contentTypeConfigs = raw.contentTypeConfigs || {};
740
+ const oldList = raw.trackedContentTypes || [];
741
+ if (Object.keys(contentTypeConfigs).length === 0 && Array.isArray(oldList) && oldList.length > 0) {
742
+ for (const uid of oldList) {
743
+ if (typeof uid === "string" && uid) {
744
+ contentTypeConfigs[uid] = {
745
+ enabled: true,
746
+ actions: [...DEFAULT_ACTIONS],
747
+ entriesLimit: 0,
748
+ durationLimit: 0,
749
+ durationUnit: "days"
750
+ };
751
+ }
752
+ }
753
+ await strapi.db.query(SETTINGS_UID).update({
754
+ where: { id: raw.id },
755
+ data: { contentTypeConfigs, trackedContentTypes: [] }
756
+ });
757
+ }
758
+ return {
759
+ contentTypeConfigs,
760
+ cleanupEnabled: raw.cleanupEnabled ?? false,
761
+ cleanupCron: raw.cleanupCron || "0 * * * *",
762
+ lastCleanupAt: raw.lastCleanupAt || null,
763
+ lastCleanupResults: raw.lastCleanupResults || null
764
+ };
765
+ },
766
+ async setSettings(data) {
767
+ const existing = await strapi.db.query(SETTINGS_UID).findOne({ where: {} });
768
+ const payload = {
769
+ contentTypeConfigs: data.contentTypeConfigs,
770
+ trackedContentTypes: []
771
+ // clear old format
772
+ };
773
+ if (existing) {
774
+ return strapi.db.query(SETTINGS_UID).update({
775
+ where: { id: existing.id },
776
+ data: payload
777
+ });
778
+ }
779
+ return strapi.db.query(SETTINGS_UID).create({ data: payload });
780
+ },
781
+ async updateScheduleConfig(data) {
782
+ const existing = await strapi.db.query(SETTINGS_UID).findOne({ where: {} });
783
+ const payload = {};
784
+ if (data.cleanupEnabled !== void 0) payload.cleanupEnabled = data.cleanupEnabled;
785
+ if (data.cleanupCron !== void 0) payload.cleanupCron = data.cleanupCron;
786
+ if (existing) {
787
+ return strapi.db.query(SETTINGS_UID).update({
788
+ where: { id: existing.id },
789
+ data: payload
790
+ });
791
+ }
792
+ return strapi.db.query(SETTINGS_UID).create({ data: payload });
793
+ },
794
+ async updateCleanupResults(data) {
795
+ const existing = await strapi.db.query(SETTINGS_UID).findOne({ where: {} });
796
+ if (existing) {
797
+ return strapi.db.query(SETTINGS_UID).update({
798
+ where: { id: existing.id },
799
+ data
800
+ });
801
+ }
802
+ },
803
+ async getContentTypes() {
804
+ return Object.entries(strapi.contentTypes).filter(([uid]) => uid.startsWith("api::")).map(([uid, ct]) => ({
805
+ uid,
806
+ displayName: ct.info?.displayName || uid,
807
+ kind: ct.kind
808
+ }));
809
+ }
810
+ });
811
+ const ENTRY_UID = "plugin::timeline.timeline-entry";
812
+ const timelineService = ({ strapi }) => ({
813
+ async createSnapshot(params) {
814
+ try {
815
+ const result = await strapi.db.query(ENTRY_UID).create({
816
+ data: {
817
+ action: params.action,
818
+ contentType: params.contentType,
819
+ entryDocumentId: params.entryDocumentId,
820
+ content: params.content,
821
+ contentTypeSchema: params.contentTypeSchema || null,
822
+ userId: params.userId ? Number(params.userId) : null,
823
+ userName: params.userName || null,
824
+ locale: params.locale || null
825
+ }
826
+ });
827
+ strapi.log.info(
828
+ `[Timeline] Snapshot: ${params.action} on ${params.contentType} (doc: ${params.entryDocumentId})`
829
+ );
830
+ return result;
831
+ } catch (error) {
832
+ strapi.log.error(`[Timeline] Failed to create snapshot: ${error}`);
833
+ return null;
834
+ }
835
+ },
836
+ async findEntries(params) {
837
+ const {
838
+ contentType,
839
+ entryDocumentId,
840
+ userId,
841
+ action,
842
+ locale,
843
+ dateFrom,
844
+ dateTo,
845
+ sort = "createdAt",
846
+ sortOrder = "desc",
847
+ page = 1,
848
+ pageSize = 20
849
+ } = params;
850
+ const where = {};
851
+ if (contentType) {
852
+ const ctArr = Array.isArray(contentType) ? contentType : [contentType];
853
+ if (ctArr.length === 1) where.contentType = ctArr[0];
854
+ else if (ctArr.length > 1) where.contentType = { $in: ctArr };
855
+ }
856
+ if (entryDocumentId) where.entryDocumentId = entryDocumentId;
857
+ if (userId) {
858
+ const uArr = Array.isArray(userId) ? userId.map(Number) : [Number(userId)];
859
+ if (uArr.length === 1) where.userId = uArr[0];
860
+ else if (uArr.length > 1) where.userId = { $in: uArr };
861
+ }
862
+ if (action) {
863
+ const aArr = Array.isArray(action) ? action : [action];
864
+ if (aArr.length === 1) where.action = aArr[0];
865
+ else if (aArr.length > 1) where.action = { $in: aArr };
866
+ }
867
+ if (locale) {
868
+ const lArr = Array.isArray(locale) ? locale : [locale];
869
+ if (lArr.length === 1) where.locale = lArr[0];
870
+ else if (lArr.length > 1) where.locale = { $in: lArr };
871
+ }
872
+ if (dateFrom || dateTo) {
873
+ where.createdAt = {};
874
+ if (dateFrom) where.createdAt.$gte = new Date(dateFrom);
875
+ if (dateTo) {
876
+ const end = new Date(dateTo);
877
+ end.setHours(23, 59, 59, 999);
878
+ where.createdAt.$lte = end;
879
+ }
880
+ }
881
+ const allowedSortFields = ["createdAt", "action", "contentType", "userName"];
882
+ const sortField = allowedSortFields.includes(sort) ? sort : "createdAt";
883
+ const [entries, count] = await Promise.all([
884
+ strapi.db.query(ENTRY_UID).findMany({
885
+ where,
886
+ orderBy: { [sortField]: sortOrder },
887
+ offset: (page - 1) * pageSize,
888
+ limit: pageSize
889
+ }),
890
+ strapi.db.query(ENTRY_UID).count({ where })
891
+ ]);
892
+ return {
893
+ results: entries,
894
+ pagination: {
895
+ page,
896
+ pageSize,
897
+ total: count,
898
+ pageCount: Math.ceil(count / pageSize)
899
+ }
900
+ };
901
+ },
902
+ async getDistinctUsers() {
903
+ const knex = strapi.db.connection;
904
+ const rows = await knex("timeline_entries").select("user_id", "user_name").whereNotNull("user_id").groupBy("user_id", "user_name").orderBy("user_name", "asc");
905
+ return rows.map((r) => ({
906
+ userId: r.user_id,
907
+ userName: r.user_name || `User #${r.user_id}`
908
+ }));
909
+ },
910
+ async getDistinctLocales() {
911
+ const knex = strapi.db.connection;
912
+ const rows = await knex("timeline_entries").select("locale").whereNotNull("locale").where("locale", "!=", "").groupBy("locale").orderBy("locale", "asc");
913
+ return rows.map((r) => r.locale);
914
+ },
915
+ async findByDocumentId(contentType, entryDocumentId) {
916
+ return strapi.db.query(ENTRY_UID).findMany({
917
+ where: { contentType, entryDocumentId },
918
+ orderBy: { createdAt: "desc" }
919
+ });
920
+ },
921
+ async deleteAll() {
922
+ const knex = strapi.db.connection;
923
+ await knex("timeline_entries").delete();
924
+ },
925
+ async deleteByContentTypes(contentTypes2) {
926
+ if (!contentTypes2.length) return 0;
927
+ const knex = strapi.db.connection;
928
+ return knex("timeline_entries").whereIn("content_type", contentTypes2).delete();
929
+ },
930
+ async enforceEntriesLimit(contentType) {
931
+ try {
932
+ const settings = await strapi.plugin("timeline").service("settings").getSettings();
933
+ const config2 = settings.contentTypeConfigs?.[contentType];
934
+ if (!config2?.entriesLimit || config2.entriesLimit <= 0) return;
935
+ const count = await strapi.db.query(ENTRY_UID).count({ where: { contentType } });
936
+ if (count <= config2.entriesLimit) return;
937
+ const excess = count - config2.entriesLimit;
938
+ const oldEntries = await strapi.db.query(ENTRY_UID).findMany({
939
+ where: { contentType },
940
+ orderBy: { createdAt: "asc" },
941
+ limit: excess,
942
+ select: ["id"]
943
+ });
944
+ if (oldEntries.length > 0) {
945
+ const ids = oldEntries.map((e) => e.id);
946
+ await strapi.db.query(ENTRY_UID).deleteMany({ where: { id: { $in: ids } } });
947
+ strapi.log.info(`[Timeline] Enforced entries limit for ${contentType}: removed ${ids.length} old entries`);
948
+ }
949
+ } catch (error) {
950
+ strapi.log.error(`[Timeline] Failed to enforce entries limit: ${error}`);
951
+ }
952
+ },
953
+ async deleteByAge(contentType, cutoffDate) {
954
+ const knex = strapi.db.connection;
955
+ return knex("timeline_entries").where("content_type", contentType).where("created_at", "<", cutoffDate).delete();
956
+ }
957
+ });
958
+ const scheduleService = ({ strapi }) => ({
959
+ async getStatus() {
960
+ const settings = await strapi.plugin("timeline").service("settings").getSettings();
961
+ const contentTypeConfigs = settings.contentTypeConfigs || {};
962
+ const durationsConfigured = {};
963
+ for (const [uid, config2] of Object.entries(contentTypeConfigs)) {
964
+ if (config2.enabled && config2.durationLimit > 0) {
965
+ const ct = strapi.contentTypes[uid];
966
+ durationsConfigured[uid] = {
967
+ durationLimit: config2.durationLimit,
968
+ durationUnit: config2.durationUnit,
969
+ displayName: ct?.info?.displayName || uid
970
+ };
971
+ }
972
+ }
973
+ return {
974
+ cleanupEnabled: settings.cleanupEnabled,
975
+ cleanupCron: settings.cleanupCron,
976
+ lastCleanupAt: settings.lastCleanupAt,
977
+ lastCleanupResults: settings.lastCleanupResults,
978
+ durationsConfigured
979
+ };
980
+ },
981
+ async runCleanup() {
982
+ const settings = await strapi.plugin("timeline").service("settings").getSettings();
983
+ const configs = settings.contentTypeConfigs || {};
984
+ const results = {};
985
+ for (const [uid, config2] of Object.entries(configs)) {
986
+ if (!config2.enabled || !config2.durationLimit || config2.durationLimit <= 0) continue;
987
+ const cutoff = /* @__PURE__ */ new Date();
988
+ switch (config2.durationUnit) {
989
+ case "hours":
990
+ cutoff.setHours(cutoff.getHours() - config2.durationLimit);
991
+ break;
992
+ case "days":
993
+ cutoff.setDate(cutoff.getDate() - config2.durationLimit);
994
+ break;
995
+ case "weeks":
996
+ cutoff.setDate(cutoff.getDate() - config2.durationLimit * 7);
997
+ break;
998
+ case "months":
999
+ cutoff.setMonth(cutoff.getMonth() - config2.durationLimit);
1000
+ break;
1001
+ default:
1002
+ continue;
1003
+ }
1004
+ const deleted = await strapi.plugin("timeline").service("timeline").deleteByAge(uid, cutoff);
1005
+ if (deleted > 0) {
1006
+ results[uid] = { deleted };
1007
+ }
1008
+ }
1009
+ await strapi.plugin("timeline").service("settings").updateCleanupResults({
1010
+ lastCleanupAt: (/* @__PURE__ */ new Date()).toISOString(),
1011
+ lastCleanupResults: results
1012
+ });
1013
+ strapi.log.info(`[Timeline] Cleanup complete: ${JSON.stringify(results)}`);
1014
+ return results;
1015
+ }
1016
+ });
1017
+ const services = {
1018
+ service,
1019
+ settings: settingsService,
1020
+ timeline: timelineService,
1021
+ schedule: scheduleService
1022
+ };
1023
+ const index = {
1024
+ register,
1025
+ bootstrap,
1026
+ destroy,
1027
+ config,
1028
+ controllers,
1029
+ routes,
1030
+ services,
1031
+ contentTypes,
1032
+ policies,
1033
+ middlewares
1034
+ };
1035
+ export {
1036
+ index as default
1037
+ };