webspresso 0.0.97 → 0.0.99

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 (136) hide show
  1. package/.agents/ADMIN.md +163 -5
  2. package/.agents/AGENTS.md +1 -1
  3. package/.agents/ARCHITECTURE.md +10 -0
  4. package/.agents/AUTH.md +169 -0
  5. package/.agents/CLI.md +195 -1
  6. package/.agents/ORM.md +88 -4
  7. package/.agents/PLUGINS.md +221 -9
  8. package/.agents/QUEUE.md +127 -0
  9. package/.agents/ROUTING.md +127 -7
  10. package/AGENTS.md +1 -0
  11. package/bin/commands/add-esbuild.js +80 -0
  12. package/bin/commands/add-tailwind.js +76 -31
  13. package/bin/commands/analytics-prune.js +57 -0
  14. package/bin/commands/api.js +207 -15
  15. package/bin/commands/build.js +99 -0
  16. package/bin/commands/dev.js +64 -35
  17. package/bin/commands/doctor.js +26 -0
  18. package/bin/commands/email-prune.js +47 -0
  19. package/bin/commands/make-job.js +86 -0
  20. package/bin/commands/make-model.js +250 -0
  21. package/bin/commands/new.js +45 -28
  22. package/bin/commands/openapi.js +102 -0
  23. package/bin/commands/page.js +6 -3
  24. package/bin/commands/queue-prune.js +59 -0
  25. package/bin/commands/routes.js +52 -0
  26. package/bin/commands/service.js +6 -3
  27. package/bin/commands/typegen.js +106 -0
  28. package/bin/commands/upload-prune.js +79 -0
  29. package/bin/webspresso.js +22 -0
  30. package/core/applySchema.js +13 -6
  31. package/core/auth/hash.js +21 -12
  32. package/core/auth/index.js +60 -0
  33. package/core/auth/manager.js +134 -0
  34. package/core/auth/middleware.js +88 -0
  35. package/core/auth/oauth.js +517 -0
  36. package/core/auth/qrcode.js +499 -0
  37. package/core/auth/totp.js +504 -0
  38. package/core/compression/middleware.js +23 -17
  39. package/core/openapi/build-from-api-routes.js +160 -29
  40. package/core/orm/cache/fingerprint.js +15 -1
  41. package/core/orm/cache/memory-provider.js +27 -2
  42. package/core/orm/eager-loader.js +144 -10
  43. package/core/orm/index.js +20 -7
  44. package/core/orm/model.js +53 -7
  45. package/core/orm/query-builder.js +90 -4
  46. package/core/orm/repository.js +44 -3
  47. package/core/orm/search.js +213 -0
  48. package/core/queue/adapters/database.js +94 -0
  49. package/core/queue/adapters/memory.js +62 -0
  50. package/core/queue/adapters/redis.js +80 -0
  51. package/core/queue/cron.js +331 -0
  52. package/core/queue/index.js +8 -1
  53. package/core/queue/manager.js +205 -1
  54. package/core/queue/scheduler.js +359 -0
  55. package/core/sse/index.js +19 -0
  56. package/core/sse/stream.js +430 -0
  57. package/core/ssr/stream.js +11 -0
  58. package/index.d.ts +1188 -10
  59. package/index.js +88 -96
  60. package/package.json +1 -1
  61. package/plugins/admin-panel/api.js +279 -17
  62. package/plugins/admin-panel/app.js +114 -22
  63. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  64. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  65. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
  66. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  67. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  68. package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
  69. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  70. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  71. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  72. package/plugins/admin-panel/core/admin-module.js +13 -7
  73. package/plugins/admin-panel/core/api-extensions.js +30 -2
  74. package/plugins/admin-panel/core/registry.js +96 -16
  75. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  76. package/plugins/admin-panel/field-renderers/index.js +2 -0
  77. package/plugins/admin-panel/index.js +96 -0
  78. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  79. package/plugins/admin-panel/modules/admin-users.js +16 -1
  80. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  81. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  82. package/plugins/admin-panel/modules/dashboard.js +283 -0
  83. package/plugins/admin-panel/modules/menu.js +56 -18
  84. package/plugins/admin-panel/modules/user-management.js +24 -6
  85. package/plugins/api-key/crypto.js +101 -0
  86. package/plugins/api-key/index.js +288 -0
  87. package/plugins/api-key/middleware.js +461 -0
  88. package/plugins/api-key/migration-template.js +48 -0
  89. package/plugins/api-key/model.js +50 -0
  90. package/plugins/api-key/scopes.js +125 -0
  91. package/plugins/audit-log/admin-component.js +487 -59
  92. package/plugins/audit-log/api-handlers.js +148 -0
  93. package/plugins/audit-log/index.js +153 -3
  94. package/plugins/audit-log/purge.js +60 -10
  95. package/plugins/dashboard/app.js +152 -27
  96. package/plugins/dashboard/index.js +143 -39
  97. package/plugins/dashboard/styles.js +78 -0
  98. package/plugins/data-exchange/export-xlsx.js +9 -2
  99. package/plugins/data-exchange/import.js +9 -2
  100. package/plugins/data-exchange/index.js +6 -3
  101. package/plugins/email/index.js +132 -2
  102. package/plugins/email/purge.js +60 -10
  103. package/plugins/file-manager/admin-component.js +20 -4
  104. package/plugins/file-manager/index.js +21 -4
  105. package/plugins/index.js +10 -0
  106. package/plugins/oauth/index.js +338 -0
  107. package/plugins/orm-cache-admin/index.js +4 -2
  108. package/plugins/queue/index.js +15 -0
  109. package/plugins/schema-explorer/admin-component.js +714 -0
  110. package/plugins/schema-explorer/relations-graph.js +205 -0
  111. package/plugins/schema-explorer.js +248 -69
  112. package/plugins/site-analytics/admin-component.js +23 -0
  113. package/plugins/site-analytics/api-handlers.js +42 -0
  114. package/plugins/site-analytics/index.js +147 -2
  115. package/plugins/site-analytics/purge.js +168 -0
  116. package/plugins/swagger.js +261 -13
  117. package/plugins/upload/index.js +26 -0
  118. package/plugins/upload/orphan-pruner.js +292 -0
  119. package/src/api/api-loader.js +4 -1
  120. package/src/bundle/index.js +517 -0
  121. package/src/bundle/manifest.js +165 -0
  122. package/src/dev/banner.js +296 -0
  123. package/src/dev/colors.js +70 -0
  124. package/src/dev/index.js +62 -0
  125. package/src/dev/profiler.js +354 -0
  126. package/src/dev/typegen.js +497 -0
  127. package/src/dev-reload/index.js +611 -0
  128. package/src/discovery/file-route-parser.js +44 -1
  129. package/src/discovery/scan-routes.js +2 -2
  130. package/src/file-router.js +347 -162
  131. package/src/helpers.js +967 -98
  132. package/src/njk-frontmatter.js +12 -0
  133. package/src/pages/page-loader.js +61 -11
  134. package/src/routing/cascading.js +378 -0
  135. package/src/routing/middleware-resolver.js +92 -0
  136. package/src/server.js +635 -63
