@intranefr/superbackend 1.5.0 → 1.5.2

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 (198) hide show
  1. package/.env.example +15 -0
  2. package/README.md +11 -0
  3. package/analysis-only.skill +0 -0
  4. package/index.js +23 -0
  5. package/package.json +8 -2
  6. package/src/admin/endpointRegistry.js +120 -0
  7. package/src/controllers/admin.controller.js +90 -6
  8. package/src/controllers/adminBlockDefinitions.controller.js +127 -0
  9. package/src/controllers/adminBlockDefinitionsAi.controller.js +54 -0
  10. package/src/controllers/adminCache.controller.js +342 -0
  11. package/src/controllers/adminContextBlockDefinitions.controller.js +141 -0
  12. package/src/controllers/adminCrons.controller.js +388 -0
  13. package/src/controllers/adminDbBrowser.controller.js +124 -0
  14. package/src/controllers/adminEjsVirtual.controller.js +13 -3
  15. package/src/controllers/adminExperiments.controller.js +200 -0
  16. package/src/controllers/adminHeadless.controller.js +9 -2
  17. package/src/controllers/adminHealthChecks.controller.js +570 -0
  18. package/src/controllers/adminI18n.controller.js +51 -29
  19. package/src/controllers/adminLlm.controller.js +126 -2
  20. package/src/controllers/adminPages.controller.js +720 -0
  21. package/src/controllers/adminPagesContextBlocksAi.controller.js +54 -0
  22. package/src/controllers/adminProxy.controller.js +113 -0
  23. package/src/controllers/adminRateLimits.controller.js +138 -0
  24. package/src/controllers/adminRbac.controller.js +803 -0
  25. package/src/controllers/adminScripts.controller.js +126 -4
  26. package/src/controllers/adminSeoConfig.controller.js +71 -48
  27. package/src/controllers/blogAdmin.controller.js +279 -0
  28. package/src/controllers/blogAiAdmin.controller.js +224 -0
  29. package/src/controllers/blogAutomationAdmin.controller.js +141 -0
  30. package/src/controllers/blogInternal.controller.js +26 -0
  31. package/src/controllers/blogPublic.controller.js +89 -0
  32. package/src/controllers/experiments.controller.js +85 -0
  33. package/src/controllers/fileManager.controller.js +190 -0
  34. package/src/controllers/fileManagerStoragePolicy.controller.js +23 -0
  35. package/src/controllers/healthChecksPublic.controller.js +196 -0
  36. package/src/controllers/internalExperiments.controller.js +17 -0
  37. package/src/controllers/metrics.controller.js +64 -4
  38. package/src/controllers/orgAdmin.controller.js +80 -0
  39. package/src/helpers/mongooseHelper.js +258 -0
  40. package/src/helpers/scriptBase.js +230 -0
  41. package/src/helpers/scriptRunner.js +335 -0
  42. package/src/middleware/rbac.js +62 -0
  43. package/src/middleware.js +810 -48
  44. package/src/models/BlockDefinition.js +27 -0
  45. package/src/models/BlogAutomationLock.js +14 -0
  46. package/src/models/BlogAutomationRun.js +39 -0
  47. package/src/models/BlogPost.js +42 -0
  48. package/src/models/CacheEntry.js +26 -0
  49. package/src/models/ConsoleEntry.js +32 -0
  50. package/src/models/ConsoleLog.js +23 -0
  51. package/src/models/ContextBlockDefinition.js +33 -0
  52. package/src/models/CronExecution.js +47 -0
  53. package/src/models/CronJob.js +70 -0
  54. package/src/models/Experiment.js +75 -0
  55. package/src/models/ExperimentAssignment.js +23 -0
  56. package/src/models/ExperimentEvent.js +26 -0
  57. package/src/models/ExperimentMetricBucket.js +30 -0
  58. package/src/models/ExternalDbConnection.js +49 -0
  59. package/src/models/FileEntry.js +22 -0
  60. package/src/models/GlobalSetting.js +1 -2
  61. package/src/models/HealthAutoHealAttempt.js +57 -0
  62. package/src/models/HealthCheck.js +132 -0
  63. package/src/models/HealthCheckRun.js +51 -0
  64. package/src/models/HealthIncident.js +49 -0
  65. package/src/models/Page.js +95 -0
  66. package/src/models/PageCollection.js +42 -0
  67. package/src/models/ProxyEntry.js +66 -0
  68. package/src/models/RateLimitCounter.js +19 -0
  69. package/src/models/RateLimitMetricBucket.js +20 -0
  70. package/src/models/RbacGrant.js +25 -0
  71. package/src/models/RbacGroup.js +16 -0
  72. package/src/models/RbacGroupMember.js +13 -0
  73. package/src/models/RbacGroupRole.js +13 -0
  74. package/src/models/RbacRole.js +25 -0
  75. package/src/models/RbacUserRole.js +13 -0
  76. package/src/models/ScriptDefinition.js +1 -0
  77. package/src/models/Webhook.js +2 -0
  78. package/src/routes/admin.routes.js +2 -0
  79. package/src/routes/adminBlog.routes.js +21 -0
  80. package/src/routes/adminBlogAi.routes.js +16 -0
  81. package/src/routes/adminBlogAutomation.routes.js +27 -0
  82. package/src/routes/adminCache.routes.js +20 -0
  83. package/src/routes/adminConsoleManager.routes.js +302 -0
  84. package/src/routes/adminCrons.routes.js +25 -0
  85. package/src/routes/adminDbBrowser.routes.js +65 -0
  86. package/src/routes/adminEjsVirtual.routes.js +2 -1
  87. package/src/routes/adminExperiments.routes.js +29 -0
  88. package/src/routes/adminHeadless.routes.js +2 -1
  89. package/src/routes/adminHealthChecks.routes.js +28 -0
  90. package/src/routes/adminI18n.routes.js +4 -3
  91. package/src/routes/adminLlm.routes.js +4 -2
  92. package/src/routes/adminPages.routes.js +55 -0
  93. package/src/routes/adminProxy.routes.js +15 -0
  94. package/src/routes/adminRateLimits.routes.js +17 -0
  95. package/src/routes/adminRbac.routes.js +38 -0
  96. package/src/routes/adminSeoConfig.routes.js +5 -4
  97. package/src/routes/adminUiComponents.routes.js +2 -1
  98. package/src/routes/blogInternal.routes.js +14 -0
  99. package/src/routes/blogPublic.routes.js +9 -0
  100. package/src/routes/experiments.routes.js +30 -0
  101. package/src/routes/fileManager.routes.js +62 -0
  102. package/src/routes/fileManagerStoragePolicy.routes.js +9 -0
  103. package/src/routes/healthChecksPublic.routes.js +9 -0
  104. package/src/routes/internalExperiments.routes.js +15 -0
  105. package/src/routes/log.routes.js +43 -60
  106. package/src/routes/metrics.routes.js +4 -2
  107. package/src/routes/orgAdmin.routes.js +1 -0
  108. package/src/routes/pages.routes.js +123 -0
  109. package/src/routes/proxy.routes.js +46 -0
  110. package/src/routes/rbac.routes.js +47 -0
  111. package/src/routes/webhook.routes.js +2 -1
  112. package/src/routes/workflows.routes.js +4 -0
  113. package/src/services/blockDefinitionsAi.service.js +247 -0
  114. package/src/services/blog.service.js +99 -0
  115. package/src/services/blogAutomation.service.js +978 -0
  116. package/src/services/blogCronsBootstrap.service.js +185 -0
  117. package/src/services/blogPublishing.service.js +58 -0
  118. package/src/services/cacheLayer.service.js +696 -0
  119. package/src/services/consoleManager.service.js +738 -0
  120. package/src/services/consoleOverride.service.js +7 -1
  121. package/src/services/cronScheduler.service.js +350 -0
  122. package/src/services/dbBrowser.service.js +536 -0
  123. package/src/services/ejsVirtual.service.js +102 -32
  124. package/src/services/experiments.service.js +273 -0
  125. package/src/services/experimentsAggregation.service.js +308 -0
  126. package/src/services/experimentsCronsBootstrap.service.js +118 -0
  127. package/src/services/experimentsRetention.service.js +43 -0
  128. package/src/services/experimentsWs.service.js +134 -0
  129. package/src/services/fileManager.service.js +475 -0
  130. package/src/services/fileManagerStoragePolicy.service.js +285 -0
  131. package/src/services/globalSettings.service.js +15 -0
  132. package/src/services/healthChecks.service.js +650 -0
  133. package/src/services/healthChecksBootstrap.service.js +109 -0
  134. package/src/services/healthChecksScheduler.service.js +106 -0
  135. package/src/services/jsonConfigs.service.js +2 -2
  136. package/src/services/llmDefaults.service.js +190 -0
  137. package/src/services/migrationAssets/s3.js +2 -2
  138. package/src/services/pages.service.js +602 -0
  139. package/src/services/pagesContext.service.js +331 -0
  140. package/src/services/pagesContextBlocksAi.service.js +349 -0
  141. package/src/services/proxy.service.js +535 -0
  142. package/src/services/rateLimiter.service.js +623 -0
  143. package/src/services/rbac.service.js +212 -0
  144. package/src/services/scriptsRunner.service.js +215 -15
  145. package/src/services/uiComponentsAi.service.js +6 -19
  146. package/src/services/workflow.service.js +23 -8
  147. package/src/utils/orgRoles.js +14 -0
  148. package/src/utils/rbac/engine.js +60 -0
  149. package/src/utils/rbac/rightsRegistry.js +33 -0
  150. package/views/admin-blog-automation.ejs +877 -0
  151. package/views/admin-blog-edit.ejs +542 -0
  152. package/views/admin-blog.ejs +399 -0
  153. package/views/admin-cache.ejs +681 -0
  154. package/views/admin-console-manager.ejs +680 -0
  155. package/views/admin-crons.ejs +645 -0
  156. package/views/admin-dashboard.ejs +28 -8
  157. package/views/admin-db-browser.ejs +445 -0
  158. package/views/admin-ejs-virtual.ejs +16 -10
  159. package/views/admin-experiments.ejs +91 -0
  160. package/views/admin-file-manager.ejs +942 -0
  161. package/views/admin-health-checks.ejs +725 -0
  162. package/views/admin-i18n.ejs +59 -5
  163. package/views/admin-llm.ejs +99 -1
  164. package/views/admin-organizations.ejs +163 -1
  165. package/views/admin-pages.ejs +2424 -0
  166. package/views/admin-proxy.ejs +491 -0
  167. package/views/admin-rate-limiter.ejs +625 -0
  168. package/views/admin-rbac.ejs +1331 -0
  169. package/views/admin-scripts.ejs +597 -3
  170. package/views/admin-seo-config.ejs +61 -7
  171. package/views/admin-ui-components.ejs +57 -25
  172. package/views/admin-workflows.ejs +7 -7
  173. package/views/file-manager.ejs +866 -0
  174. package/views/pages/blocks/contact.ejs +27 -0
  175. package/views/pages/blocks/cta.ejs +18 -0
  176. package/views/pages/blocks/faq.ejs +20 -0
  177. package/views/pages/blocks/features.ejs +19 -0
  178. package/views/pages/blocks/hero.ejs +13 -0
  179. package/views/pages/blocks/html.ejs +5 -0
  180. package/views/pages/blocks/image.ejs +14 -0
  181. package/views/pages/blocks/testimonials.ejs +26 -0
  182. package/views/pages/blocks/text.ejs +10 -0
  183. package/views/pages/layouts/default.ejs +51 -0
  184. package/views/pages/layouts/minimal.ejs +42 -0
  185. package/views/pages/layouts/sidebar.ejs +54 -0
  186. package/views/pages/partials/footer.ejs +13 -0
  187. package/views/pages/partials/header.ejs +12 -0
  188. package/views/pages/partials/sidebar.ejs +8 -0
  189. package/views/pages/runtime/page.ejs +10 -0
  190. package/views/pages/templates/article.ejs +20 -0
  191. package/views/pages/templates/default.ejs +12 -0
  192. package/views/pages/templates/landing.ejs +14 -0
  193. package/views/pages/templates/listing.ejs +15 -0
  194. package/views/partials/admin-image-upload-modal.ejs +221 -0
  195. package/views/partials/dashboard/nav-items.ejs +12 -0
  196. package/views/partials/dashboard/palette.ejs +5 -3
  197. package/views/partials/llm-provider-model-picker.ejs +183 -0
  198. package/src/routes/llmUi.routes.js +0 -26
