@intranefr/superbackend 1.5.3 → 1.6.4

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 (106) hide show
  1. package/cookies.txt +6 -0
  2. package/cookies1.txt +6 -0
  3. package/cookies2.txt +6 -0
  4. package/cookies3.txt +6 -0
  5. package/cookies4.txt +5 -0
  6. package/cookies_old.txt +5 -0
  7. package/cookies_old_test.txt +6 -0
  8. package/cookies_super.txt +5 -0
  9. package/cookies_super_test.txt +6 -0
  10. package/cookies_test.txt +6 -0
  11. package/index.js +7 -0
  12. package/package.json +3 -1
  13. package/plugins/core-waiting-list-migration/README.md +118 -0
  14. package/plugins/core-waiting-list-migration/index.js +438 -0
  15. package/plugins/global-settings-presets/index.js +20 -0
  16. package/plugins/hello-cli/index.js +17 -0
  17. package/plugins/ui-components-seeder/components/suiAlert.js +212 -0
  18. package/plugins/ui-components-seeder/components/suiToast.js +186 -0
  19. package/plugins/ui-components-seeder/index.js +31 -0
  20. package/public/js/admin-ui-components-preview.js +281 -0
  21. package/public/js/admin-ui-components.js +408 -0
  22. package/public/js/llm-provider-model-picker.js +193 -0
  23. package/public/test-iframe-fix.html +63 -0
  24. package/public/test-iframe.html +14 -0
  25. package/src/admin/endpointRegistry.js +68 -0
  26. package/src/controllers/admin.controller.js +25 -5
  27. package/src/controllers/adminDataCleanup.controller.js +45 -0
  28. package/src/controllers/adminLlm.controller.js +0 -8
  29. package/src/controllers/adminLogin.controller.js +269 -0
  30. package/src/controllers/adminPlugins.controller.js +55 -0
  31. package/src/controllers/adminRegistry.controller.js +106 -0
  32. package/src/controllers/adminStats.controller.js +4 -4
  33. package/src/controllers/registry.controller.js +32 -0
  34. package/src/controllers/waitingList.controller.js +52 -74
  35. package/src/middleware/auth.js +71 -1
  36. package/src/middleware/rbac.js +62 -0
  37. package/src/middleware.js +480 -156
  38. package/src/models/GlobalSetting.js +11 -1
  39. package/src/models/UiComponent.js +2 -0
  40. package/src/models/User.js +1 -1
  41. package/src/routes/admin.routes.js +3 -3
  42. package/src/routes/adminAgents.routes.js +2 -2
  43. package/src/routes/adminAssets.routes.js +11 -11
  44. package/src/routes/adminBlog.routes.js +2 -2
  45. package/src/routes/adminBlogAi.routes.js +2 -2
  46. package/src/routes/adminBlogAutomation.routes.js +2 -2
  47. package/src/routes/adminCache.routes.js +2 -2
  48. package/src/routes/adminConsoleManager.routes.js +2 -2
  49. package/src/routes/adminCrons.routes.js +2 -2
  50. package/src/routes/adminDataCleanup.routes.js +26 -0
  51. package/src/routes/adminDbBrowser.routes.js +2 -2
  52. package/src/routes/adminEjsVirtual.routes.js +2 -2
  53. package/src/routes/adminFeatureFlags.routes.js +6 -6
  54. package/src/routes/adminHeadless.routes.js +2 -2
  55. package/src/routes/adminHealthChecks.routes.js +2 -2
  56. package/src/routes/adminI18n.routes.js +2 -2
  57. package/src/routes/adminJsonConfigs.routes.js +8 -8
  58. package/src/routes/adminLlm.routes.js +8 -8
  59. package/src/routes/adminLogin.routes.js +23 -0
  60. package/src/routes/adminMarkdowns.routes.js +3 -9
  61. package/src/routes/adminMigration.routes.js +12 -12
  62. package/src/routes/adminPages.routes.js +2 -2
  63. package/src/routes/adminPlugins.routes.js +15 -0
  64. package/src/routes/adminProxy.routes.js +2 -2
  65. package/src/routes/adminRateLimits.routes.js +8 -8
  66. package/src/routes/adminRbac.routes.js +2 -2
  67. package/src/routes/adminRegistry.routes.js +24 -0
  68. package/src/routes/adminScripts.routes.js +2 -2
  69. package/src/routes/adminSeoConfig.routes.js +10 -10
  70. package/src/routes/adminTelegram.routes.js +2 -2
  71. package/src/routes/adminTerminals.routes.js +2 -2
  72. package/src/routes/adminUiComponents.routes.js +2 -2
  73. package/src/routes/adminUploadNamespaces.routes.js +7 -7
  74. package/src/routes/blogInternal.routes.js +2 -2
  75. package/src/routes/experiments.routes.js +2 -2
  76. package/src/routes/formsAdmin.routes.js +6 -6
  77. package/src/routes/globalSettings.routes.js +8 -8
  78. package/src/routes/internalExperiments.routes.js +2 -2
  79. package/src/routes/notificationAdmin.routes.js +7 -7
  80. package/src/routes/orgAdmin.routes.js +16 -16
  81. package/src/routes/pages.routes.js +3 -3
  82. package/src/routes/registry.routes.js +11 -0
  83. package/src/routes/stripeAdmin.routes.js +12 -12
  84. package/src/routes/userAdmin.routes.js +7 -7
  85. package/src/routes/waitingListAdmin.routes.js +2 -2
  86. package/src/routes/workflows.routes.js +3 -3
  87. package/src/services/dataCleanup.service.js +286 -0
  88. package/src/services/jsonConfigs.service.js +262 -0
  89. package/src/services/plugins.service.js +348 -0
  90. package/src/services/registry.service.js +452 -0
  91. package/src/services/uiComponents.service.js +180 -0
  92. package/src/services/waitingListJson.service.js +401 -0
  93. package/src/utils/rbac/rightsRegistry.js +118 -0
  94. package/test-access.js +63 -0
  95. package/test-iframe-fix.html +63 -0
  96. package/test-iframe.html +14 -0
  97. package/views/admin-403.ejs +92 -0
  98. package/views/admin-dashboard-home.ejs +52 -2
  99. package/views/admin-dashboard.ejs +143 -2
  100. package/views/admin-data-cleanup.ejs +357 -0
  101. package/views/admin-login.ejs +286 -0
  102. package/views/admin-plugins-system.ejs +223 -0
  103. package/views/admin-ui-components.ejs +82 -402
  104. package/views/admin-users.ejs +207 -11
  105. package/views/partials/dashboard/nav-items.ejs +2 -0
  106. package/views/partials/llm-provider-model-picker.ejs +0 -161
