@intranefr/superbackend 1.4.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.
Files changed (188) hide show
  1. package/.commiat +4 -0
  2. package/.env.example +47 -0
  3. package/README.md +110 -0
  4. package/index.js +94 -0
  5. package/package.json +67 -0
  6. package/public/css/styles.css +139 -0
  7. package/public/js/animations.js +41 -0
  8. package/sdk/error-tracking/browser/package.json +16 -0
  9. package/sdk/error-tracking/browser/src/core.js +270 -0
  10. package/sdk/error-tracking/browser/src/embed.js +18 -0
  11. package/sdk/error-tracking/browser/src/index.js +1 -0
  12. package/server.js +5 -0
  13. package/src/admin/endpointRegistry.js +300 -0
  14. package/src/controllers/admin.controller.js +321 -0
  15. package/src/controllers/adminAssets.controller.js +530 -0
  16. package/src/controllers/adminAssetsStorage.controller.js +260 -0
  17. package/src/controllers/adminEjsVirtual.controller.js +354 -0
  18. package/src/controllers/adminFeatureFlags.controller.js +155 -0
  19. package/src/controllers/adminHeadless.controller.js +1071 -0
  20. package/src/controllers/adminI18n.controller.js +604 -0
  21. package/src/controllers/adminJsonConfigs.controller.js +97 -0
  22. package/src/controllers/adminLlm.controller.js +273 -0
  23. package/src/controllers/adminMigration.controller.js +257 -0
  24. package/src/controllers/adminSeoConfig.controller.js +515 -0
  25. package/src/controllers/adminStats.controller.js +121 -0
  26. package/src/controllers/adminUploadNamespaces.controller.js +208 -0
  27. package/src/controllers/assets.controller.js +248 -0
  28. package/src/controllers/auth.controller.js +93 -0
  29. package/src/controllers/billing.controller.js +223 -0
  30. package/src/controllers/featureFlags.controller.js +35 -0
  31. package/src/controllers/forms.controller.js +217 -0
  32. package/src/controllers/globalSettings.controller.js +252 -0
  33. package/src/controllers/headlessCrud.controller.js +126 -0
  34. package/src/controllers/i18n.controller.js +12 -0
  35. package/src/controllers/invite.controller.js +249 -0
  36. package/src/controllers/jsonConfigs.controller.js +19 -0
  37. package/src/controllers/metrics.controller.js +149 -0
  38. package/src/controllers/notificationAdmin.controller.js +264 -0
  39. package/src/controllers/notifications.controller.js +131 -0
  40. package/src/controllers/org.controller.js +357 -0
  41. package/src/controllers/orgAdmin.controller.js +491 -0
  42. package/src/controllers/stripeAdmin.controller.js +410 -0
  43. package/src/controllers/user.controller.js +361 -0
  44. package/src/controllers/userAdmin.controller.js +277 -0
  45. package/src/controllers/waitingList.controller.js +167 -0
  46. package/src/controllers/webhook.controller.js +200 -0
  47. package/src/middleware/auth.js +66 -0
  48. package/src/middleware/errorCapture.js +170 -0
  49. package/src/middleware/headlessApiTokenAuth.js +57 -0
  50. package/src/middleware/org.js +108 -0
  51. package/src/middleware.js +901 -0
  52. package/src/models/ActionEvent.js +31 -0
  53. package/src/models/ActivityLog.js +41 -0
  54. package/src/models/Asset.js +84 -0
  55. package/src/models/AuditEvent.js +93 -0
  56. package/src/models/EmailLog.js +28 -0
  57. package/src/models/ErrorAggregate.js +72 -0
  58. package/src/models/FormSubmission.js +41 -0
  59. package/src/models/GlobalSetting.js +38 -0
  60. package/src/models/HeadlessApiToken.js +24 -0
  61. package/src/models/HeadlessModelDefinition.js +41 -0
  62. package/src/models/I18nEntry.js +77 -0
  63. package/src/models/I18nLocale.js +33 -0
  64. package/src/models/Invite.js +70 -0
  65. package/src/models/JsonConfig.js +46 -0
  66. package/src/models/Notification.js +60 -0
  67. package/src/models/Organization.js +57 -0
  68. package/src/models/OrganizationMember.js +43 -0
  69. package/src/models/StripeCatalogItem.js +77 -0
  70. package/src/models/StripeWebhookEvent.js +57 -0
  71. package/src/models/User.js +89 -0
  72. package/src/models/VirtualEjsFile.js +60 -0
  73. package/src/models/VirtualEjsFileVersion.js +43 -0
  74. package/src/models/VirtualEjsGroupChange.js +32 -0
  75. package/src/models/WaitingList.js +41 -0
  76. package/src/models/Webhook.js +63 -0
  77. package/src/models/Workflow.js +29 -0
  78. package/src/models/WorkflowExecution.js +12 -0
  79. package/src/routes/admin.routes.js +26 -0
  80. package/src/routes/adminAssets.routes.js +28 -0
  81. package/src/routes/adminAssetsStorage.routes.js +13 -0
  82. package/src/routes/adminAudit.routes.js +196 -0
  83. package/src/routes/adminEjsVirtual.routes.js +17 -0
  84. package/src/routes/adminErrors.routes.js +164 -0
  85. package/src/routes/adminFeatureFlags.routes.js +12 -0
  86. package/src/routes/adminHeadless.routes.js +38 -0
  87. package/src/routes/adminI18n.routes.js +22 -0
  88. package/src/routes/adminJsonConfigs.routes.js +15 -0
  89. package/src/routes/adminLlm.routes.js +12 -0
  90. package/src/routes/adminMigration.routes.js +81 -0
  91. package/src/routes/adminSeoConfig.routes.js +20 -0
  92. package/src/routes/adminUploadNamespaces.routes.js +13 -0
  93. package/src/routes/assets.routes.js +21 -0
  94. package/src/routes/auth.routes.js +12 -0
  95. package/src/routes/billing.routes.js +11 -0
  96. package/src/routes/errorTracking.routes.js +31 -0
  97. package/src/routes/featureFlags.routes.js +9 -0
  98. package/src/routes/forms.routes.js +9 -0
  99. package/src/routes/formsAdmin.routes.js +13 -0
  100. package/src/routes/globalSettings.routes.js +18 -0
  101. package/src/routes/headless.routes.js +15 -0
  102. package/src/routes/i18n.routes.js +8 -0
  103. package/src/routes/invite.routes.js +9 -0
  104. package/src/routes/jsonConfigs.routes.js +8 -0
  105. package/src/routes/log.routes.js +111 -0
  106. package/src/routes/metrics.routes.js +9 -0
  107. package/src/routes/notificationAdmin.routes.js +15 -0
  108. package/src/routes/notifications.routes.js +12 -0
  109. package/src/routes/org.routes.js +31 -0
  110. package/src/routes/orgAdmin.routes.js +20 -0
  111. package/src/routes/publicAssets.routes.js +7 -0
  112. package/src/routes/stripeAdmin.routes.js +20 -0
  113. package/src/routes/user.routes.js +22 -0
  114. package/src/routes/userAdmin.routes.js +15 -0
  115. package/src/routes/waitingList.routes.js +13 -0
  116. package/src/routes/waitingListAdmin.routes.js +9 -0
  117. package/src/routes/webhook.routes.js +32 -0
  118. package/src/routes/workflowWebhook.routes.js +54 -0
  119. package/src/routes/workflows.routes.js +110 -0
  120. package/src/services/assets.service.js +110 -0
  121. package/src/services/audit.service.js +62 -0
  122. package/src/services/auditLogger.js +165 -0
  123. package/src/services/ejsVirtual.service.js +614 -0
  124. package/src/services/email.service.js +351 -0
  125. package/src/services/errorLogger.js +221 -0
  126. package/src/services/featureFlags.service.js +202 -0
  127. package/src/services/forms.service.js +214 -0
  128. package/src/services/globalSettings.service.js +49 -0
  129. package/src/services/headlessApiTokens.service.js +158 -0
  130. package/src/services/headlessCrypto.service.js +31 -0
  131. package/src/services/headlessModels.service.js +356 -0
  132. package/src/services/i18n.service.js +314 -0
  133. package/src/services/i18nInferredKeys.service.js +337 -0
  134. package/src/services/jsonConfigs.service.js +392 -0
  135. package/src/services/llm.service.js +749 -0
  136. package/src/services/migration.service.js +581 -0
  137. package/src/services/migrationAssets/fsLocal.js +58 -0
  138. package/src/services/migrationAssets/index.js +134 -0
  139. package/src/services/migrationAssets/s3.js +75 -0
  140. package/src/services/migrationAssets/sftp.js +92 -0
  141. package/src/services/notification.service.js +212 -0
  142. package/src/services/objectStorage.service.js +514 -0
  143. package/src/services/seoConfig.service.js +402 -0
  144. package/src/services/storage.js +150 -0
  145. package/src/services/stripe.service.js +185 -0
  146. package/src/services/stripeHelper.service.js +264 -0
  147. package/src/services/uploadNamespaces.service.js +326 -0
  148. package/src/services/webhook.service.js +157 -0
  149. package/src/services/workflow.service.js +271 -0
  150. package/src/utils/asyncHandler.js +5 -0
  151. package/src/utils/encryption.js +80 -0
  152. package/src/utils/jwt.js +40 -0
  153. package/src/utils/orgRoles.js +156 -0
  154. package/src/utils/validation.js +26 -0
  155. package/src/utils/webhookRetry.js +93 -0
  156. package/views/admin-assets.ejs +444 -0
  157. package/views/admin-audit.ejs +283 -0
  158. package/views/admin-coolify-deploy.ejs +207 -0
  159. package/views/admin-dashboard-home.ejs +291 -0
  160. package/views/admin-dashboard.ejs +397 -0
  161. package/views/admin-ejs-virtual.ejs +280 -0
  162. package/views/admin-errors.ejs +368 -0
  163. package/views/admin-feature-flags.ejs +390 -0
  164. package/views/admin-forms.ejs +526 -0
  165. package/views/admin-global-settings.ejs +436 -0
  166. package/views/admin-headless.ejs +2020 -0
  167. package/views/admin-i18n-locales.ejs +221 -0
  168. package/views/admin-i18n.ejs +728 -0
  169. package/views/admin-json-configs.ejs +410 -0
  170. package/views/admin-llm.ejs +884 -0
  171. package/views/admin-metrics.ejs +274 -0
  172. package/views/admin-migration.ejs +814 -0
  173. package/views/admin-notifications.ejs +430 -0
  174. package/views/admin-organizations.ejs +984 -0
  175. package/views/admin-seo-config.ejs +673 -0
  176. package/views/admin-stripe-pricing.ejs +558 -0
  177. package/views/admin-test.ejs +342 -0
  178. package/views/admin-users.ejs +452 -0
  179. package/views/admin-waiting-list.ejs +547 -0
  180. package/views/admin-webhooks.ejs +329 -0
  181. package/views/admin-workflows.ejs +310 -0
  182. package/views/partials/admin-assets-script.ejs +2022 -0
  183. package/views/partials/admin-test-sidebar.ejs +14 -0
  184. package/views/partials/dashboard/nav-items.ejs +66 -0
  185. package/views/partials/dashboard/palette.ejs +63 -0
  186. package/views/partials/dashboard/sidebar.ejs +21 -0
  187. package/views/partials/dashboard/tab-bar.ejs +26 -0
  188. package/views/partials/footer.ejs +3 -0
