thatcher 1.0.62 → 1.0.63
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
CHANGED
|
@@ -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/server/server.js
CHANGED
|
@@ -138,6 +138,14 @@ export function createServer(options) {
|
|
|
138
138
|
return await handleBulkOperation(req, res, entity, thatcher, configEngine);
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
if (req.method === 'POST' && entity === 'entity_template' && id === 'create' && !action) {
|
|
142
|
+
return await handleCreateEntityTemplate(req, res, thatcher, configEngine);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (req.method === 'POST' && entity === 'entity_template' && id && action === 'delete') {
|
|
146
|
+
return await handleDeleteEntityTemplate(req, res, id, thatcher, configEngine);
|
|
147
|
+
}
|
|
148
|
+
|
|
141
149
|
// Check if user has custom route for this
|
|
142
150
|
const userRoutePath = path.join(process.cwd(), 'app/api', ...parts, 'route.js');
|
|
143
151
|
const routeExists = await fileExists(userRoutePath);
|
|
@@ -438,6 +446,77 @@ async function handleBulkOperation(req, res, entityName, thatcher, configEngineA
|
|
|
438
446
|
}
|
|
439
447
|
}
|
|
440
448
|
|
|
449
|
+
async function handleCreateEntityTemplate(req, res, thatcher, configEngineArg) {
|
|
450
|
+
const user = await requireAuthedPartner(req, res);
|
|
451
|
+
if (!user) return;
|
|
452
|
+
|
|
453
|
+
let configEngine = configEngineArg || thatcher?.configEngine || globalThis.__thatcherConfigEngine;
|
|
454
|
+
if (!configEngine) {
|
|
455
|
+
const { getConfigEngineSync } = await import('../lib/config-generator-engine.js');
|
|
456
|
+
configEngine = getConfigEngineSync();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
let body;
|
|
460
|
+
try {
|
|
461
|
+
body = await readBody(req);
|
|
462
|
+
} catch (e) {
|
|
463
|
+
res.writeHead(400);
|
|
464
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const { entity, name, field_values } = body || {};
|
|
468
|
+
if (!entity || typeof entity !== 'string') {
|
|
469
|
+
res.writeHead(400);
|
|
470
|
+
res.end(JSON.stringify({ error: 'entity required' }));
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
try {
|
|
474
|
+
configEngine.generateEntitySpec(entity);
|
|
475
|
+
} catch {
|
|
476
|
+
res.writeHead(400);
|
|
477
|
+
res.end(JSON.stringify({ error: `Unknown entity "${entity}"` }));
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (!name || typeof name !== 'string' || !name.trim()) {
|
|
481
|
+
res.writeHead(400);
|
|
482
|
+
res.end(JSON.stringify({ error: 'name required' }));
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
try {
|
|
487
|
+
const { create } = await import('../lib/busybase/store.js');
|
|
488
|
+
const record = await create('entity_template', { entity, name: name.trim(), field_values: field_values || {} }, user);
|
|
489
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
490
|
+
res.end(JSON.stringify({ ok: true, id: record.id }));
|
|
491
|
+
} catch (err) {
|
|
492
|
+
apiLog.error(err.message);
|
|
493
|
+
res.writeHead(500);
|
|
494
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function handleDeleteEntityTemplate(req, res, templateId, thatcher, configEngineArg) {
|
|
499
|
+
const user = await requireAuthedPartner(req, res);
|
|
500
|
+
if (!user) return;
|
|
501
|
+
|
|
502
|
+
try {
|
|
503
|
+
const { remove, get } = await import('../lib/busybase/store.js');
|
|
504
|
+
const existing = await get('entity_template', templateId);
|
|
505
|
+
if (!existing) {
|
|
506
|
+
res.writeHead(404);
|
|
507
|
+
res.end(JSON.stringify({ error: 'Template not found' }));
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
await remove('entity_template', templateId);
|
|
511
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
512
|
+
res.end(JSON.stringify({ ok: true }));
|
|
513
|
+
} catch (err) {
|
|
514
|
+
apiLog.error(err.message);
|
|
515
|
+
res.writeHead(500);
|
|
516
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
441
520
|
async function handleCreateWebhook(req, res, thatcher, configEngineArg) {
|
|
442
521
|
const user = await requireAuthedPartner(req, res);
|
|
443
522
|
if (!user) return;
|
|
@@ -111,12 +111,13 @@ export function renderEntityDetail(entityName, item, spec, user) {
|
|
|
111
111
|
</div>` : `<div style="margin-bottom:1.5rem"><h1 style="font-size:1.5rem;font-weight:700">${displayNameSafe}</h1></div>`
|
|
112
112
|
|
|
113
113
|
const editBtn = userCanEdit ? `<a href="/${entityName}/${item.id}/edit" class="btn btn-outline btn-sm">Edit</a>` : ''
|
|
114
|
+
const cloneBtn = canCreate(user, entityName) ? `<a href="/${entityName}/new?clone_from=${esc(item.id)}" class="btn btn-outline btn-sm">Clone</a>` : ''
|
|
114
115
|
const delBtn = userCanDelete ? `<button data-action="showDeleteConfirm" class="btn btn-error btn-outline btn-sm">Delete</button>` : ''
|
|
115
116
|
|
|
116
117
|
const content = `
|
|
117
118
|
<div style="display:flex;justify-content:space-between;align-items:flex-start">
|
|
118
119
|
<div style="flex:1">${headerExtra}</div>
|
|
119
|
-
<div style="display:flex;gap:0.5rem;margin-left:1rem">${editBtn}${delBtn}</div>
|
|
120
|
+
<div style="display:flex;gap:0.5rem;margin-left:1rem">${editBtn}${cloneBtn}${delBtn}</div>
|
|
120
121
|
</div>
|
|
121
122
|
<div class="card-clean">
|
|
122
123
|
<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 +130,7 @@ export function renderEntityDetail(entityName, item, spec, user) {
|
|
|
129
130
|
return page(user, `${label} Detail`, bc, content, [script])
|
|
130
131
|
}
|
|
131
132
|
|
|
132
|
-
export function renderEntityForm(entityName, item, spec, user, isNew = false, refOptions = {}) {
|
|
133
|
+
export function renderEntityForm(entityName, item, spec, user, isNew = false, refOptions = {}, templates = []) {
|
|
133
134
|
const label = spec?.label || entityName
|
|
134
135
|
const fields = spec?.fields || {}
|
|
135
136
|
const lbl = (k, f, req) => `<label class="form-label" for="field-${k}">${esc(f.label||k)}${req ? '<span class="req">*</span>' : ''}</label>`
|
|
@@ -165,8 +166,14 @@ export function renderEntityForm(entityName, item, spec, user, isNew = false, re
|
|
|
165
166
|
const bc = isNew
|
|
166
167
|
? [{ href: '/', label: 'Dashboard' }, { href: `/${entityName}`, label: spec?.labelPlural || label }, { label: 'Create' }]
|
|
167
168
|
: [{ href: '/', label: 'Dashboard' }, { href: `/${entityName}`, label: spec?.labelPlural || label }, { href: `/${entityName}/${item?.id}`, label: item?.name || item?.title || `#${item?.id}` }, { label: 'Edit' }]
|
|
169
|
+
const templatePicker = isNew && templates.length
|
|
170
|
+
? `<div class="form-field full"><label class="form-label" for="template-picker">Start from template</label>
|
|
171
|
+
<select id="template-picker" onchange="if(this.value)window.location='/${entityName}/new?template='+this.value">
|
|
172
|
+
<option value="">Blank</option>${templates.map(t => `<option value="${esc(t.id)}">${esc(t.name)}</option>`).join('')}
|
|
173
|
+
</select></div>`
|
|
174
|
+
: ''
|
|
168
175
|
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}
|
|
176
|
+
<div class="form-section"><form id="entity-form" class="form-grid" aria-label="${isNew ? 'Create' : 'Edit'} ${esc(label)}">${templatePicker}${formFields}${pwField}
|
|
170
177
|
<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
178
|
<a href="/${entityName}${isNew ? '' : '/' + item?.id}" class="btn-ghost-clean">Cancel</a></div></form></div></div>`
|
|
172
179
|
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;form.querySelectorAll('input[type=checkbox]').forEach(cb=>{data[cb.name]=cb.checked});form.querySelectorAll('input[type=number]').forEach(inp=>{if(inp.name&&data[inp.name]!==undefined&&data[inp.name]!=='')data[inp.name]=Number(data[inp.name])});const url=${isNew}?'/api/${entityName}':'/api/${entityName}/${item?.id}';const method=${isNew}?'POST':'PUT';try{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}})`
|
|
@@ -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
|
+
}
|