@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.
- package/.env.example +10 -0
- package/analysis-only.skill +0 -0
- package/package.json +2 -1
- package/src/controllers/admin.controller.js +68 -1
- package/src/controllers/adminExperiments.controller.js +200 -0
- package/src/controllers/adminScripts.controller.js +105 -74
- package/src/controllers/experiments.controller.js +85 -0
- package/src/controllers/internalExperiments.controller.js +17 -0
- package/src/helpers/mongooseHelper.js +258 -0
- package/src/helpers/scriptBase.js +230 -0
- package/src/helpers/scriptRunner.js +335 -0
- package/src/middleware.js +65 -11
- package/src/models/CacheEntry.js +1 -1
- package/src/models/ConsoleLog.js +1 -1
- package/src/models/Experiment.js +75 -0
- package/src/models/ExperimentAssignment.js +23 -0
- package/src/models/ExperimentEvent.js +26 -0
- package/src/models/ExperimentMetricBucket.js +30 -0
- package/src/models/GlobalSetting.js +1 -2
- package/src/models/RateLimitCounter.js +1 -1
- package/src/models/ScriptDefinition.js +1 -0
- package/src/models/Webhook.js +2 -0
- package/src/routes/admin.routes.js +2 -0
- package/src/routes/adminConsoleManager.routes.js +1 -1
- package/src/routes/adminExperiments.routes.js +29 -0
- package/src/routes/blogInternal.routes.js +2 -2
- package/src/routes/experiments.routes.js +30 -0
- package/src/routes/internalExperiments.routes.js +15 -0
- package/src/services/blogCronsBootstrap.service.js +7 -6
- package/src/services/consoleManager.service.js +56 -18
- package/src/services/consoleOverride.service.js +1 -0
- package/src/services/experiments.service.js +273 -0
- package/src/services/experimentsAggregation.service.js +308 -0
- package/src/services/experimentsCronsBootstrap.service.js +118 -0
- package/src/services/experimentsRetention.service.js +43 -0
- package/src/services/experimentsWs.service.js +134 -0
- package/src/services/globalSettings.service.js +15 -0
- package/src/services/jsonConfigs.service.js +2 -2
- package/src/services/scriptsRunner.service.js +214 -14
- package/src/utils/rbac/rightsRegistry.js +4 -0
- package/views/admin-dashboard.ejs +28 -8
- package/views/admin-experiments.ejs +91 -0
- package/views/admin-scripts.ejs +596 -2
- package/views/partials/dashboard/nav-items.ejs +1 -0
- package/views/partials/dashboard/palette.ejs +5 -3
- package/src/middleware/internalCronAuth.js +0 -29
package/src/models/Webhook.js
CHANGED
|
@@ -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 {
|
|
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(
|
|
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
|
|
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: '
|
|
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: '
|
|
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(/\/+$/, '')
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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](...
|
|
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](...
|
|
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
|
-
|
|
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 =
|
|
734
|
+
module.exports = {
|
|
735
|
+
consoleManager,
|
|
736
|
+
MODULE_PREFIXES,
|
|
737
|
+
handleConsoleCall
|
|
738
|
+
};
|
|
@@ -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
|
+
};
|