webspresso 0.0.99 → 0.0.101

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 (108) hide show
  1. package/.agents/ADMIN.md +47 -4
  2. package/.agents/AGENTS.md +4 -2
  3. package/.agents/AUTH.md +309 -0
  4. package/.agents/CLI.md +128 -0
  5. package/.agents/EMAIL.md +84 -2
  6. package/.agents/ERRORS.md +59 -0
  7. package/.agents/EVENTS.md +179 -0
  8. package/.agents/ORM.md +149 -0
  9. package/.agents/PLUGINS.md +44 -7
  10. package/.agents/QUEUE.md +18 -10
  11. package/.agents/ROUTING.md +97 -1
  12. package/.agents/STREAMING.md +35 -0
  13. package/AGENTS.md +2 -0
  14. package/README.md +145 -93
  15. package/bin/commands/add-docker.js +467 -0
  16. package/bin/commands/db-makemigrations.js +83 -0
  17. package/bin/commands/deploy-check.js +339 -0
  18. package/bin/commands/list.js +96 -0
  19. package/bin/commands/make-command.js +93 -0
  20. package/bin/commands/make-event.js +80 -0
  21. package/bin/commands/make-factory.js +72 -0
  22. package/bin/commands/make-mail.js +93 -0
  23. package/bin/commands/make-policy.js +106 -0
  24. package/bin/commands/run.js +195 -0
  25. package/bin/webspresso.js +21 -0
  26. package/core/auth/bouncer.js +196 -0
  27. package/core/auth/index.js +73 -1
  28. package/core/auth/magic-link.js +170 -0
  29. package/core/auth/manager.js +343 -3
  30. package/core/auth/middleware.js +45 -6
  31. package/core/auth/session-manager.js +255 -0
  32. package/core/auth/sessions.js +351 -0
  33. package/core/auth/tokens.js +1 -0
  34. package/core/auth/totp.js +4 -1
  35. package/core/auth/user-agent.js +161 -0
  36. package/core/commands/define-command.js +48 -0
  37. package/core/commands/index.js +18 -0
  38. package/core/commands/registry.js +156 -0
  39. package/core/commands/runner.js +76 -0
  40. package/core/compression/compressible.js +1 -1
  41. package/core/compression/middleware.js +35 -251
  42. package/core/content/service.js +8 -2
  43. package/core/events/discovery.js +142 -0
  44. package/core/events/dispatcher.js +552 -0
  45. package/core/events/index.js +23 -0
  46. package/core/events/signal.js +92 -0
  47. package/core/forms/flash.js +234 -0
  48. package/core/forms/form-builder.js +214 -0
  49. package/core/forms/index.js +16 -0
  50. package/core/mail/index.js +23 -0
  51. package/core/mail/mailable.js +250 -0
  52. package/core/mail/mailer.js +184 -0
  53. package/core/mail/transports.js +238 -0
  54. package/core/orm/factory.js +250 -0
  55. package/core/orm/index.js +8 -1
  56. package/core/orm/migrations/diff.js +185 -0
  57. package/core/orm/migrations/index.js +22 -4
  58. package/core/orm/model.js +55 -1
  59. package/core/orm/repository.js +283 -13
  60. package/core/orm/scopes.js +3 -2
  61. package/core/orm/types.js +6 -1
  62. package/core/queue/cron.js +252 -39
  63. package/core/queue/manager.js +20 -2
  64. package/core/queue/scheduler.js +115 -59
  65. package/core/ssr/stream.js +62 -2
  66. package/index.d.ts +540 -14
  67. package/index.js +44 -0
  68. package/package.json +11 -5
  69. package/plugins/admin-panel/api.js +509 -3
  70. package/plugins/admin-panel/index.js +17 -0
  71. package/plugins/admin-panel/modules/admin-users.js +1 -1
  72. package/plugins/audit-log/admin-component.js +54 -54
  73. package/plugins/audit-log/index.js +2 -2
  74. package/plugins/content/client/inline-edit.js +2 -46
  75. package/plugins/content/models/content-entry.js +4 -3
  76. package/plugins/content/models/content-type.js +4 -3
  77. package/plugins/cors.js +15 -4
  78. package/plugins/email/auth-bridge.js +66 -0
  79. package/plugins/email/bundled-templates.js +15 -0
  80. package/plugins/email/index.js +48 -0
  81. package/plugins/email/preview-handler.js +555 -0
  82. package/plugins/email/service.js +23 -12
  83. package/plugins/email/templates/auth-magic-link.mjml +13 -0
  84. package/plugins/email/transport.js +30 -11
  85. package/plugins/file-manager/admin-component.js +49 -49
  86. package/plugins/file-manager/index.js +4 -4
  87. package/plugins/index.js +2 -0
  88. package/plugins/redirect/index.js +1 -1
  89. package/plugins/webhooks/admin-component.js +608 -0
  90. package/plugins/webhooks/api-handlers.js +237 -0
  91. package/plugins/webhooks/crypto.js +144 -0
  92. package/plugins/webhooks/dispatcher.js +708 -0
  93. package/plugins/webhooks/index.js +263 -0
  94. package/plugins/webhooks/matcher.js +113 -0
  95. package/plugins/webhooks/migration-template.js +75 -0
  96. package/plugins/webhooks/models.js +98 -0
  97. package/plugins/webhooks/sender.js +176 -0
  98. package/src/api/api-loader.js +1 -1
  99. package/src/app-context.js +48 -6
  100. package/src/bundle/index.js +2 -1
  101. package/src/dev-reload/index.js +9 -1
  102. package/src/discovery/scan-routes.js +10 -1
  103. package/src/file-router.js +173 -4
  104. package/src/helpers.js +150 -1
  105. package/src/njk-frontmatter.js +3 -0
  106. package/src/pages/page-loader.js +1 -1
  107. package/src/routing/cascading.js +120 -4
  108. package/src/server.js +180 -26