@@ -0,0 +1,286 @@
1
+ const mongoose = require('mongoose');
2
+
3
+ function toSafeJsonError(error) {
4
+ const msg = error?.message || 'Operation failed';
5
+ const code = error?.code;
6
+ if (code === 'VALIDATION') return { status: 400, body: { error: msg } };
7
+ if (code === 'NOT_FOUND') return { status: 404, body: { error: msg } };
8
+ if (code === 'FORBIDDEN') return { status: 403, body: { error: msg } };
9
+ return { status: 500, body: { error: msg } };
10
+ }
11
+
12
+ function ensureDbConnection() {
13
+ const db = mongoose.connection?.db;
14
+ if (!db) {
15
+ throw Object.assign(new Error('MongoDB connection is not ready'), { code: 'FORBIDDEN' });
16
+ }
17
+ return db;
18
+ }
19
+
20
+ function normalizeCollectionName(value) {
21
+ const name = String(value || '').trim();
22
+ if (!name) throw Object.assign(new Error('collection is required'), { code: 'VALIDATION' });
23
+ if (!/^[A-Za-z0-9_.-]+$/.test(name)) {
24
+ throw Object.assign(new Error('Invalid collection name'), { code: 'VALIDATION' });
25
+ }
26
+ return name;
27
+ }
28
+
29
+ function normalizeDateField(value, fallback = 'createdAt') {
30
+ const field = String(value || fallback).trim();
31
+ if (!field) throw Object.assign(new Error('dateField is required'), { code: 'VALIDATION' });
32
+ if (field.includes('$')) {
33
+ throw Object.assign(new Error('Invalid dateField'), { code: 'VALIDATION' });
34
+ }
35
+ if (!/^[A-Za-z0-9_.-]+$/.test(field)) {
36
+ throw Object.assign(new Error('Invalid dateField'), { code: 'VALIDATION' });
37
+ }
38
+ return field;
39
+ }
40
+
41
+ function normalizeOlderThanDays(value) {
42
+ const n = Number(value);
43
+ if (!Number.isFinite(n) || n <= 0) {
44
+ throw Object.assign(new Error('olderThanDays must be a positive number'), { code: 'VALIDATION' });
45
+ }
46
+ if (n > 36500) {
47
+ throw Object.assign(new Error('olderThanDays is too large'), { code: 'VALIDATION' });
48
+ }
49
+ return Math.floor(n);
50
+ }
51
+
52
+ function normalizeLimit(value, fallback = 5000) {
53
+ if (value === undefined || value === null || value === '') return fallback;
54
+ const n = Number(value);
55
+ if (!Number.isFinite(n) || n <= 0) {
56
+ throw Object.assign(new Error('limit must be a positive number'), { code: 'VALIDATION' });
57
+ }
58
+ return Math.min(Math.floor(n), 50000);
59
+ }
60
+
61
+ async function getCollectionStatsByName(db, collection) {
62
+ const command = await db.command({ collStats: collection });
63
+ return {
64
+ name: collection,
65
+ ns: command?.ns || null,
66
+ count: Number(command?.count || 0),
67
+ sizeBytes: Number(command?.size || 0),
68
+ storageSizeBytes: Number(command?.storageSize || 0),
69
+ totalIndexSizeBytes: Number(command?.totalIndexSize || 0),
70
+ avgObjSizeBytes: Number(command?.avgObjSize || 0),
71
+ };
72
+ }
73
+
74
+ async function listCollectionStats() {
75
+ const db = ensureDbConnection();
76
+ const collections = await db.listCollections({}, { nameOnly: true }).toArray();
77
+ const names = (collections || []).map((c) => c.name).filter(Boolean).sort();
78
+
79
+ const stats = [];
80
+ for (const name of names) {
81
+ try {
82
+ // collStats can fail on internal collections depending on Mongo version/config.
83
+ const s = await getCollectionStatsByName(db, name);
84
+ stats.push(s);
85
+ } catch {
86
+ stats.push({
87
+ name,
88
+ ns: null,
89
+ count: 0,
90
+ sizeBytes: 0,
91
+ storageSizeBytes: 0,
92
+ totalIndexSizeBytes: 0,
93
+ avgObjSizeBytes: 0,
94
+ unavailable: true,
95
+ });
96
+ }
97
+ }
98
+
99
+ return stats;
100
+ }
101
+
102
+ async function getMongoGlobalStats() {
103
+ const db = ensureDbConnection();
104
+ const stats = await db.stats();
105
+ return {
106
+ db: stats?.db || null,
107
+ collections: Number(stats?.collections || 0),
108
+ views: Number(stats?.views || 0),
109
+ objects: Number(stats?.objects || 0),
110
+ dataSizeBytes: Number(stats?.dataSize || 0),
111
+ storageSizeBytes: Number(stats?.storageSize || 0),
112
+ indexes: Number(stats?.indexes || 0),
113
+ indexSizeBytes: Number(stats?.indexSize || 0),
114
+ totalSizeBytes: Number(stats?.totalSize || 0),
115
+ };
116
+ }
117
+
118
+ async function getOverviewStats() {
119
+ const [global, collections] = await Promise.all([
120
+ getMongoGlobalStats(),
121
+ listCollectionStats(),
122
+ ]);
123
+
124
+ return { global, collections };
125
+ }
126
+
127
+ async function ensureCollectionExists(db, collectionName) {
128
+ const collections = await db.listCollections({ name: collectionName }, { nameOnly: true }).toArray();
129
+ if (!collections || collections.length === 0) {
130
+ throw Object.assign(new Error('Collection not found'), { code: 'NOT_FOUND' });
131
+ }
132
+ }
133
+
134
+ function buildOlderThanQuery({ dateField, cutoff }) {
135
+ return {
136
+ [dateField]: {
137
+ $type: 'date',
138
+ $lt: cutoff,
139
+ },
140
+ };
141
+ }
142
+
143
+ function estimateReclaimableBytes({ candidateCount, collectionStats }) {
144
+ const avg = Number(collectionStats?.avgObjSizeBytes || 0);
145
+ if (avg > 0) return Math.round(candidateCount * avg);
146
+
147
+ const sizeBytes = Number(collectionStats?.sizeBytes || 0);
148
+ const count = Number(collectionStats?.count || 0);
149
+ if (sizeBytes > 0 && count > 0) {
150
+ return Math.round(candidateCount * (sizeBytes / count));
151
+ }
152
+
153
+ return 0;
154
+ }
155
+
156
+ async function dryRunCollectionCleanup({ collection, olderThanDays, dateField = 'createdAt' }) {
157
+ const db = ensureDbConnection();
158
+ const collectionName = normalizeCollectionName(collection);
159
+ const safeDateField = normalizeDateField(dateField);
160
+ const days = normalizeOlderThanDays(olderThanDays);
161
+
162
+ await ensureCollectionExists(db, collectionName);
163
+
164
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
165
+ const query = buildOlderThanQuery({ dateField: safeDateField, cutoff });
166
+ const mongoCollection = db.collection(collectionName);
167
+
168
+ const [candidateCount, collectionStats] = await Promise.all([
169
+ mongoCollection.countDocuments(query),
170
+ getCollectionStatsByName(db, collectionName),
171
+ ]);
172
+
173
+ const estimatedReclaimableBytes = estimateReclaimableBytes({
174
+ candidateCount,
175
+ collectionStats,
176
+ });
177
+
178
+ return {
179
+ collection: collectionName,
180
+ dateField: safeDateField,
181
+ olderThanDays: days,
182
+ cutoffIso: cutoff.toISOString(),
183
+ candidateCount,
184
+ estimatedReclaimableBytes,
185
+ collectionStats,
186
+ notes: [
187
+ 'Estimate is based on average object size and is not guaranteed to be reclaimed physically on disk immediately.',
188
+ ],
189
+ };
190
+ }
191
+
192
+ async function executeCollectionCleanup({
193
+ collection,
194
+ olderThanDays,
195
+ dateField = 'createdAt',
196
+ limit,
197
+ confirm,
198
+ }) {
199
+ if (confirm !== true) {
200
+ throw Object.assign(new Error('Cleanup confirmation is required (confirm=true)'), { code: 'VALIDATION' });
201
+ }
202
+
203
+ const db = ensureDbConnection();
204
+ const collectionName = normalizeCollectionName(collection);
205
+ const safeDateField = normalizeDateField(dateField);
206
+ const days = normalizeOlderThanDays(olderThanDays);
207
+ const maxDelete = normalizeLimit(limit, 5000);
208
+
209
+ await ensureCollectionExists(db, collectionName);
210
+
211
+ const start = Date.now();
212
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
213
+ const query = buildOlderThanQuery({ dateField: safeDateField, cutoff });
214
+ const mongoCollection = db.collection(collectionName);
215
+
216
+ const dryRun = await dryRunCollectionCleanup({
217
+ collection: collectionName,
218
+ olderThanDays: days,
219
+ dateField: safeDateField,
220
+ });
221
+
222
+ let remaining = maxDelete;
223
+ let deletedCount = 0;
224
+ const batchSize = 1000;
225
+
226
+ while (remaining > 0) {
227
+ const currentBatch = Math.min(batchSize, remaining);
228
+ const ids = await mongoCollection
229
+ .find(query, { projection: { _id: 1 } })
230
+ .sort({ _id: 1 })
231
+ .limit(currentBatch)
232
+ .toArray();
233
+
234
+ if (!ids.length) break;
235
+
236
+ const idList = ids.map((d) => d._id).filter(Boolean);
237
+ if (!idList.length) break;
238
+
239
+ const out = await mongoCollection.deleteMany({ _id: { $in: idList } });
240
+ const deleted = Number(out?.deletedCount || 0);
241
+ deletedCount += deleted;
242
+ remaining -= idList.length;
243
+
244
+ if (deleted === 0) break;
245
+ }
246
+
247
+ return {
248
+ collection: collectionName,
249
+ dateField: safeDateField,
250
+ olderThanDays: days,
251
+ cutoffIso: cutoff.toISOString(),
252
+ limitApplied: maxDelete,
253
+ dryRunCandidateCount: dryRun.candidateCount,
254
+ deletedCount,
255
+ estimatedReclaimableBytes: dryRun.estimatedReclaimableBytes,
256
+ durationMs: Date.now() - start,
257
+ notes: dryRun.notes,
258
+ };
259
+ }
260
+
261
+ async function inferCollectionFields(collectionName, sampleSize = 10) {
262
+ const db = ensureDbConnection();
263
+ const coll = db.collection(normalizeCollectionName(collectionName));
264
+
265
+ const docs = await coll.find({}).limit(sampleSize).toArray();
266
+ const fieldSet = new Set();
267
+
268
+ for (const doc of docs) {
269
+ const keys = Object.keys(doc);
270
+ for (const key of keys) {
271
+ fieldSet.add(key);
272
+ }
273
+ }
274
+
275
+ return Array.from(fieldSet).sort();
276
+ }
277
+
278
+ module.exports = {
279
+ toSafeJsonError,
280
+ getMongoGlobalStats,
281
+ listCollectionStats,
282
+ getOverviewStats,
283
+ dryRunCollectionCleanup,
284
+ executeCollectionCleanup,
285
+ inferCollectionFields,
286
+ };
@@ -122,6 +122,108 @@ function clearAllJsonConfigCache() {
122
122
  cache.clear();
123
123
  }
