thatcher 1.0.47 → 1.0.49

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/README.md CHANGED
@@ -194,7 +194,7 @@ Each entity derives from config:
194
194
  | Config Key | Purpose |
195
195
  |-----------|---------|
196
196
  | `fields` | Column definitions (type, required, ref, enum, etc.) |
197
- | `permission_template` | Maps roles allowed actions |
197
+ | `permission_template` | Maps roles -> allowed actions |
198
198
  | `workflow` | State machine name for lifecycle |
199
199
  | `row_access` | Scoping: `team`, `assigned`, `client` |
200
200
  | `list.defaultSort` | Default list ordering |
@@ -342,30 +342,30 @@ Use via `@/lib/email-sender` (included).
342
342
  All APIs accessible via thatcher instance:
343
343
 
344
344
  ### CRUD
345
- - `thatcher.list(entity, where, opts)` Array
346
- - `thatcher.get(entity, id)` object
347
- - `thatcher.create(entity, data, user)` object
348
- - `thatcher.update(entity, id, data, user)` object
349
- - `thatcher.delete(entity, id)` void
345
+ - `thatcher.list(entity, where, opts)` -> Array
346
+ - `thatcher.get(entity, id)` -> object
347
+ - `thatcher.create(entity, data, user)` -> object
348
+ - `thatcher.update(entity, id, data, user)` -> object
349
+ - `thatcher.delete(entity, id)` -> void
350
350
 
351
351
  ### Search
352
- - `thatcher.search(entity, query, where, opts)` Array (uses FTS)
352
+ - `thatcher.search(entity, query, where, opts)` -> Array (uses FTS)
353
353
 
354
354
  ### Workflow
355
- - `thatcher.transition(entityType, entityId, workflowName, toState, user, reason)` object
356
- - `thatcher.getAvailableTransitions(workflowName, currentState, user, record)` Array
355
+ - `thatcher.transition(entityType, entityId, workflowName, toState, user, reason)` -> object
356
+ - `thatcher.getAvailableTransitions(workflowName, currentState, user, record)` -> Array
357
357
 
358
358
  ### AuthZ
359
- - `thatcher.can(user, spec, action)` boolean
360
- - `thatcher.requirePermission(user, spec, action)` throws if denied
359
+ - `thatcher.can(user, spec, action)` -> boolean
360
+ - `thatcher.requirePermission(user, spec, action)` -> throws if denied
361
361
 
362
362
  ### Config
363
- - `thatcher.getConfigEngine()` ConfigGeneratorEngine
364
- - `thatcher.getEntitySpec(entityName)` spec object
365
- - `thatcher.getAllEntities()` Array<string>
363
+ - `thatcher.getConfigEngine()` -> ConfigGeneratorEngine
364
+ - `thatcher.getEntitySpec(entityName)` -> spec object
365
+ - `thatcher.getAllEntities()` -> Array<string>
366
366
 
367
367
  ### Direct DB
368
- - `thatcher.withTransaction(cb)` Promise<result>
368
+ - `thatcher.withTransaction(cb)` -> Promise<result>
369
369
 
370
370
  ## Environment Variables
