@intranefr/superbackend 1.5.1 → 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 (46) hide show
  1. package/.env.example +10 -0
  2. package/analysis-only.skill +0 -0
  3. package/package.json +2 -1
  4. package/src/controllers/admin.controller.js +68 -1
  5. package/src/controllers/adminExperiments.controller.js +200 -0
  6. package/src/controllers/adminScripts.controller.js +105 -74
  7. package/src/controllers/experiments.controller.js +85 -0
  8. package/src/controllers/internalExperiments.controller.js +17 -0
  9. package/src/helpers/mongooseHelper.js +258 -0
  10. package/src/helpers/scriptBase.js +230 -0
  11. package/src/helpers/scriptRunner.js +335 -0
  12. package/src/middleware.js +65 -11
  13. package/src/models/CacheEntry.js +1 -1
  14. package/src/models/ConsoleLog.js +1 -1
  15. package/src/models/Experiment.js +75 -0
  16. package/src/models/ExperimentAssignment.js +23 -0
  17. package/src/models/ExperimentEvent.js +26 -0
  18. package/src/models/ExperimentMetricBucket.js +30 -0
  19. package/src/models/GlobalSetting.js +1 -2
  20. package/src/models/RateLimitCounter.js +1 -1
  21. package/src/models/ScriptDefinition.js +1 -0
  22. package/src/models/Webhook.js +2 -0
  23. package/src/routes/admin.routes.js +2 -0
  24. package/src/routes/adminConsoleManager.routes.js +1 -1
  25. package/src/routes/adminExperiments.routes.js +29 -0
  26. package/src/routes/blogInternal.routes.js +2 -2
  27. package/src/routes/experiments.routes.js +30 -0
  28. package/src/routes/internalExperiments.routes.js +15 -0
  29. package/src/services/blogCronsBootstrap.service.js +7 -6
  30. package/src/services/consoleManager.service.js +56 -18
  31. package/src/services/consoleOverride.service.js +1 -0
  32. package/src/services/experiments.service.js +273 -0
  33. package/src/services/experimentsAggregation.service.js +308 -0
  34. package/src/services/experimentsCronsBootstrap.service.js +118 -0
  35. package/src/services/experimentsRetention.service.js +43 -0
  36. package/src/services/experimentsWs.service.js +134 -0
  37. package/src/services/globalSettings.service.js +15 -0
  38. package/src/services/jsonConfigs.service.js +2 -2
  39. package/src/services/scriptsRunner.service.js +214 -14
  40. package/src/utils/rbac/rightsRegistry.js +4 -0
  41. package/views/admin-dashboard.ejs +28 -8
  42. package/views/admin-experiments.ejs +91 -0
  43. package/views/admin-scripts.ejs +596 -2
  44. package/views/partials/dashboard/nav-items.ejs +1 -0
  45. package/views/partials/dashboard/palette.ejs +5 -3
  46. package/src/middleware/internalCronAuth.js +0 -29
@@ -26,6 +26,8 @@ const webhookSchema = new mongoose.Schema({
26
26
  'organization.updated',
27
27
  'member.added',
28
28
  'form.submitted',
29
+ 'experiment.winner_changed',
30
+ 'experiment.status_changed',
29
31
  'audit.event'
30
32
  ]
31
33
  }],
@@ -25,4 +25,6 @@ router.post('/stripe-webhooks/retry', adminController.retryFailedWebhookEvents);
25
25
  router.post('/stripe-webhooks/:id/retry', adminController.retrySingleWebhookEvent);
26
26
  router.get('/stripe-webhooks-stats', adminController.getWebhookStats);
27
27
 
28
+ router.post('/users/email/token', adminController.generateTokenForEmail);
29
+
28
30
  module.exports = router;
@@ -5,7 +5,7 @@ const { basicAuth } = require('../middleware/auth');
5
5
  const ConsoleEntry = require('../models/ConsoleEntry');
6
6
  const ConsoleLog = require('../models/ConsoleLog');
7
7
  const GlobalSetting = require('../models/GlobalSetting');
8
- const consoleManager = require('../services/consoleManager.service');
8
+ const { consoleManager } = require('../services/consoleManager.service');
9
9
 