package/index.js CHANGED
@@ -191,6 +191,31 @@ module.exports = {
191
191
  RouteTable,
192
192
  mountDiscoveredRoutes,
193
193
  resolveMiddlewares: require('./src/routing/middleware-resolver').resolveMiddlewares,
194
+
195
+ // Forms & Flash subsystem (AdonisJS & Django-inspired)
196
+ createFlashMiddleware: require('./core/forms').createFlashMiddleware,
197
+ normalizeErrors: require('./core/forms').normalizeErrors,
198
+ renderField: require('./core/forms').renderField,
199
+ renderFormFor: require('./core/forms').renderFormFor,
200
+
201
+ // Authorization & Policies (AdonisJS Bouncer & Django Permissions-inspired)
202
+ Bouncer: require('./core/auth/bouncer').Bouncer,
203
+ createBouncer: require('./core/auth/bouncer').createBouncer,
204
+ createBouncerMiddleware: require('./core/auth/bouncer').createBouncerMiddleware,
205
+ definePolicy: require('./core/auth/policy').definePolicy,
206
+ defineGate: require('./core/auth/policy').defineGate,
207
+ can: require('./core/auth/policy').can,
208
+ cannot: require('./core/auth/policy').cannot,
209
+ authorize: require('./core/auth/policy').authorize,
210
+
211
+ // Decoupled Events & Signals (AdonisJS Events & Django Signals-inspired)
212
+ EventDispatcher: require('./core/events').EventDispatcher,
213
+ createEventDispatcher: require('./core/events').createEventDispatcher,
214
+ events: require('./core/events').events,
215
+ Signal: require('./core/events').Signal,
216
+ defineSignal: require('./core/events').defineSignal,
217
+ defineListener: require('./core/events').defineListener,
218
+ loadListeners: require('./core/events').loadListeners,
194
219
  };
195
220
 
196
221
  // Define lazy getters for plugins and heavy core subsystems
@@ -272,5 +297,24 @@ Object.defineProperties(module.exports, {
272
297
  createDevProfilerMiddleware: { get: () => require('./src/dev').createDevProfilerMiddleware, enumerable: true, configurable: true },
273
298
  generateTypes: { get: () => require('./src/dev/typegen').generateTypes, enumerable: true, configurable: true },
274
299
  typegen: { get: () => require('./src/dev/typegen'), enumerable: true, configurable: true },
300
+
301
+ // Webhooks plugin
302
+ webhooks: { get: () => require('./plugins/webhooks'), enumerable: true, configurable: true },
303
+ webhooksPlugin: { get: () => require('./plugins/webhooks').webhooksPlugin, enumerable: true, configurable: true },
304
+
305
+ // Mail & Mailable (AdonisJS & Django-inspired)
306
+ mail: { get: () => require('./core/mail'), enumerable: true, configurable: true },
307
+ Mailable: { get: () => require('./core/mail').Mailable, enumerable: true, configurable: true },
308
+ createMailer: { get: () => require('./core/mail').createMailer, enumerable: true, configurable: true },
309
+ Mailer: { get: () => require('./core/mail').Mailer, enumerable: true, configurable: true },
310
+ MemoryTransport: { get: () => require('./core/mail').MemoryTransport, enumerable: true, configurable: true },
311
+ ConsoleTransport: { get: () => require('./core/mail').ConsoleTransport, enumerable: true, configurable: true },
312
+ FileTransport: { get: () => require('./core/mail').FileTransport, enumerable: true, configurable: true },
313
+
314
+ // Commands & CLI (Django manage.py & Adonis Ace-inspired)
315
+ commands: { get: () => require('./core/commands'), enumerable: true, configurable: true },
316
+ defineCommand: { get: () => require('./core/commands').defineCommand, enumerable: true, configurable: true },
317
+ createCommandRegistry: { get: () => require('./core/commands').createCommandRegistry, enumerable: true, configurable: true },
318
+ executeCommand: { get: () => require('./core/commands').executeCommand, enumerable: true, configurable: true },
275
319
  });