371
371
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thatcher",
3
- "version": "1.0.47",
3
+ "version": "1.0.49",
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",
package/src/index.js CHANGED
@@ -63,6 +63,9 @@ export class Thatcher {
63
63
  // Load plugins
64
64
  await this.loadPlugins();
65
65
 
66
+ const { registerAutomationEngine } = await import(resolveModule('./lib/automation-engine.js'));
67
+ registerAutomationEngine();
68
+
66
69
  // Hot reload
67
70
  if (this.options.server.hotReload) {
68
71
  this.setupHotReload();
@@ -0,0 +1,113 @@
1
+ import { hookEngine } from './hook-engine.js';
2
+ import { update as updateRecord, create as createRecord } from './busybase/store.js';
3
+ import { getConfigEngineSync } from './config-generator-engine.js';
4
+ import { createLogger } from './logger.js';
5
+
6
+ const log = createLogger('[AutomationEngine]');
7
+
8
+ const OPERATORS = {
9
+ eq: (a, b) => a === b,
10
+ neq: (a, b) => a !== b,
11
+ gt: (a, b) => Number(a) > Number(b),
12
+ gte: (a, b) => Number(a) >= Number(b),
13
+ lt: (a, b) => Number(a) < Number(b),
14
+ lte: (a, b) => Number(a) <= Number(b),
15
+ in: (a, b) => Array.isArray(b) && b.includes(a),
16
+ changed: (a, b, prev, field) => prev && prev[field] !== a,
17
+ present: (a) => a !== null && a !== undefined && a !== '',
18
+ };
19
+
20
+ function evalCondition(cond, data, prev) {
21
+ const { field, op = 'eq', value } = cond;
22
+ const actual = data[field];
23
+ const fn = OPERATORS[op];
24
+ if (!fn) { log.error(`unknown automation operator: ${op}`); return false; }
25
+ return fn(actual, value, prev, field);
26
+ }
27
+
28
+ function evalConditions(conditions, data, prev) {
29
+ if (!conditions || !conditions.length) return true;
30
+ return conditions.every(c => evalCondition(c, data, prev));
31
+ }
32
+
33
+ async function runAction(action, context) {
34
+ const { entity, id, data, user } = context;
35
+ switch (action.type) {
36
+ case 'set_field': {
37
+ const patch = { [action.field]: action.value };
38
+ await updateRecord(entity, id, patch, user);
39
+ return;
40
+ }
41
+ case 'create_entity': {
42
+ const payload = { ...(action.data || {}) };
43
+ for (const [k, v] of Object.entries(payload)) {
44
+ if (typeof v === 'string' && v.startsWith('$')) payload[k] = data[v.slice(1)];
45
+ }
46
+ await createRecord(action.entity, payload, user);
47
+ return;
48
+ }
49
+ case 'notify': {
50
+ await hookEngine.execute('automation:notify', { entity, id, data, user, message: action.message, target: action.target });
51
+ return;
52
+ }
53
+ default:
54
+ log.error(`unknown automation action type: ${action.type}`);
55
+ }
56
+ }
57
+
58
+ function normalizeContext(context) {
59
+ const data = context.data || context.record || {};
60
+ const prev = context.before || context.prev || null;
61
+ return { ...context, data, prev, entity: context.entity, id: context.id ?? data.id };
62
+ }
63
+
64
+ async function runRule(rule, rawContext) {
65
+ const context = normalizeContext(rawContext);
66
+ if (!evalConditions(rule.when, context.data, context.prev)) return;
67
+ for (const action of rule.then || []) {
68
+ try {
69
+ await runAction(action, context);
70
+ } catch (error) {
71
+ log.error(`automation rule "${rule.id || rule.name}" action failed:`, { message: error.message });
72
+ }
73
+ }
74
+ }
75
+
76
+ function getRules(entityName, trigger) {
77
+ let config;
78
+ try {
79
+ config = getConfigEngineSync().getConfig();
80
+ } catch {
81
+ return [];
82
+ }
83
+ const rules = config?.automation?.rules || [];
84
+ return rules.filter(r => (!r.entity || r.entity === entityName) && (!r.trigger || r.trigger === trigger));
85
+ }
86
+
87
+ let registered = false;
88
+
89
+ export function registerAutomationEngine() {
90
+ if (registered) return;
91
+ registered = true;
92
+
93
+ const config = getConfigEngineSync().getConfig();
94
+ const entityNames = Object.keys(config?.entities || {});
95
+
96
+ for (const entityName of entityNames) {
97
+ for (const trigger of ['create', 'update', 'delete']) {
98
+ hookEngine.register(`${trigger}:${entityName}:after`, async (context) => {
99
+ for (const rule of getRules(entityName, trigger)) await runRule(rule, context);
100
+ return context;
101
+ });
102
+ }
103
+ hookEngine.register(`transition:${entityName}`, async (context) => {
104
+ for (const rule of getRules(entityName, 'transition')) await runRule(rule, context);
105
+ return context;
106
+ });
107
+ }
108
+ }
109
+
110
+ export function dispatchAutomation(trigger, entity, context) {
111
+ const rules = getRules(entity, trigger);
112
+ return Promise.all(rules.map(rule => runRule(rule, context)));
113
+ }
@@ -203,13 +203,17 @@ export async function count(entity, where = {}, options = {}) {
203
203
  const tbl = tableName(entity);
204
204
  let rows = unwrap(await applyWhere(client().from(tbl).select('*'), where), 'count');
205
205
  rows = applyVisibility(spec, rows, where, options);
206
+ if (options.user && (spec.rowAccess || spec.row_access)) {
207
+ const { permissionService } = await import('../services/permission.service.js');
208
+ rows = permissionService.filterRecords(options.user, spec, rows);
209
+ }
206
210
  return rows.length;
207
211
  }
208
212
 
209
- export async function listWithPagination(entity, where = {}, page = 1, pageSize = 50) {
213
+ export async function listWithPagination(entity, where = {}, page = 1, pageSize = 50, options = {}) {
210
214
  const finalPage = Math.max(1, page);
211
- const total = await count(entity, where);
212
- const items = await list(entity, where, { offset: (finalPage - 1) * pageSize, limit: pageSize });
215
+ const total = await count(entity, where, options);
216
+ const items = await list(entity, where, { ...options, offset: (finalPage - 1) * pageSize, limit: pageSize });
213
217
  return { items, pagination: { page: finalPage, pageSize, total, totalPages: Math.ceil(total / pageSize) } };
214
218
  }
215
219
 
@@ -337,11 +341,11 @@ export async function search(entity, query, where = {}, options = {}) {
337
341
  return matched;
338
342
  }
339
343
 
340
- export async function searchWithPagination(entity, query, where = {}, page = 1, pageSize = null) {
344
+ export async function searchWithPagination(entity, query, where = {}, page = 1, pageSize = null, options = {}) {
341
345
  const spec = specOf(entity);
342
346
  const finalPageSize = pageSize || spec.list?.pageSize || 50;
343
347
  const finalPage = Math.max(1, page);
344
- const all = await search(entity, query, where);
348
+ const all = await search(entity, query, where, options);
345
349
  const total = all.length;
346
350
  const items = all.slice((finalPage - 1) * finalPageSize, finalPage * finalPageSize);
347
351
  return { items, pagination: { page: finalPage, pageSize: finalPageSize, total, totalPages: Math.ceil(total / finalPageSize) } };
@@ -43,7 +43,7 @@ export function createCrudHandlers(entityName, spec) {
43
43
  let items, pagination;
44
44
 
45
45
  if (q) {
46
- const result = await searchWithPagination(entityName, q, {}, finalPage, finalPageSize);
46
+ const result = await searchWithPagination(entityName, q, {}, finalPage, finalPageSize, { user });
47
47
  items = result.items;
48
48
  pagination = result.pagination;
49
49
  } else {
@@ -54,7 +54,7 @@ export function createCrudHandlers(entityName, spec) {
54
54
  coercedFilters[key] = fd ? coerceFieldValue(value, fd.type) : value;
55
55
  }
56
56
  }
57
- const result = await listWithPagination(entityName, coercedFilters, finalPage, finalPageSize);
57
+ const result = await listWithPagination(entityName, coercedFilters, finalPage, finalPageSize, { user });
58
58
  items = result.items;
59
59
  pagination = result.pagination;
60
60
  }
@@ -200,7 +200,7 @@ export function createCrudHandlers(entityName, spec) {
200
200
  id,
201
201
  data: { id, uploaded_files: files },
202
202
  user,
203
- });
203
+ }).catch(e => log.error(e.message));
204
204
  return ok({ id, uploaded_files: files });
205
205
  }
206
206
 
@@ -124,6 +124,51 @@ export const fieldRegistry = {
124
124
  }),