124
124
 
125
+ // Enhanced cache helpers
126
+ function isJsonConfigCached(slug) {
127
+ const key = String(slug || '').trim();
128
+ if (!key) return false;
129
+ const entry = cache.get(key);
130
+ if (!entry) return false;
131
+ if (typeof entry.expiresAt === 'number' && Date.now() > entry.expiresAt) {
132
+ cache.delete(key);
133
+ return false;
134
+ }
135
+ return true;
136
+ }
137
+
138
+ function getJsonConfigCacheInfo(slug) {
139
+ const key = String(slug || '').trim();
140
+ if (!key) return null;
141
+ const entry = cache.get(key);
142
+ if (!entry) return { exists: false };
143
+
144
+ const now = Date.now();
145
+ const isExpired = typeof entry.expiresAt === 'number' && now > entry.expiresAt;
146
+ if (isExpired) {
147
+ cache.delete(key);
148
+ return { exists: false };
149
+ }
150
+
151
+ return {
152
+ exists: true,
153
+ expiresAt: entry.expiresAt,
154
+ ttlRemaining: entry.expiresAt ? Math.max(0, Math.floor((entry.expiresAt - now) / 1000)) : null,
155
+ size: JSON.stringify(entry.value).length
156
+ };
157
+ }
158
+
159
+ function clearJsonConfigCacheIfExists(slug) {
160
+ const key = String(slug || '').trim();
161
+ if (!key) return false;
162
+ const existed = cache.has(key);
163
+ cache.delete(key);
164
+ return existed;
165
+ }
166
+
167
+ function clearJsonConfigCacheIfExpired(slug) {
168
+ const info = getJsonConfigCacheInfo(slug);
169
+ if (!info || !info.exists) return false;
170
+ if (info.ttlRemaining !== null && info.ttlRemaining <= 0) {
171
+ cache.delete(String(slug));
172
+ return true;
173
+ }
174
+ return false;
175
+ }
176
+
177
+ function clearJsonConfigCacheBatch(slugs) {
178
+ if (!Array.isArray(slugs)) return 0;
179
+ let cleared = 0;
180
+ slugs.forEach(slug => {
181
+ if (clearJsonConfigCacheIfExists(slug)) cleared++;
182
+ });
183
+ return cleared;
184
+ }
185
+
186
+ function clearJsonConfigCacheByPattern(pattern) {
187
+ if (!pattern || typeof pattern !== 'string') return 0;
188
+ const regex = new RegExp(pattern.replace(/\*/g, '.*'));
189
+ let cleared = 0;
190
+ for (const key of cache.keys()) {
191
+ if (regex.test(key)) {
192
+ cache.delete(key);
193
+ cleared++;
194
+ }
195
+ }
196
+ return cleared;
197
+ }
198
+
199
+ function getJsonConfigCacheStats() {
200
+ const now = Date.now();
201
+ let entries = 0;
202
+ let expired = 0;
203
+ let totalSize = 0;
204
+
205
+ for (const [key, entry] of cache.entries()) {
206
+ entries++;
207
+ if (typeof entry.expiresAt === 'number' && now > entry.expiresAt) {
208
+ expired++;
209
+ } else {
210
+ totalSize += JSON.stringify(entry.value).length;
211
+ }
212
+ }
213
+
214
+ return {
215
+ totalEntries: entries,
216
+ expiredEntries: expired,
217
+ activeEntries: entries - expired,
218
+ totalSizeBytes: totalSize,
219
+ keys: Array.from(cache.keys())
220
+ };
221
+ }
222
+
223
+ function getJsonConfigCacheKeys() {
224
+ return Array.from(cache.keys());
225
+ }
226
+
125
227
  async function listJsonConfigs() {
126
228
  return JsonConfig.find()
127
229
  .sort({ updatedAt: -1 })
@@ -386,16 +488,176 @@ async function getJsonConfigPublicPayload(slug, { raw = false } = {}) {
386
488
  };
387
489
  }
