thatcher 1.0.58 → 1.0.59

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thatcher",
3
- "version": "1.0.58",
3
+ "version": "1.0.59",
4
4
  "description": "A config-driven application framework for building data-intensive web apps without code.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -54,6 +54,16 @@ export class ConfigGeneratorEngine {
54
54
  return this.masterConfig?.system?.multi_tenancy?.enabled === true;
55
55
  }
56
56
 
57
+ updateWorkflow(workflowName, updatedDef) {
58
+ if (!workflowName || typeof workflowName !== 'string') {
59
+ throw new Error('[ConfigGeneratorEngine] updateWorkflow: workflowName required');
60
+ }
61
+ const nextWorkflows = { ...(this.masterConfig.workflows || {}), [workflowName]: updatedDef };
62
+ this.masterConfig = deepFreeze({ ...this.masterConfig, workflows: nextWorkflows });
63
+ this.specCache.clear();
64
+ return this;
65
+ }
66
+
57
67
  registerPlugin(entityName, plugin = {}) {
58
68
  if (!entityName || typeof entityName !== 'string') {
59
69
  throw new Error('[ConfigGeneratorEngine] registerPlugin: entityName required');
@@ -30,6 +30,11 @@ const log = createLogger('[WorkflowEngine]');
30
30
  const LOCKOUT_SECONDS = 300; // 5 minutes default
31
31
  const workflowCache = new Map();
32
32
 
33
+ export function clearWorkflowCache(workflowName) {
34
+ if (workflowName) workflowCache.delete(workflowName);
35
+ else workflowCache.clear();
36
+ }
37
+
33
38
  function getWorkflowDef(workflowName) {
34
39
  if (workflowCache.has(workflowName)) {
35
40
  return workflowCache.get(workflowName);
@@ -114,6 +114,10 @@ export function createServer(options) {
114
114
  return await handleListMemberships(req, res, thatcher, configEngine);
115
115
  }
116
116
 
117
+ if (req.method === 'POST' && entity === 'workflow' && id && action === 'update') {
118
+ return await handleUpdateWorkflow(req, res, id, thatcher, configEngine);
119
+ }
120
+
117
121
  // Check if user has custom route for this
118
122
  const userRoutePath = path.join(process.cwd(), 'app/api', ...parts, 'route.js');
119
123
  const routeExists = await fileExists(userRoutePath);
@@ -341,6 +345,85 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
341
345
  }
342
346
  }
343
347
 
348
+ async function handleUpdateWorkflow(req, res, workflowName, thatcher, configEngineArg) {
349
+ const user = await resolveRequestUser(req);
350
+ if (!user) {
351
+ res.writeHead(401, { 'Content-Type': 'application/json' });
352
+ res.end(JSON.stringify({ error: 'Authentication required' }));
353
+ return;
354
+ }
355
+ const { isPartner } = await import('../ui/permissions-ui.js');
356
+ if (!isPartner(user)) {
357
+ res.writeHead(403, { 'Content-Type': 'application/json' });
358
+ res.end(JSON.stringify({ error: 'Forbidden' }));
359
+ return;
360
+ }
361
+
362
+ let configEngine = configEngineArg || thatcher?.configEngine || globalThis.__thatcherConfigEngine;
363
+ if (!configEngine) {
364
+ const { getConfigEngineSync } = await import('../lib/config-generator-engine.js');
365
+ configEngine = getConfigEngineSync();
366
+ }
367
+ const existingDef = configEngine.getConfig().workflows?.[workflowName];
368
+ if (!existingDef) {
369
+ res.writeHead(404);
370
+ res.end(JSON.stringify({ error: `Workflow "${workflowName}" not found` }));
371
+ return;
372
+ }
373
+
374
+ let body;
375
+ try {
376
+ body = await readBody(req);
377
+ } catch (e) {
378
+ res.writeHead(400);
379
+ res.end(JSON.stringify({ error: e.message }));
380
+ return;
381
+ }
382
+ const stages = Array.isArray(body?.stages) ? body.stages : null;
383
+ if (!stages || !stages.length) {
384
+ res.writeHead(400);
385
+ res.end(JSON.stringify({ error: 'stages array required and must be non-empty' }));
386
+ return;
387
+ }
388
+ const seenNames = new Set();
389
+ for (const s of stages) {
390
+ if (!s || typeof s.name !== 'string' || !s.name.trim()) {
391
+ res.writeHead(400);
392
+ res.end(JSON.stringify({ error: 'every stage requires a non-empty name' }));
393
+ return;
394
+ }
395
+ if (seenNames.has(s.name)) {
396
+ res.writeHead(400);
397
+ res.end(JSON.stringify({ error: `duplicate stage name "${s.name}"` }));
398
+ return;
399
+ }
400
+ seenNames.add(s.name);
401
+ }
402
+ const validNames = new Set(stages.map(s => s.name));
403
+ for (const s of stages) {
404
+ for (const target of (s.forward || [])) {
405
+ if (!validNames.has(target)) {
406
+ res.writeHead(400);
407
+ res.end(JSON.stringify({ error: `stage "${s.name}" has a forward transition to unknown stage "${target}"` }));
408
+ return;
409
+ }
410
+ }
411
+ }
412
+
413
+ try {
414
+ const updatedDef = { ...existingDef, stages };
415
+ configEngine.updateWorkflow(workflowName, updatedDef);
416
+ const { clearWorkflowCache } = await import('../lib/workflow-engine.js');
417
+ clearWorkflowCache(workflowName);
418
+ res.writeHead(200, { 'Content-Type': 'application/json' });
419
+ res.end(JSON.stringify({ ok: true, workflow: workflowName, stageCount: stages.length }));
420
+ } catch (err) {
421
+ apiLog.error(err.message);
422
+ res.writeHead(500);
423
+ res.end(JSON.stringify({ error: err.message }));
424
+ }
425
+ }
426
+
344
427
  async function handleListMemberships(req, res, thatcher, configEngineArg) {
345
428
  const user = await resolveRequestUser(req);
346
429
  if (!user) {
@@ -6,6 +6,7 @@ import { renderSettingsTemplates, renderSettingsChecklists, renderSettingsEntity
6
6
  import { renderSettingsNotifications, renderSettingsIntegrations, renderSettingsRecreation, renderSettingsReviewSettings, renderSettingsFileReview, renderSettingsMwrPermissions } from '@/ui/settings/review.js';
7
7
  import { renderChecklistsManagement } from '@/ui/checklist-renderer.js';
8
8
  import { renderJobManagement } from '@/ui/job-management-renderer.js';
9
+ import { renderWorkflowList, renderWorkflowEditor } from '@/ui/workflow-builder-renderer.js';
9
10
  import { isPartner, isManager } from '@/ui/permissions-ui.js';
10
11
  import { getSystemConfig, getSettingsCounts, getAuditData, getSystemHealth, renderBuildLogsContent } from '@/ui/page-handler-helpers.js';
11
12
  import { fileURLToPath } from 'url';
@@ -127,5 +128,19 @@ export async function handleAdminPage(normalized, segments, user) {
127
128
  let logs = []; try { logs = (await list('job_log', {})).slice(0, 20); } catch {}
128
129
  return renderJobManagement(user, jobs, logs);
129
130
  }
131
+ if (normalized === '/admin/workflows') {
132
+ const { getConfigEngineSync } = await import('@/lib/config-generator-engine.js');
133
+ const engine = getConfigEngineSync();
134
+ const config = engine.getConfig();
135
+ return renderWorkflowList(user, Object.keys(config.workflows || {}));
136
+ }
137
+ if (segments.length === 3 && segments[1] === 'workflows') {
138
+ const { getConfigEngineSync } = await import('@/lib/config-generator-engine.js');
139
+ const engine = getConfigEngineSync();
140
+ const config = engine.getConfig();
141
+ const workflowDef = config.workflows?.[segments[2]];
142
+ if (!workflowDef) return null;
143
+ return renderWorkflowEditor(user, segments[2], workflowDef);
144
+ }
130
145
  return null;
131
146
  }
@@ -0,0 +1,109 @@
1
+ import { page } from '@/ui/layout.js';
2
+ import { esc } from '@/ui/render-helpers.js';
3
+
4
+ export function renderWorkflowList(user, workflowNames) {
5
+ const rows = workflowNames.map(name =>
6
+ `<tr data-row data-navigate="/admin/workflows/${esc(name)}" style="cursor:pointer"><td>${esc(name)}</td></tr>`
7
+ ).join('') || '<tr><td>No workflows configured</td></tr>';
8
+ const content = `<div class="page-header"><h1 class="page-title">Workflows</h1></div>
9
+ <div class="table-wrap"><table class="data-table"><thead><tr><th>Name</th></tr></thead><tbody>${rows}</tbody></table></div>`;
10
+ return page(user, 'Workflows | Thatcher', [{ href: '/admin/settings', label: 'Settings' }, { label: 'Workflows' }], content);
11
+ }
12
+
13
+ function stageRow(stage, allStageNames) {
14
+ const forwardOpts = allStageNames.filter(n => n !== stage.name).map(n =>
15
+ `<label style="display:inline-flex;align-items:center;gap:4px;margin-right:10px"><input type="checkbox" name="forward_${esc(stage.name)}" value="${esc(n)}"${(stage.forward || []).includes(n) ? ' checked' : ''}>${esc(n)}</label>`
16
+ ).join('');
17
+ return `<div class="card-clean" style="margin-bottom:12px" data-stage-card="${esc(stage.name)}">
18
+ <div class="card-clean-body">
19
+ <div style="display:flex;gap:12px;align-items:center;margin-bottom:8px">
20
+ <input type="text" name="name_${esc(stage.name)}" value="${esc(stage.name)}" placeholder="stage name" style="width:140px" readonly>
21
+ <input type="text" name="label_${esc(stage.name)}" value="${esc(stage.label || '')}" placeholder="Label" style="flex:1">
22
+ <input type="text" name="role_${esc(stage.name)}" value="${esc((stage.requires_role || []).join(','))}" placeholder="requires_role (comma-separated)" style="width:200px">
23
+ <button type="button" class="btn-ghost-clean" data-action="removeStage" data-args='["${esc(stage.name)}"]'>Remove</button>
24
+ </div>
25
+ <div style="font-size:12px;color:var(--color-text-muted,#666)">Forward transitions:</div>
26
+ <div>${forwardOpts || '<span style="font-size:12px;color:var(--color-text-muted,#666)">(no other stages)</span>'}</div>
27
+ </div>
28
+ </div>`;
29
+ }
30
+
31
+ export function renderWorkflowEditor(user, workflowName, workflowDef) {
32
+ const stages = workflowDef?.stages || [];
33
+ const stageNames = stages.map(s => s.name);
34
+ const stageCards = stages.map(s => stageRow(s, stageNames)).join('');
35
+
36
+ const content = `<div class="page-header"><h1 class="page-title">Workflow: ${esc(workflowName)}</h1></div>
37
+ <form id="workflow-form">
38
+ <div id="stage-list">${stageCards}</div>
39
+ <div style="margin:16px 0;display:flex;gap:8px">
40
+ <input type="text" id="new-stage-name" placeholder="new stage name" style="width:200px">
41
+ <button type="button" class="btn-ghost-clean" data-action="addStage">Add Stage</button>
42
+ </div>
43
+ <button type="button" class="btn-primary-clean" data-action="saveWorkflow" data-args='["${esc(workflowName)}"]'>Save Workflow</button>
44
+ <span id="save-status" style="margin-left:12px;font-size:13px"></span>
45
+ </form>`;
46
+
47
+ const script = `(function(){
48
+ function collectStages(){
49
+ var cards=document.querySelectorAll('[data-stage-card]');
50
+ var stages=[];
51
+ cards.forEach(function(card,idx){
52
+ var origName=card.getAttribute('data-stage-card');
53
+ var nameInput=card.querySelector('input[name^="name_"]');
54
+ var labelInput=card.querySelector('input[name^="label_"]');
55
+ var roleInput=card.querySelector('input[name^="role_"]');
56
+ var checked=Array.prototype.slice.call(card.querySelectorAll('input[type="checkbox"]:checked')).map(function(cb){return cb.value});
57
+ var roles=(roleInput.value||'').split(',').map(function(s){return s.trim()}).filter(Boolean);
58
+ stages.push({name:origName,label:labelInput.value||origName,order:idx,forward:checked,requires_role:roles});
59
+ });
60
+ return stages;
61
+ }
62
+ window.addStage=function(){
63
+ var input=document.getElementById('new-stage-name');
64
+ var name=(input.value||'').trim();
65
+ if(!name)return;
66
+ if(document.querySelector('[data-stage-card="'+name+'"]')){alert('Stage already exists');return}
67
+ var div=document.createElement('div');
68
+ div.className='card-clean';
69
+ div.style.marginBottom='12px';
70
+ div.setAttribute('data-stage-card',name);
71
+ var body=document.createElement('div');
72
+ body.className='card-clean-body';
73
+ var row=document.createElement('div');
74
+ row.style.cssText='display:flex;gap:12px;align-items:center;margin-bottom:8px';
75
+ var nameInput=document.createElement('input');
76
+ nameInput.type='text';nameInput.name='name_'+name;nameInput.value=name;nameInput.readOnly=true;nameInput.style.width='140px';
77
+ var labelInput=document.createElement('input');
78
+ labelInput.type='text';labelInput.name='label_'+name;labelInput.placeholder='Label';labelInput.style.flex='1';
79
+ var roleInput=document.createElement('input');
80
+ roleInput.type='text';roleInput.name='role_'+name;roleInput.placeholder='requires_role (comma-separated)';roleInput.style.width='200px';
81
+ var removeBtn=document.createElement('button');
82
+ removeBtn.type='button';removeBtn.className='btn-ghost-clean';removeBtn.textContent='Remove';
83
+ removeBtn.addEventListener('click',function(){window.removeStage(name)});
84
+ row.appendChild(nameInput);row.appendChild(labelInput);row.appendChild(roleInput);row.appendChild(removeBtn);
85
+ var hint=document.createElement('div');
86
+ hint.style.cssText='font-size:12px;color:var(--color-text-muted,#666)';
87
+ hint.textContent='Forward transitions: (save and reload to link)';
88
+ body.appendChild(row);body.appendChild(hint);
89
+ div.appendChild(body);
90
+ document.getElementById('stage-list').appendChild(div);
91
+ input.value='';
92
+ };
93
+ window.removeStage=function(name){
94
+ var card=document.querySelector('[data-stage-card="'+name+'"]');
95
+ if(card)card.remove();
96
+ };
97
+ window.saveWorkflow=function(workflowName){
98
+ var stages=collectStages();
99
+ var status=document.getElementById('save-status');
100
+ status.textContent='Saving...';
101
+ fetch('/api/workflow/'+workflowName+'/update',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({stages:stages})})
102
+ .then(function(r){return r.json().then(function(d){return {ok:r.ok,d:d}})})
103
+ .then(function(res){if(res.ok){status.textContent='Saved';status.style.color='var(--color-success,#22c55e)';setTimeout(function(){location.reload()},500)}else{status.textContent='Error: '+(res.d.error||'save failed');status.style.color='var(--color-danger,#ef4444)'}})
104
+ .catch(function(err){status.textContent='Error: '+err.message;status.style.color='var(--color-danger,#ef4444)'});
105
+ };
106
+ })();`;
107
+
108
+ return page(user, `Workflow: ${workflowName} | Thatcher`, [{ href: '/admin/settings', label: 'Settings' }, { href: '/admin/workflows', label: 'Workflows' }, { label: workflowName }], content, [script]);
109
+ }