125
125
  image: createSimpleType('TEXT'),
126
126
  id: createSimpleType('TEXT PRIMARY KEY'),
127
+ people: createSimpleType('TEXT', {
128
+ coerce: (val) => (val === undefined || val === '' || val === null ? null : (typeof val === 'string' ? val : JSON.stringify(val))),
129
+ format: (val, field, spec, row) => {
130
+ const ids = Array.isArray(val) ? val : (val ? [val] : []);
131
+ if (!ids.length) return null;
132
+ return ids.map(id => row?.[`${id}_display`] || row?.assignee_names?.[id] || String(id));
133
+ },
134
+ }),
135
+ link: createSimpleType('TEXT', {
136
+ format: (val) => String(val ?? ''),
137
+ isValid: (val) => { if (!val) return true; try { new URL(String(val)); return true; } catch { return false; } },
138
+ }),
139
+ file: createSimpleType('TEXT', {
140
+ coerce: (val) => (val === undefined || val === '' || val === null ? null : (typeof val === 'string' ? val : JSON.stringify(val))),
141
+ format: (val) => {
142
+ if (!val) return null;
143
+ const obj = typeof val === 'string' ? (() => { try { return JSON.parse(val); } catch { return { name: val }; } })() : val;
144
+ return { name: obj.name || obj.filename || 'file', url: obj.url || null };
145
+ },
146
+ }),
147
+ rating: createNumberType('INTEGER', (val) => {
148
+ if (val === undefined || val === '' || val === null) return null;
149
+ const num = parseInt(val, 10);
150
+ if (isNaN(num)) throw new Error(`Invalid rating: ${val}`);
151
+ return num;
152
+ }, (val, field) => ({ value: Number(val) || 0, max: field?.max || 5 })),
153
+ progress: createNumberType('REAL', (val) => {
154
+ if (val === undefined || val === '' || val === null) return null;
155
+ const num = parseFloat(val);
156
+ if (isNaN(num)) throw new Error(`Invalid progress: ${val}`);
157
+ return num;
158
+ }, (val) => Math.max(0, Math.min(100, Number(val) || 0))),
159
+ checkbox: createSimpleType('INTEGER', {
160
+ coerce: (val) => (val === true || val === 'true' || val === 'on' || val === 1 ? 1 : 0),
161
+ format: (val) => !!val,
162
+ }),
163
+ formula: {
164
+ sqlType: 'TEXT',
165
+ coerce: () => undefined,
166
+ format: (val, field, spec, row) => {
167
+ if (typeof field?.compute !== 'function') return null;
168
+ try { return field.compute(row); } catch { return null; }
169
+ },
170
+ isValid: () => true,
171
+ },
127
172
  };