@@ -8,7 +8,7 @@ const VirtualEjsFileVersion = require('../models/VirtualEjsFileVersion');
8
8
  const VirtualEjsGroupChange = require('../models/VirtualEjsGroupChange');
9
9
 
10
10
  const llmService = require('./llm.service');
11
- const { getSettingValue } = require('./globalSettings.service');
11
+ const { resolveLlmProviderModel } = require('./llmDefaults.service');
12
12
  const { createAuditEvent } = require('./audit.service');
13
13
 
14
14
  const CACHE_TTL_MS = 5 * 60 * 1000;
@@ -151,6 +151,7 @@ async function resolveTemplateSource({ viewsRoot, relPath, allowDb = true }) {
151
151
  }
152
152
 
153
153
  if (fileDoc && fileDoc.enabled === true && typeof fileDoc.content === 'string' && fileDoc.content.trim() !== '') {
154
+ console.log(`[ejsVirtual] Resolved ${normalized} from DB (${fileDoc.content.length} chars)`);
154
155
  return {
155
156
  relPath: normalized,
156
157
  source: 'db',
@@ -160,6 +161,7 @@ async function resolveTemplateSource({ viewsRoot, relPath, allowDb = true }) {
160
161
  }
161
162
 
162
163
  const fsContent = await readFsView(viewsRoot, normalized);
164
+ console.log(`[ejsVirtual] Resolved ${normalized} from FS (${fsContent?.length || 0} chars)`);
163
165
  return {
164
166
  relPath: normalized,
165
167
  source: 'fs',
@@ -259,25 +261,11 @@ function parseMultiFilePatch(text) {
259
261
  }
260
262
 
261
263
  async function resolveLlmDefaults({ providerKey, model }) {
262
- const uiProvider = String(providerKey || '').trim();
263
- const uiModel = String(model || '').trim();
264
-
265
- const settingProvider = String(await getSettingValue('ejsVirtual.ai.providerKey', '') || '').trim();
266
- const settingModel = String(await getSettingValue('ejsVirtual.ai.model', '') || '').trim();
267
-
268
- const envProvider = String(process.env.DEFAULT_LLM_PROVIDER_KEY || '').trim();
269
- const envModel = String(process.env.DEFAULT_LLM_MODEL || '').trim();
270
-
271
- const resolvedProviderKey = uiProvider || settingProvider || envProvider;
272
- if (!resolvedProviderKey) {
273
- const err = new Error('Missing LLM providerKey (configure ejsVirtual.ai.providerKey or DEFAULT_LLM_PROVIDER_KEY, or send from UI)');
274
- err.code = 'VALIDATION';
275
- throw err;
276
- }
277
-
278
- const resolvedModel = uiModel || settingModel || envModel || 'x-ai/grok-code-fast-1';
279
-
280
- return { providerKey: resolvedProviderKey, model: resolvedModel };
264
+ return resolveLlmProviderModel({
265
+ systemKey: 'ejsVirtual.vibe.apply',
266
+ providerKey,
267
+ model,
268
+ });
281
269
  }
282
270
 
283
271
  async function vibeEdit({ prompt, paths, providerKey, model, viewsRoot, actor }) {
@@ -492,8 +480,9 @@ function resolveIncludeRelPath({ viewsRoot, parentAbsPath, includePath }) {
492
480
  }
493
481
 
494
482
  const normalizedAbs = path.resolve(includeAbs);
495
- if (!normalizedAbs.startsWith(baseRoot)) {
496
- const err = new Error('Include path escapes views root');
483
+ // Use path.sep to ensure we match the full directory name
484
+ if (!normalizedAbs.startsWith(baseRoot + path.sep) && normalizedAbs !== baseRoot) {
485
+ const err = new Error(`Include path escapes views root: ${normalizedAbs} (root: ${baseRoot})`);
497
486
  err.code = 'VALIDATION';
498
487
  throw err;
499
488
  }
@@ -505,24 +494,62 @@ function resolveIncludeRelPath({ viewsRoot, parentAbsPath, includePath }) {
505
494
  async function preloadTemplatesForRender({ viewsRoot, entryRelPath }) {
506
495
  const templatesByRelPath = new Map();
507
496
  const absByRelPath = new Map();
508
- const queue = [entryRelPath];
509
497
  const seen = new Set();
510
498
 
499
+ // 1. Pre-populate with all enabled DB overrides.
500
+ // This ensures dynamic includes (like blocks) that exist in DB are available.
501
+ try {
502
+ const dbFiles = await VirtualEjsFile.find({ enabled: true }).lean();
503
+ if (dbFiles && dbFiles.length > 0) {
504
+ console.log(`[ejsVirtual] Preloading ${dbFiles.length} enabled DB templates: ${dbFiles.map(f => `${f.path} (${f.content?.length || 0} chars)`).join(', ')}`);
505
+ for (const f of dbFiles) {
506
+ templatesByRelPath.set(f.path, {
507
+ relPath: f.path,
508
+ source: 'db',
509
+ content: f.content,
510
+ updatedAt: f.updatedAt,
511
+ });
512
+ absByRelPath.set(f.path, resolveAbsPath(viewsRoot, f.path));
513
+ }
514
+ }
515
+ } catch (err) {
516
+ console.error('[ejsVirtual] Failed to preload DB templates:', err);
517
+ }
518
+
519
+ const queue = [entryRelPath];
520
+ // Also crawl static includes of DB-overridden files
521
+ for (const p of templatesByRelPath.keys()) {
522
+ queue.push(p);
523
+ }
524
+
511
525
  while (queue.length > 0) {
512
526
  const relPath = queue.shift();
513
527
  if (!relPath || seen.has(relPath)) continue;
514
528
  seen.add(relPath);
515
529
 
516
- const src = await resolveTemplateSource({ viewsRoot, relPath, allowDb: true });
517
- const abs = resolveAbsPath(viewsRoot, relPath);
530
+ let src = templatesByRelPath.get(relPath);
531
+ if (!src) {
532
+ try {
533
+ src = await resolveTemplateSource({ viewsRoot, relPath, allowDb: true });
534
+ templatesByRelPath.set(relPath, src);
535
+ } catch (err) {
536
+ // If not found, we still want to keep going, it might be on FS or missing
537
+ console.warn(`[ejsVirtual] Failed to resolve template source for ${relPath}:`, err.message);
538
+ continue;
539
+ }
540
+ }
518
541
 
519
- templatesByRelPath.set(relPath, src);
542
+ const abs = absByRelPath.get(relPath) || resolveAbsPath(viewsRoot, relPath);
520
543
  absByRelPath.set(relPath, abs);
521
544
 
522
545
  const includes = extractIncludePaths(src.content);
523
546
  for (const inc of includes) {
524
- const incRel = resolveIncludeRelPath({ viewsRoot, parentAbsPath: abs, includePath: inc });
525
- if (incRel) queue.push(incRel);
547
+ try {
548
+ const incRel = resolveIncludeRelPath({ viewsRoot, parentAbsPath: abs, includePath: inc });
549
+ if (incRel) queue.push(incRel);
550
+ } catch (err) {
551
+ console.warn(`[ejsVirtual] Failed to resolve include path "${inc}" in ${relPath}:`, err.message);
552
+ }
526
553
  }
527
554
  }
528
555
 
@@ -533,18 +560,26 @@ async function renderToString(res, viewPath, data = {}, options = {}) {
533
560
  const relPath = normalizeViewPath(viewPath);
534
561
  const viewsRoot = path.resolve(options.viewsRoot || (res && res.app ? res.app.get('views') : null) || getDefaultViewsRoot());
535
562
 
563
+ console.log(`[ejsVirtual] Rendering ${relPath} (viewsRoot: ${viewsRoot})`);
564
+
536
565
  const { templatesByRelPath, absByRelPath } = await preloadTemplatesForRender({
537
566
  viewsRoot,
538
567
  entryRelPath: relPath,
539
568
  });
540
569
 
570
+ if (templatesByRelPath.size === 0) {
571
+ console.warn(`[ejsVirtual] No templates preloaded for ${relPath}`);
572
+ }
573
+
541
574
  const entry = templatesByRelPath.get(relPath);
542
575
  if (!entry) {
543
- const err = new Error('Template not found');
576
+ const err = new Error(`Template not found: ${relPath}`);
544
577
  err.code = 'NOT_FOUND';
545
578
  throw err;
546
579
  }
547
580
 
581
+ console.log(`[ejsVirtual] Entry template ${relPath} source: ${entry.source}`);
582
+
548
583
  const entryAbs = absByRelPath.get(relPath) || resolveAbsPath(viewsRoot, relPath);
549
584
 
550
585
  const versionKey = entry.source === 'db'
@@ -553,6 +588,13 @@ async function renderToString(res, viewPath, data = {}, options = {}) {
553
588
 
554
589
  const cached = getCachedTemplate(entry.relPath, versionKey);
555
590
  const entryTemplate = cached ? cached.template : entry.content;
591
+
592
+ if (!entryTemplate || entryTemplate.trim() === '') {
593
+ console.warn(`[ejsVirtual] Entry template ${relPath} is empty! Source: ${entry.source}`);
594
+ } else {
595
+ console.log(`[ejsVirtual] Entry template ${relPath} content start: ${entryTemplate.substring(0, 50).replace(/\n/g, '\\n')}...`);
596
+ }
597
+
556
598
  if (!cached) {
557
599
  setCachedTemplate(entry.relPath, versionKey, entry.content, {});
558
600
  }
@@ -577,10 +619,29 @@ async function renderToString(res, viewPath, data = {}, options = {}) {
577
619
 
578
620
  const incAbs = incRel ? absByRelPath.get(incRel) || resolveAbsPath(viewsRoot, incRel) : parentAbs;
579
621
  const src = incRel ? templatesByRelPath.get(incRel) : null;
622
+
580
623
  if (!incRel || !src) {
581
- return { filename: incAbs };
624
+ // If not in preloaded map, it might be on FS.
625
+ // Check if it exists on FS to avoid ENOENT crash
626
+ try {
627
+ if (incAbs && fs.existsSync(incAbs) && fs.statSync(incAbs).isFile()) {
628
+ console.log(`[ejsVirtual] Include ${incRel || includePath} found on FS (fallback)`);
629
+ return { filename: incAbs };
630
+ }
631
+ } catch (e) {
632
+ // ignore errors
633
+ }
634
+
635
+ // If it doesn't exist anywhere, return a comment instead of crashing
636
+ console.warn(`[ejsVirtual] Include not found: ${incRel || includePath} (parent: ${parentAbs})`);
637
+ return { template: `<!-- Include not found: ${incRel || includePath} -->` };
638
+ }
639
+
640
+ if (src.content === undefined || src.content === null || src.content.trim() === '') {
641
+ console.warn(`[ejsVirtual] Including ${incRel} from ${src.source} but content is empty`);
582
642
  }
583
643
 
644
+ console.log(`[ejsVirtual] Including ${incRel} from ${src.source}`);
584
645
  return {
585
646
  filename: incAbs,
586
647
  template: src.content,
@@ -589,11 +650,19 @@ async function renderToString(res, viewPath, data = {}, options = {}) {
589
650
 
590
651
  await recordIntegratedUsage(relPath, null);
591
652
 
592
- return ejs.render(entryTemplate, { ...(data || {}), ...(res?.locals || {}) }, {
653
+ const rendered = ejs.render(entryTemplate, { ...(data || {}), ...(res?.locals || {}) }, {
593
654
  filename: entryAbs,
594
655
  async: false,
595
656
  includer,
596
657
  });
658
+
659
+ if (!rendered || rendered.trim() === '') {
660
+ console.warn(`[ejsVirtual] Rendered content for ${relPath} is empty!`);
661
+ } else {
662
+ console.log(`[ejsVirtual] Rendered content for ${relPath} length: ${rendered.length} chars`);
663
+ }
664
+
665
+ return rendered;
597
666
  }
598
667
 
599
668
  async function render(res, viewPath, data = {}, options = {}) {
@@ -608,7 +677,8 @@ module.exports = {
608
677
  readFsView,
609
678
  invalidateCacheForPath,
610
679
  clearCache,
680
+ recordIntegratedUsage,
611
681
  vibeEdit,
612
682
  renderToString,
613
683
  render,
614
- };
684
+ };
@@ -0,0 +1,273 @@
1
+ const crypto = require('crypto');
2
+ const mongoose = require('mongoose');
3
+
4
+ const Experiment = require('../models/Experiment');
5
+ const ExperimentAssignment = require('../models/ExperimentAssignment');
6
+ const ExperimentEvent = require('../models/ExperimentEvent');
7
+
8
+ const cacheLayer = require('./cacheLayer.service');
9
+ const jsonConfigsService = require('./jsonConfigs.service');
10
+
11
+ function normalizeStr(v) {
12
+ return String(v || '').trim();
13
+ }
14
+
15
+ function normalizeOrgId(orgId) {
16
+ if (orgId === null || orgId === undefined || orgId === '') return null;
17
+ const str = String(orgId);
18
+ if (!mongoose.Types.ObjectId.isValid(str)) {
19
+ const err = new Error('Invalid orgId');
20
+ err.code = 'VALIDATION';
21
+ throw err;
22
+ }
23
+ return new mongoose.Types.ObjectId(str);
24
+ }
25
+
26
+ function normalizeExperimentCode(code) {
27
+ const c = normalizeStr(code);
28
+ if (!c) {
29
+ const err = new Error('experiment code is required');
30
+ err.code = 'VALIDATION';
31
+ throw err;
32
+ }
33
+ return c;
34
+ }
35
+
36
+ function normalizeSubjectId(subjectId) {
37
+ const s = normalizeStr(subjectId);
38
+ if (!s) {
39
+ const err = new Error('subjectId is required');
40
+ err.code = 'VALIDATION';
41
+ throw err;
42
+ }
43
+ return s;
44
+ }
45
+
46
+ function computeSubjectKey({ orgId, subjectId }) {
47
+ const sid = normalizeSubjectId(subjectId);
48
+ const oid = orgId ? String(orgId) : 'global';
49
+ return `org:${oid}:subject:${sid}`;
50
+ }
51
+
52
+ function computeBucketInt(input, max) {
53
+ const hash = crypto.createHash('sha256').update(String(input), 'utf8').digest('hex');
54
+ const int = parseInt(hash.slice(0, 8), 16);
55
+ return max <= 0 ? 0 : int % max;
56
+ }
57
+
58
+ function pickWeightedVariant({ experiment, subjectKey }) {
59
+ const variants = Array.isArray(experiment?.variants) ? experiment.variants : [];
60
+ const eligible = variants
61
+ .map((v) => ({
62
+ key: normalizeStr(v?.key),
63
+ weight: Number(v?.weight || 0) || 0,
64
+ configSlug: normalizeStr(v?.configSlug),
65
+ }))
66
+ .filter((v) => v.key && v.weight > 0);
67
+
68
+ if (!eligible.length) {
69
+ const err = new Error('Experiment has no weighted variants');
70
+ err.code = 'VALIDATION';
71
+ throw err;
72
+ }
73
+
74
+ const total = eligible.reduce((acc, v) => acc + v.weight, 0);
75
+ const salt = normalizeStr(experiment?.assignment?.salt) || String(experiment?._id || '');
76
+ const pos = computeBucketInt(`${salt}:${subjectKey}`, total);
77
+
78
+ let cursor = 0;
79
+ for (const v of eligible) {
80
+ cursor += v.weight;
81
+ if (pos < cursor) return v;
82
+ }
83
+
84
+ return eligible[eligible.length - 1];
85
+ }
86
+
87
+ async function getExperimentByCode({ orgId, code }) {
88
+ const c = normalizeExperimentCode(code);
89
+ const oid = orgId ? normalizeOrgId(orgId) : null;
90
+
91
+ const doc = await Experiment.findOne({ organizationId: oid, code: c }).lean();
92
+ if (doc) return doc;
93
+
94
+ if (oid) {
95
+ const globalDoc = await Experiment.findOne({ organizationId: null, code: c }).lean();
96
+ if (globalDoc) return globalDoc;
97
+ }
98
+
99
+ const err = new Error('Experiment not found');
100
+ err.code = 'NOT_FOUND';
101
+ throw err;
102
+ }
103
+
104
+ async function resolveVariantConfig(variant) {
105
+ const slug = normalizeStr(variant?.configSlug);
106
+ if (!slug) return null;
107
+ return jsonConfigsService.getJsonConfigValueBySlug(slug);
108
+ }
109
+
110
+ async function getOrCreateAssignment({ orgId, experimentCode, subjectId, context }) {
111
+ const exp = await getExperimentByCode({ orgId, code: experimentCode });
112
+ const effectiveOrgId = exp.organizationId ? String(exp.organizationId) : null;
113
+ const subjectKey = computeSubjectKey({ orgId: effectiveOrgId || orgId || null, subjectId });
114
+
115
+ const cacheKey = `${String(exp._id)}:${subjectKey}`;
116
+ const cached = await cacheLayer.get(cacheKey, { namespace: 'experiments.assignments' });
117
+ if (cached && cached.variantKey) return { experiment: exp, assignment: cached };
118
+
119
+ const existing = await ExperimentAssignment.findOne({ experimentId: exp._id, subjectKey }).lean();
120
+ if (existing) {
121
+ const assignment = {
122
+ experimentId: String(existing.experimentId),
123
+ organizationId: existing.organizationId ? String(existing.organizationId) : null,
124
+ subjectKey: existing.subjectKey,
125
+ variantKey: existing.variantKey,
126
+ assignedAt: existing.assignedAt,
127
+ context: existing.context || {},
128
+ };
129
+ await cacheLayer.set(cacheKey, assignment, { namespace: 'experiments.assignments', ttlSeconds: 60 });
130
+ return { experiment: exp, assignment };
131
+ }
132
+
133
+ if (exp.status !== 'running' && exp.status !== 'completed') {
134
+ const err = new Error('Experiment is not active');
135
+ err.code = 'CONFLICT';
136
+ throw err;
137
+ }
138
+
139
+ const picked = pickWeightedVariant({ experiment: exp, subjectKey });
140
+
141
+ const created = await ExperimentAssignment.create({
142
+ experimentId: exp._id,
143
+ organizationId: exp.organizationId || null,
144
+ subjectKey,
145
+ variantKey: picked.key,
146
+ assignedAt: new Date(),
147
+ context: context && typeof context === 'object' ? context : {},
148
+ });
149
+
150
+ const assignment = {
151
+ experimentId: String(created.experimentId),
152
+ organizationId: created.organizationId ? String(created.organizationId) : null,
153
+ subjectKey: created.subjectKey,
154
+ variantKey: created.variantKey,
155
+ assignedAt: created.assignedAt,
156
+ context: created.context || {},
157
+ };
158
+
159
+ await cacheLayer.set(cacheKey, assignment, { namespace: 'experiments.assignments', ttlSeconds: 60 });
160
+ return { experiment: exp, assignment };
161
+ }
162
+
163
+ async function ingestEvents({ orgId, experimentCode, subjectId, events }) {
164
+ const exp = await getExperimentByCode({ orgId, code: experimentCode });
165
+
166
+ const effectiveOrgId = exp.organizationId ? String(exp.organizationId) : null;
167
+ const subjectKey = computeSubjectKey({ orgId: effectiveOrgId || orgId || null, subjectId });
168
+
169
+ const list = Array.isArray(events) ? events : [];
170
+ if (!list.length) {
171
+ const err = new Error('events[] is required');
172
+ err.code = 'VALIDATION';
173
+ throw err;
174
+ }
175
+
176
+ const variantKeys = new Set((exp.variants || []).map((v) => String(v?.key || '').trim()).filter(Boolean));
177
+
178
+ const now = new Date();
179
+ const docs = [];
180
+ for (const e of list) {
181
+ if (!e || typeof e !== 'object') continue;
182
+
183
+ const eventKey = normalizeStr(e.eventKey);
184
+ if (!eventKey) {
185
+ const err = new Error('eventKey is required');
186
+ err.code = 'VALIDATION';
187
+ throw err;
188
+ }
189
+
190
+ const ts = e.ts ? new Date(e.ts) : now;
191
+ if (!Number.isFinite(ts.getTime())) {
192
+ const err = new Error('Invalid ts');
193
+ err.code = 'VALIDATION';
194
+ throw err;
195
+ }
196
+
197
+ let variantKey = normalizeStr(e.variantKey);
198
+ if (!variantKey) {
199
+ const { assignment } = await getOrCreateAssignment({ orgId, experimentCode, subjectId, context: null });
200
+ variantKey = assignment.variantKey;
201
+ }
202
+
203
+ if (!variantKeys.has(variantKey)) {
204
+ const err = new Error('Invalid variantKey');
205
+ err.code = 'VALIDATION';
206
+ throw err;
207
+ }
208
+
209
+ const value = e.value === undefined ? 1 : Number(e.value);
210
+ if (!Number.isFinite(value)) {
211
+ const err = new Error('Invalid value');
212
+ err.code = 'VALIDATION';
213
+ throw err;
214
+ }
215
+
216
+ docs.push({
217
+ experimentId: exp._id,
218
+ organizationId: exp.organizationId || null,
219
+ subjectKey,
220
+ variantKey,
221
+ eventKey,
222
+ value,
223
+ ts,
224
+ meta: e.meta && typeof e.meta === 'object' ? e.meta : {},
225
+ });
226
+ }
227
+
228
+ if (!docs.length) {
229
+ const err = new Error('No valid events provided');
230
+ err.code = 'VALIDATION';
231
+ throw err;
232
+ }
233
+
234
+ const inserted = await ExperimentEvent.insertMany(docs, { ordered: false });
235
+ return { insertedCount: Array.isArray(inserted) ? inserted.length : 0 };
236
+ }
237
+
238
+ async function getWinnerSnapshot({ orgId, experimentCode }) {
239
+ const exp = await getExperimentByCode({ orgId, code: experimentCode });
240
+
241
+ const cacheKey = String(exp._id);
242
+ const cached = await cacheLayer.get(cacheKey, { namespace: 'experiments.winner' });
243
+ if (cached && typeof cached === 'object') return { experiment: exp, snapshot: cached };
244
+
245
+ const snapshot = {
246
+ experimentId: String(exp._id),
247
+ organizationId: exp.organizationId ? String(exp.organizationId) : null,
248
+ code: exp.code,
249
+ status: exp.status,
250
+ winnerVariantKey: exp.winnerVariantKey || null,
251
+ winnerDecidedAt: exp.winnerDecidedAt || null,
252
+ winnerReason: exp.winnerReason || null,
253
+ };
254
+
255
+ await cacheLayer.set(cacheKey, snapshot, { namespace: 'experiments.winner', ttlSeconds: 30 });
256
+ return { experiment: exp, snapshot };
257
+ }
258
+
259
+ async function clearExperimentCaches(experimentId) {
260
+ const id = String(experimentId || '').trim();
261
+ if (!id) return;
262
+ await cacheLayer.delete(id, { namespace: 'experiments.winner' }).catch(() => {});
263
+ }
264
+
265
+ module.exports = {
266
+ computeSubjectKey,
267
+ getExperimentByCode,
268
+ resolveVariantConfig,
269
+ getOrCreateAssignment,
270
+ ingestEvents,
271
+ getWinnerSnapshot,
272
+ clearExperimentCaches,
273
+ };