276
320
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.99",
3
+ "version": "0.0.101",
4
4
  "description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -51,6 +51,9 @@
51
51
  "test:fast": "vitest run tests/unit/*branches.test.js tests/unit/auth.test.js tests/unit/services.test.js tests/unit/server.test.js",
52
52
  "test:watch": "vitest",
53
53
  "test:coverage": "vitest run --coverage",
54
+ "test:coverage:quick": "vitest run --coverage",
55
+ "test:coverage:changed": "vitest run --coverage --changed",
56
+ "test:coverage:full": "FULL_REPORT=true vitest run --coverage",
54
57
  "test:coverage:branches": "vitest run tests/unit/*branches.test.js --coverage",
55
58
  "test:coverage:unit": "vitest run tests/unit/ --coverage",
56
59
  "test:coverage:core": "vitest run tests/unit/core* tests/unit/server* tests/unit/routing* tests/unit/template* --coverage",
@@ -111,6 +114,7 @@
111
114
  "alpinejs": "^3.15.11",
112
115
  "bcrypt": "^5.1.1",
113
116
  "commander": "^11.1.0",
117
+ "compression": "^1.8.2",
114
118
  "connect-timeout": "^1.9.1",
115
119
  "cookie-parser": "^1.4.7",
116
120
  "csv-parse": "^7.0.2",
@@ -126,7 +130,7 @@
126
130
  "nodemailer": "^10.0.10",
127
131
  "nunjucks": "^3.2.4",
128
132
  "sanitize-html": "^2.17.7",
129
- "sharp": "^0.35.4",
133
+ "sharp": "^0.35.5",
130
134
  "swup": "^4.8.3",
131
135
  "yaml": "^2.8.3",
132
136
  "zod": "^3.23.0",
@@ -167,7 +171,7 @@
167
171
  "@types/node": "^20.14.0",
168
172
  "@vitest/coverage-v8": "^4.1.11",
169
173
  "better-sqlite3": "^11.10.0",
170
- "chokidar": "^3.5.3",
174
+ "chokidar": "^4.0.3",
171
175
  "docboot": "^0.3.0",
172
176
  "dotenv": "^16.3.1",
173
177
  "express-rate-limit": "^8.4.1",
@@ -181,9 +185,11 @@
181
185
  "node": ">=20.0.0"
182
186
  },
183
187
  "overrides": {
184
- "basic-ftp": "^5.3.0",
188
+ "basic-ftp": "^6.2.1",
189
+ "chokidar": "^4.0.3",
190
+ "katex": "^0.18.2",
185
191
  "tar": "^7.5.11",
186
- "undici": "^6.24.0",
192
+ "undici": "^6.28.1",
187
193
  "uuid": "^11.1.1",
188
194
  "js-yaml": "^5.4.2",
189
195
  "qs": "^6.16.0"
@@ -66,6 +66,14 @@ function createApiHandlers(options) {
66
66
  return getModel(modelName);
67
67
  }
68
68
 
69
+ // Helper to check if model has column (supports zdb and zod shapes)
70
+ function hasColumn(model, colName) {
71
+ if (!model || !colName) return false;
72
+ if (model.columns && typeof model.columns.has === 'function' && model.columns.has(colName)) return true;
73
+ if (model.schema?.shape && colName in model.schema.shape) return true;
74
+ return false;
75
+ }
76
+
69
77
  // Get AdminUser repository
70
78
  let AdminUserRepo = null;
71
79
  if (db && AdminUser) {
@@ -282,6 +290,10 @@ function createApiHandlers(options) {
282
290
  permissions: model.admin.permissions || null,
283
291
  displayField: model.admin.displayField || null,
284
292
  defaultSort: model.admin.defaultSort || null,
293
+ inlines: model.admin.inlines || null,
294
+ listEditable: model.admin.listEditable || null,
295
+ dateHierarchy: model.admin.dateHierarchy || null,
296
+ listFilter: model.admin.listFilter || null,
285
297
  });
286
298
  }
287
299
  }
