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 +15 -15
- package/package.json +1 -1
- package/src/index.js +3 -0
- package/src/lib/automation-engine.js +113 -0
- package/src/lib/busybase/store.js +9 -5
- package/src/lib/crud-handlers.js +3 -3
- package/src/lib/field-types.js +45 -0
- package/src/server/server.js +42 -8
- package/src/services/permission.service.js +2 -2
- package/src/ui/board-view-renderer.js +76 -0
- package/src/ui/calendar-view-renderer.js +196 -0
- package/src/ui/grid-view-renderer.js +78 -0
- package/src/lib/compression.js +0 -44
- package/src/lib/route-resolver.js +0 -160
- package/src/lib/state-protocol.js +0 -184
- package/src/lib/state-transport-client.js +0 -178
- package/src/lib/state-transport-reconnect.js +0 -126
- package/src/lib/state-transport-server.js +0 -189
- package/src/lib/static-server.js +0 -97
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
|
|
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)`
|
|
346
|
-
- `thatcher.get(entity, id)`
|
|
347
|
-
- `thatcher.create(entity, data, user)`
|
|
348
|
-
- `thatcher.update(entity, id, data, user)`
|
|
349
|
-
- `thatcher.delete(entity, id)`
|
|
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)`
|
|
352
|
+
- `thatcher.search(entity, query, where, opts)` -> Array (uses FTS)
|
|
353
353
|
|
|
354
354
|
### Workflow
|
|
355
|
-
- `thatcher.transition(entityType, entityId, workflowName, toState, user, reason)`
|
|
356
|
-
- `thatcher.getAvailableTransitions(workflowName, currentState, user, record)`
|
|
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)`
|
|
360
|
-
- `thatcher.requirePermission(user, spec, action)`
|
|
359
|
+
- `thatcher.can(user, spec, action)` -> boolean
|
|
360
|
+
- `thatcher.requirePermission(user, spec, action)` -> throws if denied
|
|
361
361
|
|
|
362
362
|
### Config
|
|
363
|
-
- `thatcher.getConfigEngine()`
|
|
364
|
-
- `thatcher.getEntitySpec(entityName)`
|
|
365
|
-
- `thatcher.getAllEntities()`
|
|
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)`
|
|
368
|
+
- `thatcher.withTransaction(cb)` -> Promise<result>
|
|
369
369
|
|
|
370
370
|
## Environment Variables
|
|
371
371
|
|
package/package.json
CHANGED
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) } };
|
package/src/lib/crud-handlers.js
CHANGED
|
@@ -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
|
|
package/src/lib/field-types.js
CHANGED
|
@@ -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) {
|
package/src/server/server.js
CHANGED
|
@@ -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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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' &&
|
|
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]
|
|
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
|
+
}
|