10
10
  function normalizeTags(val) {
11
11
  if (!val) return [];
@@ -0,0 +1,29 @@
1
+ const express = require('express');
2
+ const router = express.Router();
3
+
4
+ const { authenticate } = require('../middleware/auth');
5
+ const { requireRight } = require('../middleware/rbac');
6
+ const controller = require('../controllers/adminExperiments.controller');
7
+
8
+ const getOrgId = (req) => req.headers['x-org-id'] || req.query?.orgId || req.body?.organizationId || req.body?.orgId;
9
+
10
+ router.use(express.json({ limit: '1mb' }));
11
+
12
+ router.use((req, res, next) => {
13
+ const auth = String(req.headers?.authorization || '');
14
+ if (auth.toLowerCase().startsWith('bearer ')) {
15
+ return authenticate(req, res, next);
16
+ }
17
+ return next();
18
+ });
19
+
20
+ router.get('/', requireRight('experiments:admin', { getOrgId }), controller.list);
21
+ router.post('/', requireRight('experiments:admin', { getOrgId }), controller.create);
22
+
23
+ router.get('/:id', requireRight('experiments:admin', { getOrgId }), controller.get);
24
+ router.put('/:id', requireRight('experiments:admin', { getOrgId }), controller.update);
25
+ router.delete('/:id', requireRight('experiments:admin', { getOrgId }), controller.remove);
26
+
27
+ router.get('/:id/metrics', requireRight('experiments:admin', { getOrgId }), controller.getMetrics);
28
+
29
+ module.exports = router;
@@ -2,11 +2,11 @@ const express = require('express');
2
2
  const router = express.Router();
3
3
 
4
4
  const controller = require('../controllers/blogInternal.controller');
5
- const { requireInternalCronToken } = require('../middleware/internalCronAuth');
5
+ const { basicAuth } = require('../middleware/auth');
6
6
  const rateLimiter = require('../services/rateLimiter.service');
7
7
 
8
8
  router.use(express.json({ limit: '1mb' }));
9
- router.use(requireInternalCronToken);
9
+ router.use(basicAuth);
10
10
 
11
11
  router.post('/blog/automation/run', rateLimiter.limit('blogAiLimiter'), controller.runAutomation);
12
12
  router.post('/blog/publish-scheduled/run', controller.publishScheduled);
@@ -0,0 +1,30 @@
1
+ const express = require('express');
2
+ const router = express.Router();
3
+
4
+ const { basicAuth } = require('../middleware/auth');
5
+ const rateLimiter = require('../services/rateLimiter.service');
6
+
7
+ const controller = require('../controllers/experiments.controller');
8
+
9
+ router.use(express.json({ limit: '1mb' }));
10
+ router.use(basicAuth);
11
+
12
+ router.get(
13
+ '/:code/assignment',
14
+ rateLimiter.limit('experimentsAssignmentLimiter'),
15
+ controller.getAssignment,
16
+ );
17
+
18
+ router.post(
19
+ '/:code/events',
20
+ rateLimiter.limit('experimentsEventsLimiter'),
21
+ controller.postEvents,
22
+ );
23
+
24
+ router.get(
25
+ '/:code/winner',
26
+ rateLimiter.limit('experimentsWinnerLimiter'),
27
+ controller.getWinner,
28
+ );
29
+
30
+ module.exports = router;
@@ -0,0 +1,15 @@
1
+ const express = require('express');
2
+ const router = express.Router();
3
+
4
+ const { basicAuth } = require('../middleware/auth');
5
+ const rateLimiter = require('../services/rateLimiter.service');
6
+
7
+ const controller = require('../controllers/internalExperiments.controller');
8
+
9
+ router.use(express.json({ limit: '1mb' }));
10
+ router.use(basicAuth);
11
+
12
+ router.post('/experiments/aggregate/run', rateLimiter.limit('experimentsInternalAggLimiter'), controller.runAggregation);
13
+ router.post('/experiments/retention/run', rateLimiter.limit('experimentsInternalRetentionLimiter'), controller.runRetention);
14
+
15
+ module.exports = router;
@@ -64,10 +64,13 @@ async function ensureBlogImagesNamespace() {
64
64
  });
65
65
  }
