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.
- package/.agents/ADMIN.md +47 -4
- package/.agents/AGENTS.md +4 -2
- package/.agents/AUTH.md +309 -0
- package/.agents/CLI.md +128 -0
- package/.agents/EMAIL.md +84 -2
- package/.agents/ERRORS.md +59 -0
- package/.agents/EVENTS.md +179 -0
- package/.agents/ORM.md +149 -0
- package/.agents/PLUGINS.md +44 -7
- package/.agents/QUEUE.md +18 -10
- package/.agents/ROUTING.md +97 -1
- package/.agents/STREAMING.md +35 -0
- package/AGENTS.md +2 -0
- package/README.md +145 -93
- package/bin/commands/add-docker.js +467 -0
- package/bin/commands/db-makemigrations.js +83 -0
- package/bin/commands/deploy-check.js +339 -0
- package/bin/commands/list.js +96 -0
- package/bin/commands/make-command.js +93 -0
- package/bin/commands/make-event.js +80 -0
- package/bin/commands/make-factory.js +72 -0
- package/bin/commands/make-mail.js +93 -0
- package/bin/commands/make-policy.js +106 -0
- package/bin/commands/run.js +195 -0
- package/bin/webspresso.js +21 -0
- package/core/auth/bouncer.js +196 -0
- package/core/auth/index.js +73 -1
- package/core/auth/magic-link.js +170 -0
- package/core/auth/manager.js +343 -3
- package/core/auth/middleware.js +45 -6
- package/core/auth/session-manager.js +255 -0
- package/core/auth/sessions.js +351 -0
- package/core/auth/tokens.js +1 -0
- package/core/auth/totp.js +4 -1
- package/core/auth/user-agent.js +161 -0
- package/core/commands/define-command.js +48 -0
- package/core/commands/index.js +18 -0
- package/core/commands/registry.js +156 -0
- package/core/commands/runner.js +76 -0
- package/core/compression/compressible.js +1 -1
- package/core/compression/middleware.js +35 -251
- package/core/content/service.js +8 -2
- package/core/events/discovery.js +142 -0
- package/core/events/dispatcher.js +552 -0
- package/core/events/index.js +23 -0
- package/core/events/signal.js +92 -0
- package/core/forms/flash.js +234 -0
- package/core/forms/form-builder.js +214 -0
- package/core/forms/index.js +16 -0
- package/core/mail/index.js +23 -0
- package/core/mail/mailable.js +250 -0
- package/core/mail/mailer.js +184 -0
- package/core/mail/transports.js +238 -0
- package/core/orm/factory.js +250 -0
- package/core/orm/index.js +8 -1
- package/core/orm/migrations/diff.js +185 -0
- package/core/orm/migrations/index.js +22 -4
- package/core/orm/model.js +55 -1
- package/core/orm/repository.js +283 -13
- package/core/orm/scopes.js +3 -2
- package/core/orm/types.js +6 -1
- package/core/queue/cron.js +252 -39
- package/core/queue/manager.js +20 -2
- package/core/queue/scheduler.js +115 -59
- package/core/ssr/stream.js +62 -2
- package/index.d.ts +540 -14
- package/index.js +44 -0
- package/package.json +11 -5
- package/plugins/admin-panel/api.js +509 -3
- package/plugins/admin-panel/index.js +17 -0
- package/plugins/admin-panel/modules/admin-users.js +1 -1
- package/plugins/audit-log/admin-component.js +54 -54
- package/plugins/audit-log/index.js +2 -2
- package/plugins/content/client/inline-edit.js +2 -46
- package/plugins/content/models/content-entry.js +4 -3
- package/plugins/content/models/content-type.js +4 -3
- package/plugins/cors.js +15 -4
- package/plugins/email/auth-bridge.js +66 -0
- package/plugins/email/bundled-templates.js +15 -0
- package/plugins/email/index.js +48 -0
- package/plugins/email/preview-handler.js +555 -0
- package/plugins/email/service.js +23 -12
- package/plugins/email/templates/auth-magic-link.mjml +13 -0
- package/plugins/email/transport.js +30 -11
- package/plugins/file-manager/admin-component.js +49 -49
- package/plugins/file-manager/index.js +4 -4
- package/plugins/index.js +2 -0
- package/plugins/redirect/index.js +1 -1
- package/plugins/webhooks/admin-component.js +608 -0
- package/plugins/webhooks/api-handlers.js +237 -0
- package/plugins/webhooks/crypto.js +144 -0
- package/plugins/webhooks/dispatcher.js +708 -0
- package/plugins/webhooks/index.js +263 -0
- package/plugins/webhooks/matcher.js +113 -0
- package/plugins/webhooks/migration-template.js +75 -0
- package/plugins/webhooks/models.js +98 -0
- package/plugins/webhooks/sender.js +176 -0
- package/src/api/api-loader.js +1 -1
- package/src/app-context.js +48 -6
- package/src/bundle/index.js +2 -1
- package/src/dev-reload/index.js +9 -1
- package/src/discovery/scan-routes.js +10 -1
- package/src/file-router.js +173 -4
- package/src/helpers.js +150 -1
- package/src/njk-frontmatter.js +3 -0
- package/src/pages/page-loader.js +1 -1
- package/src/routing/cascading.js +120 -4
- 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.
|
|
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.
|
|
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": "^
|
|
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": "^
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
|