@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/.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.
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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
|
+
};
|