128
173
 
129
174
  export function getFieldHandler(type) {
@@ -3,6 +3,31 @@ import path from 'path';
3
3
  import fs from 'fs';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { createLogger } from '../lib/logger.js';
6
+ import { getLucia } from '../engine.server.js';
7
+ import { requirePermission } from '../lib/auth-middleware.js';
8
+
9
+ // Request-scoped session resolution: NOT engine.server.js's getUser(), which
10
+ // reads a module-level _currentRequest global -- unsafe here since this raw
11
+ // http server handles requests concurrently and that global would let one
12
+ // in-flight request's cookie leak into another's session lookup mid-await.
13
+ // getLucia() itself holds no per-request state, so calling it directly per
14
+ // request and validating against this request's own cookie header is safe.
15
+ async function resolveRequestUser(req) {
16
+ let lucia;
17
+ try { lucia = getLucia(); } catch { return null; }
18
+ const cookieHeader = req.headers?.cookie || '';
19
+ if (!cookieHeader) return null;
20
+ const cookieName = lucia.sessionCookieName || 'thatcher_session';
21
+ const match = cookieHeader.split(';').find(c => c.trim().startsWith(cookieName + '='));
22
+ if (!match) return null;
23
+ const sessionId = decodeURIComponent(match.split('=')[1] || '');
24
+ if (!sessionId) return null;
25
+ try {
26
+ const { user, session } = await lucia.validateSession(sessionId);
27
+ if (!user || !session) return null;
28
+ return user;
29
+ } catch { return null; }
30
+ }
6
31
 
7
32
  const log = createLogger('[Server]');
8
33
  const pageLog = createLogger('[Page]');
@@ -203,11 +228,12 @@ async function sendResponse(res, response) {
203
228
  }
204
229
 
205
230
  async function handleGenericCrud(req, res, entity, id, action, thatcher, configEngineArg) {
206
- // Simple auth: get from cookie or header
207
- let user = null;
208
- // In a real implementation, we'd decode session token
209
- // For now, default to system user for testing
210
- user = { id: 'system', role: 'admin' };
231
+ const user = await resolveRequestUser(req);
232
+ if (!user) {
233
+ res.writeHead(401, { 'Content-Type': 'application/json' });
234
+ res.end(JSON.stringify({ error: 'Authentication required' }));
235
+ return;
236
+ }
211
237
 
212
238
  // Prefer the engine passed down from createServer options (guaranteed the
213
239
  // same instance that was initialized at startup); fall back to thatcher /
@@ -217,15 +243,23 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
217
243
  const { getConfigEngineSync } = await import('../lib/config-generator-engine.js');
218
244
  configEngine = getConfigEngineSync();
219
245
  }
246
+ let spec;
220
247
  try {
221
- configEngine.generateEntitySpec(entity);
248
+ spec = configEngine.generateEntitySpec(entity);
222
249
  } catch (e) {
223
250
  res.writeHead(404);
224
251
  res.end(JSON.stringify({ error: `Entity '${entity}' not found` }));
225
252
  return;
226
253
  }
227
254
 
228
- // Permission check would go here via thatcher.can(user, spec, action)
255
+ const methodAction = { GET: 'list', POST: 'create', PUT: 'edit', PATCH: 'edit', DELETE: 'delete' }[req.method] || 'view';
256
+ try {
257
+ await requirePermission(user, spec, id && req.method === 'GET' ? 'view' : methodAction);
258
+ } catch (e) {
259
+ res.writeHead(e?.status || 403, { 'Content-Type': 'application/json' });
260
+ res.end(JSON.stringify({ error: e?.message || 'Forbidden' }));
261
+ return;
262
+ }
229
263
 
230
264
  let body;
231
265
  try {
@@ -250,7 +284,7 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
250
284
  return;
251
285
  }
252
286
  } else {
253
- result = await thatcher.list(entity);
287
+ result = await thatcher.list(entity, {}, { user });
254
288
  }