@@ -0,0 +1,337 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const ejs = require('ejs');
4
+ const cheerio = require('cheerio');
5
+
6
+ const cache = {
7
+ timestamp: 0,
8
+ ttlMs: 30000,
9
+ signature: '',
10
+ keys: [],
11
+ entries: {},
12
+ };
13
+
14
+ function toPosixPath(p) {
15
+ return String(p).split(path.sep).join('/');
16
+ }
17
+
18
+ function escapeRegex(s) {
19
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
20
+ }
21
+
22
+ function patternToRegex(pattern) {
23
+ // Minimal .gitignore-like glob to regex:
24
+ // - supports *, ?, **
25
+ // - supports leading '/' (anchored at repo root)
26
+ // - supports trailing '/' (directory prefix)
27
+ const anchored = pattern.startsWith('/');
28
+ let p = anchored ? pattern.slice(1) : pattern;
29
+ const dirOnly = p.endsWith('/');
30
+ if (dirOnly) p = p.slice(0, -1);
31
+
32
+ let re = '';
33
+ for (let i = 0; i < p.length; i += 1) {
34
+ const ch = p[i];
35
+ const next = p[i + 1];
36
+
37
+ if (ch === '*' && next === '*') {
38
+ re += '.*';
39
+ i += 1;
40
+ continue;
41
+ }
42
+ if (ch === '*') {
43
+ re += '[^/]*';
44
+ continue;
45
+ }
46
+ if (ch === '?') {
47
+ re += '[^/]';
48
+ continue;
49
+ }
50
+
51
+ re += escapeRegex(ch);
52
+ }
53
+
54
+ if (anchored) {
55
+ // Match from root
56
+ return new RegExp(`^${re}${dirOnly ? '(/|$)' : '$'}`);
57
+ }
58
+
59
+ // Match anywhere in the path
60
+ return new RegExp(`(^|/)${re}${dirOnly ? '(/|$)' : '$'}`);
61
+ }
62
+
63
+ function loadGitignoreMatchers(rootDir) {
64
+ const ignoreFile = path.join(rootDir, '.gitignore');
65
+ if (!fs.existsSync(ignoreFile)) {
66
+ return [];
67
+ }
68
+
69
+ let raw = '';
70
+ try {
71
+ raw = fs.readFileSync(ignoreFile, 'utf8');
72
+ } catch {
73
+ return [];
74
+ }
75
+
76
+ const lines = raw.split(/\r?\n/);
77
+ const matchers = [];
78
+ for (const lineRaw of lines) {
79
+ const line = String(lineRaw || '').trim();
80
+ if (!line) continue;
81
+ if (line.startsWith('#')) continue;
82
+
83
+ // Negation is not supported in this lightweight matcher.
84
+ if (line.startsWith('!')) continue;
85
+
86
+ matchers.push(patternToRegex(line));
87
+ }
88
+ return matchers;
89
+ }
90
+
91
+ function createIgnoreFn(rootDir) {
92
+ const rootPosix = toPosixPath(rootDir);
93
+ const matchers = loadGitignoreMatchers(rootDir);
94
+
95
+ return function isIgnored(absPath) {
96
+ const rel = toPosixPath(path.relative(rootDir, absPath));
97
+ if (!rel || rel === '.') return false;
98
+
99
+ // Always ignore these.
100
+ if (rel === 'node_modules' || rel.startsWith('node_modules/')) return true;
101
+ if (rel === '.git' || rel.startsWith('.git/')) return true;
102
+
103
+ // Apply .gitignore matchers
104
+ for (const re of matchers) {
105
+ if (re.test(rel)) return true;
106
+ }
107
+
108
+ // Also ignore any path that escapes root (defensive)
109
+ const absPosix = toPosixPath(absPath);
110
+ if (!absPosix.startsWith(rootPosix)) return true;
111
+
112
+ return false;
113
+ };
114
+ }
115
+
116
+ function parseDirList(raw) {
117
+ if (!raw) return [];
118
+ return String(raw)
119
+ .split(',')
120
+ .map((s) => s.trim())
121
+ .filter(Boolean);
122
+ }
123
+
124
+ function getDefaultScanDirs() {
125
+ const envDirs = parseDirList(process.env.I18N_SCAN_VIEW_DIRS);
126
+ if (envDirs.length > 0) return envDirs;
127
+
128
+ // Generic default: scan the repository root (cwd) recursively.
129
+ return [process.cwd()];
130
+ }
131
+
132
+ function walkFilesSync(dir, out, ignoreFn) {
133
+ if (!fs.existsSync(dir)) return;
134
+ if (ignoreFn && ignoreFn(dir)) return;
135
+
136
+ const stat = fs.statSync(dir);
137
+ if (!stat.isDirectory()) return;
138
+
139
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
140
+ for (const ent of entries) {
141
+ const full = path.join(dir, ent.name);
142
+ if (ent.isDirectory()) {
143
+ if (ent.name === 'node_modules' || ent.name === '.git') continue;
144
+ if (ignoreFn && ignoreFn(full)) continue;
145
+ walkFilesSync(full, out, ignoreFn);
146
+ continue;
147
+ }
148
+
149
+ if (ent.isFile() && ent.name.endsWith('.ejs')) {
150
+ if (ignoreFn && ignoreFn(full)) continue;
151
+ try {
152
+ const st = fs.statSync(full);
153
+ out.push({ filePath: full, mtimeMs: st.mtimeMs, size: st.size });
154
+ } catch {
155
+ // ignore
156
+ }
157
+ }
158
+ }
159
+ }
160
+
161
+ function computeSignature(files) {
162
+ return files
163
+ .map((f) => `${f.filePath}:${f.mtimeMs}:${f.size}`)
164
+ .sort()
165
+ .join('|');
166
+ }
167
+
168
+ function extractKeysFromEjsSource(src, { includeTCalls }) {
169
+ const keys = new Set();
170
+
171
+ // data-i18n-key="..." or '...'
172
+ const datasetRe = /data-i18n-key\s*=\s*(["'])([^"']+)\1/g;
173
+ for (let m = datasetRe.exec(src); m; m = datasetRe.exec(src)) {
174
+ const key = String(m[2] || '').trim();
175
+ if (key) keys.add(key);
176
+ }
177
+
178
+ if (includeTCalls) {
179
+ // t('foo.bar') or t("foo.bar")
180
+ const tCallRe = /\bt\s*\(\s*(["'])([^"']+)\1\s*[\),]/g;
181
+ for (let m = tCallRe.exec(src); m; m = tCallRe.exec(src)) {
182
+ const key = String(m[2] || '').trim();
183
+ if (key) keys.add(key);
184
+ }
185
+ }
186
+
187
+ return Array.from(keys);
188
+ }
189
+
190
+ function normalizeText(value) {
191
+ return String(value || '')
192
+ .replace(/\s+/g, ' ')
193
+ .trim();
194
+ }
195
+
196
+ function buildDefaultEjsLocals(filename) {
197
+ return {
198
+ title: '',
199
+ description: '',
200
+ canonicalUrl: '',
201
+ robots: '',
202
+ publicUrl: '',
203
+ assetVersion: '',
204
+ i18nInjectMeta: '0',
205
+ locale: 'fr',
206
+ defaultLocale: 'fr',
207
+ t: (key, vars, opts) => {
208
+ if (opts && typeof opts.defaultValue === 'string') return opts.defaultValue;
209
+ return key;
210
+ },
211
+ // Some templates expect an app root Vue mount; keep it minimal.
212
+ state: {},
213
+ filename,
214
+ };
215
+ }
216
+
217
+ function inferEntriesFromRenderedHtml(html) {
218
+ const $ = cheerio.load(String(html || ''), { decodeEntities: false });
219
+ const out = {};
220
+
221
+ $('[data-i18n-key]').each((_, el) => {
222
+ const key = $(el).attr('data-i18n-key');
223
+ if (!key) return;
224
+
225
+ const attrList = $(el).attr('data-i18n-attr');
226
+ const wantsHtml = $(el).is('[data-i18n-html]');
227
+
228
+ if (attrList) {
229
+ const attrs = String(attrList)
230
+ .split(',')
231
+ .map((s) => s.trim())
232
+ .filter(Boolean);
233
+ if (attrs.length === 0) return;
234
+
235
+ // Store the first attribute value as the inferred default.
236
+ const a = attrs[0];
237
+ const v = $(el).attr(a);
238
+ if (typeof v !== 'string' || v.trim() === '') return;
239
+
240
+ out[key] = { value: v, valueFormat: 'text' };
241
+ return;
242
+ }
243
+
244
+ if (wantsHtml) {
245
+ const v = $(el).html();
246
+ if (typeof v !== 'string' || v.trim() === '') return;
247
+ out[key] = { value: v.trim(), valueFormat: 'html' };
248
+ return;
249
+ }
250
+
251
+ const txt = normalizeText($(el).text());
252
+ if (!txt) return;
253
+ out[key] = { value: txt, valueFormat: 'text' };
254
+ });
255
+
256
+ return out;
257
+ }
258
+
259
+ function scanOnce({ viewDirs, includeTCalls }) {
260
+ const dirs = Array.isArray(viewDirs) && viewDirs.length > 0 ? viewDirs : getDefaultScanDirs();
261
+
262
+ const rootDir = process.cwd();
263
+ const ignoreFn = createIgnoreFn(rootDir);
264
+
265
+ const files = [];
266
+ for (const d of dirs) {
267
+ const abs = path.isAbsolute(d) ? d : path.join(process.cwd(), d);
268
+ walkFilesSync(abs, files, ignoreFn);
269
+ }
270
+
271
+ const signature = computeSignature(files);
272
+ const now = Date.now();
273
+
274
+ if (cache.keys.length > 0 && cache.signature === signature && now - cache.timestamp < cache.ttlMs) {
275
+ return cache.keys;
276
+ }
277
+
278
+ const keys = new Set();
279
+ const inferredEntries = {};
280
+ for (const f of files) {
281
+ try {
282
+ const src = fs.readFileSync(f.filePath, 'utf8');
283
+ const extracted = extractKeysFromEjsSource(src, { includeTCalls });
284
+ for (const k of extracted) keys.add(k);
285
+
286
+ // Render then parse to infer default values (best-effort)
287
+ try {
288
+ const rendered = ejs.render(src, buildDefaultEjsLocals(f.filePath), { filename: f.filePath });
289
+ const nextEntries = inferEntriesFromRenderedHtml(rendered);
290
+ for (const [k, v] of Object.entries(nextEntries)) {
291
+ // First value wins (deterministic due to file ordering by signature sort)
292
+ if (!inferredEntries[k]) inferredEntries[k] = v;
293
+ }
294
+ } catch {
295
+ // If render fails, we still keep keys extracted from the raw source.
296
+ }
297
+ } catch {
298
+ // ignore file read errors
299
+ }
300
+ }
301
+
302
+ const sorted = Array.from(keys).sort();
303
+ cache.keys = sorted;
304
+ cache.entries = inferredEntries;
305
+ cache.signature = signature;
306
+ cache.timestamp = now;
307
+
308
+ return sorted;
309
+ }
310
+
311
+ function getInferredI18nKeys(options = {}) {
312
+ return scanOnce({
313
+ viewDirs: options.viewDirs,
314
+ includeTCalls: options.includeTCalls === true,
315
+ });
316
+ }
317
+
318
+ function getInferredI18nEntries(options = {}) {
319
+ scanOnce({
320
+ viewDirs: options.viewDirs,
321
+ includeTCalls: options.includeTCalls === true,
322
+ });
323
+ return cache.entries || {};
324
+ }
325
+
326
+ function clearInferredI18nKeysCache() {
327
+ cache.timestamp = 0;
328
+ cache.signature = '';
329
+ cache.keys = [];
330
+ cache.entries = {};
331
+ }
332
+
333
+ module.exports = {
334
+ getInferredI18nKeys,
335
+ getInferredI18nEntries,
336
+ clearInferredI18nKeysCache,
337
+ };
@@ -0,0 +1,392 @@
1
+ const crypto = require('crypto');
2
+
3
+ const JsonConfig = require('../models/JsonConfig');
4
+
5
+ const cache = new Map();
6
+
7
+ function normalizeSlugBase(title) {
8
+ const str = String(title || '').trim().toLowerCase();
9
+ if (!str) return 'config';
10
+
11
+ const slug = str
12
+ .normalize('NFKD')
13
+ .replace(/[\u0300-\u036f]/g, '')
14
+ .replace(/[^a-z0-9]+/g, '-')
15
+ .replace(/(^-|-$)/g, '')
16
+ .replace(/-{2,}/g, '-');
17
+
18
+ return slug || 'config';
19
+ }
20
+
21
+ function randomSuffix4() {
22
+ return crypto.randomBytes(2).toString('hex');
23
+ }
24
+
25
+ async function generateUniqueSlugFromTitle(title, { maxAttempts = 10 } = {}) {
26
+ const base = normalizeSlugBase(title);
27
+
28
+ for (let i = 0; i < maxAttempts; i += 1) {
29
+ const candidate = `${base}-${randomSuffix4()}`;
30
+ // eslint-disable-next-line no-await-in-loop
31
+ const existing = await JsonConfig.findOne({ slug: candidate }).select('_id').lean();
32
+ if (!existing) return candidate;
33
+ }
34
+
35
+ throw new Error('Failed to generate unique slug');
36
+ }
37
+
38
+ function normalizeAlias(alias) {
39
+ const str = String(alias || '').trim().toLowerCase();
40
+ if (!str) return '';
41
+
42
+ const normalized = str
43
+ .normalize('NFKD')
44
+ .replace(/[\u0300-\u036f]/g, '')
45
+ .replace(/[^a-z0-9]+/g, '-')
46
+ .replace(/(^-|-$)/g, '')
47
+ .replace(/-{2,}/g, '-');
48
+
49
+ return normalized;
50
+ }
51
+
52
+ async function validateAliasUniqueness(alias, excludeId = null) {
53
+ if (!alias) return true;
54
+
55
+ const normalizedAlias = normalizeAlias(alias);
56
+ if (!normalizedAlias) return false;
57
+
58
+ const query = {
59
+ $or: [
60
+ { slug: normalizedAlias },
61
+ { alias: normalizedAlias }
62
+ ]
63
+ };
64
+
65
+ if (excludeId) {
66
+ query._id = { $ne: excludeId };
67
+ }
68
+
69
+ const existing = await JsonConfig.findOne(query).select('_id').lean();
70
+ return !existing;
71
+ }
72
+
73
+ function computeJsonHash(jsonRaw) {
74
+ return crypto.createHash('sha256').update(String(jsonRaw || ''), 'utf8').digest('hex');
75
+ }
76
+
77
+ function parseJsonOrThrow(jsonRaw) {
78
+ try {
79
+ return JSON.parse(String(jsonRaw));
80
+ } catch (e) {
81
+ const msg = e && e.message ? e.message : 'Invalid JSON';
82
+ const err = new Error(msg);
83
+ err.code = 'INVALID_JSON';
84
+ throw err;
85
+ }
86
+ }
87
+
88
+ function getCached(slug) {
89
+ const entry = cache.get(slug);
90
+ if (!entry) return null;
91
+ if (typeof entry.expiresAt === 'number' && Date.now() > entry.expiresAt) {
92
+ cache.delete(slug);
93
+ return null;
94
+ }
95
+ return entry.value;
96
+ }
97
+
98
+ function setCached(slug, value, ttlSeconds) {
99
+ const ttl = Number(ttlSeconds || 0);
100
+ if (Number.isNaN(ttl) || ttl <= 0) return;
101
+ cache.set(slug, { value, expiresAt: Date.now() + ttl * 1000 });
102
+ }
103
+
104
+ function clearJsonConfigCache(slug) {
105
+ if (!slug) return;
106
+ cache.delete(String(slug));
107
+ }
108
+
109
+ function clearAllJsonConfigCache() {
110
+ cache.clear();
111
+ }
112
+
113
+ async function listJsonConfigs() {
114
+ return JsonConfig.find()
115
+ .sort({ updatedAt: -1 })
116
+ .select('title slug alias publicEnabled cacheTtlSeconds updatedAt createdAt')
117
+ .lean();
118
+ }
119
+
120
+ async function getJsonConfigById(id) {
121
+ return JsonConfig.findById(id).lean();
122
+ }
123
+
124
+ async function createJsonConfig({ title, jsonRaw, publicEnabled = false, cacheTtlSeconds = 0, alias }) {
125
+ console.log('createJsonConfig called with:', { title, jsonRaw, publicEnabled, cacheTtlSeconds, alias });
126
+
127
+ const normalizedTitle = String(title || '').trim();
128
+ if (!normalizedTitle) {
129
+ const err = new Error('title is required');
130
+ err.code = 'VALIDATION';
131
+ throw err;
132
+ }
133
+
134
+ if (jsonRaw === undefined || jsonRaw === null) {
135
+ const err = new Error('jsonRaw is required');
136
+ err.code = 'VALIDATION';
137
+ throw err;
138
+ }
139
+
140
+ parseJsonOrThrow(jsonRaw);
141
+
142
+ let normalizedAlias = null;
143
+ if (alias !== undefined && alias !== null) {
144
+ normalizedAlias = normalizeAlias(alias);
145
+ console.log('Normalized alias:', normalizedAlias);
146
+ if (normalizedAlias && !(await validateAliasUniqueness(normalizedAlias))) {
147
+ const err = new Error('Alias must be unique and not conflict with existing slugs or aliases');
148
+ err.code = 'ALIAS_NOT_UNIQUE';
149
+ throw err;
150
+ }
151
+ }
152
+
153
+ const slug = await generateUniqueSlugFromTitle(normalizedTitle);
154
+
155
+ const createData = {
156
+ title: normalizedTitle,
157
+ slug,
158
+ alias: normalizedAlias || undefined,
159
+ publicEnabled: Boolean(publicEnabled),
160
+ cacheTtlSeconds: Number(cacheTtlSeconds || 0) || 0,
161
+ jsonRaw: String(jsonRaw),
162
+ jsonHash: computeJsonHash(String(jsonRaw)),
163
+ };
164
+
165
+ console.log('Creating document with data:', createData);
166
+
167
+ const doc = await JsonConfig.create(createData);
168
+ console.log('Created document:', doc.toObject());
169
+
170
+ clearJsonConfigCache(slug);
171
+ if (normalizedAlias) {
172
+ clearJsonConfigCache(normalizedAlias);
173
+ }
174
+ return doc.toObject();
175
+ }
176
+
177
+ async function updateJsonConfig(id, patch) {
178
+ console.log('updateJsonConfig called with id:', id, 'patch:', patch);
179
+
180
+ const doc = await JsonConfig.findById(id);
181
+ if (!doc) {
182
+ const err = new Error('JSON config not found');
183
+ err.code = 'NOT_FOUND';
184
+ throw err;
185
+ }
186
+
187
+ console.log('Found document:', doc.toObject());
188
+
189
+ const oldSlug = doc.slug;
190
+ const oldAlias = doc.alias;
191
+
192
+ if (patch && Object.prototype.hasOwnProperty.call(patch, 'title')) {
193
+ const title = String(patch.title || '').trim();
194
+ if (!title) {
195
+ const err = new Error('title is required');
196
+ err.code = 'VALIDATION';
197
+ throw err;
198
+ }
199
+ doc.title = title;
200
+ }
201
+
202
+ if (patch && Object.prototype.hasOwnProperty.call(patch, 'publicEnabled')) {
203
+ doc.publicEnabled = Boolean(patch.publicEnabled);
204
+ }
205
+
206
+ if (patch && Object.prototype.hasOwnProperty.call(patch, 'cacheTtlSeconds')) {
207
+ const ttl = Number(patch.cacheTtlSeconds || 0);
208
+ doc.cacheTtlSeconds = Number.isNaN(ttl) ? 0 : Math.max(0, ttl);
209
+ }
210
+
211
+ if (patch && Object.prototype.hasOwnProperty.call(patch, 'jsonRaw')) {
212
+ if (patch.jsonRaw === null || patch.jsonRaw === undefined) {
213
+ const err = new Error('jsonRaw is required');
214
+ err.code = 'VALIDATION';
215
+ throw err;
216
+ }
217
+
218
+ parseJsonOrThrow(patch.jsonRaw);
219
+ doc.jsonRaw = String(patch.jsonRaw);
220
+ doc.jsonHash = computeJsonHash(doc.jsonRaw);
221
+ }
222
+
223
+ if (patch && Object.prototype.hasOwnProperty.call(patch, 'alias')) {
224
+ const newAlias = patch.alias;
225
+ console.log('Processing alias update. newAlias:', newAlias);
226
+
227
+ if (newAlias === null || newAlias === undefined || newAlias === '') {
228
+ doc.alias = undefined;
229
+ console.log('Setting alias to undefined');
230
+ } else {
231
+ const normalizedAlias = normalizeAlias(newAlias);
232
+ console.log('Normalized alias for update:', normalizedAlias);
233
+
234
+ if (!normalizedAlias) {
235
+ const err = new Error('Invalid alias format');
236
+ err.code = 'VALIDATION';
237
+ throw err;
238
+ }
239
+
240
+ if (!(await validateAliasUniqueness(normalizedAlias, id))) {
241
+ const err = new Error('Alias must be unique and not conflict with existing slugs or aliases');
242
+ err.code = 'ALIAS_NOT_UNIQUE';
243
+ throw err;
244
+ }
245
+
246
+ doc.alias = normalizedAlias;
247
+ console.log('Setting alias to:', normalizedAlias);
248
+ }
249
+ }
250
+
251
+ if (!doc.slug || String(doc.slug).trim() === '') {
252
+ doc.slug = await generateUniqueSlugFromTitle(doc.title);
253
+ }
254
+
255
+ console.log('Document before save:', doc.toObject());
256
+ await doc.save();
257
+ console.log('Document after save:', doc.toObject());
258
+
259
+ clearJsonConfigCache(oldSlug);
260
+ clearJsonConfigCache(doc.slug);
261
+ if (oldAlias) {
262
+ clearJsonConfigCache(oldAlias);
263
+ }
264
+ if (doc.alias) {
265
+ clearJsonConfigCache(doc.alias);
266
+ }
267
+ return doc.toObject();
268
+ }
269
+
270
+ async function regenerateJsonConfigSlug(id) {
271
+ const doc = await JsonConfig.findById(id);
272
+ if (!doc) {
273
+ const err = new Error('JSON config not found');
274
+ err.code = 'NOT_FOUND';
275
+ throw err;
276
+ }
277
+
278
+ const oldSlug = doc.slug;
279
+ doc.slug = await generateUniqueSlugFromTitle(doc.title);
280
+ await doc.save();
281
+
282
+ clearJsonConfigCache(oldSlug);
283
+ clearJsonConfigCache(doc.slug);
284
+ return doc.toObject();
285
+ }
286
+
287
+ async function deleteJsonConfig(id) {
288
+ const doc = await JsonConfig.findByIdAndDelete(id).lean();
289
+ if (!doc) {
290
+ const err = new Error('JSON config not found');
291
+ err.code = 'NOT_FOUND';
292
+ throw err;
293
+ }
294
+
295
+ clearJsonConfigCache(doc.slug);
296
+ return { success: true };
297
+ }
298
+
299
+ async function getJsonConfigValueBySlug(slug, opts = {}) {
300
+ const key = String(slug || '').trim();
301
+ if (!key) {
302
+ const err = new Error('slug is required');
303
+ err.code = 'VALIDATION';
304
+ throw err;
305
+ }
306
+
307
+ const bypassCache = Boolean(opts.bypassCache);
308
+
309
+ if (!bypassCache) {
310
+ const cached = getCached(key);
311
+ if (cached !== null) return cached;
312
+ }
313
+
314
+ const doc = await JsonConfig.findOne({
315
+ $or: [
316
+ { slug: key },
317
+ { alias: key }
318
+ ]
319
+ }).lean();
320
+
321
+ if (!doc) {
322
+ const err = new Error('JSON config not found');
323
+ err.code = 'NOT_FOUND';
324
+ throw err;
325
+ }
326
+
327
+ const data = parseJsonOrThrow(doc.jsonRaw);
328
+
329
+ // Cache under both the slug and the lookup key
330
+ setCached(key, data, doc.cacheTtlSeconds);
331
+ if (doc.slug !== key) {
332
+ setCached(doc.slug, data, doc.cacheTtlSeconds);
333
+ }
334
+ if (doc.alias && doc.alias !== key) {
335
+ setCached(doc.alias, data, doc.cacheTtlSeconds);
336
+ }
337
+
338
+ return data;
339
+ }
340
+
341
+ async function getJsonConfigPublicPayload(slug, { raw = false } = {}) {
342
+ const key = String(slug || '').trim();
343
+ if (!key) {
344
+ const err = new Error('slug is required');
345
+ err.code = 'VALIDATION';
346
+ throw err;
347
+ }
348
+
349
+ const doc = await JsonConfig.findOne({
350
+ $or: [
351
+ { slug: key },
352
+ { alias: key }
353
+ ]
354
+ }).lean();
355
+
356
+ if (!doc || doc.publicEnabled !== true) {
357
+ const err = new Error('JSON config not found');
358
+ err.code = 'NOT_FOUND';
359
+ throw err;
360
+ }
361
+
362
+ const data = await getJsonConfigValueBySlug(doc.slug);
363
+
364
+ if (!raw) return data;
365
+
366
+ return {
367
+ slug: doc.slug,
368
+ alias: doc.alias,
369
+ title: doc.title,
370
+ publicEnabled: Boolean(doc.publicEnabled),
371
+ cacheTtlSeconds: Number(doc.cacheTtlSeconds || 0) || 0,
372
+ updatedAt: doc.updatedAt,
373
+ data,
374
+ };
375
+ }
376
+
377
+ module.exports = {
378
+ normalizeSlugBase,
379
+ generateUniqueSlugFromTitle,
380
+ parseJsonOrThrow,
381
+ clearJsonConfigCache,
382
+ clearAllJsonConfigCache,
383
+ listJsonConfigs,
384
+ getJsonConfigById,
385
+ createJsonConfig,
386
+ updateJsonConfig,
387
+ regenerateJsonConfigSlug,
388
+ deleteJsonConfig,
389
+ getJsonConfig: getJsonConfigValueBySlug,
390
+ getJsonConfigValueBySlug,
391
+ getJsonConfigPublicPayload,
392
+ };