@intranefr/superbackend 1.5.1 → 1.5.3

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 (73) hide show
  1. package/.env.example +10 -0
  2. package/index.js +2 -0
  3. package/manage.js +745 -0
  4. package/package.json +5 -2
  5. package/src/controllers/admin.controller.js +79 -6
  6. package/src/controllers/adminAgents.controller.js +37 -0
  7. package/src/controllers/adminExperiments.controller.js +200 -0
  8. package/src/controllers/adminLlm.controller.js +19 -0
  9. package/src/controllers/adminMarkdowns.controller.js +157 -0
  10. package/src/controllers/adminScripts.controller.js +243 -74
  11. package/src/controllers/adminTelegram.controller.js +72 -0
  12. package/src/controllers/experiments.controller.js +85 -0
  13. package/src/controllers/internalExperiments.controller.js +17 -0
  14. package/src/controllers/markdowns.controller.js +42 -0
  15. package/src/helpers/mongooseHelper.js +258 -0
  16. package/src/helpers/scriptBase.js +230 -0
  17. package/src/helpers/scriptRunner.js +335 -0
  18. package/src/middleware.js +195 -34
  19. package/src/models/Agent.js +105 -0
  20. package/src/models/AgentMessage.js +82 -0
  21. package/src/models/CacheEntry.js +1 -1
  22. package/src/models/ConsoleLog.js +1 -1
  23. package/src/models/Experiment.js +75 -0
  24. package/src/models/ExperimentAssignment.js +23 -0
  25. package/src/models/ExperimentEvent.js +26 -0
  26. package/src/models/ExperimentMetricBucket.js +30 -0
  27. package/src/models/GlobalSetting.js +1 -2
  28. package/src/models/Markdown.js +75 -0
  29. package/src/models/RateLimitCounter.js +1 -1
  30. package/src/models/ScriptDefinition.js +1 -0
  31. package/src/models/ScriptRun.js +8 -0
  32. package/src/models/TelegramBot.js +42 -0
  33. package/src/models/Webhook.js +2 -0
  34. package/src/routes/admin.routes.js +2 -0
  35. package/src/routes/adminAgents.routes.js +13 -0
  36. package/src/routes/adminConsoleManager.routes.js +1 -1
  37. package/src/routes/adminExperiments.routes.js +29 -0
  38. package/src/routes/adminLlm.routes.js +1 -0
  39. package/src/routes/adminMarkdowns.routes.js +16 -0
  40. package/src/routes/adminScripts.routes.js +4 -1
  41. package/src/routes/adminTelegram.routes.js +14 -0
  42. package/src/routes/blogInternal.routes.js +2 -2
  43. package/src/routes/experiments.routes.js +30 -0
  44. package/src/routes/internalExperiments.routes.js +15 -0
  45. package/src/routes/markdowns.routes.js +16 -0
  46. package/src/services/agent.service.js +546 -0
  47. package/src/services/agentHistory.service.js +345 -0
  48. package/src/services/agentTools.service.js +578 -0
  49. package/src/services/blogCronsBootstrap.service.js +7 -6
  50. package/src/services/consoleManager.service.js +56 -18
  51. package/src/services/consoleOverride.service.js +1 -0
  52. package/src/services/experiments.service.js +273 -0
  53. package/src/services/experimentsAggregation.service.js +308 -0
  54. package/src/services/experimentsCronsBootstrap.service.js +118 -0
  55. package/src/services/experimentsRetention.service.js +43 -0
  56. package/src/services/experimentsWs.service.js +134 -0
  57. package/src/services/globalSettings.service.js +15 -0
  58. package/src/services/jsonConfigs.service.js +24 -12
  59. package/src/services/llm.service.js +219 -6
  60. package/src/services/markdowns.service.js +522 -0
  61. package/src/services/scriptsRunner.service.js +514 -23
  62. package/src/services/telegram.service.js +130 -0
  63. package/src/utils/rbac/rightsRegistry.js +4 -0
  64. package/views/admin-agents.ejs +273 -0
  65. package/views/admin-coolify-deploy.ejs +8 -8
  66. package/views/admin-dashboard.ejs +63 -12
  67. package/views/admin-experiments.ejs +91 -0
  68. package/views/admin-markdowns.ejs +905 -0
  69. package/views/admin-scripts.ejs +817 -6
  70. package/views/admin-telegram.ejs +269 -0
  71. package/views/partials/dashboard/nav-items.ejs +4 -0
  72. package/views/partials/dashboard/palette.ejs +5 -3
  73. package/src/middleware/internalCronAuth.js +0 -29
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@intranefr/superbackend",
3
- "version": "1.5.1",
3
+ "version": "1.5.3",
4
4
  "description": "Node.js middleware that gives your project backend superpowers",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
7
  "start": "node server.js",