@@ -9,6 +9,7 @@ const { sanitizeForOutput } = require('../../core/orm/utils');
9
9
  const { checkAdminExists, setupAdmin, login, logout, requireAuth } = require('./auth');
10
10
  const { isRichTextEmpty } = require('./lib/is-rich-text-empty');
11
11
  const { sanitizeRichHtml } = require('./lib/sanitize-rich-html');
12
+ const { LoginRateLimiter } = require('./lib/login-rate-limiter');
12
13
 
13
14
  /**
14
15
  * Create API route handlers
@@ -19,19 +20,23 @@ const { sanitizeRichHtml } = require('./lib/sanitize-rich-html');
19
20
  * @param {Function} options.hashPassword - Bcrypt hash function
20
21
  * @param {Function} options.comparePassword - Bcrypt compare function
21
22
  * @param {boolean} [options.richTextSanitize=true] - Sanitize rich-text HTML before create/update
23
+ * @param {boolean|Object} [options.loginRateLimit] - Login rate limiting options
22
24
  * @returns {Object} Route handlers
23
25
  */
24
26
  function createApiHandlers(options) {
25
27
  const {
26
28
  path,
27
29
  db,
30
+ registry,
28
31
  AdminUser,
29
32
  hashPassword,
30
33
  comparePassword,
31
34
  richTextSanitize = true,
35
+ loginRateLimit,
32
36
  } = options;
33
37
  const adminPath = path || '/_admin';
34
38
  const apiPath = `${adminPath}/api`;
39
+ const rateLimiter = options.rateLimiter || new LoginRateLimiter(loginRateLimit);
35
40
 
36
41
  /** Strip XSS vectors from rich-text columns present on body; nullable columns normalize empty → null */
37
42
  function mutateRichTextFieldsInBody(body, model) {
@@ -113,11 +118,22 @@ function createApiHandlers(options) {
113
118
  */
114
119
  async function loginHandler(req, res) {
115
120
  try {
121
+ const blockStatus = rateLimiter.isBlocked(req);
122
+ if (blockStatus.blocked) {
123
+ if (typeof res.setHeader === 'function') {
124
+ res.setHeader('Retry-After', String(blockStatus.retryAfter));
125
+ }
126
+ return res.status(429).json({
127
+ error: blockStatus.message,
128
+ retryAfter: blockStatus.retryAfter,
129
+ });
130
+ }
131
+
116
132
  if (!AdminUserRepo || !comparePassword) {
117
133
  return res.status(500).json({ error: 'Admin user system not initialized' });
118
134
  }
119
135
 
120
- const { email, password } = req.body;
136
+ const { email, password } = req.body || {};
121
137
 
122
138
  if (!email || !password) {
123
139
  return res.status(400).json({ error: 'Email and password are required' });
@@ -126,9 +142,12 @@ function createApiHandlers(options) {
126
142
  const user = await login(AdminUserRepo, email, password, comparePassword);
127
143
 
128
144
  if (!user) {
145
+ rateLimiter.recordFailure(req);
129
146
  return res.status(401).json({ error: 'Invalid credentials' });
130
147
  }
131
148
 
149
+ rateLimiter.recordSuccess(req);
150
+
132
151
  // Set session
133
152
  req.session.adminUser = user;
134
153
 
@@ -258,6 +277,11 @@ function createApiHandlers(options) {
258
277
  primaryKey: model.primaryKey,
259
278
  columns: Array.from(model.columns.keys()),
260
279
  relations: Object.keys(model.relations),
280
+ group: model.admin.group || null,
281
+ readOnly: model.admin.readOnly === true,
282
+ permissions: model.admin.permissions || null,
283
+ displayField: model.admin.displayField || null,
284
+ defaultSort: model.admin.defaultSort || null,
261
285
  });
262
286
  }
263
287
  }
@@ -311,6 +335,7 @@ function isColumnSortable(name, meta, model) {
311
335
  const columns = [];
312
336
  for (const [name, meta] of model.columns) {
313
337
  const isHidden = hiddenSet.has(name);
338
+ const colAdminConfig = model.admin.columns?.[name] || null;
314
339
  columns.push({
315
340
  name,
316
341
  type: meta.type,
@@ -327,6 +352,7 @@ function isColumnSortable(name, meta, model) {
327
352
  ui: meta.ui ? { ...meta.ui, hidden: isHidden || meta.ui.hidden } : (isHidden ? { hidden: true } : null),
328
353
  hidden: isHidden, // Excluded from list display and API responses
329
354
  sortable: isColumnSortable(name, meta, model),
355
+ badge: colAdminConfig?.badge || meta.badge || model.admin.customFields?.[name]?.badge || null,
330
356
  });
331
357
  }
332
358
 
@@ -340,6 +366,48 @@ function isColumnSortable(name, meta, model) {
340
366
  relations: Object.keys(model.relations),
341
367
  queries: Object.keys(model.admin.queries || {}),
342
368
  softDelete: !!model.scopes?.softDelete,
369
+ listColumns: model.admin.listColumns || null,
370
+ searchFields: model.admin.searchFields || null,
371
+ displayField: model.admin.displayField || null,
372
+ readOnly: model.admin.readOnly === true,
373
+ permissions: model.admin.permissions || null,
374
+ defaultSort: model.admin.defaultSort || null,
375
+ group: model.admin.group || null,
376
+ columnsConfig: model.admin.columns || null,
377
+ relationsConfig: model.admin.relations || null,
378
+ actions: [
379
+ ...(registry && typeof registry.getActionsForModel === 'function' ? registry.getActionsForModel(model.name) : []),
380
+ ...(Array.isArray(model.admin.actions) ? model.admin.actions : []),
381
+ ].reduce((acc, a) => {
382
+ if (!acc.some(x => x.id === a.id)) {
383
+ acc.push({
384
+ id: a.id,
385
+ label: a.label,
386
+ icon: a.icon || null,
387
+ color: a.color || 'gray',
388
+ confirm: a.confirm === true,
389
+ confirmMessage: a.confirmMessage || null,
390
+ showWhen: typeof a.showWhen === 'object' && a.showWhen !== null ? a.showWhen : null,
391
+ });
392
+ }
393
+ return acc;
394
+ }, []),
395
+ bulkActions: [
396
+ ...(registry && typeof registry.getBulkActionsForModel === 'function' ? registry.getBulkActionsForModel(model.name) : []),
397
+ ...(Array.isArray(model.admin.bulkActions) ? model.admin.bulkActions : []),
398
+ ].reduce((acc, a) => {
399
+ if (!acc.some(x => x.id === a.id)) {
400
+ acc.push({
401
+ id: a.id,
402
+ label: a.label,
403
+ icon: a.icon || null,
404
+ color: a.color || 'gray',
405
+ confirm: a.confirm !== false,
406
+ confirmMessage: a.confirmMessage || null,
407
+ });
408
+ }
409
+ return acc;
410
+ }, []),
343
411
  });
344
412
  } catch (error) {
345
413
  res.status(500).json({ error: error.message });
@@ -477,12 +545,17 @@ function isColumnSortable(name, meta, model) {
477
545
  }
478
546
  }
479
547
 
480
- // Legacy search support (backward compatibility)
548
+ // Search support (respects model.admin.searchFields if configured, falls back to string columns)
481
549
  if (req.query.search) {
482
550
  const searchTerm = `%${req.query.search}%`;
483
- const stringColumns = Array.from(model.columns.entries())
484
- .filter(([_, meta]) => meta.type === 'string' || meta.type === 'text' || meta.type === 'file')
485
- .map(([name]) => name);
551
+ let stringColumns;
552
+ if (Array.isArray(model.admin.searchFields) && model.admin.searchFields.length > 0) {
553
+ stringColumns = model.admin.searchFields.filter((colName) => model.columns.has(colName));
554
+ } else {
555
+ stringColumns = Array.from(model.columns.entries())
556
+ .filter(([_, meta]) => meta.type === 'string' || meta.type === 'text' || meta.type === 'file')
557
+ .map(([name]) => name);
558
+ }
486
559
 
487
560
  if (stringColumns.length > 0) {
488
561
  query = query.where(function() {
@@ -515,16 +588,20 @@ function isColumnSortable(name, meta, model) {
515
588
  // Apply sorting
516
589
  const primaryKey = model.primaryKey || 'id';
517
590
  const sortParam = req.query.sort;
518
- const orderParam = (req.query.order || 'desc').toLowerCase();
519
- const sortOrder = orderParam === 'asc' ? 'asc' : 'desc';
591
+ const orderParam = (req.query.order || req.query.direction || '').toLowerCase();
520
592
 
521
593
  if (sortParam && model.columns.has(sortParam)) {
594
+ const sortOrder = orderParam === 'asc' ? 'asc' : 'desc';
522
595
  const colMeta = model.columns.get(sortParam);
523
596
  if (isColumnSortable(sortParam, colMeta, model)) {
524
597
  query = query.orderBy(sortParam, sortOrder);
525
598
  } else {
526
599
  query = query.orderBy(primaryKey, 'desc');
527
600
  }
601
+ } else if (model.admin.defaultSort && model.admin.defaultSort.column && model.columns.has(model.admin.defaultSort.column)) {
602
+ const defCol = model.admin.defaultSort.column;
603
+ const defOrder = (model.admin.defaultSort.order || model.admin.defaultSort.direction || 'desc').toLowerCase() === 'asc' ? 'asc' : 'desc';
604
+ query = query.orderBy(defCol, defOrder);
528
605
  } else {
529
606
  query = query.orderBy(primaryKey, 'desc');
530
607
  }
@@ -565,9 +642,14 @@ function isColumnSortable(name, meta, model) {
565
642
  return res.status(404).json({ error: 'Record not found' });
566
643
  }
567
644
 
645
+ if (registry && typeof registry.runHooks === 'function') {
646
+ await registry.runHooks('onRecordView', { model: model.name, id, record, req, user: req.session?.adminUser, db });
647
+ }
648
+
568
649
  res.json({ data: sanitizeForOutput(record, model) });
569
650
  } catch (error) {
570
- res.status(500).json({ error: error.message });
651
+ const statusCode = error.statusCode || error.status || 500;
652
+ res.status(statusCode).json({ error: error.message });
571
653
  }
572
654
  }
573
655
 
@@ -609,8 +691,19 @@ function isColumnSortable(name, meta, model) {
609
691
  return res.status(404).json({ error: 'Model not found or not enabled' });
610
692
  }
611
693
 
694
+ if (model.admin.readOnly === true) {
695
+ return res.status(403).json({ error: `Model "${model.name}" is read-only` });
696
+ }
697
+ if (model.admin.permissions?.create === false) {
698
+ return res.status(403).json({ error: `Creation is disabled for model "${model.name}"` });
699
+ }
700
+
612
701
  mutateRichTextFieldsInBody(req.body, model);
613
702
 
703
+ if (registry && typeof registry.runHooks === 'function') {
704
+ await registry.runHooks('beforeCreate', { model: model.name, data: req.body, req, user: req.session?.adminUser, db });
705
+ }
706
+
614
707
  // Validate rich-text fields
615
708
  for (const [colName, colMeta] of model.columns) {
616
709
  if (model.admin.customFields?.[colName]?.type === 'rich-text' && !colMeta.nullable) {
@@ -627,10 +720,15 @@ function isColumnSortable(name, meta, model) {
627
720
  const repo = db.getRepository(model.name);
628
721
  const record = await repo.create(req.body);
629
722
 
723
+ if (registry && typeof registry.runHooks === 'function') {
724
+ await registry.runHooks('afterCreate', { model: model.name, record, data: req.body, req, user: req.session?.adminUser, db });
725
+ }
726
+
630
727
  res.status(201).json({ data: sanitizeForOutput(record, model) });
631
728
  } catch (error) {
632
729
  const fields = extractValidationErrors(error);
633
- res.status(400).json({ error: error.message, ...(fields ? { fields } : {}) });
730
+ const statusCode = error.statusCode || error.status || (error.name === 'ForbiddenError' ? 403 : error.name === 'NotFoundError' ? 404 : 400);
731
+ res.status(statusCode).json({ error: error.message, ...(fields ? { fields } : {}) });
634
732
  }
635
733
  }
636
734
 
@@ -646,8 +744,19 @@ function isColumnSortable(name, meta, model) {
646
744
  return res.status(404).json({ error: 'Model not found or not enabled' });
647
745
  }
648
746
 
747
+ if (model.admin.readOnly === true) {
748
+ return res.status(403).json({ error: `Model "${model.name}" is read-only` });
749
+ }
750
+ if (model.admin.permissions?.edit === false) {
751
+ return res.status(403).json({ error: `Editing is disabled for model "${model.name}"` });
752
+ }
753
+
649
754
  mutateRichTextFieldsInBody(req.body, model);
650
755
 
756
+ if (registry && typeof registry.runHooks === 'function') {
757
+ await registry.runHooks('beforeUpdate', { model: model.name, id, data: req.body, req, user: req.session?.adminUser, db });
758
+ }
759
+
651
760
  // Validate rich-text fields (only if field is being updated)
652
761
  for (const [colName, colMeta] of model.columns) {
653
762
  if (colName in req.body && model.admin.customFields?.[colName]?.type === 'rich-text' && !colMeta.nullable) {
@@ -668,10 +777,15 @@ function isColumnSortable(name, meta, model) {
668
777
  return res.status(404).json({ error: 'Record not found' });
669
778
  }
670
779
 
780
+ if (registry && typeof registry.runHooks === 'function') {
781
+ await registry.runHooks('afterUpdate', { model: model.name, id, record, data: req.body, req, user: req.session?.adminUser, db });
782
+ }
783
+
671
784
  res.json({ data: sanitizeForOutput(record, model) });
672
785
  } catch (error) {
673
786
  const fields = extractValidationErrors(error);
674
- res.status(400).json({ error: error.message, ...(fields ? { fields } : {}) });
787
+ const statusCode = error.statusCode || error.status || (error.name === 'ForbiddenError' ? 403 : error.name === 'NotFoundError' ? 404 : 400);
788
+ res.status(statusCode).json({ error: error.message, ...(fields ? { fields } : {}) });
675
789
  }
676
790
  }
677
791
 
@@ -687,6 +801,17 @@ function isColumnSortable(name, meta, model) {
687
801
  return res.status(404).json({ error: 'Model not found or not enabled' });
688
802
  }
689
803
 
804
+ if (model.admin.readOnly === true) {
805
+ return res.status(403).json({ error: `Model "${model.name}" is read-only` });
806
+ }
807
+ if (model.admin.permissions?.delete === false) {
808
+ return res.status(403).json({ error: `Deletion is disabled for model "${model.name}"` });
809
+ }
810
+
811
+ if (registry && typeof registry.runHooks === 'function') {
812
+ await registry.runHooks('beforeDelete', { model: model.name, id, req, user: req.session?.adminUser, db });
813
+ }
814
+
690
815
  const repo = db.getRepository(model.name);
691
816
  const deleted = await repo.delete(id);
692
817
 
@@ -694,9 +819,14 @@ function isColumnSortable(name, meta, model) {
694
819
  return res.status(404).json({ error: 'Record not found' });
695
820
  }
696
821
 
822
+ if (registry && typeof registry.runHooks === 'function') {
823
+ await registry.runHooks('afterDelete', { model: model.name, id, req, user: req.session?.adminUser, db });
824
+ }
825
+
697
826
  res.json({ success: true });
698
827
  } catch (error) {
699
- res.status(500).json({ error: error.message });
828
+ const statusCode = error.statusCode || error.status || (error.name === 'ForbiddenError' ? 403 : 500);
829
+ res.status(statusCode).json({ error: error.message });
700
830
  }
701
831
  }
702
832
 
@@ -716,6 +846,10 @@ function isColumnSortable(name, meta, model) {
716
846
  return res.status(400).json({ error: 'Model does not support soft delete' });
717
847
  }
718
848
 
849
+ if (registry && typeof registry.runHooks === 'function') {
850
+ await registry.runHooks('beforeRestore', { model: model.name, id, req, user: req.session?.adminUser, db });
851
+ }
852
+
719
853
  const repo = db.getRepository(model.name);
720
854
  const record = await repo.restore(id);
721
855
 
@@ -723,14 +857,19 @@ function isColumnSortable(name, meta, model) {
723
857
  return res.status(404).json({ error: 'Record not found in trash' });
724
858
  }
725
859
 
860
+ if (registry && typeof registry.runHooks === 'function') {
861
+ await registry.runHooks('afterRestore', { model: model.name, id, record, req, user: req.session?.adminUser, db });
862
+ }
863
+
726
864
  res.json({ success: true, data: sanitizeForOutput(record, model) });
727
865
  } catch (error) {
728
- res.status(500).json({ error: error.message });
866
+ const statusCode = error.statusCode || error.status || (error.name === 'ForbiddenError' ? 403 : 500);
867
+ res.status(statusCode).json({ error: error.message });
729
868
  }
730
869
  }
731
870
 
732
871
  /**
733
- * Get relation data
872
+ * Get relation data (with search, pagination, projection, and selected item inclusion)
734
873
  */
735
874
  async function relationHandler(req, res) {
736
875
  try {
@@ -746,13 +885,133 @@ function isColumnSortable(name, meta, model) {
746
885
  return res.status(404).json({ error: 'Relation not found' });
747
886
  }
748
887
 
749
- const relatedModel = relation.model();
888
+ const rawRelatedModel = typeof relation.model === 'function' ? relation.model() : relation.model;
889
+ const relatedModel = typeof rawRelatedModel === 'string' ? getModelFromDb(rawRelatedModel) : rawRelatedModel;
890
+ if (!relatedModel) {
891
+ return res.status(404).json({ error: 'Related model not found' });
892
+ }
750
893
  const relatedRepo = db.getRepository(relatedModel.name);
751
894
 
752
- // Get all related records (for dropdown/select)
753
- const records = await relatedRepo.findAll();
895
+ const relationConfig = model.admin?.relations?.[relationName] || {};
896
+ let displayField = relationConfig.displayField || relatedModel.admin?.displayField || null;
897
+ const primaryKey = relatedModel.primaryKey || 'id';
898
+ const searchFields = relationConfig.searchFields || relatedModel.admin?.searchFields || null;
754
899
 
755
- res.json({ data: sanitizeForOutput(records, relatedModel) });
900
+ if (!displayField && relatedModel.columns) {
901
+ const candidate = ['name', 'title', 'label', 'username', 'email', 'slug'].find(c => relatedModel.columns.has(c));
902
+ if (candidate) {
903
+ displayField = candidate;
904
+ }
905
+ }
906
+
907
+ const search = (req.query.search || req.query.q || '').trim();
908
+ const page = Math.max(1, parseInt(req.query.page, 10) || 1);
909
+ const defaultLimit = typeof relationConfig.limit === 'number' && relationConfig.limit > 0 ? relationConfig.limit : 50;
910
+ const limit = Math.min(200, Math.max(1, parseInt(req.query.limit || req.query.perPage, 10) || defaultLimit));
911
+ const isAll = req.query.all === 'true' || req.query.all === true;
912
+ const selectedId = req.query.selected || req.query.includeId || null;
913
+
914
+ let selectColumns = null;
915
+ if (displayField && relatedModel.columns) {
916
+ const cols = new Set([primaryKey]);
917
+ if (relatedModel.columns.has(displayField)) {
918
+ cols.add(displayField);
919
+ }
920
+ if (Array.isArray(searchFields)) {
921
+ for (const sf of searchFields) {
922
+ if (relatedModel.columns.has(sf)) cols.add(sf);
923
+ }
924
+ }
925
+ selectColumns = Array.from(cols);
926
+ }
927
+
928
+ let query = relatedRepo.query();
929
+ let countQuery = relatedRepo.query();
930
+
931
+ // Apply search filter if provided
932
+ if (search && relatedModel.columns) {
933
+ const searchTerm = `%${search}%`;
934
+ let targetSearchCols = [];
935
+ if (Array.isArray(searchFields) && searchFields.length > 0) {
936
+ targetSearchCols = searchFields.filter(col => relatedModel.columns.has(col));
937
+ } else if (displayField && relatedModel.columns.has(displayField)) {
938
+ targetSearchCols = [displayField];
939
+ } else {
940
+ targetSearchCols = Array.from(relatedModel.columns.entries())
941
+ .filter(([_, meta]) => meta.type === 'string' || meta.type === 'text')
942
+ .map(([name]) => name);
943
+ }
944
+
945
+ if (targetSearchCols.length > 0) {
946
+ const filterFn = function() {
947
+ for (let i = 0; i < targetSearchCols.length; i++) {
948
+ if (i === 0) {
949
+ this.where(targetSearchCols[i], 'like', searchTerm);
950
+ } else {
951
+ this.orWhere(targetSearchCols[i], 'like', searchTerm);
952
+ }
953
+ }
954
+ };
955
+ query = query.where(filterFn);
956
+ countQuery = countQuery.where(filterFn);
957
+ }
958
+ }
959
+
960
+ // Count total matches
961
+ const total = await countQuery.count();
962
+
963
+ // Ordering
964
+ if (displayField && relatedModel.columns?.has(displayField)) {
965
+ query = query.orderBy(displayField, 'asc');
966
+ } else {
967
+ query = query.orderBy(primaryKey, 'desc');
968
+ }
969
+
970
+ // Pagination
971
+ if (isAll) {
972
+ query = query.limit(1000);
973
+ } else {
974
+ const offset = (page - 1) * limit;
975
+ query = query.offset(offset).limit(limit);
976
+ }
977
+
978
+ if (selectColumns && selectColumns.length > 0) {
979
+ query = query.select(selectColumns);
980
+ }
981
+
982
+ let records = await query.list();
983
+
984
+ // If selectedId was requested and is not present in the current paginated list, include it
985
+ if (selectedId !== null && selectedId !== undefined && selectedId !== '') {
986
+ const hasSelected = records.some(r => String(r[primaryKey]) === String(selectedId));
987
+ if (!hasSelected) {
988
+ try {
989
+ const findOpts = selectColumns && selectColumns.length > 0 ? { select: selectColumns } : {};
990
+ const selectedRecord = await relatedRepo.findById(selectedId, findOpts);
991
+ if (selectedRecord) {
992
+ records = [selectedRecord, ...records];
993
+ }
994
+ } catch (_) {
995
+ // Ignore if invalid ID format
996
+ }
997
+ }
998
+ }
999
+
1000
+ const totalPages = isAll ? 1 : Math.ceil(total / limit);
1001
+ const hasMore = isAll ? false : (page * limit < total);
1002
+
1003
+ res.json({
1004
+ data: sanitizeForOutput(records, relatedModel),
1005
+ displayField,
1006
+ primaryKey,
1007
+ pagination: {
1008
+ page,
1009
+ limit: isAll ? total : limit,
1010
+ total,
1011
+ totalPages,
1012
+ hasMore,
1013
+ },
1014
+ });
756
1015
  } catch (error) {
757
1016
  res.status(500).json({ error: error.message });
758
1017
  }
@@ -833,9 +1092,12 @@ function isColumnSortable(name, meta, model) {
833
1092
  relationHandler,
834
1093
  queryHandler,
835
1094
  resetHandler,
1095
+ rateLimiter,
1096
+ resetLoginAttempts: (ip) => rateLimiter.reset(ip),
836
1097
  };
837
1098
  }
838
1099
 
839
1100
  module.exports = {
840
1101
  createApiHandlers,
1102
+ LoginRateLimiter,
841
1103
  };