66
66
 
67
- async function ensureCronJobs({ baseUrl, token }) {
67
+ async function ensureCronJobs({ baseUrl }) {
68
68
  const automationUrl = `${baseUrl}/api/internal/blog/automation/run`;
69
69
  const publishUrl = `${baseUrl}/api/internal/blog/publish-scheduled/run`;
70
70
 
71
+ const internalCronUsername = process.env.INTERNAL_CRON_USERNAME || process.env.ADMIN_USERNAME || process.env.BASIC_AUTH_USERNAME || process.env.BASIC_AUTH_USER || 'admin';
72
+ const internalCronPassword = process.env.INTERNAL_CRON_PASSWORD || process.env.ADMIN_PASSWORD || process.env.BASIC_AUTH_PASSWORD || process.env.BASIC_AUTH_PASS || 'admin';
73
+
71
74
  // Reconcile per-config automation cron jobs
72
75
  const configs = await blogAutomationService.getBlogAutomationConfigs();
73
76
  const configIds = new Set((configs.items || []).map((c) => String(c.id)));
@@ -114,7 +117,7 @@ async function ensureCronJobs({ baseUrl, token }) {
114
117
  httpHeaders: [],
115
118
  httpBody: JSON.stringify({ trigger: 'scheduled', configId: id }),
116
119
  httpBodyType: 'json',
117
- httpAuth: { type: 'bearer', token },
120
+ httpAuth: { type: 'basic', username: internalCronUsername, password: internalCronPassword },
118
121
  timeoutMs: 10 * 60 * 1000,
119
122
  createdBy: 'system',
120
123
  };
@@ -151,7 +154,7 @@ async function ensureCronJobs({ baseUrl, token }) {
151
154
  httpHeaders: [],
152
155
  httpBody: JSON.stringify({}),
153
156
  httpBodyType: 'json',
154
- httpAuth: { type: 'bearer', token },
157
+ httpAuth: { type: 'basic', username: internalCronUsername, password: internalCronPassword },
155
158
  timeoutMs: 2 * 60 * 1000,
156
159
  createdBy: 'system',
157
160
  });
@@ -165,14 +168,12 @@ async function bootstrap() {
165
168
 
166
169
  await ensureBlogImagesNamespace();
167
170
 
168
- const token = await ensureInternalTokenExists();
169
-
170
171
  // CronScheduler HTTP jobs need an absolute base URL.
171
172
  const baseUrl =
172
173
  String(process.env.SUPERBACKEND_BASE_URL || process.env.PUBLIC_URL || '').trim() ||
173
174
  'http://localhost:3000';
174
175
 
175
- await ensureCronJobs({ baseUrl: baseUrl.replace(/\/+$/, ''), token });
176
+ await ensureCronJobs({ baseUrl: baseUrl.replace(/\/+$/, '') });
176
177
  }
177
178
 
178
179
  module.exports = {
@@ -13,6 +13,21 @@ const { logErrorSync } = require("./errorLogger");
13
13
  const CronJob = require("../models/CronJob");
14
14
  const ScriptDefinition = require("../models/ScriptDefinition");
15
15
 
16
+ // Import consoleOverride to access the truly original console
17
+ const consoleOverride = require("./consoleOverride.service");
18
+
19
+ // Simplified module prefix tracking
20
+ let currentModulePrefix = '';
21
+
22
+ // Module prefix mapping based on module name
23
+ const MODULE_PREFIXES = {
24
+ 'cronScheduler.service.js': '[SuperBackend][Cron]',
25
+ 'healthChecksScheduler.service.js': '[SuperBackend][Health]',
26
+ 'consoleManager.service.js': '[SuperBackend][Console]',
27
+ 'middleware': '[SuperBackend][Core]',
28
+ 'middleware.js': '[SuperBackend][Core]'
29
+ };
30
+
16
31
  let isActive = false;
17
32
  let previousConsole = null;
18
33
  let isHandling = false;
@@ -41,19 +56,9 @@ function normalizeMessage(message) {
41
56
  .slice(0, 500);
42
57
  }
43
58
 
44
- function extractTopFrame(stack) {
45
- if (!stack) return "";
46
- // Skip: Error, console wrapper, handleConsoleCall
47
- const lines = String(stack).split("\n").slice(3, 6);
48
- for (const line of lines) {
49
- const match = line.match(/at\s+(?:(.+?)\s+)?\(?(.+?):(\d+):(\d+)\)?/);
50
- if (match) {
51
- const fn = match[1] || "<anonymous>";
52
- const file = match[2].split("/").pop();
53
- return `${fn}@${file}:${match[3]}`;
54
- }
55
- }
56
- return "";
59
+ // Simplified approach - no stack trace parsing needed
60
+ function getModulePrefix() {
61
+ return currentModulePrefix;
57
62
  }
58
63
 
59
64
  function computeHash({ method, messageTemplate, topFrame }) {
@@ -377,16 +382,26 @@ let configFromMemory = null;
377
382
  function handleConsoleCall(method, args, stack) {
378
383
  const message = buildMessageFromArgs(args);
379
384
  const messageTemplate = normalizeMessage(message);
380
- const topFrame = extractTopFrame(stack);
385
+ // Use empty string for topFrame since we're not using stack trace parsing
386
+ const topFrame = "";
381
387
  const hash = computeHash({ method, messageTemplate, topFrame });
382
388
 
389
+ // Get the current module prefix (simplified approach)
390
+ const prefix = getModulePrefix();
391
+
392
+ // Add prefix to args if prefix exists
393
+ let prefixedArgs = args;
394
+ if (prefix) {
395
+ prefixedArgs = [prefix, ...args];
396
+ }
397
+
383
398
  let entryFromMemory = memoryEntries.get(hash);
384
399
 
385
400
  asyncUpdate();
386
401
 
387
402
  if (!configFromMemory && !entryFromMemory) {
388
403
  // First pass - always log and wait for async update to complete
389
- previousConsole[method](...args);
404
+ previousConsole[method](...prefixedArgs);
390
405
  return;
391
406
  }
392
407
 
@@ -396,7 +411,7 @@ function handleConsoleCall(method, args, stack) {
396
411
  : configFromMemory?.defaultEntryEnabled !== false;
397
412
 
398
413
  if (isEnabled) {
399
- previousConsole[method](...args);
414
+ previousConsole[method](...prefixedArgs);
400
415
  } else {
401
416
  // Entry is disabled - suppress stdout but still capture error aggregation for errors
402
417
  if (method === "error") {
@@ -568,11 +583,30 @@ async function ensureRetentionCron() {
568
583
 
569
584
  const consoleManager = {
570
585
  getConsole:()=>console,
586
+
587
+ // New method to set module prefix
588
+ setModulePrefix(moduleName) {
589
+ // If moduleName is already a prefix, use it directly
590
+ if (moduleName.startsWith('[') && moduleName.endsWith(']')) {
591
+ currentModulePrefix = moduleName;
592
+ } else {
593
+ // Otherwise look it up in the mapping
594
+ currentModulePrefix = MODULE_PREFIXES[moduleName] || '';
595
+ }
596
+ },
597
+
598
+ // Get current module prefix
599
+ getModulePrefix() {
600
+ return currentModulePrefix;
601
+ },
602
+
571
603
  init() {
572
604
  if (isActive) return;
573
605
  if (isHandling) return;
574
606
 
575
- previousConsole = { ...console };
607
+ // Use the truly original console from consoleOverride if available
608
+ // Otherwise fall back to current console
609
+ previousConsole = consoleOverride.TRULY_ORIGINAL_CONSOLE || { ...console };
576
610
 
577
611
  METHODS.forEach((method) => {
578
612
  console[method] = (...args) => {
@@ -697,4 +731,8 @@ const consoleManager = {
697
731
  },
698
732
  };
699
733
 
700
- module.exports = consoleManager;
734
+ module.exports = {
735
+ consoleManager,
736
+ MODULE_PREFIXES,
737
+ handleConsoleCall
738
+ };
@@ -294,3 +294,4 @@ process.on('SIGTERM', () => {
294
294
  });
295
295
 
296
296
  module.exports = consoleOverride;
297
+ module.exports.TRULY_ORIGINAL_CONSOLE = TRULY_ORIGINAL_CONSOLE;
@@ -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
+ };