@intranefr/superbackend 1.5.2 → 1.6.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 (134) hide show
  1. package/cookies.txt +6 -0
  2. package/cookies1.txt +6 -0
  3. package/cookies2.txt +6 -0
  4. package/cookies3.txt +6 -0
  5. package/cookies4.txt +5 -0
  6. package/cookies_old.txt +5 -0
  7. package/cookies_old_test.txt +6 -0
  8. package/cookies_super.txt +5 -0
  9. package/cookies_super_test.txt +6 -0
  10. package/cookies_test.txt +6 -0
  11. package/index.js +9 -0
  12. package/manage.js +745 -0
  13. package/package.json +6 -2
  14. package/plugins/core-waiting-list-migration/README.md +118 -0
  15. package/plugins/core-waiting-list-migration/index.js +438 -0
  16. package/plugins/global-settings-presets/index.js +20 -0
  17. package/plugins/hello-cli/index.js +17 -0
  18. package/plugins/ui-components-seeder/components/suiAlert.js +212 -0
  19. package/plugins/ui-components-seeder/components/suiToast.js +186 -0
  20. package/plugins/ui-components-seeder/index.js +31 -0
  21. package/public/js/admin-ui-components-preview.js +281 -0
  22. package/public/js/admin-ui-components.js +408 -0
  23. package/public/js/llm-provider-model-picker.js +193 -0
  24. package/public/test-iframe-fix.html +63 -0
  25. package/public/test-iframe.html +14 -0
  26. package/src/admin/endpointRegistry.js +68 -0
  27. package/src/controllers/admin.controller.js +36 -10
  28. package/src/controllers/adminAgents.controller.js +37 -0
  29. package/src/controllers/adminDataCleanup.controller.js +45 -0
  30. package/src/controllers/adminLlm.controller.js +19 -8
  31. package/src/controllers/adminLogin.controller.js +269 -0
  32. package/src/controllers/adminMarkdowns.controller.js +157 -0
  33. package/src/controllers/adminPlugins.controller.js +55 -0
  34. package/src/controllers/adminRegistry.controller.js +106 -0
  35. package/src/controllers/adminScripts.controller.js +138 -0
  36. package/src/controllers/adminStats.controller.js +4 -4
  37. package/src/controllers/adminTelegram.controller.js +72 -0
  38. package/src/controllers/markdowns.controller.js +42 -0
  39. package/src/controllers/registry.controller.js +32 -0
  40. package/src/controllers/waitingList.controller.js +52 -74
  41. package/src/helpers/mongooseHelper.js +6 -6
  42. package/src/helpers/scriptBase.js +2 -2
  43. package/src/middleware/auth.js +71 -1
  44. package/src/middleware/rbac.js +62 -0
  45. package/src/middleware.js +584 -176
  46. package/src/models/Agent.js +105 -0
  47. package/src/models/AgentMessage.js +82 -0
  48. package/src/models/GlobalSetting.js +11 -1
  49. package/src/models/Markdown.js +75 -0
  50. package/src/models/ScriptRun.js +8 -0
  51. package/src/models/TelegramBot.js +42 -0
  52. package/src/models/UiComponent.js +2 -0
  53. package/src/models/User.js +1 -1
  54. package/src/routes/admin.routes.js +3 -3
  55. package/src/routes/adminAgents.routes.js +13 -0
  56. package/src/routes/adminAssets.routes.js +11 -11
  57. package/src/routes/adminBlog.routes.js +2 -2
  58. package/src/routes/adminBlogAi.routes.js +2 -2
  59. package/src/routes/adminBlogAutomation.routes.js +2 -2
  60. package/src/routes/adminCache.routes.js +2 -2
  61. package/src/routes/adminConsoleManager.routes.js +2 -2
  62. package/src/routes/adminCrons.routes.js +2 -2
  63. package/src/routes/adminDataCleanup.routes.js +26 -0
  64. package/src/routes/adminDbBrowser.routes.js +2 -2
  65. package/src/routes/adminEjsVirtual.routes.js +2 -2
  66. package/src/routes/adminFeatureFlags.routes.js +6 -6
  67. package/src/routes/adminHeadless.routes.js +2 -2
  68. package/src/routes/adminHealthChecks.routes.js +2 -2
  69. package/src/routes/adminI18n.routes.js +2 -2
  70. package/src/routes/adminJsonConfigs.routes.js +8 -8
  71. package/src/routes/adminLlm.routes.js +8 -7
  72. package/src/routes/adminLogin.routes.js +23 -0
  73. package/src/routes/adminMarkdowns.routes.js +10 -0
  74. package/src/routes/adminMigration.routes.js +12 -12
  75. package/src/routes/adminPages.routes.js +2 -2
  76. package/src/routes/adminPlugins.routes.js +15 -0
  77. package/src/routes/adminProxy.routes.js +2 -2
  78. package/src/routes/adminRateLimits.routes.js +8 -8
  79. package/src/routes/adminRbac.routes.js +2 -2
  80. package/src/routes/adminRegistry.routes.js +24 -0
  81. package/src/routes/adminScripts.routes.js +6 -3
  82. package/src/routes/adminSeoConfig.routes.js +10 -10
  83. package/src/routes/adminTelegram.routes.js +14 -0
  84. package/src/routes/adminTerminals.routes.js +2 -2
  85. package/src/routes/adminUiComponents.routes.js +2 -2
  86. package/src/routes/adminUploadNamespaces.routes.js +7 -7
  87. package/src/routes/blogInternal.routes.js +2 -2
  88. package/src/routes/experiments.routes.js +2 -2
  89. package/src/routes/formsAdmin.routes.js +6 -6
  90. package/src/routes/globalSettings.routes.js +8 -8
  91. package/src/routes/internalExperiments.routes.js +2 -2
  92. package/src/routes/markdowns.routes.js +16 -0
  93. package/src/routes/notificationAdmin.routes.js +7 -7
  94. package/src/routes/orgAdmin.routes.js +16 -16
  95. package/src/routes/pages.routes.js +3 -3
  96. package/src/routes/registry.routes.js +11 -0
  97. package/src/routes/stripeAdmin.routes.js +12 -12
  98. package/src/routes/userAdmin.routes.js +7 -7
  99. package/src/routes/waitingListAdmin.routes.js +2 -2
  100. package/src/routes/workflows.routes.js +3 -3
  101. package/src/services/agent.service.js +546 -0
  102. package/src/services/agentHistory.service.js +345 -0
  103. package/src/services/agentTools.service.js +578 -0
  104. package/src/services/dataCleanup.service.js +286 -0
  105. package/src/services/jsonConfigs.service.js +284 -10
  106. package/src/services/llm.service.js +219 -6
  107. package/src/services/markdowns.service.js +522 -0
  108. package/src/services/plugins.service.js +348 -0
  109. package/src/services/registry.service.js +452 -0
  110. package/src/services/scriptsRunner.service.js +328 -37
  111. package/src/services/telegram.service.js +130 -0
  112. package/src/services/uiComponents.service.js +180 -0
  113. package/src/services/waitingListJson.service.js +401 -0
  114. package/src/utils/rbac/rightsRegistry.js +118 -0
  115. package/test-access.js +63 -0
  116. package/test-iframe-fix.html +63 -0
  117. package/test-iframe.html +14 -0
  118. package/views/admin-403.ejs +92 -0
  119. package/views/admin-agents.ejs +273 -0
  120. package/views/admin-coolify-deploy.ejs +8 -8
  121. package/views/admin-dashboard-home.ejs +52 -2
  122. package/views/admin-dashboard.ejs +179 -7
  123. package/views/admin-data-cleanup.ejs +357 -0
  124. package/views/admin-experiments.ejs +1 -1
  125. package/views/admin-login.ejs +286 -0
  126. package/views/admin-markdowns.ejs +905 -0
  127. package/views/admin-plugins-system.ejs +223 -0
  128. package/views/admin-scripts.ejs +221 -4
  129. package/views/admin-telegram.ejs +269 -0
  130. package/views/admin-ui-components.ejs +82 -402
  131. package/views/admin-users.ejs +207 -11
  132. package/views/partials/dashboard/nav-items.ejs +5 -0
  133. package/views/partials/llm-provider-model-picker.ejs +0 -161
  134. package/analysis-only.skill +0 -0