@@ -375,6 +387,10 @@ function isColumnSortable(name, meta, model) {
375
387
  group: model.admin.group || null,
376
388
  columnsConfig: model.admin.columns || null,
377
389
  relationsConfig: model.admin.relations || null,
390
+ inlines: model.admin.inlines || null,
391
+ listEditable: model.admin.listEditable || null,
392
+ dateHierarchy: model.admin.dateHierarchy || null,
393
+ listFilter: model.admin.listFilter || null,
378
394
  actions: [
379
395
  ...(registry && typeof registry.getActionsForModel === 'function' ? registry.getActionsForModel(model.name) : []),
380
396
  ...(Array.isArray(model.admin.actions) ? model.admin.actions : []),
@@ -445,6 +461,37 @@ function isColumnSortable(name, meta, model) {
445
461
  }
446
462
  }
447
463
 
464
+ // Date hierarchy filter (Django date_hierarchy-inspired)
465
+ const dateHierarchyCol = req.query.date_field || model.admin?.dateHierarchy;
466
+ if (dateHierarchyCol && hasColumn(model, dateHierarchyCol)) {
467
+ const { year, month, day, date_from, date_to } = req.query;
468
+ if (year) {
469
+ const yr = String(year);
470
+ if (month) {
471
+ const mo = String(month).padStart(2, '0');
472
+ if (day) {
473
+ const da = String(day).padStart(2, '0');
474
+ query = query.where(dateHierarchyCol, 'like', `${yr}-${mo}-${da}%`);
475
+ countQuery = countQuery.where(dateHierarchyCol, 'like', `${yr}-${mo}-${da}%`);
476
+ } else {
477
+ query = query.where(dateHierarchyCol, 'like', `${yr}-${mo}%`);
478
+ countQuery = countQuery.where(dateHierarchyCol, 'like', `${yr}-${mo}%`);
479
+ }
480
+ } else {
481
+ query = query.where(dateHierarchyCol, 'like', `${yr}%`);
482
+ countQuery = countQuery.where(dateHierarchyCol, 'like', `${yr}%`);
483
+ }
484
+ }
485
+ if (date_from) {
486
+ query = query.where(dateHierarchyCol, '>=', date_from);
487
+ countQuery = countQuery.where(dateHierarchyCol, '>=', date_from);
488
+ }
489
+ if (date_to) {
490
+ query = query.where(dateHierarchyCol, '<=', date_to);
491
+ countQuery = countQuery.where(dateHierarchyCol, '<=', date_to);
492
+ }
493
+ }
494
+
448
495
  // Parse filter parameters from query string
449
496
  // Express's qs library automatically parses filter[column][prop] into nested objects
450
497
  // So req.query.filter is already { column: { op: '...', value: '...' } }
@@ -646,13 +693,72 @@ function isColumnSortable(name, meta, model) {
646
693
  await registry.runHooks('onRecordView', { model: model.name, id, record, req, user: req.session?.adminUser, db });
647
694
  }
648
695
 
649
- res.json({ data: sanitizeForOutput(record, model) });
696
+ const sanitized = sanitizeForOutput(record, model);
697
+
698
+ // Auto-load inlines (Django Inlines-inspired)
699
+ if (model.admin?.inlines && Array.isArray(model.admin.inlines)) {
700
+ const inlinesData = {};
701
+ for (const inlineDef of model.admin.inlines) {
702
+ const targetModel = getModelFromDb(inlineDef.model);
703
+ if (targetModel) {
704
+ const targetRepo = db.getRepository(targetModel.name);
705
+ const fk = inlineDef.foreignKey || `${model.name.toLowerCase()}_id`;
706
+ const rows = await targetRepo.query().where(fk, id).find();
707
+ inlinesData[inlineDef.model] = sanitizeForOutput(rows, targetModel);
708
+ }
709
+ }
710
+ sanitized._inlines = inlinesData;
711
+ }
712
+
713
+ res.json({ data: sanitized });
650
714
  } catch (error) {
651
715
  const statusCode = error.statusCode || error.status || 500;
652
716
  res.status(statusCode).json({ error: error.message });
653
717
  }
654
718
  }
655
719
 
720
+ /**
721
+ * Sync inline child records for a parent record (Django Inlines-inspired)
722
+ */
723
+ async function syncInlines(parentRecord, inlinesPayload, model) {
724
+ if (!model.admin?.inlines || !inlinesPayload || typeof inlinesPayload !== 'object') {
725
+ return;
726
+ }
727
+ for (const inlineDef of model.admin.inlines) {
728
+ const inlineModelName = inlineDef.model;
729
+ const items = inlinesPayload[inlineModelName];
730
+ if (!Array.isArray(items)) continue;
731
+
732
+ const targetModel = getModelFromDb(inlineModelName);
733
+ if (!targetModel) continue;
734
+
735
+ const targetRepo = db.getRepository(targetModel.name);
736
+ const fk = inlineDef.foreignKey || `${model.name.toLowerCase()}_id`;
737
+ const parentId = parentRecord[model.primaryKey || 'id'];
738
+
739
+ for (const item of items) {
740
+ if (!item || typeof item !== 'object') continue;
741
+ const itemId = item[targetModel.primaryKey || 'id'];
742
+
743
+ if (item._delete === true || item._destroy === true) {
744
+ if (itemId) {
745
+ await targetRepo.delete(itemId);
746
+ }
747
+ } else if (itemId) {
748
+ const cleanItem = { ...item };
749
+ delete cleanItem._delete;
750
+ delete cleanItem._destroy;
751
+ await targetRepo.update(itemId, cleanItem);
752
+ } else {
753
+ const cleanItem = { ...item, [fk]: parentId };
754
+ delete cleanItem._delete;
755
+ delete cleanItem._destroy;
756
+ await targetRepo.create(cleanItem);
757
+ }
758
+ }
759
+ }
760
+ }
761
+
656
762
  /** Extract structured field errors from Zod, ORM or database validation exceptions */