388
490
 
491
+ // Cache-aware update helpers
492
+ async function updateJsonConfigWithCacheInvalidation(id, patch) {
493
+ logger.log('updateJsonConfigWithCacheInvalidation called with id:', id, 'patch:', patch);
494
+
495
+ const doc = await JsonConfig.findById(id);
496
+ if (!doc) {
497
+ const err = new Error('JSON config not found');
498
+ err.code = 'NOT_FOUND';
499
+ throw err;
500
+ }
501
+
502
+ const oldSlug = doc.slug;
503
+ const oldAlias = doc.alias;
504
+
505
+ // Apply updates using existing update logic
506
+ if (patch && Object.prototype.hasOwnProperty.call(patch, 'title')) {
507
+ const title = String(patch.title || '').trim();
508
+ if (!title) {
509
+ const err = new Error('title is required');
510
+ err.code = 'VALIDATION';
511
+ throw err;
512
+ }
513
+ doc.title = title;
514
+ }
515
+
516
+ if (patch && Object.prototype.hasOwnProperty.call(patch, 'publicEnabled')) {
517
+ doc.publicEnabled = Boolean(patch.publicEnabled);
518
+ }
519
+
520
+ if (patch && Object.prototype.hasOwnProperty.call(patch, 'cacheTtlSeconds')) {
521
+ const ttl = Number(patch.cacheTtlSeconds || 0);
522
+ doc.cacheTtlSeconds = Number.isNaN(ttl) ? 0 : Math.max(0, ttl);
523
+ }
524
+
525
+ if (patch && Object.prototype.hasOwnProperty.call(patch, 'jsonRaw')) {
526
+ if (patch.jsonRaw === null || patch.jsonRaw === undefined) {
527
+ const err = new Error('jsonRaw is required');
528
+ err.code = 'VALIDATION';
529
+ throw err;
530
+ }
531
+
532
+ parseJsonOrThrow(patch.jsonRaw);
533
+ doc.jsonRaw = String(patch.jsonRaw);
534
+ doc.jsonHash = computeJsonHash(doc.jsonRaw);
535
+ }
536
+
537
+ if (patch && Object.prototype.hasOwnProperty.call(patch, 'alias')) {
538
+ const newAlias = patch.alias;
539
+
540
+ if (newAlias === null || newAlias === undefined || newAlias === '') {
541
+ doc.alias = undefined;
542
+ } else {
543
+ const normalizedAlias = normalizeAlias(newAlias);
544
+
545
+ if (!normalizedAlias) {
546
+ const err = new Error('Invalid alias format');
547
+ err.code = 'VALIDATION';
548
+ throw err;
549
+ }
550
+
551
+ if (!(await validateAliasUniqueness(normalizedAlias, doc._id))) {
552
+ const err = new Error('Alias must be unique and not conflict with existing slugs or aliases');
553
+ err.code = 'ALIAS_NOT_UNIQUE';
554
+ throw err;
555
+ }
556
+
557
+ doc.alias = normalizedAlias;
558
+ }
559
+ }
560
+
561
+ await doc.save();
562
+
563
+ // Clear all related cache entries
564
+ clearJsonConfigCache(oldSlug);
565
+ clearJsonConfigCache(doc.slug);
566
+ if (oldAlias) {
567
+ clearJsonConfigCache(oldAlias);
568
+ }
569
+ if (doc.alias) {
570
+ clearJsonConfigCache(doc.alias);
571
+ }
572
+
573
+ return doc.toObject();
574
+ }
575
+
576
+ async function updateJsonConfigValueBySlug(slug, updateFn, options = {}) {
577
+ const key = String(slug || '').trim();
578
+ if (!key) {
579
+ const err = new Error('slug is required');
580
+ err.code = 'VALIDATION';
581
+ throw err;
582
+ }
583
+
584
+ if (typeof updateFn !== 'function') {
585
+ const err = new Error('updateFn must be a function');
586
+ err.code = 'VALIDATION';
587
+ throw err;
588
+ }
589
+
590
+ // Get current value (bypass cache to ensure fresh data)
591
+ const currentValue = await getJsonConfigValueBySlug(key, { bypassCache: true });
592
+
593
+ // Apply update function
594
+ let newValue;
595
+ try {
596
+ newValue = await updateFn(currentValue);
597
+ } catch (error) {
598
+ const err = new Error(`Update function failed: ${error.message}`);
599
+ err.code = 'UPDATE_FAILED';
600
+ err.cause = error;
601
+ throw err;
602
+ }
603
+
604
+ // Validate new value
605
+ const jsonRaw = JSON.stringify(newValue);
606
+ parseJsonOrThrow(jsonRaw); // This will throw if invalid JSON
607
+
608
+ // Find the document
609
+ const doc = await JsonConfig.findOne({
610
+ $or: [
611
+ { slug: key },
612
+ { alias: key }
613
+ ]
614
+ });
615
+
616
+ if (!doc) {
617
+ const err = new Error('JSON config not found');
618
+ err.code = 'NOT_FOUND';
619
+ throw err;
620
+ }
621
+
622
+ // Update the document
623
+ doc.jsonRaw = jsonRaw;
624
+ doc.jsonHash = computeJsonHash(jsonRaw);
625
+ await doc.save();
626
+
627
+ // Clear cache if requested (default: true)
628
+ if (options.invalidateCache !== false) {
629
+ clearJsonConfigCache(doc.slug);
630
+ if (doc.alias) {
631
+ clearJsonConfigCache(doc.alias);
632
+ }
633
+ clearJsonConfigCache(key);
634
+ }
635
+
636
+ return newValue;
637
+ }
638
+
389
639
  module.exports = {
390
640
  normalizeSlugBase,
391
641
  generateUniqueSlugFromTitle,
392
642
  parseJsonOrThrow,
393
643
  clearJsonConfigCache,
394
644
  clearAllJsonConfigCache,
645
+ // Enhanced cache helpers
646
+ isJsonConfigCached,
647
+ getJsonConfigCacheInfo,
648
+ clearJsonConfigCacheIfExists,
649
+ clearJsonConfigCacheIfExpired,
650
+ clearJsonConfigCacheBatch,
651
+ clearJsonConfigCacheByPattern,
652
+ getJsonConfigCacheStats,
653
+ getJsonConfigCacheKeys,
654
+ // Core functions
395
655
  listJsonConfigs,
396
656
  getJsonConfigById,
397
657
  createJsonConfig,
398
658
  updateJsonConfig,
659
+ updateJsonConfigWithCacheInvalidation,
660
+ updateJsonConfigValueBySlug,
399
661
  regenerateJsonConfigSlug,
400
662
  deleteJsonConfig,
401
663
  getJsonConfig: getJsonConfigValueBySlug,