@@ -0,0 +1,522 @@
1
+ const crypto = require('crypto');
2
+
3
+ const Markdown = require('../models/Markdown');
4
+
5
+ // Error codes
6
+ const ERROR_CODES = {
7
+ VALIDATION: 'VALIDATION',
8
+ NOT_FOUND: 'NOT_FOUND',
9
+ PATH_NOT_UNIQUE: 'PATH_NOT_UNIQUE',
10
+ INVALID_MARKDOWN: 'INVALID_MARKDOWN',
11
+ INVALID_GROUP_CODE: 'INVALID_GROUP_CODE',
12
+ };
13
+
14
+ // Path operations
15
+ function normalizeGroupCode(group_code) {
16
+ if (!group_code) return '';
17
+
18
+ return String(group_code)
19
+ .trim()
20
+ .toLowerCase()
21
+ .replace(/[^a-z0-9_-]/g, '')
22
+ .replace(/_{3,}/g, '__') // Normalize multiple underscores
23
+ .replace(/^_|_$/g, ''); // Remove leading/trailing
24
+ }
25
+
26
+ function parseGroupCode(group_code) {
27
+ if (!group_code) return [];
28
+ return group_code.split('__').filter(part => part.length > 0);
29
+ }
30
+
31
+ function buildGroupCode(parts) {
32
+ return parts.filter(part => part.length > 0).join('__');
33
+ }
34
+
35
+ function normalizeCategory(category) {
36
+ const str = String(category || '').trim().toLowerCase();
37
+ if (!str) return 'general';
38
+
39
+ return str
40
+ .normalize('NFKD')
41
+ .replace(/[\u0300-\u036f]/g, '')
42
+ .replace(/[^a-z0-9_-]/g, '')
43
+ .replace(/_{2,}/g, '_')
44
+ .replace(/^_|_$/g, '');
45
+ }
46
+
47
+ function normalizeSlugBase(title) {
48
+ const str = String(title || '').trim().toLowerCase();
49
+ if (!str) return 'markdown';
50
+
51
+ const slug = str
52
+ .normalize('NFKD')
53
+ .replace(/[\u0300-\u036f]/g, '')
54
+ .replace(/[^a-z0-9]+/g, '-')
55
+ .replace(/(^-|-$)/g, '')
56
+ .replace(/-{2,}/g, '-');
57
+
58
+ return slug || 'markdown';
59
+ }
60
+
61
+ function randomSuffix4() {
62
+ return crypto.randomBytes(2).toString('hex');
63
+ }
64
+
65
+ async function generateUniqueSlugFromTitle(title, category, group_code, { maxAttempts = 10 } = {}) {
66
+ const base = normalizeSlugBase(title);
67
+
68
+ for (let i = 0; i < maxAttempts; i += 1) {
69
+ const candidate = `${base}-${randomSuffix4()}`;
70
+ // eslint-disable-next-line no-await-in-loop
71
+ const existing = await Markdown.findOne({
72
+ category: String(category).trim(),
73
+ group_code: group_code ? String(group_code).trim() : '',
74
+ slug: candidate
75
+ }).select('_id').lean();
76
+ if (!existing) return candidate;
77
+ }
78
+
79
+ throw new Error('Failed to generate unique slug');
80
+ }
81
+
82
+ async function validatePathUniqueness(category, group_code, slug, excludeId = null) {
83
+ const query = {
84
+ category: String(category).trim(),
85
+ group_code: group_code ? String(group_code).trim() : '',
86
+ slug: String(slug).trim(),
87
+ };
88
+
89
+ if (excludeId) {
90
+ query._id = { $ne: excludeId };
91
+ }
92
+
93
+ const existing = await Markdown.findOne(query).select('_id').lean();
94
+ return !existing;
95
+ }
96
+
97
+ function validateMarkdownContent(markdownRaw) {
98
+ if (typeof markdownRaw !== 'string') {
99
+ const err = new Error('markdownRaw must be a string');
100
+ err.code = ERROR_CODES.VALIDATION;
101
+ throw err;
102
+ }
103
+
104
+ // Basic markdown validation (can be extended)
105
+ const content = String(markdownRaw).trim();
106
+ if (content.length > 1000000) { // 1MB limit
107
+ const err = new Error('markdownRaw content too large (max 1MB)');
108
+ err.code = ERROR_CODES.VALIDATION;
109
+ throw err;
110
+ }
111
+
112
+ return content;
113
+ }
114
+
115
+ // Core CRUD operations
116
+ async function getMarkdownByPath(category, group_code, slug) {
117
+ const doc = await Markdown.findOne({
118
+ category: String(category).trim(),
119
+ group_code: group_code ? String(group_code).trim() : '',
120
+ slug: String(slug).trim(),
121
+ publicEnabled: true,
122
+ status: 'published'
123
+ }).lean();
124
+
125
+ if (!doc) {
126
+ const err = new Error('Markdown not found');
127
+ err.code = ERROR_CODES.NOT_FOUND;
128
+ throw err;
129
+ }
130
+
131
+ return doc;
132
+ }
133
+
134
+ async function createMarkdown({ title, category, group_code, markdownRaw, publicEnabled = false, cacheTtlSeconds = 0, ownerUserId, orgId }) {
135
+ // Validation
136
+ const normalizedTitle = String(title || '').trim();
137
+ if (!normalizedTitle) {
138
+ const err = new Error('title is required');
139
+ err.code = ERROR_CODES.VALIDATION;
140
+ throw err;
141
+ }
142
+
143
+ const normalizedCategory = normalizeCategory(category);
144
+ if (!normalizedCategory) {
145
+ const err = new Error('category is required');
146
+ err.code = ERROR_CODES.VALIDATION;
147
+ throw err;
148
+ }
149
+
150
+ const normalizedGroupCode = normalizeGroupCode(group_code);
151
+ const normalizedSlug = await generateUniqueSlugFromTitle(normalizedTitle, normalizedCategory, normalizedGroupCode);
152
+ const validatedMarkdown = validateMarkdownContent(markdownRaw);
153
+
154
+ // Validate uniqueness
155
+ if (!(await validatePathUniqueness(normalizedCategory, normalizedGroupCode, normalizedSlug))) {
156
+ const err = new Error('Path must be unique (category + group_code + slug)');
157
+ err.code = ERROR_CODES.PATH_NOT_UNIQUE;
158
+ throw err;
159
+ }
160
+
161
+ const createData = {
162
+ title: normalizedTitle,
163
+ slug: normalizedSlug,
164
+ category: normalizedCategory,
165
+ group_code: normalizedGroupCode,
166
+ markdownRaw: validatedMarkdown,
167
+ publicEnabled: Boolean(publicEnabled),
168
+ cacheTtlSeconds: Number(cacheTtlSeconds || 0) || 0,
169
+ ownerUserId,
170
+ orgId,
171
+ };
172
+
173
+ const doc = await Markdown.create(createData);
174
+
175
+ return doc.toObject();
176
+ }
177
+
178
+ async function getMarkdownById(id) {
179
+ return Markdown.findById(id)
180
+ .select('title slug category group_code markdownRaw publicEnabled status cacheTtlSeconds updatedAt createdAt ownerUserId orgId')
181
+ .lean();
182
+ }
183
+
184
+ async function updateMarkdown(id, patch) {
185
+ const doc = await Markdown.findById(id);
186
+ if (!doc) {
187
+ const err = new Error('Markdown not found');
188
+ err.code = ERROR_CODES.NOT_FOUND;
189
+ throw err;
190
+ }
191
+
192
+ const oldCategory = doc.category;
193
+ const oldGroupCode = doc.group_code;
194
+ const oldSlug = doc.slug;
195
+
196
+ // Update fields
197
+ if (patch && Object.prototype.hasOwnProperty.call(patch, 'title')) {
198
+ const title = String(patch.title || '').trim();
199
+ if (!title) {
200
+ const err = new Error('title is required');
201
+ err.code = ERROR_CODES.VALIDATION;
202
+ throw err;
203
+ }
204
+ doc.title = title;
205
+ }
206
+
207
+ if (patch && Object.prototype.hasOwnProperty.call(patch, 'category')) {
208
+ doc.category = normalizeCategory(patch.category);
209
+ }
210
+
211
+ if (patch && Object.prototype.hasOwnProperty.call(patch, 'group_code')) {
212
+ doc.group_code = normalizeGroupCode(patch.group_code);
213
+ }
214
+
215
+ if (patch && Object.prototype.hasOwnProperty.call(patch, 'markdownRaw')) {
216
+ doc.markdownRaw = validateMarkdownContent(patch.markdownRaw);
217
+ }
218
+
219
+ if (patch && Object.prototype.hasOwnProperty.call(patch, 'publicEnabled')) {
220
+ doc.publicEnabled = Boolean(patch.publicEnabled);
221
+ }
222
+
223
+ if (patch && Object.prototype.hasOwnProperty.call(patch, 'status')) {
224
+ const validStatuses = ['draft', 'published', 'archived'];
225
+ if (!validStatuses.includes(patch.status)) {
226
+ const err = new Error('Invalid status. Must be draft, published, or archived');
227
+ err.code = ERROR_CODES.VALIDATION;
228
+ throw err;
229
+ }
230
+ doc.status = patch.status;
231
+ }
232
+
233
+ if (patch && Object.prototype.hasOwnProperty.call(patch, 'cacheTtlSeconds')) {
234
+ const ttl = Number(patch.cacheTtlSeconds || 0);
235
+ doc.cacheTtlSeconds = Number.isNaN(ttl) ? 0 : Math.max(0, ttl);
236
+ }
237
+
238
+ if (patch && Object.prototype.hasOwnProperty.call(patch, 'ownerUserId')) {
239
+ doc.ownerUserId = patch.ownerUserId;
240
+ }
241
+
242
+ if (patch && Object.prototype.hasOwnProperty.call(patch, 'orgId')) {
243
+ doc.orgId = patch.orgId;
244
+ }
245
+
246
+ // Validate path uniqueness if category, group_code, or slug changed
247
+ if (doc.category !== oldCategory || doc.group_code !== oldGroupCode) {
248
+ if (!(await validatePathUniqueness(doc.category, doc.group_code, doc.slug, id))) {
249
+ const err = new Error('Path must be unique (category + group_code + slug)');
250
+ err.code = ERROR_CODES.PATH_NOT_UNIQUE;
251
+ throw err;
252
+ }
253
+ }
254
+
255
+ await doc.save();
256
+
257
+ return doc.toObject();
258
+ }
259
+
260
+ async function deleteMarkdown(id) {
261
+ const doc = await Markdown.findByIdAndDelete(id).lean();
262
+ if (!doc) {
263
+ const err = new Error('Markdown not found');
264
+ err.code = ERROR_CODES.NOT_FOUND;
265
+ throw err;
266
+ }
267
+
268
+ return { success: true };
269
+ }
270
+
271
+ // List operations
272
+ async function listMarkdowns(filters = {}, pagination = {}, options = {}) {
273
+ const {
274
+ category,
275
+ group_code,
276
+ status,
277
+ ownerUserId,
278
+ orgId,
279
+ search
280
+ } = filters;
281
+
282
+ const { isAdmin = false } = options;
283
+
284
+ const { page = 1, limit = 50, sort = { updatedAt: -1 } } = pagination;
285
+ const skip = Math.max(0, (page - 1) * limit);
286
+ const normalizedLimit = Math.min(Number(limit) || 50, 200);
287
+
288
+ // Build filter
289
+ const filter = {};
290
+
291
+ if (category) {
292
+ filter.category = String(category).trim();
293
+ }
294
+
295
+ if (group_code) {
296
+ filter.group_code = String(group_code).trim();
297
+ }
298
+
299
+ // Apply status filter: explicit status or default for non-admin
300
+ if (status) {
301
+ filter.status = String(status);
302
+ } else if (!isAdmin) {
303
+ filter.status = 'published';
304
+ }
305
+
306
+ if (ownerUserId) {
307
+ filter.ownerUserId = ownerUserId;
308
+ }
309
+
310
+ if (orgId) {
311
+ filter.orgId = orgId;
312
+ }
313
+
314
+ if (search) {
315
+ filter.$or = [
316
+ { title: { $regex: search, $options: 'i' } },
317
+ { markdownRaw: { $regex: search, $options: 'i' } }
318
+ ];
319
+ }
320
+
321
+ // Execute query with pagination
322
+ const [items, total] = await Promise.all([
323
+ Markdown.find(filter)
324
+ .sort(sort)
325
+ .skip(skip)
326
+ .limit(normalizedLimit)
327
+ .select('title slug category group_code markdownRaw publicEnabled status cacheTtlSeconds updatedAt createdAt ownerUserId orgId')
328
+ .lean(),
329
+ Markdown.countDocuments(filter),
330
+ ]);
331
+
332
+ return { items, total, limit: normalizedLimit, skip };
333
+ }
334
+
335
+ // Tree structure for explorer mode
336
+ async function getMarkdownTree(category, options = {}) {
337
+ const { isAdmin = false } = options;
338
+ const normalizedCategory = String(category || '').trim();
339
+ if (!normalizedCategory) return {};
340
+
341
+ // Build filter based on admin mode
342
+ const filter = { category: normalizedCategory };
343
+ if (!isAdmin) {
344
+ filter.status = 'published';
345
+ }
346
+
347
+ const docs = await Markdown.find(filter)
348
+ .select('group_code slug title status')
349
+ .lean();
350
+
351
+ // Build tree structure
352
+ const tree = {};
353
+
354
+ for (const doc of docs) {
355
+ const parts = parseGroupCode(doc.group_code);
356
+ let current = tree;
357
+
358
+ // Navigate/create folder structure
359
+ for (const part of parts) {
360
+ if (!current[part]) {
361
+ current[part] = { _type: 'folder', children: {} };
362
+ }
363
+ current = current[part].children;
364
+ }
365
+
366
+ // Add file
367
+ current[doc.slug] = {
368
+ _type: 'file',
369
+ title: doc.title,
370
+ slug: doc.slug,
371
+ group_code: doc.group_code,
372
+ status: doc.status
373
+ };
374
+ }
375
+
376
+ return tree;
377
+ }
378
+
379
+ // Folder contents for explorer mode (exact folder matching)
380
+ async function getFolderContents(category, group_code, pagination = {}, options = {}) {
381
+ const { isAdmin = false } = options;
382
+ const normalizedCategory = String(category || '').trim();
383
+ const normalizedGroupCode = group_code ? String(group_code).trim() : '';
384
+
385
+ const { page = 1, limit = 100, sort = { title: 1 } } = pagination;
386
+ const skip = Math.max(0, (page - 1) * limit);
387
+ const normalizedLimit = Math.min(Number(limit) || 100, 200);
388
+
389
+ // Exact match only (no prefix matching for Windows Explorer-style navigation)
390
+ const filter = {
391
+ category: normalizedCategory,
392
+ group_code: normalizedGroupCode
393
+ };
394
+
395
+ if (!isAdmin) {
396
+ filter.status = 'published';
397
+ }
398
+
399
+ const [items, total] = await Promise.all([
400
+ Markdown.find(filter)
401
+ .sort(sort)
402
+ .skip(skip)
403
+ .limit(normalizedLimit)
404
+ .select('title slug category group_code markdownRaw publicEnabled status cacheTtlSeconds updatedAt createdAt ownerUserId orgId')
405
+ .lean(),
406
+ Markdown.countDocuments(filter),
407
+ ]);
408
+
409
+ const result = { items, total, limit: normalizedLimit, skip };
410
+ return result;
411
+ }
412
+
413
+ // Get unique group codes for tree building (performance optimized)
414
+ async function getUniqueGroupCodes(category, options = {}) {
415
+ const { isAdmin = false } = options;
416
+ const normalizedCategory = String(category || '').trim();
417
+
418
+ const filter = { category: normalizedCategory };
419
+ if (!isAdmin) {
420
+ filter.status = 'published';
421
+ }
422
+
423
+ // Use distinct to get all existing group codes
424
+ const groupCodes = await Markdown.distinct('group_code', filter);
425
+
426
+ // Normalize results: convert null/undefined to "" and ensure uniqueness
427
+ const normalizedCodes = Array.from(new Set(groupCodes.map(code => code || '')));
428
+
429
+ // Explicitly check for documents where group_code field might be missing
430
+ // because distinct() omits values for documents where the field is missing.
431
+ const hasMissingField = await Markdown.findOne({
432
+ ...filter,
433
+ group_code: { $exists: false }
434
+ }).select('_id').lean();
435
+
436
+ if (hasMissingField && !normalizedCodes.includes('')) {
437
+ normalizedCodes.push('');
438
+ }
439
+
440
+ return normalizedCodes;
441
+ }
442
+
443
+ // Search functionality
444
+ async function searchMarkdowns(query, options = {}) {
445
+ const { category, group_code, limit = 50 } = options;
446
+
447
+ const searchFilter = {
448
+ status: 'published',
449
+ publicEnabled: true,
450
+ };
451
+
452
+ if (category) {
453
+ searchFilter.category = String(category).trim();
454
+ }
455
+
456
+ if (group_code) {
457
+ searchFilter.group_code = String(group_code).trim();
458
+ }
459
+
460
+ // Text search
461
+ if (query) {
462
+ searchFilter.$or = [
463
+ { title: { $regex: query, $options: 'i' } },
464
+ { markdownRaw: { $regex: query, $options: 'i' } }
465
+ ];
466
+ }
467
+
468
+ return Markdown.find(searchFilter)
469
+ .select('title slug category group_code updatedAt')
470
+ .sort({ updatedAt: -1 })
471
+ .limit(Number(limit))
472
+ .lean();
473
+ }
474
+
475
+ async function upsertMarkdown({ title, category, group_code, slug, markdownRaw, publicEnabled = false, status = 'published', ownerUserId, orgId }) {
476
+ const normalizedCategory = normalizeCategory(category);
477
+ const normalizedGroupCode = normalizeGroupCode(group_code);
478
+ const normalizedSlug = slug || normalizeSlugBase(title);
479
+
480
+ const query = {
481
+ category: normalizedCategory,
482
+ group_code: normalizedGroupCode,
483
+ slug: normalizedSlug,
484
+ };
485
+
486
+ const update = {
487
+ title: String(title || '').trim(),
488
+ markdownRaw: validateMarkdownContent(markdownRaw),
489
+ publicEnabled: Boolean(publicEnabled),
490
+ status,
491
+ ownerUserId,
492
+ orgId,
493
+ };
494
+
495
+ const doc = await Markdown.findOneAndUpdate(query, update, {
496
+ new: true,
497
+ upsert: true,
498
+ setDefaultsOnInsert: true,
499
+ });
500
+
501
+ return doc.toObject();
502
+ }
503
+
504
+ module.exports = {
505
+ ERROR_CODES,
506
+ normalizeGroupCode,
507
+ parseGroupCode,
508
+ buildGroupCode,
509
+ normalizeCategory,
510
+ validatePathUniqueness,
511
+ getMarkdownByPath,
512
+ createMarkdown,
513
+ upsertMarkdown,
514
+ getMarkdownById,
515
+ updateMarkdown,
516
+ deleteMarkdown,
517
+ listMarkdowns,
518
+ getMarkdownTree,
519
+ getFolderContents,
520
+ getUniqueGroupCodes,
521
+ searchMarkdowns,
522
+ };