657
763
  function extractValidationErrors(error) {
658
764
  if (!error) return null;
@@ -717,8 +823,17 @@ function isColumnSortable(name, meta, model) {
717
823
  }
718
824
  }
719
825
 
826
+ const payload = { ...req.body };
827
+ const inlinesData = payload._inlines || payload.inlines;
828
+ delete payload._inlines;
829
+ delete payload.inlines;
830
+
720
831
  const repo = db.getRepository(model.name);
721
- const record = await repo.create(req.body);
832
+ const record = await repo.create(payload);
833
+
834
+ if (inlinesData) {
835
+ await syncInlines(record, inlinesData, model);
836
+ }
722
837
 
723
838
  if (registry && typeof registry.runHooks === 'function') {
724
839
  await registry.runHooks('afterCreate', { model: model.name, record, data: req.body, req, user: req.session?.adminUser, db });
@@ -770,13 +885,22 @@ function isColumnSortable(name, meta, model) {
770
885
  }
771
886
  }
772
887
 
888
+ const payload = { ...req.body };
889
+ const inlinesData = payload._inlines || payload.inlines;
890
+ delete payload._inlines;
891
+ delete payload.inlines;
892
+
773
893
  const repo = db.getRepository(model.name);
774
- const record = await repo.update(id, req.body);
894
+ const record = await repo.update(id, payload);
775
895
 
776
896
  if (!record) {
777
897
  return res.status(404).json({ error: 'Record not found' });
778
898
  }
779
899
 
900
+ if (inlinesData) {
901
+ await syncInlines(record, inlinesData, model);
902
+ }
903
+
780
904
  if (registry && typeof registry.runHooks === 'function') {
781
905
  await registry.runHooks('afterUpdate', { model: model.name, id, record, data: req.body, req, user: req.session?.adminUser, db });
782
906
  }
@@ -1074,6 +1198,379 @@ function isColumnSortable(name, meta, model) {
1074
1198
  }
1075
1199
  }
1076
1200
 