8
- "dev": "nodemon --verbose --ignore uploads --ignore '*.log' server.js",
8
+ "dev": "nodemon --verbose --ignore uploads --ignore stdout.log --ignore '*.log' server.js",
9
9
  "start:minio": "docker compose -f compose.standalone.yml --profile minio-only up -d minio",
10
10
  "minio:envs": "node -e \"console.log(['S3_ENDPOINT=http://localhost:9000','S3_REGION=us-east-1','S3_ACCESS_KEY_ID=minioadmin','S3_SECRET_ACCESS_KEY=minioadmin','S3_BUCKET=saasbackend','S3_FORCE_PATH_STYLE=true'].join('\\n'))\"",
11
11
  "build:sdk:error-tracking:browser": "esbuild sdk/error-tracking/browser/src/embed.js --bundle --format=iife --global-name=saasbackendErrorTrackingEmbed --outfile=sdk/error-tracking/browser/dist/embed.iife.js",
@@ -47,11 +47,13 @@
47
47
  "mysql2": "^3.16.1",
48
48
  "node-cron": "^4.2.1",
49
49
  "node-pty": "^1.1.0",
50
+ "node-telegram-bot-api": "^0.67.0",
50
51
  "openai": "^4.0.0",
51
52
  "redis": "^4.7.1",
52
53
  "resend": "^6.4.0",
53
54
  "ssh2-sftp-client": "^12.0.1",
54
55
  "stripe": "^14.0.0",
56
+ "terminal-kit": "^3.1.2",
55
57
  "vm2": "^3.10.0",
56
58
  "ws": "^8.18.0"
57
59
  },
@@ -63,6 +65,7 @@
63
65
  },
