thatcher 1.0.62 → 1.0.64
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/package.json +1 -1
- package/src/config/spec-helpers.js +4 -2
- package/src/lib/config-generator-engine.js +12 -0
- package/src/lib/crud-handlers.js +4 -0
- package/src/lib/field-registry.js +8 -1
- package/src/lib/validation/entity-validators.js +23 -1
- package/src/server/server.js +216 -0
- package/src/ui/entity-renderer.js +37 -6
- package/src/ui/page-handler-admin.js +13 -0
- package/src/ui/page-handler.js +32 -3
- package/src/ui/template-renderer.js +48 -0
package/package.json
CHANGED
|
@@ -94,10 +94,12 @@ export function getInitialState(spec) {
|
|
|
94
94
|
state[key] = field.default;
|
|
95
95
|
} else if (field.type === 'bool') {
|
|
96
96
|
state[key] = false;
|
|
97
|
-
} else if (field.type === 'int' || field.type === 'decimal') {
|
|
97
|
+
} else if (field.type === 'int' || field.type === 'decimal' || field.type === 'currency') {
|
|
98
98
|
state[key] = 0;
|
|
99
|
-
} else if (field.type === 'json') {
|
|
99
|
+
} else if (field.type === 'json' || field.type === 'multiselect') {
|
|
100
100
|
state[key] = [];
|
|
101
|
+
} else if (field.type === 'file' || field.type === 'attachment') {
|
|
102
|
+
state[key] = null;
|
|
101
103
|
} else if (field.type === 'date' || field.type === 'timestamp') {
|
|
102
104
|
state[key] = null;
|
|
103
105
|
} else {
|
|
@@ -68,11 +68,23 @@ const WEBHOOK_DELIVERY_ENTITY_DEFAULT = {
|
|
|
68
68
|
},
|
|
69
69
|
};
|
|
70
70
|
|
|
71
|
+
const ENTITY_TEMPLATE_ENTITY_DEFAULT = {
|
|
72
|
+
label: 'Entity Template',
|
|
73
|
+
label_plural: 'Entity Templates',
|
|
74
|
+
system_entity: true,
|
|
75
|
+
fields: {
|
|
76
|
+
entity: { type: 'text', required: true, label: 'Entity' },
|
|
77
|
+
name: { type: 'text', required: true, label: 'Name' },
|
|
78
|
+
field_values: { type: 'json', label: 'Field Values' },
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
71
82
|
function withWebhookDefaults(masterConfig) {
|
|
72
83
|
const entities = { ...(masterConfig.entities || {}) };
|
|
73
84
|
let changed = false;
|
|
74
85
|
if (!entities.webhook) { entities.webhook = WEBHOOK_ENTITY_DEFAULT; changed = true; }
|
|
75
86
|
if (!entities.webhook_delivery) { entities.webhook_delivery = WEBHOOK_DELIVERY_ENTITY_DEFAULT; changed = true; }
|
|
87
|
+
if (!entities.entity_template) { entities.entity_template = ENTITY_TEMPLATE_ENTITY_DEFAULT; changed = true; }
|
|
76
88
|
return changed ? { ...masterConfig, entities } : masterConfig;
|
|
77
89
|
}
|
|
78
90
|
|
package/src/lib/crud-handlers.js
CHANGED
|
@@ -215,10 +215,14 @@ function coerceFieldValue(value, type) {
|
|
|
215
215
|
switch (type) {
|
|
216
216
|
case 'int':
|
|
217
217
|
case 'decimal':
|
|
218
|
+
case 'currency':
|
|
218
219
|
return Number(value);
|
|
219
220
|
case 'bool':
|
|
220
221
|
return Boolean(value);
|
|
221
222
|
case 'json':
|
|
223
|
+
case 'multiselect':
|
|
224
|
+
case 'file':
|
|
225
|
+
case 'attachment':
|
|
222
226
|
return typeof value === 'string' ? JSON.parse(value) : value;
|
|
223
227
|
case 'date':
|
|
224
228
|
case 'timestamp':
|
|
@@ -4,11 +4,15 @@ export function coerceFieldValue(value, type) {
|
|
|
4
4
|
switch (type) {
|
|
5
5
|
case 'int':
|
|
6
6
|
case 'decimal':
|
|
7
|
+
case 'currency':
|
|
7
8
|
return Number(value);
|
|
8
9
|
case 'bool':
|
|
9
10
|
case 'boolean':
|
|
10
11
|
return value === true || value === 'true' || value === 1;
|
|
11
12
|
case 'json':
|
|
13
|
+
case 'multiselect':
|
|
14
|
+
case 'file':
|
|
15
|
+
case 'attachment':
|
|
12
16
|
return typeof value === 'string' ? JSON.parse(value) : value;
|
|
13
17
|
case 'date':
|
|
14
18
|
case 'timestamp': {
|
|
@@ -31,10 +35,13 @@ export function deserializeField(value, type) {
|
|
|
31
35
|
|
|
32
36
|
switch (type) {
|
|
33
37
|
case 'json':
|
|
38
|
+
case 'multiselect':
|
|
39
|
+
case 'file':
|
|
40
|
+
case 'attachment':
|
|
34
41
|
try {
|
|
35
42
|
return typeof value === 'string' ? JSON.parse(value) : value;
|
|
36
43
|
} catch {
|
|
37
|
-
return {};
|
|
44
|
+
return type === 'multiselect' ? [] : {};
|
|
38
45
|
}
|
|
39
46
|
case 'bool':
|
|
40
47
|
return Boolean(value);
|
|
@@ -45,6 +45,18 @@ export async function validateField(fieldDef, value, options = {}) {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// Multi-select: value validated as array by validateType above; membership checked here (needs entityName for option-list resolution, same as enum)
|
|
49
|
+
if (fieldDef.type === 'multiselect' && fieldDef.options) {
|
|
50
|
+
const allowed = resolveEnumOptions(fieldDef, entityName);
|
|
51
|
+
const arr = Array.isArray(value) ? value : (typeof value === 'string' ? JSON.parse(value) : []);
|
|
52
|
+
if (allowed.length > 0 && arr.some(v => !allowed.includes(v))) {
|
|
53
|
+
return {
|
|
54
|
+
valid: false,
|
|
55
|
+
error: `Invalid value(s) for '${fieldName}'. Expected values from: ${allowed.join(', ')}`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
48
60
|
// Reference validation
|
|
49
61
|
if (fieldDef.type === 'ref' && fieldDef.ref) {
|
|
50
62
|
if (existingValue !== undefined && value === existingValue) {
|
|
@@ -72,14 +84,24 @@ function validateType(fieldDef, value, fieldName) {
|
|
|
72
84
|
|
|
73
85
|
if (type === 'string' || type === 'text') {
|
|
74
86
|
if (typeof value !== 'string') return `Field '${fieldName}' must be a string`;
|
|
75
|
-
} else if (type === 'number' || type === 'int' || type === 'decimal') {
|
|
87
|
+
} else if (type === 'number' || type === 'int' || type === 'decimal' || type === 'currency') {
|
|
76
88
|
if (typeof value !== 'number' || isNaN(value)) return `Field '${fieldName}' must be a number`;
|
|
89
|
+
if (type === 'currency' && !Number.isInteger(value)) return `Field '${fieldName}' must be an integer number of cents`;
|
|
90
|
+
if (fieldDef.step && (value % fieldDef.step !== 0)) return `Field '${fieldName}' must be a multiple of ${fieldDef.step}`;
|
|
77
91
|
if (min !== undefined && value < min) return `Field '${fieldName}' must be at least ${min}`;
|
|
78
92
|
if (max !== undefined && value > max) return `Field '${fieldName}' must be at most ${max}`;
|
|
79
93
|
} else if (type === 'boolean' || type === 'bool') {
|
|
80
94
|
if (typeof value !== 'boolean') return `Field '${fieldName}' must be a boolean`;
|
|
81
95
|
} else if (type === 'timestamp' || type === 'date') {
|
|
82
96
|
if (isNaN(Number(value))) return `Field '${fieldName}' must be a valid timestamp`;
|
|
97
|
+
} else if (type === 'multiselect') {
|
|
98
|
+
const arr = Array.isArray(value) ? value : (typeof value === 'string' ? (() => { try { return JSON.parse(value); } catch { return null; } })() : null);
|
|
99
|
+
if (!Array.isArray(arr)) return `Field '${fieldName}' must be an array`;
|
|
100
|
+
} else if (type === 'file' || type === 'attachment') {
|
|
101
|
+
const f = typeof value === 'string' ? (() => { try { return JSON.parse(value); } catch { return null; } })() : value;
|
|
102
|
+
if (!f || typeof f !== 'object' || typeof f.stored_name !== 'string' || typeof f.url !== 'string') {
|
|
103
|
+
return `Field '${fieldName}' must be file metadata with stored_name and url`;
|
|
104
|
+
}
|
|
83
105
|
} else if (type === 'json') {
|
|
84
106
|
if (typeof value === 'string') {
|
|
85
107
|
try { JSON.parse(value); } catch { return `Field '${fieldName}' must be valid JSON`; }
|
package/src/server/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import http from 'http';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import fs from 'fs';
|
|
4
|
+
import crypto from 'crypto';
|
|
4
5
|
import { fileURLToPath } from 'url';
|
|
5
6
|
import { createLogger } from '../lib/logger.js';
|
|
6
7
|
import { getLucia } from '../engine.server.js';
|
|
@@ -138,6 +139,18 @@ export function createServer(options) {
|
|
|
138
139
|
return await handleBulkOperation(req, res, entity, thatcher, configEngine);
|
|
139
140
|
}
|
|
140
141
|
|
|
142
|
+
if (req.method === 'POST' && entity === 'entity_template' && id === 'create' && !action) {
|
|
143
|
+
return await handleCreateEntityTemplate(req, res, thatcher, configEngine);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (req.method === 'POST' && entity === 'entity_template' && id && action === 'delete') {
|
|
147
|
+
return await handleDeleteEntityTemplate(req, res, id, thatcher, configEngine);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (req.method === 'POST' && entity === 'upload' && !id) {
|
|
151
|
+
return await handleFileUpload(req, res, thatcher, configEngine);
|
|
152
|
+
}
|
|
153
|
+
|
|
141
154
|
// Check if user has custom route for this
|
|
142
155
|
const userRoutePath = path.join(process.cwd(), 'app/api', ...parts, 'route.js');
|
|
143
156
|
const routeExists = await fileExists(userRoutePath);
|
|
@@ -198,6 +211,25 @@ async function serveStaticFile(pathname, req, res) {
|
|
|
198
211
|
// Could serve SPA
|
|
199
212
|
return false;
|
|
200
213
|
}
|
|
214
|
+
if (pathname.startsWith('/uploads/')) {
|
|
215
|
+
// basename strips any traversal the URL decoder let through; uploaded
|
|
216
|
+
// files are served read-only, by their sanitized stored name only.
|
|
217
|
+
const name = path.basename(pathname.slice('/uploads/'.length));
|
|
218
|
+
const filePath = path.join(process.cwd(), 'uploads', name);
|
|
219
|
+
try {
|
|
220
|
+
if (await fileExists(filePath) && path.dirname(filePath) === path.join(process.cwd(), 'uploads')) {
|
|
221
|
+
const content = await fs.promises.readFile(filePath);
|
|
222
|
+
res.setHeader('Content-Type', 'application/octet-stream');
|
|
223
|
+
res.setHeader('Content-Disposition', `attachment; filename="${name}"`);
|
|
224
|
+
res.writeHead(200);
|
|
225
|
+
res.end(content);
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
} catch (err) {
|
|
229
|
+
if (err.code !== 'ENOENT') staticLog.error(`${filePath} ${err.code}`, { message: err.message });
|
|
230
|
+
}
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
201
233
|
// Try to serve from public/ or static/
|
|
202
234
|
const filePath = path.join(process.cwd(), 'public', pathname);
|
|
203
235
|
try {
|
|
@@ -438,6 +470,77 @@ async function handleBulkOperation(req, res, entityName, thatcher, configEngineA
|
|
|
438
470
|
}
|
|
439
471
|
}
|
|
440
472
|
|
|
473
|
+
async function handleCreateEntityTemplate(req, res, thatcher, configEngineArg) {
|
|
474
|
+
const user = await requireAuthedPartner(req, res);
|
|
475
|
+
if (!user) return;
|
|
476
|
+
|
|
477
|
+
let configEngine = configEngineArg || thatcher?.configEngine || globalThis.__thatcherConfigEngine;
|
|
478
|
+
if (!configEngine) {
|
|
479
|
+
const { getConfigEngineSync } = await import('../lib/config-generator-engine.js');
|
|
480
|
+
configEngine = getConfigEngineSync();
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
let body;
|
|
484
|
+
try {
|
|
485
|
+
body = await readBody(req);
|
|
486
|
+
} catch (e) {
|
|
487
|
+
res.writeHead(400);
|
|
488
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const { entity, name, field_values } = body || {};
|
|
492
|
+
if (!entity || typeof entity !== 'string') {
|
|
493
|
+
res.writeHead(400);
|
|
494
|
+
res.end(JSON.stringify({ error: 'entity required' }));
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
try {
|
|
498
|
+
configEngine.generateEntitySpec(entity);
|
|
499
|
+
} catch {
|
|
500
|
+
res.writeHead(400);
|
|
501
|
+
res.end(JSON.stringify({ error: `Unknown entity "${entity}"` }));
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (!name || typeof name !== 'string' || !name.trim()) {
|
|
505
|
+
res.writeHead(400);
|
|
506
|
+
res.end(JSON.stringify({ error: 'name required' }));
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
try {
|
|
511
|
+
const { create } = await import('../lib/busybase/store.js');
|
|
512
|
+
const record = await create('entity_template', { entity, name: name.trim(), field_values: field_values || {} }, user);
|
|
513
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
514
|
+
res.end(JSON.stringify({ ok: true, id: record.id }));
|
|
515
|
+
} catch (err) {
|
|
516
|
+
apiLog.error(err.message);
|
|
517
|
+
res.writeHead(500);
|
|
518
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function handleDeleteEntityTemplate(req, res, templateId, thatcher, configEngineArg) {
|
|
523
|
+
const user = await requireAuthedPartner(req, res);
|
|
524
|
+
if (!user) return;
|
|
525
|
+
|
|
526
|
+
try {
|
|
527
|
+
const { remove, get } = await import('../lib/busybase/store.js');
|
|
528
|
+
const existing = await get('entity_template', templateId);
|
|
529
|
+
if (!existing) {
|
|
530
|
+
res.writeHead(404);
|
|
531
|
+
res.end(JSON.stringify({ error: 'Template not found' }));
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
await remove('entity_template', templateId);
|
|
535
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
536
|
+
res.end(JSON.stringify({ ok: true }));
|
|
537
|
+
} catch (err) {
|
|
538
|
+
apiLog.error(err.message);
|
|
539
|
+
res.writeHead(500);
|
|
540
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
441
544
|
async function handleCreateWebhook(req, res, thatcher, configEngineArg) {
|
|
442
545
|
const user = await requireAuthedPartner(req, res);
|
|
443
546
|
if (!user) return;
|
|
@@ -856,6 +959,119 @@ async function handleCsvImport(req, res, entity, thatcher, configEngineArg) {
|
|
|
856
959
|
}
|
|
857
960
|
}
|
|
858
961
|
|
|
962
|
+
const UPLOAD_ALLOWED_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'application/pdf', 'text/plain', 'text/csv', 'application/json']);
|
|
963
|
+
const UPLOAD_MAX_SIZE = 10 * 1024 * 1024;
|
|
964
|
+
const UPLOAD_DIR = path.join(process.cwd(), 'uploads');
|
|
965
|
+
|
|
966
|
+
// Sanitize to a flat, extension-preserving, path-traversal-safe name: strip
|
|
967
|
+
// any directory component, keep only [a-zA-Z0-9._-], cap length. The stored
|
|
968
|
+
// filename is never the client-supplied one directly -- a random prefix
|
|
969
|
+
// prevents overwrite/collision and the sanitization prevents "../../etc" or
|
|
970
|
+
// null-byte tricks reaching fs.writeFile.
|
|
971
|
+
function sanitizeUploadFilename(name) {
|
|
972
|
+
const base = path.basename(String(name || 'file')).replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 128) || 'file';
|
|
973
|
+
const prefix = crypto.randomBytes(8).toString('hex');
|
|
974
|
+
return `${prefix}_${base}`;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
async function readMultipartFile(req) {
|
|
978
|
+
const ct = req.headers['content-type'] || '';
|
|
979
|
+
const boundaryMatch = ct.match(/boundary=(?:"([^"]+)"|([^;]+))/i);
|
|
980
|
+
if (!boundaryMatch) throw new Error('Multipart boundary not found');
|
|
981
|
+
const boundary = '--' + (boundaryMatch[1] || boundaryMatch[2]).trim();
|
|
982
|
+
|
|
983
|
+
const chunks = [];
|
|
984
|
+
let size = 0;
|
|
985
|
+
await new Promise((resolve, reject) => {
|
|
986
|
+
const timeout = setTimeout(() => { req.destroy(); reject(new Error('Request timeout')); }, 30000);
|
|
987
|
+
req.on('data', chunk => {
|
|
988
|
+
size += chunk.length;
|
|
989
|
+
if (size > UPLOAD_MAX_SIZE) { clearTimeout(timeout); req.destroy(); reject(new Error('File too large')); return; }
|
|
990
|
+
chunks.push(chunk);
|
|
991
|
+
});
|
|
992
|
+
req.on('end', () => { clearTimeout(timeout); resolve(); });
|
|
993
|
+
req.on('error', (err) => { clearTimeout(timeout); reject(err); });
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
const buf = Buffer.concat(chunks);
|
|
997
|
+
const boundaryBuf = Buffer.from(boundary);
|
|
998
|
+
const parts = [];
|
|
999
|
+
let start = buf.indexOf(boundaryBuf);
|
|
1000
|
+
while (start !== -1) {
|
|
1001
|
+
const next = buf.indexOf(boundaryBuf, start + boundaryBuf.length);
|
|
1002
|
+
if (next === -1) break;
|
|
1003
|
+
parts.push(buf.slice(start + boundaryBuf.length, next));
|
|
1004
|
+
start = next;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
for (const part of parts) {
|
|
1008
|
+
const headerEnd = part.indexOf('\r\n\r\n');
|
|
1009
|
+
if (headerEnd === -1) continue;
|
|
1010
|
+
const headerText = part.slice(0, headerEnd).toString('utf-8');
|
|
1011
|
+
if (!/name="file"/i.test(headerText)) continue;
|
|
1012
|
+
const filenameMatch = headerText.match(/filename="([^"]*)"/i);
|
|
1013
|
+
if (!filenameMatch || !filenameMatch[1]) continue;
|
|
1014
|
+
const typeMatch = headerText.match(/Content-Type:\s*([^\r\n]+)/i);
|
|
1015
|
+
const contentType = (typeMatch ? typeMatch[1] : 'application/octet-stream').trim();
|
|
1016
|
+
let body = part.slice(headerEnd + 4);
|
|
1017
|
+
if (body.slice(-2).toString() === '\r\n') body = body.slice(0, -2);
|
|
1018
|
+
return { filename: filenameMatch[1], contentType, buffer: body };
|
|
1019
|
+
}
|
|
1020
|
+
throw new Error('No file field found in upload');
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
async function handleFileUpload(req, res, thatcher, configEngineArg) {
|
|
1024
|
+
const user = await resolveRequestUser(req);
|
|
1025
|
+
if (!user) {
|
|
1026
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1027
|
+
res.end(JSON.stringify({ error: 'Authentication required' }));
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
let file;
|
|
1032
|
+
try {
|
|
1033
|
+
file = await readMultipartFile(req);
|
|
1034
|
+
} catch (e) {
|
|
1035
|
+
res.writeHead(400);
|
|
1036
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
if (!UPLOAD_ALLOWED_TYPES.has(file.contentType)) {
|
|
1041
|
+
res.writeHead(400);
|
|
1042
|
+
res.end(JSON.stringify({ error: `Unsupported file type: ${file.contentType}` }));
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
if (file.buffer.length === 0) {
|
|
1046
|
+
res.writeHead(400);
|
|
1047
|
+
res.end(JSON.stringify({ error: 'Empty file' }));
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
try {
|
|
1052
|
+
await fs.promises.mkdir(UPLOAD_DIR, { recursive: true });
|
|
1053
|
+
const storedName = sanitizeUploadFilename(file.filename);
|
|
1054
|
+
const destPath = path.join(UPLOAD_DIR, storedName);
|
|
1055
|
+
// Belt-and-suspenders: confirm the resolved path is still inside UPLOAD_DIR
|
|
1056
|
+
// even though sanitizeUploadFilename already strips traversal sequences.
|
|
1057
|
+
if (path.dirname(destPath) !== UPLOAD_DIR) throw new Error('Invalid upload path');
|
|
1058
|
+
await fs.promises.writeFile(destPath, file.buffer);
|
|
1059
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
1060
|
+
res.end(JSON.stringify({
|
|
1061
|
+
filename: file.filename,
|
|
1062
|
+
stored_name: storedName,
|
|
1063
|
+
content_type: file.contentType,
|
|
1064
|
+
size: file.buffer.length,
|
|
1065
|
+
url: `/uploads/${storedName}`,
|
|
1066
|
+
uploaded_by: user.id,
|
|
1067
|
+
}));
|
|
1068
|
+
} catch (err) {
|
|
1069
|
+
apiLog.error(err.message);
|
|
1070
|
+
res.writeHead(500);
|
|
1071
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
859
1075
|
async function readBody(req) {
|
|
860
1076
|
return new Promise((resolve, reject) => {
|
|
861
1077
|
let data = '';
|
|
@@ -63,7 +63,7 @@ function roleLabel(r) {
|
|
|
63
63
|
return KNOWN_ROLE_LABELS[key] || (key.length > 8 ? 'Staff' : (key.charAt(0).toUpperCase() + key.slice(1)))
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
function formatFieldValue(k, v, entityName) {
|
|
66
|
+
function formatFieldValue(k, v, entityName, f) {
|
|
67
67
|
if (entityName === 'user' && k === 'role') return `<span class="pill pill-neutral">${roleLabel(v)}</span>`
|
|
68
68
|
if (entityName === 'user' && k === 'status') {
|
|
69
69
|
const cls = v === 'active' ? 'pill-success' : v === 'deleted' ? 'pill-danger' : 'pill-neutral'
|
|
@@ -71,6 +71,15 @@ function formatFieldValue(k, v, entityName) {
|
|
|
71
71
|
}
|
|
72
72
|
if (entityName === 'user' && k === 'email' && v) { const e = esc(v); return `<a href="mailto:${e}" class="text-primary hover:underline">${e}</a>` }
|
|
73
73
|
if (k === 'photo_url' && v && v.startsWith('http')) return `<img src="${esc(v)}" style="width:2.5rem;height:2.5rem;border-radius:50%;object-fit:cover" alt="avatar" onerror="this.style.display='none'"/>`
|
|
74
|
+
if (f?.type === 'currency' && typeof v === 'number') return esc((f.currency_symbol || '$') + (v / 100).toFixed(2))
|
|
75
|
+
if (f?.type === 'multiselect') {
|
|
76
|
+
const arr = Array.isArray(v) ? v : (typeof v === 'string' && v ? (() => { try { return JSON.parse(v) } catch { return [] } })() : [])
|
|
77
|
+
return arr.length ? arr.map(x => `<span class="pill pill-neutral" style="margin-right:4px">${esc(x)}</span>`).join('') : '-'
|
|
78
|
+
}
|
|
79
|
+
if (f?.type === 'file' || f?.type === 'attachment') {
|
|
80
|
+
const meta = typeof v === 'object' && v ? v : (typeof v === 'string' && v ? (() => { try { return JSON.parse(v) } catch { return null } })() : null)
|
|
81
|
+
return meta?.url ? `<a href="${esc(meta.url)}" class="text-primary hover:underline" target="_blank" rel="noopener">${esc(meta.filename || 'Download')}</a>` : '-'
|
|
82
|
+
}
|
|
74
83
|
return fmtVal(v, k)
|
|
75
84
|
}
|
|
76
85
|
|
|
@@ -85,7 +94,7 @@ export function renderEntityDetail(entityName, item, spec, user) {
|
|
|
85
94
|
const fieldRows = visibleFields.map(([k, f]) =>
|
|
86
95
|
`<div class="detail-row">
|
|
87
96
|
<span class="detail-row-label">${esc(f.label || k)}</span>
|
|
88
|
-
<span class="detail-row-value">${formatFieldValue(k, item[k], entityName)}</span>
|
|
97
|
+
<span class="detail-row-value">${formatFieldValue(k, item[k], entityName, f)}</span>
|
|
89
98
|
</div>`
|
|
90
99
|
).join('')
|
|
91
100
|
|
|
@@ -111,12 +120,13 @@ export function renderEntityDetail(entityName, item, spec, user) {
|
|
|
111
120
|
</div>` : `<div style="margin-bottom:1.5rem"><h1 style="font-size:1.5rem;font-weight:700">${displayNameSafe}</h1></div>`
|
|
112
121
|
|
|
113
122
|
const editBtn = userCanEdit ? `<a href="/${entityName}/${item.id}/edit" class="btn btn-outline btn-sm">Edit</a>` : ''
|
|
123
|
+
const cloneBtn = canCreate(user, entityName) ? `<a href="/${entityName}/new?clone_from=${esc(item.id)}" class="btn btn-outline btn-sm">Clone</a>` : ''
|
|
114
124
|
const delBtn = userCanDelete ? `<button data-action="showDeleteConfirm" class="btn btn-error btn-outline btn-sm">Delete</button>` : ''
|
|
115
125
|
|
|
116
126
|
const content = `
|
|
117
127
|
<div style="display:flex;justify-content:space-between;align-items:flex-start">
|
|
118
128
|
<div style="flex:1">${headerExtra}</div>
|
|
119
|
-
<div style="display:flex;gap:0.5rem;margin-left:1rem">${editBtn}${delBtn}</div>
|
|
129
|
+
<div style="display:flex;gap:0.5rem;margin-left:1rem">${editBtn}${cloneBtn}${delBtn}</div>
|
|
120
130
|
</div>
|
|
121
131
|
<div class="card-clean">
|
|
122
132
|
<div class="card-clean-body"><div class="detail-grid">${fieldRows || '<p style="color:var(--color-text-muted);font-size:0.875rem;grid-column:1/-1">No details available</p>'}</div></div>
|
|
@@ -129,7 +139,7 @@ export function renderEntityDetail(entityName, item, spec, user) {
|
|
|
129
139
|
return page(user, `${label} Detail`, bc, content, [script])
|
|
130
140
|
}
|
|
131
141
|
|
|
132
|
-
export function renderEntityForm(entityName, item, spec, user, isNew = false, refOptions = {}) {
|
|
142
|
+
export function renderEntityForm(entityName, item, spec, user, isNew = false, refOptions = {}, templates = []) {
|
|
133
143
|
const label = spec?.label || entityName
|
|
134
144
|
const fields = spec?.fields || {}
|
|
135
145
|
const lbl = (k, f, req) => `<label class="form-label" for="field-${k}">${esc(f.label||k)}${req ? '<span class="req">*</span>' : ''}</label>`
|
|
@@ -158,6 +168,21 @@ export function renderEntityForm(entityName, item, spec, user, isNew = false, re
|
|
|
158
168
|
const opts = (Array.isArray(f.options) ? f.options : []).map(o => { const ov = typeof o === 'string' ? o : o.value; const ol = typeof o === 'string' ? o : o.label; return `<option value="${esc(ov)}" ${val===ov?'selected':''}>${esc(ol)}</option>` }).join('')
|
|
159
169
|
return `<div class="form-field">${lbl(k,f,f.required)}<select id="field-${k}" name="${k}" class="form-input" ${req}><option value="">Select ${esc(f.label||k)}...</option>${opts}</select></div>`
|
|
160
170
|
}
|
|
171
|
+
if (f.type === 'multiselect' && f.options) {
|
|
172
|
+
const selected = new Set(Array.isArray(val) ? val : (typeof val === 'string' && val ? (() => { try { return JSON.parse(val) } catch { return [] } })() : []))
|
|
173
|
+
const opts = (Array.isArray(f.options) ? f.options : []).map(o => { const ov = typeof o === 'string' ? o : o.value; const ol = typeof o === 'string' ? o : o.label; return `<label style="display:flex;align-items:center;gap:6px;margin:2px 0"><input type="checkbox" name="${k}[]" value="${esc(ov)}" class="checkbox checkbox-primary" ${selected.has(ov)?'checked':''}/><span>${esc(ol)}</span></label>` }).join('')
|
|
174
|
+
return `<div class="form-field full">${lbl(k,f,f.required)}<div id="field-${k}" data-multiselect="${k}">${opts}</div></div>`
|
|
175
|
+
}
|
|
176
|
+
if (f.type === 'currency') {
|
|
177
|
+
const symbol = esc(f.currency_symbol || '$')
|
|
178
|
+
const decimalVal = typeof val === 'number' ? (val / 100).toFixed(2) : val
|
|
179
|
+
return `<div class="form-field">${lbl(k,f,f.required)}<div style="display:flex;align-items:center;gap:6px"><span>${symbol}</span><input type="number" step="0.01" id="field-${k}" name="${k}" value="${esc(decimalVal)}" class="form-input" data-currency="${k}" ${req} placeholder="0.00"/></div></div>`
|
|
180
|
+
}
|
|
181
|
+
if (f.type === 'file' || f.type === 'attachment') {
|
|
182
|
+
const existing = val && typeof val === 'object' ? val : (typeof val === 'string' && val ? (() => { try { return JSON.parse(val) } catch { return null } })() : null)
|
|
183
|
+
const existingNote = existing?.filename ? `<div style="font-size:0.8rem;color:var(--color-text-muted)" data-existing-file="${k}">Current: ${esc(existing.filename)}</div>` : ''
|
|
184
|
+
return `<div class="form-field">${lbl(k,f,f.required)}<input type="file" id="field-${k}" data-attachment="${k}" class="form-input" ${existing ? '' : req}/>${existingNote}<input type="hidden" id="field-${k}-value" name="${k}" value="${existing ? esc(JSON.stringify(existing)) : ''}"/></div>`
|
|
185
|
+
}
|
|
161
186
|
return `<div class="form-field">${lbl(k,f,f.required)}<input type="${type}" id="field-${k}" name="${k}" value="${esc(val)}" class="form-input" ${req} ${placeholder}/></div>`
|
|
162
187
|
}).join('\n')
|
|
163
188
|
|
|
@@ -165,11 +190,17 @@ export function renderEntityForm(entityName, item, spec, user, isNew = false, re
|
|
|
165
190
|
const bc = isNew
|
|
166
191
|
? [{ href: '/', label: 'Dashboard' }, { href: `/${entityName}`, label: spec?.labelPlural || label }, { label: 'Create' }]
|
|
167
192
|
: [{ href: '/', label: 'Dashboard' }, { href: `/${entityName}`, label: spec?.labelPlural || label }, { href: `/${entityName}/${item?.id}`, label: item?.name || item?.title || `#${item?.id}` }, { label: 'Edit' }]
|
|
193
|
+
const templatePicker = isNew && templates.length
|
|
194
|
+
? `<div class="form-field full"><label class="form-label" for="template-picker">Start from template</label>
|
|
195
|
+
<select id="template-picker" onchange="if(this.value)window.location='/${entityName}/new?template='+this.value">
|
|
196
|
+
<option value="">Blank</option>${templates.map(t => `<option value="${esc(t.id)}">${esc(t.name)}</option>`).join('')}
|
|
197
|
+
</select></div>`
|
|
198
|
+
: ''
|
|
168
199
|
const content = `<div class="form-shell"><div style="margin-bottom:24px"><h1 style="font-size:24px;font-weight:700">${isNew ? 'Create' : 'Edit'} ${esc(label)}</h1></div>
|
|
169
|
-
<div class="form-section"><form id="entity-form" class="form-grid" aria-label="${isNew ? 'Create' : 'Edit'} ${esc(label)}">${formFields}${pwField}
|
|
200
|
+
<div class="form-section"><form id="entity-form" class="form-grid" aria-label="${isNew ? 'Create' : 'Edit'} ${esc(label)}">${templatePicker}${formFields}${pwField}
|
|
170
201
|
<div class="form-actions" style="grid-column:1/-1"><button type="submit" id="submit-btn" class="btn-primary-clean"><span class="btn-text">Save</span><span class="btn-loading-text" style="display:none">Saving...</span></button>
|
|
171
202
|
<a href="/${entityName}${isNew ? '' : '/' + item?.id}" class="btn-ghost-clean">Cancel</a></div></form></div></div>`
|
|
172
|
-
const script = `${TOAST_SCRIPT}const form=document.getElementById('entity-form');const sb=document.getElementById('submit-btn');form.addEventListener('submit',async(e)=>{e.preventDefault();sb.classList.add('btn-loading');sb.querySelector('.btn-text').style.display='none';sb.querySelector('.btn-loading-text').style.display='inline';sb.disabled=true;const fd=new FormData(form);const data={};for(const[k,v]of fd.entries())data[k]=v
|
|
203
|
+
const script = `${TOAST_SCRIPT}const form=document.getElementById('entity-form');const sb=document.getElementById('submit-btn');form.addEventListener('submit',async(e)=>{e.preventDefault();sb.classList.add('btn-loading');sb.querySelector('.btn-text').style.display='none';sb.querySelector('.btn-loading-text').style.display='inline';sb.disabled=true;try{const fileInputs=[...form.querySelectorAll('input[type=file][data-attachment]')];for(const fi of fileInputs){const f=fi.files&&fi.files[0];if(!f)continue;const uf=new FormData();uf.append('file',f);const ures=await fetch('/api/upload',{method:'POST',body:uf});const ud=await ures.json();if(!ures.ok)throw new Error(ud.error||'File upload failed');const hidden=document.getElementById('field-'+fi.dataset.attachment+'-value');hidden.value=JSON.stringify(ud)}const fd=new FormData(form);const data={};for(const[k,v]of fd.entries()){if(k.endsWith('[]'))continue;data[k]=v}form.querySelectorAll('input[type=checkbox]:not([name$="[]"])').forEach(cb=>{data[cb.name]=cb.checked});form.querySelectorAll('[data-multiselect]').forEach(ms=>{const name=ms.dataset.multiselect;data[name]=[...ms.querySelectorAll('input[type=checkbox]:checked')].map(cb=>cb.value)});form.querySelectorAll('input[type=number]:not([data-currency])').forEach(inp=>{if(inp.name&&data[inp.name]!==undefined&&data[inp.name]!=='')data[inp.name]=Number(data[inp.name])});form.querySelectorAll('input[data-currency]').forEach(inp=>{const name=inp.dataset.currency;if(inp.value!=='')data[name]=Math.round(Number(inp.value)*100)});const url=${isNew}?'/api/${entityName}':'/api/${entityName}/${item?.id}';const method=${isNew}?'POST':'PUT';const res=await fetch(url,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});const result=await res.json();if(res.ok){showToast('${isNew?'Created':'Updated'} successfully!','success');const ed=result.data||result;setTimeout(()=>{window.location='/${entityName}/'+(ed.id||'${item?.id}')},500)}else{showToast(result.message||result.error||'Save failed','error');sb.classList.remove('btn-loading');sb.querySelector('.btn-text').style.display='inline';sb.querySelector('.btn-loading-text').style.display='none';sb.disabled=false}}catch(err){showToast('Error: '+err.message,'error');sb.classList.remove('btn-loading');sb.querySelector('.btn-text').style.display='inline';sb.querySelector('.btn-loading-text').style.display='none';sb.disabled=false}})`
|
|
173
204
|
return page(user, `${isNew ? 'Create' : 'Edit'} ${label}`, bc, content, [script])
|
|
174
205
|
}
|
|
175
206
|
|
|
@@ -9,6 +9,7 @@ import { renderJobManagement } from '@/ui/job-management-renderer.js';
|
|
|
9
9
|
import { renderWorkflowList, renderWorkflowEditor } from '@/ui/workflow-builder-renderer.js';
|
|
10
10
|
import { renderRolesList, renderTemplateList, renderPermissionMatrix } from '@/ui/rbac-renderer.js';
|
|
11
11
|
import { renderWebhookList, renderWebhookDetail } from '@/ui/webhook-renderer.js';
|
|
12
|
+
import { renderTemplateList as renderEntityTemplateList } from '@/ui/template-renderer.js';
|
|
12
13
|
import { isPartner, isManager } from '@/ui/permissions-ui.js';
|
|
13
14
|
import { getSystemConfig, getSettingsCounts, getAuditData, getSystemHealth, renderBuildLogsContent } from '@/ui/page-handler-helpers.js';
|
|
14
15
|
import { fileURLToPath } from 'url';
|
|
@@ -178,5 +179,17 @@ export async function handleAdminPage(normalized, segments, user) {
|
|
|
178
179
|
try { deliveries = (await list('webhook_delivery', { webhook_id: segments[2] }, { sort: { field: 'created_at', dir: 'DESC' } })).slice(0, 20); } catch {}
|
|
179
180
|
return renderWebhookDetail(user, webhook, deliveries);
|
|
180
181
|
}
|
|
182
|
+
if (normalized === '/admin/templates') {
|
|
183
|
+
const { getConfigEngineSync } = await import('@/lib/config-generator-engine.js');
|
|
184
|
+
const engine = getConfigEngineSync();
|
|
185
|
+
const entityNames = engine.getAllEntities().filter(e => e !== 'entity_template' && e !== 'webhook' && e !== 'webhook_delivery' && e !== 'user_organization');
|
|
186
|
+
let allTemplates = []; try { allTemplates = await list('entity_template', {}); } catch {}
|
|
187
|
+
const templatesByEntity = {};
|
|
188
|
+
for (const t of allTemplates) {
|
|
189
|
+
if (!templatesByEntity[t.entity]) templatesByEntity[t.entity] = [];
|
|
190
|
+
templatesByEntity[t.entity].push(t);
|
|
191
|
+
}
|
|
192
|
+
return renderEntityTemplateList(user, entityNames, templatesByEntity);
|
|
193
|
+
}
|
|
181
194
|
return null;
|
|
182
195
|
}
|
package/src/ui/page-handler.js
CHANGED
|
@@ -72,14 +72,43 @@ async function handleSearch(user, req) {
|
|
|
72
72
|
}
|
|
73
73
|
return renderAdvancedSearch(user, results, { teams, entityNames: allEntityNames });
|
|
74
74
|
}
|
|
75
|
-
|
|
75
|
+
const SYSTEM_FIELDS_TO_STRIP = new Set(['id', 'created_at', 'created_by', 'updated_at', 'status', 'organization_id']);
|
|
76
|
+
|
|
77
|
+
async function handleGenericEntityView(user, entityName, id, req) {
|
|
76
78
|
const spec = getSpec(entityName); if (!spec) return null;
|
|
77
79
|
if (isClientUser(user) && !canClientAccessEntity(user, entityName)) return renderAccessDenied(user, entityName, 'view');
|
|
78
80
|
if (id === 'new') {
|
|
79
81
|
if (!canCreate(user, entityName)) return renderAccessDenied(user, entityName, 'create');
|
|
80
82
|
const resolvedSpec = resolveEnumOptions(spec);
|
|
81
83
|
const { renderEntityForm: lazyEntityForm } = await lazyRenderer('entity-renderer.js');
|
|
82
|
-
|
|
84
|
+
|
|
85
|
+
let prefill = null;
|
|
86
|
+
const params = req ? reqUrl(req).searchParams : null;
|
|
87
|
+
const cloneFromId = params?.get('clone_from');
|
|
88
|
+
const templateId = params?.get('template');
|
|
89
|
+
if (cloneFromId) {
|
|
90
|
+
const source = await get(entityName, cloneFromId, { user });
|
|
91
|
+
if (source) {
|
|
92
|
+
prefill = {};
|
|
93
|
+
for (const [key, value] of Object.entries(source)) {
|
|
94
|
+
if (!SYSTEM_FIELDS_TO_STRIP.has(key)) prefill[key] = value;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
} else if (templateId) {
|
|
98
|
+
const template = await get('entity_template', templateId);
|
|
99
|
+
if (template && template.entity === entityName) {
|
|
100
|
+
try {
|
|
101
|
+
prefill = typeof template.field_values === 'string' ? JSON.parse(template.field_values || '{}') : (template.field_values || {});
|
|
102
|
+
} catch {
|
|
103
|
+
prefill = {};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let templates = [];
|
|
109
|
+
try { templates = await list('entity_template', { entity: entityName }); } catch {}
|
|
110
|
+
|
|
111
|
+
return lazyEntityForm(entityName, prefill, resolvedSpec, user, true, await getRefOptions(resolvedSpec), templates);
|
|
83
112
|
}
|
|
84
113
|
if (!canView(user, entityName)) return renderAccessDenied(user, entityName, 'view');
|
|
85
114
|
const item = await get(entityName, id, { user }); if (!item) return null;
|
|
@@ -264,7 +293,7 @@ export async function handlePage(pathname, req, res) {
|
|
|
264
293
|
const client = await get('client', segments[1], { user }); if (!client) return null;
|
|
265
294
|
return renderClientDashboard(user, client, await getClientDashboardStats(segments[1]));
|
|
266
295
|
}
|
|
267
|
-
if (segments.length === 2) return handleGenericEntityView(user, segments[0], segments[1]);
|
|
296
|
+
if (segments.length === 2) return handleGenericEntityView(user, segments[0], segments[1], req);
|
|
268
297
|
if (segments.length === 3 && segments[2] === 'edit') return handleGenericEntityEdit(user, segments[0], segments[1]);
|
|
269
298
|
|
|
270
299
|
return null;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { page } from '@/ui/layout.js';
|
|
2
|
+
import { esc } from '@/ui/render-helpers.js';
|
|
3
|
+
|
|
4
|
+
export function renderTemplateList(user, entityNames, templatesByEntity) {
|
|
5
|
+
const sections = entityNames.map(entityName => {
|
|
6
|
+
const templates = templatesByEntity[entityName] || [];
|
|
7
|
+
const rows = templates.map(t =>
|
|
8
|
+
`<tr><td>${esc(t.name)}</td><td><button type="button" class="btn-ghost-clean" data-action="deleteTemplate" data-args='["${esc(t.id)}"]'>Delete</button></td></tr>`
|
|
9
|
+
).join('') || '<tr><td colspan="2">No templates</td></tr>';
|
|
10
|
+
return `<div class="card-clean" style="margin-bottom:16px"><div class="card-clean-body">
|
|
11
|
+
<h3 style="margin-bottom:8px">${esc(entityName)}</h3>
|
|
12
|
+
<table class="data-table"><thead><tr><th>Name</th><th></th></tr></thead><tbody>${rows}</tbody></table>
|
|
13
|
+
<div style="margin-top:8px;display:flex;gap:8px">
|
|
14
|
+
<input type="text" id="new-tpl-name-${esc(entityName)}" placeholder="Template name" style="flex:1">
|
|
15
|
+
<button type="button" class="btn-primary-clean" data-action="createTemplate" data-args='["${esc(entityName)}"]'>Add (blank)</button>
|
|
16
|
+
</div>
|
|
17
|
+
</div></div>`;
|
|
18
|
+
}).join('');
|
|
19
|
+
|
|
20
|
+
const content = `<div class="page-header"><h1 class="page-title">Record Templates</h1></div>
|
|
21
|
+
<p style="font-size:13px;color:var(--color-text-muted,#666);margin-bottom:16px">Templates created here start blank; edit field_values via the entity_template record to set starter values.</p>
|
|
22
|
+
${sections}
|
|
23
|
+
<span id="tpl-status" style="font-size:13px"></span>`;
|
|
24
|
+
|
|
25
|
+
const script = `(function(){
|
|
26
|
+
window.createTemplate=function(entity){
|
|
27
|
+
var input=document.getElementById('new-tpl-name-'+entity);
|
|
28
|
+
var name=(input.value||'').trim();
|
|
29
|
+
var status=document.getElementById('tpl-status');
|
|
30
|
+
if(!name){status.textContent='Name required';return}
|
|
31
|
+
status.textContent='Creating...';
|
|
32
|
+
fetch('/api/entity_template/create',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({entity:entity,name:name,field_values:{}})})
|
|
33
|
+
.then(function(r){return r.json().then(function(d){return {ok:r.ok,d:d}})})
|
|
34
|
+
.then(function(res){if(res.ok){location.reload()}else{status.textContent='Error: '+(res.d.error||'create failed')}})
|
|
35
|
+
.catch(function(err){status.textContent='Error: '+err.message});
|
|
36
|
+
};
|
|
37
|
+
window.deleteTemplate=function(id){
|
|
38
|
+
var status=document.getElementById('tpl-status');
|
|
39
|
+
status.textContent='Deleting...';
|
|
40
|
+
fetch('/api/entity_template/'+id+'/delete',{method:'POST'})
|
|
41
|
+
.then(function(r){return r.json().then(function(d){return {ok:r.ok,d:d}})})
|
|
42
|
+
.then(function(res){if(res.ok){location.reload()}else{status.textContent='Error: '+(res.d.error||'delete failed')}})
|
|
43
|
+
.catch(function(err){status.textContent='Error: '+err.message});
|
|
44
|
+
};
|
|
45
|
+
})();`;
|
|
46
|
+
|
|
47
|
+
return page(user, 'Record Templates | Thatcher', [{ href: '/admin/settings', label: 'Settings' }, { label: 'Templates' }], content, [script]);
|
|
48
|
+
}
|