1201
+
1202
+
1203
+ /**
1204
+ * List inline records for a specific parent record (Django Inlines-inspired)
1205
+ */
1206
+ async function inlineListHandler(req, res) {
1207
+ try {
1208
+ const { model: modelName, id, inlineModel: inlineModelName } = req.params;
1209
+ const model = getModelFromDb(modelName);
1210
+ const inlineModel = getModelFromDb(inlineModelName);
1211
+
1212
+ if (!model || !inlineModel) {
1213
+ return res.status(404).json({ error: 'Model or inline model not found' });
1214
+ }
1215
+
1216
+ const inlineDef = (model.admin?.inlines || []).find(i => i.model === inlineModelName) || {};
1217
+ const fk = inlineDef.foreignKey || `${model.name.toLowerCase()}_id`;
1218
+
1219
+ const repo = db.getRepository(inlineModel.name);
1220
+ const records = await repo.query().where(fk, id).find();
1221
+
1222
+ res.json({ data: sanitizeForOutput(records, inlineModel) });
1223
+ } catch (error) {
1224
+ res.status(500).json({ error: error.message });
1225
+ }
1226
+ }
1227
+
1228
+ /**
1229
+ * Create inline record for a specific parent record (Django Inlines-inspired)
1230
+ */
1231
+ async function inlineCreateHandler(req, res) {
1232
+ try {
1233
+ const { model: modelName, id, inlineModel: inlineModelName } = req.params;
1234
+ const model = getModelFromDb(modelName);
1235
+ const inlineModel = getModelFromDb(inlineModelName);
1236
+
1237
+ if (!model || !inlineModel) {
1238
+ return res.status(404).json({ error: 'Model or inline model not found' });
1239
+ }
1240
+
1241
+ const inlineDef = (model.admin?.inlines || []).find(i => i.model === inlineModelName) || {};
1242
+ const fk = inlineDef.foreignKey || `${model.name.toLowerCase()}_id`;
1243
+
1244
+ const repo = db.getRepository(inlineModel.name);
1245
+ const payload = { ...req.body, [fk]: id };
1246
+ const record = await repo.create(payload);
1247
+
1248
+ res.status(201).json({ data: sanitizeForOutput(record, inlineModel) });
1249
+ } catch (error) {
1250
+ res.status(400).json({ error: error.message });
1251
+ }
1252
+ }
1253
+
1254
+ /**
1255
+ * Update inline record
1256
+ */
1257
+ async function inlineUpdateHandler(req, res) {
1258
+ try {
1259
+ const { inlineModel: inlineModelName, inlineId } = req.params;
1260
+ const inlineModel = getModelFromDb(inlineModelName);
1261
+
1262
+ if (!inlineModel) {
1263
+ return res.status(404).json({ error: 'Inline model not found' });
1264
+ }
1265
+
1266
+ const repo = db.getRepository(inlineModel.name);
1267
+ const record = await repo.update(inlineId, req.body);
1268
+
1269
+ if (!record) {
1270
+ return res.status(404).json({ error: 'Inline record not found' });
1271
+ }
1272
+
1273
+ res.json({ data: sanitizeForOutput(record, inlineModel) });
1274
+ } catch (error) {
1275
+ res.status(400).json({ error: error.message });
1276
+ }
1277
+ }
1278
+
1279
+ /**
1280
+ * Delete inline record
1281
+ */
1282
+ async function inlineDeleteHandler(req, res) {
1283
+ try {
1284
+ const { inlineModel: inlineModelName, inlineId } = req.params;
1285
+ const inlineModel = getModelFromDb(inlineModelName);
1286
+
1287
+ if (!inlineModel) {
1288
+ return res.status(404).json({ error: 'Inline model not found' });
1289
+ }
1290
+
1291
+ const repo = db.getRepository(inlineModel.name);
1292
+ await repo.delete(inlineId);
1293
+
1294
+ res.json({ success: true });
1295
+ } catch (error) {
1296
+ res.status(500).json({ error: error.message });
1297
+ }
1298
+ }
1299
+
1300
+ /**
1301
+ * Quick-edit a single record inline without full form submission (Django list_editable-inspired)
1302
+ */
1303
+ async function quickEditHandler(req, res) {
1304
+ try {
1305
+ const { model: modelName, id } = req.params;
1306
+ const model = getModelFromDb(modelName);
1307
+
1308
+ if (!model || !model.admin || model.admin.enabled !== true) {
1309
+ return res.status(404).json({ error: 'Model not found or not enabled' });
1310
+ }
1311
+ if (model.admin.readOnly === true || model.admin.permissions?.edit === false) {
1312
+ return res.status(403).json({ error: `Editing is disabled for model "${model.name}"` });
1313
+ }
1314
+
1315
+ const allowedCols = Array.isArray(model.admin.listEditable) ? model.admin.listEditable : null;
1316
+ const updates = {};
1317
+ for (const [k, v] of Object.entries(req.body)) {
1318
+ if (allowedCols && !allowedCols.includes(k)) {
1319
+ return res.status(400).json({ error: `Column "${k}" is not list-editable on model "${model.name}"` });
1320
+ }
1321
+ if (hasColumn(model, k)) {
1322
+ updates[k] = v;
1323
+ }
1324
+ }
1325
+
1326
+ if (Object.keys(updates).length === 0) {
1327
+ return res.status(400).json({ error: 'No valid editable fields provided' });
1328
+ }
1329
+
1330
+ const repo = db.getRepository(model.name);
1331
+ const record = await repo.update(id, updates);
1332
+ if (!record) {
1333
+ return res.status(404).json({ error: 'Record not found' });
1334
+ }
1335
+
1336
+ res.json({ success: true, data: sanitizeForOutput(record, model) });
1337
+ } catch (error) {
1338
+ const fields = extractValidationErrors(error);
1339
+ const statusCode = error.statusCode || error.status || 400;
1340
+ res.status(statusCode).json({ error: error.message, ...(fields ? { fields } : {}) });
1341
+ }
1342
+ }
1343
+
1344
+ /**
1345
+ * Batch edit multiple records inline (Django list_editable bulk save)
1346
+ */
1347
+ async function batchEditHandler(req, res) {
1348
+ try {
1349
+ const { model: modelName } = req.params;
1350
+ const model = getModelFromDb(modelName);
1351
+
1352
+ if (!model || !model.admin || model.admin.enabled !== true) {
1353
+ return res.status(404).json({ error: 'Model not found or not enabled' });
1354
+ }
1355
+ if (model.admin.readOnly === true || model.admin.permissions?.edit === false) {
1356
+ return res.status(403).json({ error: `Editing is disabled for model "${model.name}"` });
1357
+ }
1358
+
1359
+ const updates = req.body.updates;
1360
+ if (!Array.isArray(updates) || updates.length === 0) {
1361
+ return res.status(400).json({ error: 'Array of updates is required' });
1362
+ }
1363
+
1364
+ const allowedCols = Array.isArray(model.admin.listEditable) ? model.admin.listEditable : null;
1365
+ const repo = db.getRepository(model.name);
1366
+ const results = [];
1367
+
1368
+ for (const item of updates) {
1369
+ const { id, changes } = item;
1370
+ if (!id || !changes || typeof changes !== 'object') continue;
1371
+
1372
+ const cleanChanges = {};
1373
+ for (const [k, v] of Object.entries(changes)) {
1374
+ if (allowedCols && !allowedCols.includes(k)) continue;
1375
+ if (hasColumn(model, k)) {
1376
+ cleanChanges[k] = v;
1377
+ }
1378
+ }
1379
+
1380
+ if (Object.keys(cleanChanges).length > 0) {
1381
+ const updated = await repo.update(id, cleanChanges);
1382
+ if (updated) {
1383
+ results.push(sanitizeForOutput(updated, model));
1384
+ }
1385
+ }
1386
+ }
1387
+
1388
+ res.json({ success: true, count: results.length, records: results });
1389
+ } catch (error) {
1390
+ res.status(500).json({ error: error.message });
1391
+ }
1392
+ }
1393
+
1394
+ /**
1395
+ * Date hierarchy drilldown aggregation (Django date_hierarchy-inspired)
1396
+ */
1397
+ async function dateHierarchyHandler(req, res) {
1398
+ try {
1399
+ const { model: modelName } = req.params;
1400
+ const model = getModelFromDb(modelName);
1401
+
1402
+ if (!model || !model.admin || model.admin.enabled !== true) {
1403
+ return res.status(404).json({ error: 'Model not found or not enabled' });
1404
+ }
1405
+
1406
+ const dateField = req.query.field || model.admin.dateHierarchy || 'created_at';
1407
+ if (!hasColumn(model, dateField)) {
1408
+ return res.status(400).json({ error: `Date field "${dateField}" does not exist on model "${model.name}"` });
1409
+ }
1410
+
1411
+ const year = req.query.year ? parseInt(req.query.year) : null;
1412
+ const month = req.query.month ? parseInt(req.query.month) : null;
1413
+
1414
+ const repo = db.getRepository(model.name);
1415
+ let query = repo.query().whereNotNull(dateField);
1416
+
1417
+ // If drilldown to days
1418
+ if (year && month) {
1419
+ const prefix = `${year}-${String(month).padStart(2, '0')}`;
1420
+ query = query.where(dateField, 'like', `${prefix}%`);
1421
+ const rows = await query.select(dateField).find();
1422
+
1423
+ const counts = {};
1424
+ for (const r of rows) {
1425
+ const val = String(r[dateField]);
1426
+ const dayMatch = val.match(/^\d{4}-\d{2}-(\d{2})/);
1427
+ if (dayMatch) {
1428
+ const d = parseInt(dayMatch[1]);
1429
+ counts[d] = (counts[d] || 0) + 1;
1430
+ }
1431
+ }
1432
+
1433
+ const days = Object.entries(counts)
1434
+ .map(([d, count]) => ({ day: parseInt(d), count }))
1435
+ .sort((a, b) => a.day - b.day);
1436
+
1437
+ return res.json({ level: 'days', year, month, items: days });
1438
+ }
1439
+
1440
+ // If drilldown to months
1441
+ if (year) {
1442
+ query = query.where(dateField, 'like', `${year}%`);
1443
+ const rows = await query.select(dateField).find();
1444
+
1445
+ const counts = {};
1446
+ for (const r of rows) {
1447
+ const val = String(r[dateField]);
1448
+ const match = val.match(/^\d{4}-(\d{2})/);
1449
+ if (match) {
1450
+ const m = parseInt(match[1]);
1451
+ counts[m] = (counts[m] || 0) + 1;
1452
+ }
1453
+ }
1454
+
1455
+ const months = Object.entries(counts)
1456
+ .map(([m, count]) => ({ month: parseInt(m), count }))
1457
+ .sort((a, b) => a.month - b.month);
1458
+
1459
+ return res.json({ level: 'months', year, items: months });
1460
+ }
1461
+
1462
+ // Top level: drilldown by year
1463
+ const rows = await query.select(dateField).find();
1464
+ const counts = {};
1465
+ for (const r of rows) {
1466
+ const val = String(r[dateField]);
1467
+ const match = val.match(/^(\d{4})/);
1468
+ if (match) {
1469
+ const y = parseInt(match[1]);
1470
+ counts[y] = (counts[y] || 0) + 1;
1471
+ }
1472
+ }
1473
+
1474
+ const years = Object.entries(counts)
1475
+ .map(([y, count]) => ({ year: parseInt(y), count }))
1476
+ .sort((a, b) => b.year - a.year);
1477
+
1478
+ return res.json({ level: 'years', field: dateField, items: years });
1479
+ } catch (error) {
1480
+ res.status(500).json({ error: error.message });
1481
+ }
1482
+ }
1483
+
1484
+ /**
1485
+ * Filter facets aggregation for list_filter columns (Django list_filter-inspired)
1486
+ */
1487
+ async function filterFacetsHandler(req, res) {
1488
+ try {
1489
+ const { model: modelName } = req.params;
1490
+ const model = getModelFromDb(modelName);
1491
+
1492
+ if (!model || !model.admin || model.admin.enabled !== true) {
1493
+ return res.status(404).json({ error: 'Model not found or not enabled' });
1494
+ }
1495
+
1496
+ const requestedFields = req.query.fields
1497
+ ? req.query.fields.split(',').map(s => s.trim())
1498
+ : (model.admin.listFilter || model.admin.filterable || []);
1499
+
1500
+ const repo = db.getRepository(model.name);
1501
+ const facets = {};
1502
+
1503
+ for (const field of requestedFields) {
1504
+ if (!hasColumn(model, field)) continue;
1505
+ const rows = await repo.query().whereNotNull(field).select(field).find();
1506
+ const counts = {};
1507
+ for (const r of rows) {
1508
+ const val = r[field];
1509
+ const key = val === null || val === undefined ? '' : String(val);
1510
+ counts[key] = (counts[key] || 0) + 1;
1511
+ }
1512
+ facets[field] = Object.entries(counts).map(([value, count]) => ({
1513
+ value,
1514
+ count,
1515
+ })).sort((a, b) => b.count - a.count);
1516
+ }
1517
+
1518
+ res.json({ facets });
1519
+ } catch (error) {
1520
+ res.status(500).json({ error: error.message });
1521
+ }
1522
+ }
1523
+
1524
+ /**
1525
+ * Execute model action or bulk action (Django Actions-inspired)
1526
+ */
1527
+ async function modelActionHandler(req, res) {
1528
+ try {
1529
+ const { model: modelName, actionId } = req.params;
1530
+ const model = getModelFromDb(modelName);
1531
+
1532
+ if (!model || !model.admin || model.admin.enabled !== true) {
1533
+ return res.status(404).json({ error: 'Model not found or not enabled' });
1534
+ }
1535
+
1536
+ const { ids } = req.body;
1537
+ if (!Array.isArray(ids) || ids.length === 0) {
1538
+ return res.status(400).json({ error: 'Array of record ids is required' });
1539
+ }
1540
+
1541
+ const modelActions = Array.isArray(model.admin.actions) ? model.admin.actions : [];
1542
+ const modelBulkActions = Array.isArray(model.admin.bulkActions) ? model.admin.bulkActions : [];
1543
+ const action = modelActions.find(a => a.id === actionId) || modelBulkActions.find(a => a.id === actionId);
1544
+
1545
+ const repo = db.getRepository(model.name);
1546
+
1547
+ if (action && typeof action.handler === 'function') {
1548
+ const result = await action.handler({
1549
+ ids,
1550
+ model,
1551
+ repo,
1552
+ db,
1553
+ req,
1554
+ user: req.session?.adminUser,
1555
+ });
1556
+ return res.json({ success: true, ...(typeof result === 'object' ? result : { result }) });
1557
+ }
1558
+
1559
+ if (registry && typeof registry.getBulkAction === 'function') {
1560
+ const bulkAction = registry.getBulkAction(actionId);
1561
+ if (bulkAction && typeof bulkAction.handler === 'function') {
1562
+ const records = await repo.query().whereIn(model.primaryKey || 'id', ids).find();
1563
+ const result = await bulkAction.handler(records, model.name, { db, req, user: req.session?.adminUser });
1564
+ return res.json({ success: true, ...(typeof result === 'object' ? result : { result }) });
1565
+ }
1566
+ }
1567
+
1568
+ return res.status(404).json({ error: `Action "${actionId}" not found for model "${model.name}"` });
1569
+ } catch (error) {
1570
+ res.status(500).json({ error: error.message });
1571
+ }
1572
+ }
1573
+
1077
1574
  return {
1078
1575
  checkHandler,
1079
1576
  setupHandler,
@@ -1094,6 +1591,15 @@ function isColumnSortable(name, meta, model) {
1094
1591
  resetHandler,
1095
1592
  rateLimiter,
1096
1593
  resetLoginAttempts: (ip) => rateLimiter.reset(ip),
1594
+ inlineListHandler,
1595
+ inlineCreateHandler,
1596
+ inlineUpdateHandler,
1597
+ inlineDeleteHandler,
1598
+ quickEditHandler,
1599
+ batchEditHandler,
1600
+ dateHierarchyHandler,
1601
+ filterFacetsHandler,
1602
+ modelActionHandler,
1097
1603
  };
1098
1604
  }
1099
1605