64
66
  "jest": {
65
67
  "testEnvironment": "node",
68
+ "testTimeout": 15000,
66
69
  "collectCoverageFrom": [
67
70
  "src/**/*.js",
68
71
  "!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');
@@ -351,7 +352,7 @@ const getWebhookStats = asyncHandler(async (req, res) => {
351
352
  const provisionCoolifyDeploy = asyncHandler(async (req, res) => {
352
353
  try {
353
354
  const { overwrite } = req.body;
354
- const managePath = path.join(process.cwd(), "manage.sh");
355
+ const managePath = path.join(process.cwd(), "manage.js");
355
356
  const exists = fs.existsSync(managePath);
356
357
 
357
358
  if (exists && !overwrite) {
@@ -362,13 +363,19 @@ const provisionCoolifyDeploy = asyncHandler(async (req, res) => {
362
363
  });
363
364
  }
364
365
 
365
- // In ref-superbackend, manage.sh already exists in the root of the repository
366
- // If it didn't, we would write it here. For this case, we'll just success.
366
+ // Copy the improved manage.js from polybot
367
+ const sourceManageJs = path.join(__dirname, "../../../manage.js");
368
+ if (fs.existsSync(sourceManageJs)) {
369
+ fs.copyFileSync(sourceManageJs, managePath);
370
+ // Make it executable
371
+ fs.chmodSync(managePath, '755');
372
+ }
373
+
367
374
  res.json({
368
375
  success: true,
369
376
  message: exists
370
- ? "Coolify Headless Deploy script (manage.sh) was already there."
371
- : "Coolify Headless Deploy script (manage.sh) is ready in the root directory.",
377
+ ? "Coolify Headless Deploy script (manage.js) was updated."
378
+ : "Coolify Headless Deploy script (manage.js) is ready in the root directory.",
372
379
  path: managePath,
373
380
  });
374
381
  } catch (error) {
@@ -458,6 +465,71 @@ async function cleanupUserData(userId) {
458
465
  }
459
466
  }
460
467
 
468
+ const generateTokenForEmail = asyncHandler(async (req, res) => {
469
+ const { email } = req.body;
470
+
471
+ if (!email) {
472
+ return res.status(400).json({ error: 'Email is required' });
473
+ }
474
+
475
+ // Find or create user
476
+ let user = await User.findOne({ email: email.toLowerCase() });
477
+
478
+ if (!user) {
479
+ // Generate random password
480
+ const randomPassword = crypto.randomBytes(16).toString('hex');
481
+
482
+ // Create user with admin role
483
+ user = new User({
484
+ email: email.toLowerCase(),
485
+ passwordHash: randomPassword,
486
+ name: email,
487
+ role: 'admin' // All auto-created users get admin role
488
+ });
489
+
490
+ await user.save();
491
+
492
+ // Create default organization automatically
493
+ const defaultOrgSlug = process.env.POLYBOT_DEFAULT_ORG_SLUG || 'polybot';
494
+ const defaultOrgName = process.env.POLYBOT_DEFAULT_ORG_NAME || 'Polybot';
495
+
496
+ let org = await Organization.findOne({ slug: defaultOrgSlug });
497
+ if (!org) {
498
+ org = new Organization({
499
+ name: defaultOrgName,
500
+ slug: defaultOrgSlug,
501
+ ownerUserId: user._id
502
+ });
503
+ await org.save();
504
+ }
505
+
506
+ // Add user to organization
507
+ const existingMember = await OrganizationMember.findOne({
508
+ userId: user._id,
509
+ orgId: org._id
510
+ });
511
+
512
+ if (!existingMember) {
513
+ const member = new OrganizationMember({
514
+ userId: user._id,
515
+ orgId: org._id,
516
+ role: 'admin'
517
+ });
518
+ await member.save();
519
+ }
520
+ }
521
+
522
+ // Generate tokens with 1 second expiry for access, 2 hours for refresh
523
+ const token = generateAccessToken(user._id, user.role);
524
+ const refreshToken = generateRefreshToken(user._id);
525
+
526
+ res.json({
527
+ token,
528
+ refreshToken,
529
+ user: user.toJSON()
530
+ });
531
+ });
532
+
461
533
  module.exports = {
462
534
  getUsers,
463
535
  registerUser,
@@ -467,10 +539,11 @@ module.exports = {
467
539
  deleteUser,
468
540
  reconcileUser,
469
541
  generateToken,
542
+ generateTokenForEmail,
470
543
  getWebhookEvents,
471
544
  getWebhookEvent,
472
545
  retryFailedWebhookEvents,
473
546
  retrySingleWebhookEvent,
474
547
  getWebhookStats,
475
- provisionCoolifyDeploy
548
+ provisionCoolifyDeploy,
476
549
  };
@@ -0,0 +1,37 @@
1
+ const Agent = require('../models/Agent');
2
+
3
+ exports.listAgents = async (req, res) => {
4
+ try {
5
+ const agents = await Agent.find().lean();
6
+ return res.json({ items: agents });
7
+ } catch (error) {
8
+ return res.status(500).json({ error: error.message });
9
+ }
10
+ };
11
+
12
+ exports.createAgent = async (req, res) => {
13
+ try {
14
+ const agent = await Agent.create(req.body);
15
+ return res.json(agent);
16
+ } catch (error) {
17
+ return res.status(500).json({ error: error.message });
18
+ }
19
+ };
20
+
21
+ exports.updateAgent = async (req, res) => {
22
+ try {
23
+ const agent = await Agent.findByIdAndUpdate(req.params.id, req.body, { new: true });
24
+ return res.json(agent);
25
+ } catch (error) {
26
+ return res.status(500).json({ error: error.message });
27
+ }
28
+ };
29
+
30
+ exports.deleteAgent = async (req, res) => {
31
+ try {
32
+ await Agent.findByIdAndDelete(req.params.id);
33
+ return res.json({ success: true });
34
+ } catch (error) {
35
+ return res.status(500).json({ error: error.message });
36
+ }
37
+ };
@@ -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
+ };
@@ -387,6 +387,24 @@ async function listCosts(req, res) {
387
387
  }
388
388
  }
389
389
 
390
+ async function listProviders(req, res) {
391
+ try {
392
+ const providers = await getJsonSetting(PROVIDERS_KEY, {});
393
+ const safeProviders = {};
394
+ if (providers && typeof providers === "object") {
395
+ for (const [key, value] of Object.entries(providers)) {
396
+ if (!value || typeof value !== "object") continue;
397
+ const { apiKey, ...rest } = value;
398
+ safeProviders[key] = rest;
399
+ }
400
+ }
401
+ res.json({ providers: safeProviders });
402
+ } catch (error) {
403
+ console.error("[adminLlm] listProviders error", error);
404
+ res.status(500).json({ error: "Failed to load providers" });
405
+ }
406
+ }
407
+
390
408
  module.exports = {
391
409
  getConfig,
392
410
  saveConfig,
@@ -394,4 +412,5 @@ module.exports = {
394
412
  listAudit,
395
413
  listCosts,
396
414
  listOpenRouterModels,
415
+ listProviders,
397
416
  };
@@ -0,0 +1,157 @@
1
+ const {
2
+ ERROR_CODES,
3
+ listMarkdowns,
4
+ getMarkdownById,
5
+ createMarkdown,
6
+ updateMarkdown,
7
+ deleteMarkdown,
8
+ getFolderContents,
9
+ getUniqueGroupCodes,
10
+ validatePathUniqueness,
11
+ } = require('../services/markdowns.service');
12
+
13
+ function handleServiceError(res, error) {
14
+ const msg = error?.message || 'Operation failed';
15
+ const code = error?.code;
16
+
17
+ if (code === 'VALIDATION' || code === 'INVALID_MARKDOWN' || code === 'INVALID_GROUP_CODE') {
18
+ return res.status(400).json({ error: msg });
19
+ }
20
+ if (code === 'NOT_FOUND') {
21
+ return res.status(404).json({ error: msg });
22
+ }
23
+ if (code === 'PATH_NOT_UNIQUE') {
24
+ return res.status(409).json({ error: msg });
25
+ }
26
+
27
+ return res.status(500).json({ error: msg });
28
+ }
29
+
30
+ function parseJsonMaybe(value) {
31
+ if (value === undefined || value === null) return null;
32
+ if (typeof value !== 'string') return value;
33
+ const trimmed = value.trim();
34
+ if (!trimmed) return null;
35
+ try {
36
+ return JSON.parse(trimmed);
37
+ } catch (_) {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ exports.list = async (req, res) => {
43
+ try {
44
+ const filters = {
45
+ category: req.query.category,
46
+ group_code: req.query.group_code,
47
+ status: req.query.status,
48
+ ownerUserId: req.query.ownerUserId,
49
+ orgId: req.query.orgId,
50
+ search: req.query.search,
51
+ };
52
+
53
+ const pagination = {
54
+ page: Number(req.query.page) || 1,
55
+ limit: Number(req.query.limit) || 50,
56
+ sort: parseJsonMaybe(req.query.sort) || { updatedAt: -1 },
57
+ };
58
+
59
+ const result = await listMarkdowns(filters, pagination, { isAdmin: true });
60
+ return res.json(result);
61
+ } catch (error) {
62
+ console.error('Error listing markdowns:', error);
63
+ return handleServiceError(res, error);
64
+ }
65
+ };
66
+
67
+ exports.get = async (req, res) => {
68
+ try {
69
+ const item = await getMarkdownById(req.params.id);
70
+ if (!item) return res.status(404).json({ error: 'Markdown not found' });
71
+ return res.json({ item });
72
+ } catch (error) {
73
+ console.error('Error fetching markdown:', error);
74
+ return handleServiceError(res, error);
75
+ }
76
+ };
77
+
78
+ exports.create = async (req, res) => {
79
+ try {
80
+ const item = await createMarkdown(req.body || {});
81
+ return res.status(201).json({ item });
82
+ } catch (error) {
83
+ console.error('Error creating markdown:', error);
84
+ return handleServiceError(res, error);
85
+ }
86
+ };
87
+
88
+ exports.update = async (req, res) => {
89
+ try {
90
+ const item = await updateMarkdown(req.params.id, req.body || {});
91
+ return res.json({ item });
92
+ } catch (error) {
93
+ console.error('Error updating markdown:', error);
94
+ return handleServiceError(res, error);
95
+ }
96
+ };
97
+
98
+ exports.remove = async (req, res) => {
99
+ try {
100
+ const result = await deleteMarkdown(req.params.id);
101
+ return res.json(result);
102
+ } catch (error) {
103
+ console.error('Error deleting markdown:', error);
104
+ return handleServiceError(res, error);
105
+ }
106
+ };
107
+
108
+ exports.getFolderContents = async (req, res) => {
109
+ try {
110
+ const { category } = req.params;
111
+ const { group_code } = req.params;
112
+
113
+ const pagination = {
114
+ page: Number(req.query.page) || 1,
115
+ limit: Number(req.query.limit) || 100,
116
+ sort: parseJsonMaybe(req.query.sort) || { title: 1 },
117
+ };
118
+
119
+ const result = await getFolderContents(category, group_code, pagination, { isAdmin: true });
120
+ return res.json(result);
121
+ } catch (error) {
122
+ console.error('Error getting folder contents:', error);
123
+ return handleServiceError(res, error);
124
+ }
125
+ };
126
+
127
+ exports.validatePath = async (req, res) => {
128
+ try {
129
+ const { category, group_code, slug, excludeId } = req.body;
130
+
131
+ if (!category || !slug) {
132
+ return res.status(400).json({ error: 'category and slug are required' });
133
+ }
134
+
135
+ const isUnique = await validatePathUniqueness(category, group_code, slug, excludeId);
136
+ return res.json({ unique: isUnique });
137
+ } catch (error) {
138
+ console.error('Error validating path:', error);
139
+ return handleServiceError(res, error);
140
+ }
141
+ };
142
+
143
+ exports.getGroupCodes = async (req, res) => {
144
+ try {
145
+ const { category } = req.params;
146
+
147
+ if (!category) {
148
+ return res.status(400).json({ error: 'category is required' });
149
+ }
150
+
151
+ const groupCodes = await getUniqueGroupCodes(category, { isAdmin: true });
152
+ return res.json(groupCodes);
153
+ } catch (error) {
154
+ console.error('Error getting group codes:', error);
155
+ return handleServiceError(res, error);
156
+ }
157
+ };