255
289
  break;
256
290
 
@@ -47,12 +47,12 @@ class PermissionService {
47
47
  return false;
48
48
  }
49
49
 
50
- if (scope === 'assigned' && record[ownerField] && record[ownerField] !== user.id && user.role !== partnerRole) {
50
+ if (scope === 'assigned' && user.role !== partnerRole && record[ownerField] !== user.id) {
51
51
  return false;
52
52
  }
53
53
 
54
54
  if (scope === 'assigned_or_team' && user.role !== partnerRole) {
55
- const assignedMatch = record[ownerField] && record[ownerField] === user.id;
55
+ const assignedMatch = record[ownerField] === user.id;
56
56
  const teamMatch = record.team_id && user.team_id && record.team_id === user.team_id;
57
57
  if (!assignedMatch && !teamMatch) return false;
58
58
  }
@@ -0,0 +1,76 @@
1
+ import { page } from '@/ui/layout.js';
2
+ import { esc, statusPill, TABLE_SCRIPT } from '@/ui/render-helpers.js';
3
+ import { getStateField, getStageLabels } from '@/lib/workflow-engine.js';
4
+
5
+ function cardTitleField(spec) {
6
+ if (spec.list?.titleField) return spec.list.titleField;
7
+ const candidates = ['name', 'title', 'label'];
8
+ for (const c of candidates) if (spec.fields?.[c]) return c;
9
+ const first = Object.keys(spec.fields || {}).find(k => spec.fields[k]?.type === 'text');
10
+ return first || 'id';
11
+ }
12
+
13
+ function boardCard(entityName, record, titleField) {
14
+ const title = esc(record[titleField] ?? record.id);
15
+ const status = record.status !== undefined ? statusPill(record.status) : '';
16
+ return `<div class="board-card" draggable="true" data-id="${esc(record.id)}" data-navigate="/${esc(entityName)}/${esc(record.id)}">
17
+ <div class="board-card-title">${title}</div>
18
+ ${status ? `<div class="board-card-meta">${status}</div>` : ''}
19
+ </div>`;
20
+ }
21
+
22
+ function boardColumn(entityName, stageKey, stageLabel, records, titleField) {
23
+ const cards = records.map(r => boardCard(entityName, r, titleField)).join('') ||
24
+ `<div class="board-column-empty">No items</div>`;
25
+ return `<div class="board-column" data-stage="${esc(stageKey)}">
26
+ <div class="board-column-header"><span>${esc(stageLabel)}</span><span class="board-column-count">${records.length}</span></div>
27
+ <div class="board-column-body" data-drop-zone="${esc(stageKey)}">${cards}</div>
28
+ </div>`;
29
+ }
30
+
31
+ const BOARD_SCRIPT = `(function(){
32
+ document.addEventListener('dragstart',e=>{const c=e.target.closest('.board-card');if(!c)return;e.dataTransfer.setData('text/plain',c.dataset.id);c.classList.add('dragging')});
33
+ document.addEventListener('dragend',e=>{const c=e.target.closest('.board-card');if(c)c.classList.remove('dragging')});
34
+ document.addEventListener('dragover',e=>{const z=e.target.closest('[data-drop-zone]');if(z)e.preventDefault()});
35
+ document.addEventListener('drop',async e=>{
36
+ const zone=e.target.closest('[data-drop-zone]');if(!zone)return;e.preventDefault();
37
+ const id=e.dataTransfer.getData('text/plain');if(!id)return;
38
+ const toStage=zone.dataset.dropZone;
39
+ const board=document.getElementById('board-root');
40
+ const entity=board.dataset.entity, workflow=board.dataset.workflow;
41
+ try{
42
+ const r=await fetch('/api/'+entity+'/'+id+'/transition',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({workflow,toState:toStage})});
43
+ if(r.ok)location.reload();else{const d=await r.json().catch(()=>({}));showToast(d.error||'Transition failed','error')}
44
+ }catch(err){showToast(err.message,'error')}
45
+ });
46
+ })();`;
47
+
48
+ export function renderBoardView(user, entityName, spec, records, options = {}) {
49
+ const workflowName = options.workflow || spec.workflow;
50
+ if (!workflowName) {
51
+ return page(user, `${spec.labelPlural || spec.label} | Board`, null,
52
+ `<div class="board-empty-state">This entity has no workflow configured; board view requires a <code>workflow</code> key on the entity spec.</div>`);
53
+ }
54
+ const stateField = getStateField(workflowName);
55
+ const stageLabels = getStageLabels(workflowName);
56
+ const titleField = cardTitleField(spec);
57
+
58
+ const grouped = {};
59
+ for (const key of Object.keys(stageLabels)) grouped[key] = [];
60
+ for (const r of records) {
61
+ const stage = r[stateField];
62
+ if (!grouped[stage]) grouped[stage] = [];
63
+ grouped[stage].push(r);
64
+ }
65
+
66
+ const columns = Object.entries(stageLabels)
67
+ .map(([key, label]) => boardColumn(entityName, key, label, grouped[key] || [], titleField))
68
+ .join('');
69
+
70
+ const content = `<div class="page-header">
71
+ <div><h1 class="page-title">${esc(spec.labelPlural || spec.label)}</h1><p class="page-subtitle">${records.length} items</p></div>
72
+ </div>
73
+ <div id="board-root" class="board-view" data-entity="${esc(entityName)}" data-workflow="${esc(workflowName)}">${columns}</div>`;
74
+
75
+ return page(user, `${spec.labelPlural || spec.label} | Board`, null, content, [TABLE_SCRIPT, BOARD_SCRIPT]);
76
+ }