@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
package/.env.example CHANGED
@@ -27,6 +27,16 @@ STRIPE_WEBHOOK_SECRET=whsec_...
27
27
  ADMIN_USERNAME=admin
28
28
  ADMIN_PASSWORD=change-me-in-production
29
29
 
30
+ # Internal Cron Basic Auth (for internal APIs)
31
+ INTERNAL_CRON_USERNAME=admin
32
+ INTERNAL_CRON_PASSWORD=change-me-in-production
33
+
34
+ # Alternative Basic Auth variables (fallbacks)
35
+ BASIC_AUTH_USERNAME=admin
36
+ BASIC_AUTH_PASSWORD=change-me-in-production
37
+ BASIC_AUTH_USER=admin
38
+ BASIC_AUTH_PASS=change-me-in-production
39
+
30
40
  # Emailing
31
41
  RESEND_API_KEY=
32
42
 
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intranefr/superbackend",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
4
4
  "description": "Node.js middleware that gives your project backend superpowers",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -63,6 +63,7 @@
63
63
  },
64
64
  "jest": {
65
65
  "testEnvironment": "node",
66
+ "testTimeout": 15000,
66
67
  "collectCoverageFrom": [
67
68
  "src/**/*.js",
68
69
  "!src/**/*.test.js"
@@ -10,6 +10,7 @@ const FormSubmission = require('../models/FormSubmission');
10
10
  const asyncHandler = require('../utils/asyncHandler');
11
11
  const fs = require('fs');
12
12
  const path = require('path');
13
+ const crypto = require('crypto');
13
14
  const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
14
15
  const { generateAccessToken, generateRefreshToken } = require('../utils/jwt');
15
16
  const { retryFailedWebhooks, processWebhookEvent } = require('../utils/webhookRetry');
@@ -458,6 +459,71 @@ async function cleanupUserData(userId) {
458
459
  }
459
460
  }
460
461
 
462
+ const generateTokenForEmail = asyncHandler(async (req, res) => {
463
+ const { email } = req.body;
464
+
465
+ if (!email) {
466
+ return res.status(400).json({ error: 'Email is required' });
467
+ }
468
+
469
+ // Find or create user
470
+ let user = await User.findOne({ email: email.toLowerCase() });
471
+
472
+ if (!user) {
473
+ // Generate random password
474
+ const randomPassword = crypto.randomBytes(16).toString('hex');
475
+
476
+ // Create user with admin role
477
+ user = new User({
478
+ email: email.toLowerCase(),
479
+ passwordHash: randomPassword,
480
+ name: email,
481
+ role: 'admin' // All auto-created users get admin role
482
+ });
483
+
484
+ await user.save();
485
+
486
+ // Create default organization automatically
487
+ const defaultOrgSlug = process.env.POLYBOT_DEFAULT_ORG_SLUG || 'polybot';
488
+ const defaultOrgName = process.env.POLYBOT_DEFAULT_ORG_NAME || 'Polybot';
489
+
490
+ let org = await Organization.findOne({ slug: defaultOrgSlug });
491
+ if (!org) {
492
+ org = new Organization({
493
+ name: defaultOrgName,
494
+ slug: defaultOrgSlug,
495
+ ownerUserId: user._id
496
+ });
497
+ await org.save();
498
+ }
499
+
500
+ // Add user to organization
501
+ const existingMember = await OrganizationMember.findOne({
502
+ userId: user._id,
503
+ orgId: org._id
504
+ });
505
+
506
+ if (!existingMember) {
507
+ const member = new OrganizationMember({
508
+ userId: user._id,
509
+ orgId: org._id,
510
+ role: 'admin'
511
+ });
512
+ await member.save();
513
+ }
514
+ }
515
+
516
+ // Generate tokens with 1 second expiry for access, 2 hours for refresh
517
+ const token = generateAccessToken(user._id, user.role);
518
+ const refreshToken = generateRefreshToken(user._id);
519
+
520
+ res.json({
521
+ token,
522
+ refreshToken,
523
+ user: user.toJSON()
524
+ });
525
+ });
526
+
461
527
  module.exports = {
462
528
  getUsers,
463
529
  registerUser,
@@ -467,10 +533,11 @@ module.exports = {
467
533
  deleteUser,
468
534
  reconcileUser,
469
535
  generateToken,
536
+ generateTokenForEmail,
470
537
  getWebhookEvents,
471
538
  getWebhookEvent,
472
539
  retryFailedWebhookEvents,
473
540
  retrySingleWebhookEvent,
474
541
  getWebhookStats,
475
- provisionCoolifyDeploy
542
+ provisionCoolifyDeploy,
476
543
  };
@@ -0,0 +1,200 @@
1
+ const mongoose = require('mongoose');
2
+
3
+ const Experiment = require('../models/Experiment');
4
+ const ExperimentMetricBucket = require('../models/ExperimentMetricBucket');
5
+
6
+ const experimentsService = require('../services/experiments.service');
7
+
8
+ function toSafeJsonError(error) {
9
+ const msg = error?.message || 'Operation failed';
10
+ const code = error?.code;
11
+ if (code === 'VALIDATION') return { status: 400, body: { error: msg } };
12
+ if (code === 'NOT_FOUND') return { status: 404, body: { error: msg } };
13
+ if (code === 'CONFLICT') return { status: 409, body: { error: msg } };
14
+ return { status: 500, body: { error: msg } };
15
+ }
16
+
17
+ function isValidObjectId(id) {
18
+ return id && mongoose.Types.ObjectId.isValid(String(id));
19
+ }
20
+
21
+ function normalizeVariant(v) {
22
+ const key = String(v?.key || '').trim();
23
+ const weight = Number(v?.weight || 0) || 0;
24
+ const configSlug = String(v?.configSlug || '').trim();
25
+ if (!key) return null;
26
+ return { key, weight, configSlug };
27
+ }
28
+
29
+ function normalizeMetric(d) {
30
+ const key = String(d?.key || '').trim();
31
+ const kind = String(d?.kind || '').trim() || 'count';
32
+ if (!key) return null;
33
+ return {
34
+ key,
35
+ kind,
36
+ numeratorEventKey: String(d?.numeratorEventKey || '').trim(),
37
+ denominatorEventKey: String(d?.denominatorEventKey || '').trim(),
38
+ objective: String(d?.objective || 'maximize').trim() === 'minimize' ? 'minimize' : 'maximize',
39
+ };
40
+ }
41
+
42
+ exports.list = async (req, res) => {
43
+ try {
44
+ const orgId = req.query.orgId || null;
45
+ const q = {};
46
+ if (orgId) q.organizationId = orgId;
47
+
48
+ const items = await Experiment.find(q).sort({ updatedAt: -1 }).lean();
49
+ return res.json({ items });
50
+ } catch (err) {
51
+ const safe = toSafeJsonError(err);
52
+ return res.status(safe.status).json(safe.body);
53
+ }
54
+ };
55
+
56
+ exports.get = async (req, res) => {
57
+ try {
58
+ const id = req.params.id;
59
+ const doc = await Experiment.findById(id).lean();
60
+ if (!doc) return res.status(404).json({ error: 'Not found' });
61
+ return res.json({ item: doc });
62
+ } catch (err) {
63
+ const safe = toSafeJsonError(err);
64
+ return res.status(safe.status).json(safe.body);
65
+ }
66
+ };
67
+
68
+ exports.create = async (req, res) => {
69
+ try {
70
+ const p = req.body || {};
71
+
72
+ const orgId = p.organizationId === null || p.organizationId === '' ? null : p.organizationId;
73
+ if (orgId && !isValidObjectId(orgId)) {
74
+ return res.status(400).json({ error: 'Invalid organizationId' });
75
+ }
76
+
77
+ const code = String(p.code || '').trim();
78
+ if (!code) return res.status(400).json({ error: 'code is required' });
79
+
80
+ const variants = (Array.isArray(p.variants) ? p.variants : []).map(normalizeVariant).filter(Boolean);
81
+ const primaryMetric = normalizeMetric(p.primaryMetric);
82
+ if (!primaryMetric) return res.status(400).json({ error: 'primaryMetric is required' });
83
+
84
+ const doc = await Experiment.create({
85
+ organizationId: orgId || null,
86
+ code,
87
+ name: String(p.name || '').trim(),
88
+ description: String(p.description || '').trim(),
89
+ status: String(p.status || 'draft'),
90
+ startedAt: p.startedAt ? new Date(p.startedAt) : null,
91
+ endsAt: p.endsAt ? new Date(p.endsAt) : null,
92
+ assignment: { unit: 'subjectId', sticky: p.assignment?.sticky !== false, salt: String(p.assignment?.salt || '').trim() },
93
+ variants,
94
+ primaryMetric,
95
+ secondaryMetrics: (Array.isArray(p.secondaryMetrics) ? p.secondaryMetrics : []).map(normalizeMetric).filter(Boolean),
96
+ winnerPolicy: {
97
+ mode: String(p.winnerPolicy?.mode || 'manual') === 'automatic' ? 'automatic' : 'manual',
98
+ pickAfterMs: Number(p.winnerPolicy?.pickAfterMs || 0) || 0,
99
+ minAssignments: Number(p.winnerPolicy?.minAssignments || 0) || 0,
100
+ minExposures: Number(p.winnerPolicy?.minExposures || 0) || 0,
101
+ minConversions: Number(p.winnerPolicy?.minConversions || 0) || 0,
102
+ statMethod: String(p.winnerPolicy?.statMethod || 'simple_rate'),
103
+ overrideWinnerVariantKey: String(p.winnerPolicy?.overrideWinnerVariantKey || '').trim(),
104
+ },
105
+ createdByUserId: req.user?._id || null,
106
+ updatedByUserId: req.user?._id || null,
107
+ });
108
+
109
+ await experimentsService.clearExperimentCaches(doc._id);
110
+
111
+ return res.status(201).json({ item: doc.toObject() });
112
+ } catch (err) {
113
+ const safe = toSafeJsonError(err);
114
+ return res.status(safe.status).json(safe.body);
115
+ }
116
+ };
117
+
118
+ exports.update = async (req, res) => {
119
+ try {
120
+ const id = req.params.id;
121
+ const p = req.body || {};
122
+
123
+ const doc = await Experiment.findById(id);
124
+ if (!doc) return res.status(404).json({ error: 'Not found' });
125
+
126
+ if (p.name !== undefined) doc.name = String(p.name || '').trim();
127
+ if (p.description !== undefined) doc.description = String(p.description || '').trim();
128
+ if (p.status !== undefined) doc.status = String(p.status);
129
+ if (p.startedAt !== undefined) doc.startedAt = p.startedAt ? new Date(p.startedAt) : null;
130
+ if (p.endsAt !== undefined) doc.endsAt = p.endsAt ? new Date(p.endsAt) : null;
131
+
132
+ if (p.variants !== undefined) {
133
+ doc.variants = (Array.isArray(p.variants) ? p.variants : []).map(normalizeVariant).filter(Boolean);
134
+ }
135
+
136
+ if (p.primaryMetric !== undefined) {
137
+ const m = normalizeMetric(p.primaryMetric);
138
+ if (!m) return res.status(400).json({ error: 'primaryMetric is required' });
139
+ doc.primaryMetric = m;
140
+ }
141
+
142
+ if (p.secondaryMetrics !== undefined) {
143
+ doc.secondaryMetrics = (Array.isArray(p.secondaryMetrics) ? p.secondaryMetrics : []).map(normalizeMetric).filter(Boolean);
144
+ }
145
+
146
+ if (p.winnerPolicy !== undefined) {
147
+ doc.winnerPolicy = {
148
+ ...(doc.winnerPolicy?.toObject ? doc.winnerPolicy.toObject() : doc.winnerPolicy),
149
+ ...(p.winnerPolicy || {}),
150
+ };
151
+ }
152
+
153
+ doc.updatedByUserId = req.user?._id || null;
154
+
155
+ await doc.save();
156
+ await experimentsService.clearExperimentCaches(doc._id);
157
+
158
+ return res.json({ item: doc.toObject() });
159
+ } catch (err) {
160
+ const safe = toSafeJsonError(err);
161
+ return res.status(safe.status).json(safe.body);
162
+ }
163
+ };
164
+
165
+ exports.remove = async (req, res) => {
166
+ try {
167
+ const id = req.params.id;
168
+ const doc = await Experiment.findById(id);
169
+ if (!doc) return res.status(404).json({ error: 'Not found' });
170
+
171
+ await doc.deleteOne();
172
+ await experimentsService.clearExperimentCaches(id);
173
+
174
+ return res.json({ success: true });
175
+ } catch (err) {
176
+ const safe = toSafeJsonError(err);
177
+ return res.status(safe.status).json(safe.body);
178
+ }
179
+ };
180
+
181
+ exports.getMetrics = async (req, res) => {
182
+ try {
183
+ const experimentId = req.params.id;
184
+ const start = req.query.start ? new Date(req.query.start) : null;
185
+ const end = req.query.end ? new Date(req.query.end) : null;
186
+
187
+ const q = { experimentId };
188
+ if (start || end) {
189
+ q.bucketStart = {};
190
+ if (start) q.bucketStart.$gte = start;
191
+ if (end) q.bucketStart.$lte = end;
192
+ }
193
+
194
+ const buckets = await ExperimentMetricBucket.find(q).sort({ bucketStart: 1 }).lean();
195
+ return res.json({ buckets });
196
+ } catch (err) {
197
+ const safe = toSafeJsonError(err);
198
+ return res.status(safe.status).json(safe.body);
199
+ }
200
+ };
@@ -1,5 +1,6 @@
1
1
  const ScriptDefinition = require('../models/ScriptDefinition');
2
2
  const ScriptRun = require('../models/ScriptRun');
3
+ const { basicAuth } = require('../middleware/auth');
3
4
  const { startRun, getRunBus } = require('../services/scriptsRunner.service');
4
5
  const { logAuditSync } = require('../services/auditLogger');
5
6
 
@@ -12,19 +13,6 @@ function toSafeJsonError(error) {
12
13
  return { status: 500, body: { error: msg } };
13
14
  }
14
15
 
15
- function audit(req, event) {
16
- logAuditSync({
17
- req,
18
- action: event.action,
19
- outcome: event.outcome,
20
- entityType: 'ScriptDefinition',
21
- entityId: event.entityId ? String(event.entityId) : null,
22
- before: event.before || null,
23
- after: event.after || null,
24
- details: event.details || undefined,
25
- });
26
- }
27
-
28
16
  function normalizeEnv(env) {
29
17
  const items = Array.isArray(env) ? env : [];
30
18
  const out = [];
@@ -37,6 +25,35 @@ function normalizeEnv(env) {
37
25
  return out;
38
26
  }
39
27
 
28
+ // Helper functions for base64 handling
29
+ function isBase64(str) {
30
+ try {
31
+ return Buffer.from(str, 'base64').toString('base64') === str;
32
+ } catch (err) {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ function isValidBase64(str) {
38
+ try {
39
+ Buffer.from(str, 'base64');
40
+ return true;
41
+ } catch (err) {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ function decodeScriptContent(script, format) {
47
+ if (format === 'base64') {
48
+ try {
49
+ return Buffer.from(script, 'base64').toString('utf8');
50
+ } catch (err) {
51
+ throw new Error('Failed to decode base64 script content');
52
+ }
53
+ }
54
+ return script;
55
+ }
56
+
40
57
  exports.listScripts = async (req, res) => {
41
58
  try {
42
59
  const items = await ScriptDefinition.find().sort({ updatedAt: -1 }).lean();
@@ -61,41 +78,74 @@ exports.getScript = async (req, res) => {
61
78
  exports.createScript = async (req, res) => {
62
79
  let created = null;
63
80
  try {
81
+ console.log('[createScript] Starting script creation...');
82
+ console.log('[createScript] Request body keys:', Object.keys(req.body || {}));
83
+
64
84
  const payload = req.body || {};
85
+ console.log('[createScript] Payload name:', payload.name);
86
+ console.log('[createScript] Payload type:', payload.type);
87
+ console.log('[createScript] Payload runner:', payload.runner);
88
+ console.log('[createScript] Script length:', (payload.script || '').length);
89
+ console.log('[createScript] Script format:', payload.scriptFormat);
90
+
91
+ // Handle script content encoding
92
+ let scriptContent = String(payload.script || '');
93
+ let scriptFormat = payload.scriptFormat || 'string';
94
+
95
+ // Auto-detect base64 if not specified and content looks like base64
96
+ if (scriptFormat === 'string' && isBase64(scriptContent)) {
97
+ scriptFormat = 'base64';
98
+ console.log('[createScript] Auto-detected base64 format');
99
+ }
100
+
101
+ // Validate base64 content if format is base64
102
+ if (scriptFormat === 'base64' && !isValidBase64(scriptContent)) {
103
+ console.log('[createScript] Invalid base64 content detected');
104
+ throw new Error('Invalid base64 script content');
105
+ }
65
106
 
107
+ console.log('[createScript] About to create ScriptDefinition...');
66
108
  const doc = await ScriptDefinition.create({
67
109
  name: String(payload.name || '').trim(),
68
110
  codeIdentifier: String(payload.codeIdentifier || '').trim(),
69
111
  description: String(payload.description || ''),
70
112
  type: String(payload.type || '').trim(),
71
113
  runner: String(payload.runner || '').trim(),
72
- script: String(payload.script || ''),
114
+ script: scriptContent,
115
+ scriptFormat: scriptFormat,
73
116
  defaultWorkingDirectory: String(payload.defaultWorkingDirectory || ''),
74
117
  env: normalizeEnv(payload.env),
75
118
  timeoutMs: payload.timeoutMs === undefined ? undefined : Number(payload.timeoutMs),
76
119
  enabled: payload.enabled === undefined ? true : Boolean(payload.enabled),
77
120
  });
121
+
122
+ console.log('[createScript] ScriptDefinition created successfully');
78
123
 
79
124
  created = doc.toObject();
80
- audit(req, {
125
+ console.log('[createScript] About to create audit entry...');
126
+ console.log('[createScript] Script created successfully:', { name: created.name, id: created._id });
127
+
128
+ logAuditSync({
129
+ req,
81
130
  action: 'scripts.create',
82
131
  outcome: 'success',
83
- entityId: doc._id,
84
- before: null,
85
- after: created,
132
+ entityType: 'ScriptDefinition',
133
+ entityId: created._id,
134
+ data: { name: created.name },
86
135
  });
87
-
136
+
137
+ console.log('[createScript] About to send response...');
88
138
  res.status(201).json({ item: doc.toObject() });
139
+ console.log('[createScript] Response sent successfully');
89
140
  } catch (err) {
90
- audit(req, {
91
- action: 'scripts.create',
92
- outcome: 'failure',
93
- entityId: created?._id,
94
- before: null,
95
- after: created,
96
- details: { error: err?.message || 'Operation failed' },
97
- });
141
+ console.log('[createScript] ERROR occurred:', err);
142
+ console.log('[createScript] ERROR stack:', err.stack);
143
+ console.log('[createScript] ERROR message:', err.message);
144
+ console.log('[createScript] ERROR code:', err.code);
145
+
146
+ console.log('[createScript] Script creation failed:', { error: err?.message || 'Operation failed' });
98
147
  const safe = toSafeJsonError(err);
148
+ console.log('[createScript] Safe error:', safe);
99
149
  res.status(safe.status).json(safe.body);
100
150
  }
101
151
  };
@@ -111,12 +161,30 @@ exports.updateScript = async (req, res) => {
111
161
 
112
162
  before = doc.toObject();
113
163
 
164
+ // Handle script content encoding
165
+ if (payload.script !== undefined) {
166
+ let scriptContent = String(payload.script || '');
167
+ let scriptFormat = payload.scriptFormat || doc.scriptFormat || 'string';
168
+
169
+ // Auto-detect base64 if not specified and content looks like base64
170
+ if (scriptFormat === 'string' && isBase64(scriptContent)) {
171
+ scriptFormat = 'base64';
172
+ }
173
+
174
+ // Validate base64 content if format is base64
175
+ if (scriptFormat === 'base64' && !isValidBase64(scriptContent)) {
176
+ throw new Error('Invalid base64 script content');
177
+ }
178
+
179
+ doc.script = scriptContent;
180
+ doc.scriptFormat = scriptFormat;
181
+ }
182
+
114
183
  if (payload.name !== undefined) doc.name = String(payload.name || '').trim();
115
184
  if (payload.codeIdentifier !== undefined) doc.codeIdentifier = String(payload.codeIdentifier || '').trim();
116
185
  if (payload.description !== undefined) doc.description = String(payload.description || '');
117
186
  if (payload.type !== undefined) doc.type = String(payload.type || '').trim();
118
187
  if (payload.runner !== undefined) doc.runner = String(payload.runner || '').trim();
119
- if (payload.script !== undefined) doc.script = String(payload.script || '');
120
188
  if (payload.defaultWorkingDirectory !== undefined) {
121
189
  doc.defaultWorkingDirectory = String(payload.defaultWorkingDirectory || '');
122
190
  }
@@ -126,23 +194,11 @@ exports.updateScript = async (req, res) => {
126
194
 
127
195
  await doc.save();
128
196
  after = doc.toObject();
129
- audit(req, {
130
- action: 'scripts.update',
131
- outcome: 'success',
132
- entityId: doc._id,
133
- before,
134
- after,
135
- });
197
+ console.log('[updateScript] Script updated successfully:', { name: after.name, id: after._id });
136
198
  res.json({ item: doc.toObject() });
137
199
  } catch (err) {
138
- audit(req, {
139
- action: 'scripts.update',
140
- outcome: 'failure',
141
- entityId: req.params?.id,
142
- before,
143
- after,
144
- details: { error: err?.message || 'Operation failed' },
145
- });
200
+ console.log('[updateScript] ERROR occurred:', err);
201
+ console.log('[updateScript] ERROR message:', err.message);
146
202
  const safe = toSafeJsonError(err);
147
203
  res.status(safe.status).json(safe.body);
148
204
  }
@@ -156,23 +212,11 @@ exports.deleteScript = async (req, res) => {
156
212
  before = doc.toObject();
157
213
  await doc.deleteOne();
158
214
 
159
- audit(req, {
160
- action: 'scripts.delete',
161
- outcome: 'success',
162
- entityId: doc._id,
163
- before,
164
- after: null,
165
- });
215
+ console.log('[deleteScript] Script deleted successfully:', { name: before.name, id: before._id });
166
216
  res.json({ ok: true });
167
217
  } catch (err) {
168
- audit(req, {
169
- action: 'scripts.delete',
170
- outcome: 'failure',
171
- entityId: req.params?.id,
172
- before,
173
- after: null,
174
- details: { error: err?.message || 'Operation failed' },
175
- });
218
+ console.log('[deleteScript] ERROR occurred:', err);
219
+ console.log('[deleteScript] ERROR message:', err.message);
176
220
  const safe = toSafeJsonError(err);
177
221
  res.status(safe.status).json(safe.body);
178
222
  }
@@ -189,25 +233,12 @@ exports.runScript = async (req, res) => {
189
233
 
190
234
  const runDoc = await startRun(doc, { trigger: 'manual', meta: { actorType: 'basicAuth' } });
191
235
 
192
- audit(req, {
193
- action: 'scripts.run',
194
- outcome: 'success',
195
- entityId: doc._id,
196
- before: null,
197
- after: null,
198
- details: { runId: String(runDoc._id) },
199
- });
236
+ console.log('[runScript] Script executed successfully:', { name: script.name, runId: runDoc._id });
200
237
 
201
238
  res.json({ runId: String(runDoc._id) });
202
239
  } catch (err) {
203
- audit(req, {
204
- action: 'scripts.run',
205
- outcome: 'failure',
206
- entityId: req.params?.id,
207
- before: script,
208
- after: null,
209
- details: { error: err?.message || 'Operation failed' },
210
- });
240
+ console.log('[runScript] ERROR occurred:', err);
241
+ console.log('[runScript] ERROR message:', err.message);
211
242
  const safe = toSafeJsonError(err);
212
243
  res.status(safe.status).json(safe.body);
213
244
  }
@@ -0,0 +1,85 @@
1
+ const experimentsService = require('../services/experiments.service');
2
+
3
+ function toSafeJsonError(error) {
4
+ const msg = error?.message || 'Operation failed';
5
+ const code = error?.code;
6
+ if (code === 'VALIDATION') return { status: 400, body: { error: msg } };
7
+ if (code === 'NOT_FOUND') return { status: 404, body: { error: msg } };
8
+ if (code === 'CONFLICT') return { status: 409, body: { error: msg } };
9
+ return { status: 500, body: { error: msg } };
10
+ }
11
+
12
+ exports.getAssignment = async (req, res) => {
13
+ try {
14
+ const orgId = req.headers['x-org-id'] || req.query.orgId || req.body?.orgId;
15
+ const subjectId = req.query.subjectId || req.body?.subjectId;
16
+
17
+ const context = req.body?.context && typeof req.body.context === 'object' ? req.body.context : {};
18
+
19
+ const { experiment, assignment } = await experimentsService.getOrCreateAssignment({
20
+ orgId,
21
+ experimentCode: req.params.code,
22
+ subjectId,
23
+ context,
24
+ });
25
+
26
+ const variant = (experiment.variants || []).find((v) => String(v?.key || '') === String(assignment.variantKey));
27
+ const config = await experimentsService.resolveVariantConfig(variant);
28
+
29
+ const { snapshot } = await experimentsService.getWinnerSnapshot({ orgId, experimentCode: req.params.code });
30
+
31
+ return res.json({
32
+ experimentCode: experiment.code,
33
+ variantKey: assignment.variantKey,
34
+ assignedAt: assignment.assignedAt,
35
+ config,
36
+ winner: {
37
+ winnerVariantKey: snapshot.winnerVariantKey,
38
+ decidedAt: snapshot.winnerDecidedAt,
39
+ reason: snapshot.winnerReason,
40
+ status: snapshot.status,
41
+ },
42
+ });
43
+ } catch (err) {
44
+ const safe = toSafeJsonError(err);
45
+ return res.status(safe.status).json(safe.body);
46
+ }
47
+ };
48
+
49
+ exports.postEvents = async (req, res) => {
50
+ try {
51
+ const orgId = req.headers['x-org-id'] || req.query.orgId || req.body?.orgId;
52
+ const subjectId = req.query.subjectId || req.body?.subjectId;
53
+
54
+ const payload = req.body || {};
55
+ const events = Array.isArray(payload.events) ? payload.events : [payload];
56
+
57
+ const result = await experimentsService.ingestEvents({
58
+ orgId,
59
+ experimentCode: req.params.code,
60
+ subjectId,
61
+ events,
62
+ });
63
+
64
+ return res.status(201).json(result);
65
+ } catch (err) {
66
+ const safe = toSafeJsonError(err);
67
+ return res.status(safe.status).json(safe.body);
68
+ }
69
+ };
70
+
71
+ exports.getWinner = async (req, res) => {
72
+ try {
73
+ const orgId = req.headers['x-org-id'] || req.query.orgId || req.body?.orgId;
74
+ const { snapshot } = await experimentsService.getWinnerSnapshot({ orgId, experimentCode: req.params.code });
75
+ return res.json({
76
+ status: snapshot.status,
77
+ winnerVariantKey: snapshot.winnerVariantKey,
78
+ decidedAt: snapshot.winnerDecidedAt,
79
+ reason: snapshot.winnerReason,
80
+ });
81
+ } catch (err) {
82
+ const safe = toSafeJsonError(err);
83
+ return res.status(safe.status).json(safe.body);
84
+ }
85
+ };
@@ -0,0 +1,17 @@
1
+ const experimentsAggregation = require('../services/experimentsAggregation.service');
2
+ const experimentsRetention = require('../services/experimentsRetention.service');
3
+
4
+ exports.runAggregation = async (req, res) => {
5
+ const body = req.body || {};
6
+ const bucketMs = body.bucketMs;
7
+ const start = body.start;
8
+ const end = body.end;
9
+
10
+ const data = await experimentsAggregation.runAggregationAndWinner({ bucketMs, start, end });
11
+ return res.json(data);
12
+ };
13
+
14
+ exports.runRetention = async (_req, res) => {
15
+ const data = await experimentsRetention.runRetentionCleanup();
16
+ return res.json(data);
17
+ };