thatcher 1.0.4 → 1.0.6
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 +18 -1
- package/src/app/api/debug/[[...path]]/route.js +217 -0
- package/src/app/api/formula/[[...path]]/route.js +142 -0
- package/src/cli.js +0 -1
- package/src/config/spec-helpers.js +6 -0
- package/src/engine.server.js +2 -2
- package/src/index.js +21 -11
- package/src/lib/auth-middleware.js +4 -2
- package/src/lib/busybase-adapter.js +162 -0
- package/src/lib/config-field-helpers.js +1 -1
- package/src/lib/config-generator-engine.js +24 -0
- package/src/lib/crud-handlers.js +21 -1
- package/src/lib/database-core.js +4 -7
- package/src/lib/database-migrations.js +254 -0
- package/src/lib/date-utils.js +164 -0
- package/src/lib/debug-registry.js +165 -0
- package/src/lib/error-handler.js +78 -0
- package/src/lib/export-sink.js +226 -0
- package/src/lib/field-iterator.js +126 -0
- package/src/lib/hyperformula-service.js +304 -0
- package/src/lib/metrics-collector.js +196 -0
- package/src/lib/observability-bootstrap.js +121 -0
- package/src/lib/perf-profiler.js +238 -0
- package/src/lib/query-engine.js +47 -0
- package/src/lib/query-string-adapter.js +97 -2
- package/src/lib/request-tracing.js +216 -0
- package/src/lib/response-formatter.js +0 -2
- package/src/lib/route-resolver.js +10 -0
- package/src/lib/status-helpers.js +128 -0
- package/src/lib/tracing.js +287 -0
- package/src/lib/utils.js +81 -0
- package/src/lib/validate.js +124 -1
- package/src/lib/xstate-workflow-engine.js +478 -0
- package/src/plugins/index.js +27 -0
- package/src/server/server.js +23 -4
- package/src/services/permission.service.js +6 -2
- package/src/ui/advanced-search-renderer.js +3 -3
- package/src/ui/advanced-widgets.js +4 -4
- package/src/ui/common-handlers.js +4 -2
- package/src/ui/dashboard-renderer.js +4 -4
- package/src/ui/engagement-cards.js +10 -10
- package/src/ui/engagement-grid-renderer.js +11 -8
- package/src/ui/entity-renderer.js +4 -2
- package/src/ui/event-delegation.js +348 -8
- package/src/ui/file-dialogs.js +9 -9
- package/src/ui/format-helpers.js +37 -3
- package/src/ui/highlight-threading-renderer.js +9 -9
- package/src/ui/job-management-renderer.js +6 -6
- package/src/ui/monitoring-dashboard-client.js +17 -3
- package/src/ui/notifications-renderer.js +6 -7
- package/src/ui/page-handler.js +9 -0
- package/src/ui/render-helpers.js +8 -1
- package/src/ui/review-comparison-renderer.js +1 -1
- package/src/ui/review-detail-renderer.js +6 -6
- package/src/ui/review-mwr-renderer.js +13 -10
- package/src/ui/review-widgets.js +5 -5
- package/src/ui/rfi-detail-renderer.js +9 -9
- package/src/ui/settings-renderer-advanced.js +13 -13
- package/src/ui/settings-renderer-teams.js +10 -9
- package/src/ui/settings-renderer.js +25 -24
- package/src/ui/spacing-system.js +8 -4
- package/src/ui/widgets.js +2 -2
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* XState Workflow Engine - State machine-based workflow management for Thatcher
|
|
3
|
+
* Replaces the legacy workflow engine with xstate v5 state machines and actors
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createMachine, createActor, assign, fromPromise, sendParent } from 'xstate';
|
|
7
|
+
import { getConfigEngineSync } from './config-generator-engine.js';
|
|
8
|
+
import { hookEngine } from './hook-engine.js';
|
|
9
|
+
import { AppError } from './error-handler.js';
|
|
10
|
+
import { HTTP } from '../config/constants.js';
|
|
11
|
+
import { createLogger } from './logger.js';
|
|
12
|
+
|
|
13
|
+
const logger = createLogger('[XStateWorkflow]');
|
|
14
|
+
|
|
15
|
+
const _actors = new Map();
|
|
16
|
+
const _transitionHistory = [];
|
|
17
|
+
const MAX_HISTORY = 1000;
|
|
18
|
+
const LOCKOUT_MS = 300000;
|
|
19
|
+
|
|
20
|
+
export class XStateWorkflowEngine {
|
|
21
|
+
constructor() {
|
|
22
|
+
this._machineCache = new Map();
|
|
23
|
+
this._inspector = null;
|
|
24
|
+
this._inspectionEvents = [];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async init() {
|
|
28
|
+
this._setupInspector();
|
|
29
|
+
|
|
30
|
+
if (globalThis.__debug__) {
|
|
31
|
+
globalThis.__debug__.expose('xstate', {
|
|
32
|
+
actors: () => this.getActiveActors(),
|
|
33
|
+
machines: () => this.getCachedMachines(),
|
|
34
|
+
history: () => [..._transitionHistory],
|
|
35
|
+
inspector: () => this._inspector,
|
|
36
|
+
stats: () => this.getStats(),
|
|
37
|
+
}, 'XState Workflow Engine');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
logger.info('XState Workflow Engine initialized');
|
|
41
|
+
return this;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_setupInspector() {
|
|
45
|
+
this._inspector = {
|
|
46
|
+
inspect: (inspectionEvent) => {
|
|
47
|
+
this._inspectionEvents.push({
|
|
48
|
+
type: inspectionEvent.type,
|
|
49
|
+
timestamp: Date.now(),
|
|
50
|
+
actorId: inspectionEvent.actorRef?.id,
|
|
51
|
+
snapshot: inspectionEvent.snapshot?.value,
|
|
52
|
+
event: inspectionEvent.event?.type,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
while (this._inspectionEvents.length > MAX_HISTORY) {
|
|
56
|
+
this._inspectionEvents.shift();
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
compileWorkflow(workflowName) {
|
|
63
|
+
if (this._machineCache.has(workflowName)) {
|
|
64
|
+
return this._machineCache.get(workflowName);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const config = getConfigEngineSync().getConfig();
|
|
68
|
+
const wf = config?.workflows?.[workflowName];
|
|
69
|
+
if (!wf) throw new Error(`Workflow "${workflowName}" not found`);
|
|
70
|
+
|
|
71
|
+
const stages = wf.stages || wf.states || [];
|
|
72
|
+
const stageMap = {};
|
|
73
|
+
for (const stage of stages) {
|
|
74
|
+
stageMap[stage.name] = stage;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const states = {};
|
|
78
|
+
for (const stage of stages) {
|
|
79
|
+
const on = {};
|
|
80
|
+
|
|
81
|
+
const forward = stage.forward || [];
|
|
82
|
+
const backward = stage.backward || [];
|
|
83
|
+
const allTransitions = [...forward, ...backward];
|
|
84
|
+
|
|
85
|
+
for (const target of allTransitions) {
|
|
86
|
+
const targetCfg = stageMap[target];
|
|
87
|
+
const isForward = forward.includes(target);
|
|
88
|
+
|
|
89
|
+
on[`TRANSITION_TO_${target.toUpperCase()}`] = {
|
|
90
|
+
target,
|
|
91
|
+
guard: ({ context }) => {
|
|
92
|
+
if (targetCfg?.requires_role?.length > 0 && context.user?.role) {
|
|
93
|
+
return targetCfg.requires_role.includes(context.user.role);
|
|
94
|
+
}
|
|
95
|
+
if (targetCfg?.entry === 'partner_only' && context.user?.role !== 'partner') {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
if (targetCfg?.readonly) return false;
|
|
99
|
+
if (context.lastTransitionAt) {
|
|
100
|
+
const elapsed = Date.now() - context.lastTransitionAt;
|
|
101
|
+
if (elapsed < LOCKOUT_MS) return false;
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
},
|
|
105
|
+
actions: [
|
|
106
|
+
assign({
|
|
107
|
+
status: () => target,
|
|
108
|
+
previousStatus: ({ context }) => context.status,
|
|
109
|
+
lastTransitionAt: () => Date.now(),
|
|
110
|
+
transitionHistory: ({ context }) => [
|
|
111
|
+
...context.transitionHistory,
|
|
112
|
+
{
|
|
113
|
+
from: context.status,
|
|
114
|
+
to: target,
|
|
115
|
+
at: Date.now(),
|
|
116
|
+
user: context.user?.email || 'system',
|
|
117
|
+
direction: isForward ? 'forward' : 'backward',
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
}),
|
|
121
|
+
({ context }) => {
|
|
122
|
+
hookEngine.execute(`workflow:${workflowName}:transition`, {
|
|
123
|
+
workflow: workflowName,
|
|
124
|
+
entityId: context.entityId,
|
|
125
|
+
from: context.previousStatus,
|
|
126
|
+
to: target,
|
|
127
|
+
user: context.user,
|
|
128
|
+
direction: isForward ? 'forward' : 'backward',
|
|
129
|
+
}).catch(err => logger.error('Transition hook error', { error: err.message }));
|
|
130
|
+
|
|
131
|
+
hookEngine.execute(`transition:${context.entityType}`, {
|
|
132
|
+
entity: context.entityType,
|
|
133
|
+
id: context.entityId,
|
|
134
|
+
from: context.previousStatus,
|
|
135
|
+
to: target,
|
|
136
|
+
user: context.user,
|
|
137
|
+
}).catch(err => logger.error('Entity transition hook error', { error: err.message }));
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
states[stage.name] = {
|
|
144
|
+
on,
|
|
145
|
+
entry: ({ context }) => {
|
|
146
|
+
hookEngine.execute(`workflow:${workflowName}:enter:${stage.name}`, {
|
|
147
|
+
workflow: workflowName,
|
|
148
|
+
entityId: context.entityId,
|
|
149
|
+
stage: stage.name,
|
|
150
|
+
user: context.user,
|
|
151
|
+
}).catch(err => logger.error('Stage entry hook error', { error: err.message }));
|
|
152
|
+
},
|
|
153
|
+
exit: ({ context }) => {
|
|
154
|
+
hookEngine.execute(`workflow:${workflowName}:exit:${stage.name}`, {
|
|
155
|
+
workflow: workflowName,
|
|
156
|
+
entityId: context.entityId,
|
|
157
|
+
stage: stage.name,
|
|
158
|
+
user: context.user,
|
|
159
|
+
}).catch(err => logger.error('Stage exit hook error', { error: err.message }));
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const initialStage = wf.initial || wf.initial_stage || stages[0]?.name;
|
|
165
|
+
|
|
166
|
+
const machine = createMachine({
|
|
167
|
+
id: workflowName,
|
|
168
|
+
initial: initialStage,
|
|
169
|
+
context: {
|
|
170
|
+
entityId: null,
|
|
171
|
+
entityType: null,
|
|
172
|
+
user: null,
|
|
173
|
+
status: initialStage,
|
|
174
|
+
previousStatus: null,
|
|
175
|
+
lastTransitionAt: null,
|
|
176
|
+
transitionHistory: [],
|
|
177
|
+
...wf.context,
|
|
178
|
+
},
|
|
179
|
+
states,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
this._machineCache.set(workflowName, { machine, stageMap, wf });
|
|
183
|
+
return { machine, stageMap, wf };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
createActorForEntity(workflowName, entityId, initialState, user = null, entityType = null) {
|
|
187
|
+
const { machine } = this.compileWorkflow(workflowName);
|
|
188
|
+
|
|
189
|
+
const actor = createActor(machine, {
|
|
190
|
+
input: {
|
|
191
|
+
entityId,
|
|
192
|
+
entityType,
|
|
193
|
+
user,
|
|
194
|
+
status: initialState,
|
|
195
|
+
},
|
|
196
|
+
inspect: this._inspector,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
actor.start();
|
|
200
|
+
|
|
201
|
+
if (initialState) {
|
|
202
|
+
actor.send({ type: 'SNAPSHOT', status: initialState });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const actorKey = `${workflowName}:${entityId}`;
|
|
206
|
+
_actors.set(actorKey, {
|
|
207
|
+
actor,
|
|
208
|
+
workflowName,
|
|
209
|
+
entityId,
|
|
210
|
+
entityType,
|
|
211
|
+
createdAt: Date.now(),
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
logger.info('Actor created', { workflowName, entityId, actorId: actor.id });
|
|
215
|
+
return actor;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
getActor(workflowName, entityId) {
|
|
219
|
+
const key = `${workflowName}:${entityId}`;
|
|
220
|
+
return _actors.get(key)?.actor;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
getActiveActors() {
|
|
224
|
+
return Array.from(_actors.entries()).map(([key, { actor, workflowName, entityId, entityType, createdAt }]) => ({
|
|
225
|
+
key,
|
|
226
|
+
actorId: actor.id,
|
|
227
|
+
workflowName,
|
|
228
|
+
entityId,
|
|
229
|
+
entityType,
|
|
230
|
+
status: actor.getSnapshot().value,
|
|
231
|
+
createdAt,
|
|
232
|
+
isActive: !actor.getSnapshot().done,
|
|
233
|
+
}));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
stopActor(workflowName, entityId) {
|
|
237
|
+
const key = `${workflowName}:${entityId}`;
|
|
238
|
+
const entry = _actors.get(key);
|
|
239
|
+
if (entry) {
|
|
240
|
+
entry.actor.stop();
|
|
241
|
+
_actors.delete(key);
|
|
242
|
+
logger.info('Actor stopped', { workflowName, entityId });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
sendEvent(workflowName, entityId, eventType, eventData = {}) {
|
|
247
|
+
const actor = this.getActor(workflowName, entityId);
|
|
248
|
+
if (!actor) {
|
|
249
|
+
throw new AppError(`No active actor for ${workflowName}:${entityId}`, 'NO_ACTOR', HTTP.NOT_FOUND);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
actor.send({ type: eventType, ...eventData });
|
|
253
|
+
return actor.getSnapshot();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
getSnapshot(workflowName, entityId) {
|
|
257
|
+
const actor = this.getActor(workflowName, entityId);
|
|
258
|
+
if (!actor) return null;
|
|
259
|
+
return actor.getSnapshot();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
getCachedMachines() {
|
|
263
|
+
return Array.from(this._machineCache.entries()).map(([name, { wf, stageMap }]) => ({
|
|
264
|
+
name,
|
|
265
|
+
stages: Object.keys(stageMap),
|
|
266
|
+
initial: wf.initial || wf.initial_stage,
|
|
267
|
+
}));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
getStats() {
|
|
271
|
+
return {
|
|
272
|
+
activeActors: _actors.size,
|
|
273
|
+
cachedMachines: this._machineCache.size,
|
|
274
|
+
inspectionEvents: this._inspectionEvents.length,
|
|
275
|
+
transitionHistory: _transitionHistory.length,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async close() {
|
|
280
|
+
for (const [, { actor }] of _actors.entries()) {
|
|
281
|
+
actor.stop();
|
|
282
|
+
}
|
|
283
|
+
_actors.clear();
|
|
284
|
+
this._machineCache.clear();
|
|
285
|
+
this._inspectionEvents.length = 0;
|
|
286
|
+
_transitionHistory.length = 0;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
let _xstateEngine = null;
|
|
291
|
+
|
|
292
|
+
export async function createXStateWorkflowEngine() {
|
|
293
|
+
if (!_xstateEngine) {
|
|
294
|
+
_xstateEngine = new XStateWorkflowEngine();
|
|
295
|
+
await _xstateEngine.init();
|
|
296
|
+
}
|
|
297
|
+
return _xstateEngine;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function getXStateWorkflowEngine() {
|
|
301
|
+
return _xstateEngine;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function validateTransition(workflowName, fromState, toState, user) {
|
|
305
|
+
const config = getConfigEngineSync().getConfig();
|
|
306
|
+
const wf = config?.workflows?.[workflowName];
|
|
307
|
+
if (!wf) throw new Error(`Workflow "${workflowName}" not found`);
|
|
308
|
+
|
|
309
|
+
const stages = wf.stages || wf.states || [];
|
|
310
|
+
const stageMap = {};
|
|
311
|
+
for (const stage of stages) {
|
|
312
|
+
stageMap[stage.name] = stage;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const fromCfg = stageMap[fromState];
|
|
316
|
+
const toCfg = stageMap[toState];
|
|
317
|
+
|
|
318
|
+
if (!fromCfg) throw new AppError(`Invalid current state: ${fromState}`, 'INVALID_STATE', HTTP.BAD_REQUEST);
|
|
319
|
+
if (!toCfg) throw new AppError(`Invalid target state: ${toState}`, 'INVALID_STATE', HTTP.BAD_REQUEST);
|
|
320
|
+
|
|
321
|
+
const forward = fromCfg.forward || [];
|
|
322
|
+
const backward = fromCfg.backward || [];
|
|
323
|
+
|
|
324
|
+
if (!forward.includes(toState) && !backward.includes(toState)) {
|
|
325
|
+
throw new AppError(
|
|
326
|
+
`Cannot transition from "${fromState}" to "${toState}". Allowed: ${[...forward, ...backward].join(', ') || 'none'}`,
|
|
327
|
+
'TRANSITION_INVALID',
|
|
328
|
+
HTTP.BAD_REQUEST
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const requiresRole = toCfg.requires_role || [];
|
|
333
|
+
if (requiresRole.length > 0 && user && !requiresRole.includes(user.role)) {
|
|
334
|
+
throw new AppError(`Role "${user.role}" cannot enter state "${toState}"`, 'INSUFFICIENT_PERMISSIONS', HTTP.FORBIDDEN);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (toCfg.entry === 'partner_only' && user?.role !== 'partner') {
|
|
338
|
+
throw new AppError(`Only partners can enter "${toState}"`, 'ENTRY_CONSTRAINT', HTTP.FORBIDDEN);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (toCfg.readonly) {
|
|
342
|
+
throw new AppError(`State "${toState}" is read-only`, 'STATE_READONLY', HTTP.FORBIDDEN);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
forward: forward.includes(toState),
|
|
347
|
+
backward: backward.includes(toState),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function getAvailableTransitions(workflowName, currentState, user, record = null) {
|
|
352
|
+
const config = getConfigEngineSync().getConfig();
|
|
353
|
+
const wf = config?.workflows?.[workflowName];
|
|
354
|
+
if (!wf) return [];
|
|
355
|
+
|
|
356
|
+
const stages = wf.stages || wf.states || [];
|
|
357
|
+
const stageMap = {};
|
|
358
|
+
for (const stage of stages) {
|
|
359
|
+
stageMap[stage.name] = stage;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const currentCfg = stageMap[currentState];
|
|
363
|
+
if (!currentCfg) return [];
|
|
364
|
+
|
|
365
|
+
const available = [];
|
|
366
|
+
const candidates = [...(currentCfg.forward || []), ...(currentCfg.backward || [])];
|
|
367
|
+
const currentOrder = currentCfg.order || 0;
|
|
368
|
+
|
|
369
|
+
for (const stateName of candidates) {
|
|
370
|
+
try {
|
|
371
|
+
if (record?.last_transition_at) {
|
|
372
|
+
const elapsed = Date.now() / 1000 - record.last_transition_at;
|
|
373
|
+
if (elapsed < LOCKOUT_MS / 1000) continue;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
validateTransition(workflowName, currentState, stateName, user);
|
|
377
|
+
const cfg = stageMap[stateName];
|
|
378
|
+
available.push({
|
|
379
|
+
stage: stateName,
|
|
380
|
+
label: cfg.label || stateName,
|
|
381
|
+
forward: (cfg.order || 0) > currentOrder,
|
|
382
|
+
backward: (cfg.order || 0) < currentOrder,
|
|
383
|
+
});
|
|
384
|
+
} catch {
|
|
385
|
+
// Skip invalid
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return available;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export async function transition(entityType, entityId, workflowName, toState, user, reason = '') {
|
|
393
|
+
const { get } = await import('./query-engine.js');
|
|
394
|
+
const { update: updateRecord } = await import('./query-engine-write.js');
|
|
395
|
+
|
|
396
|
+
const record = get(entityType, entityId);
|
|
397
|
+
if (!record) throw new AppError('Record not found', 'NOT_FOUND', HTTP.NOT_FOUND);
|
|
398
|
+
|
|
399
|
+
validateTransition(workflowName, record.status || record.stage, toState, user);
|
|
400
|
+
|
|
401
|
+
const updates = {
|
|
402
|
+
status: toState,
|
|
403
|
+
updated_at: Math.floor(Date.now() / 1000),
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
if (reason) {
|
|
407
|
+
updates.transition_reason = reason;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const updated = updateRecord(entityType, entityId, updates, user);
|
|
411
|
+
|
|
412
|
+
_transitionHistory.push({
|
|
413
|
+
entityType,
|
|
414
|
+
entityId,
|
|
415
|
+
workflowName,
|
|
416
|
+
from: record.status,
|
|
417
|
+
to: toState,
|
|
418
|
+
user: user?.email || 'system',
|
|
419
|
+
reason,
|
|
420
|
+
timestamp: Date.now(),
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
while (_transitionHistory.length > MAX_HISTORY) {
|
|
424
|
+
_transitionHistory.shift();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
hookEngine.execute(`transition:${entityType}`, {
|
|
428
|
+
entity: entityType,
|
|
429
|
+
id: entityId,
|
|
430
|
+
from: record.status,
|
|
431
|
+
to: toState,
|
|
432
|
+
user,
|
|
433
|
+
record: updated,
|
|
434
|
+
}).catch(err => logger.error('Transition hook error', { error: err.message }));
|
|
435
|
+
|
|
436
|
+
return updated;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export function getStateField(workflowName) {
|
|
440
|
+
const config = getConfigEngineSync().getConfig();
|
|
441
|
+
const wf = config?.workflows?.[workflowName];
|
|
442
|
+
return wf?.state_field || 'status';
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export function getStageLabels(workflowName) {
|
|
446
|
+
const config = getConfigEngineSync().getConfig();
|
|
447
|
+
const wf = config?.workflows?.[workflowName];
|
|
448
|
+
if (!wf) return {};
|
|
449
|
+
|
|
450
|
+
const stages = wf.stages || wf.states || [];
|
|
451
|
+
const labels = {};
|
|
452
|
+
for (const stage of stages) {
|
|
453
|
+
labels[stage.name] = stage.label || stage.name;
|
|
454
|
+
}
|
|
455
|
+
return labels;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export function getStateLocks(workflowName, state) {
|
|
459
|
+
const config = getConfigEngineSync().getConfig();
|
|
460
|
+
const wf = config?.workflows?.[workflowName];
|
|
461
|
+
if (!wf) return [];
|
|
462
|
+
|
|
463
|
+
const stages = wf.stages || wf.states || [];
|
|
464
|
+
const stage = stages.find(s => s.name === state);
|
|
465
|
+
return stage?.locks || [];
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export function getStateActions(workflowName, state) {
|
|
469
|
+
const config = getConfigEngineSync().getConfig();
|
|
470
|
+
const wf = config?.workflows?.[workflowName];
|
|
471
|
+
if (!wf) return [];
|
|
472
|
+
|
|
473
|
+
const stages = wf.stages || wf.states || [];
|
|
474
|
+
const stage = stages.find(s => s.name === state);
|
|
475
|
+
return stage?.actions || [];
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export default XStateWorkflowEngine;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
|
|
7
|
+
export async function loadPlugins(configEngine) {
|
|
8
|
+
const pluginDir = __dirname;
|
|
9
|
+
let files;
|
|
10
|
+
try { files = fs.readdirSync(pluginDir).filter(f => f.endsWith('.plugin.js')); }
|
|
11
|
+
catch { return []; }
|
|
12
|
+
|
|
13
|
+
const loaded = [];
|
|
14
|
+
for (const file of files) {
|
|
15
|
+
try {
|
|
16
|
+
const mod = await import(`file://${path.join(pluginDir, file)}?t=${Date.now()}`);
|
|
17
|
+
const plugin = mod.default || mod;
|
|
18
|
+
if (!plugin.entityName) { console.warn(`[plugins] ${file} missing entityName, skipped`); continue; }
|
|
19
|
+
configEngine.registerPlugin(plugin.entityName, plugin);
|
|
20
|
+
loaded.push(file);
|
|
21
|
+
} catch (e) {
|
|
22
|
+
console.error(`[plugins] Failed to load ${file}:`, e.message);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (loaded.length) console.log(`[plugins] Loaded: ${loaded.join(', ')}`);
|
|
26
|
+
return loaded;
|
|
27
|
+
}
|
package/src/server/server.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import http from 'http';
|
|
7
|
+
import path from 'path';
|
|
7
8
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
8
9
|
import * as thatcherLib from '../index.js';
|
|
9
10
|
|
|
@@ -17,13 +18,24 @@ export function createServer(options) {
|
|
|
17
18
|
let systemInitialized = false;
|
|
18
19
|
const moduleCache = new Map();
|
|
19
20
|
|
|
20
|
-
// Debug registry
|
|
21
|
+
// Debug registry. expose/get/list MUST exist before any module that calls
|
|
22
|
+
// __debug__.expose() at load time (config-generator-engine, hook-engine,
|
|
23
|
+
// observability) is imported, or every request that loads them crashes.
|
|
24
|
+
const _debugExposed = new Map();
|
|
21
25
|
globalThis.__debug__ = globalThis.__debug__ || {};
|
|
22
26
|
globalThis.__debug__.moduleCache = { get size() { return moduleCache.size; }, entries: () => [...moduleCache.keys()] };
|
|
23
27
|
globalThis.__debug__.activeRequests = { count: 0 };
|
|
28
|
+
globalThis.__debug__.configStats = { specCacheHits: 0, specCacheMisses: 0 };
|
|
24
29
|
globalThis.__debug__.hooks = null;
|
|
25
30
|
globalThis.__debug__.serverStart = SERVER_START;
|
|
26
31
|
globalThis.__debug__.uptime = () => Date.now() - SERVER_START;
|
|
32
|
+
globalThis.__debug__.expose = globalThis.__debug__.expose || function (key, value, description = '') {
|
|
33
|
+
_debugExposed.set(key, { value, description, exposedAt: new Date().toISOString() });
|
|
34
|
+
if (!globalThis[key]) globalThis[key] = value;
|
|
35
|
+
return value;
|
|
36
|
+
};
|
|
37
|
+
globalThis.__debug__.get = globalThis.__debug__.get || function (key) { const e = _debugExposed.get(key); return e ? e.value : undefined; };
|
|
38
|
+
globalThis.__debug__.list = globalThis.__debug__.list || function () { return [..._debugExposed.entries()].map(([key, e]) => ({ key, description: e.description, exposedAt: e.exposedAt, type: typeof e.value })); };
|
|
27
39
|
|
|
28
40
|
// Load module with caching
|
|
29
41
|
const load = (p) => {
|
|
@@ -80,7 +92,7 @@ export function createServer(options) {
|
|
|
80
92
|
}
|
|
81
93
|
|
|
82
94
|
// Fall back to generic CRUD
|
|
83
|
-
return await handleGenericCrud(req, res, entity, id, action, thatcher);
|
|
95
|
+
return await handleGenericCrud(req, res, entity, id, action, thatcher, configEngine);
|
|
84
96
|
}
|
|
85
97
|
|
|
86
98
|
// Static file serving (simplified)
|
|
@@ -199,14 +211,21 @@ async function sendResponse(res, response) {
|
|
|
199
211
|
/**
|
|
200
212
|
* Generic CRUD handler
|
|
201
213
|
*/
|
|
202
|
-
async function handleGenericCrud(req, res, entity, id, action, thatcher) {
|
|
214
|
+
async function handleGenericCrud(req, res, entity, id, action, thatcher, configEngineArg) {
|
|
203
215
|
// Simple auth: get from cookie or header
|
|
204
216
|
let user = null;
|
|
205
217
|
// In a real implementation, we'd decode session token
|
|
206
218
|
// For now, default to system user for testing
|
|
207
219
|
user = { id: 'system', role: 'admin' };
|
|
208
220
|
|
|
209
|
-
|
|
221
|
+
// Prefer the engine passed down from createServer options (guaranteed the
|
|
222
|
+
// same instance that was initialized at startup); fall back to thatcher /
|
|
223
|
+
// the module singleton only if it wasn't threaded through.
|
|
224
|
+
let configEngine = configEngineArg || thatcher?.configEngine || globalThis.__thatcherConfigEngine;
|
|
225
|
+
if (!configEngine) {
|
|
226
|
+
const { getConfigEngineSync } = await import('../lib/config-generator-engine.js');
|
|
227
|
+
configEngine = getConfigEngineSync();
|
|
228
|
+
}
|
|
210
229
|
let spec;
|
|
211
230
|
try {
|
|
212
231
|
spec = configEngine.generateEntitySpec(entity);
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { getCollaboratorRole, checkCollaboratorAccess } from '../services/collaborator-role.service.js';
|
|
7
|
-
import { PermissionError } from '
|
|
8
|
-
import { getConfigEngineSync } from '
|
|
7
|
+
import { PermissionError } from '../lib/error-handler.js';
|
|
8
|
+
import { getConfigEngineSync } from '../lib/config-generator-engine.js';
|
|
9
9
|
|
|
10
10
|
class PermissionService {
|
|
11
11
|
/**
|
|
@@ -179,3 +179,7 @@ class PermissionService {
|
|
|
179
179
|
|
|
180
180
|
export const permissionService = new PermissionService();
|
|
181
181
|
export default permissionService;
|
|
182
|
+
|
|
183
|
+
// Free-function wrappers over the singleton (parity with moonlanding's permission.service API).
|
|
184
|
+
export function can(user, spec, action) { return permissionService.can(user, spec, action); }
|
|
185
|
+
export function check(user, spec, action) { return permissionService.require(user, spec, action); }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { statusLabel } from '@/ui/renderer.js';
|
|
2
2
|
import { page } from '@/ui/layout.js';
|
|
3
|
-
import { fmtDate } from '@/ui/render-helpers.js';
|
|
3
|
+
import { fmtDate, esc } from '@/ui/render-helpers.js';
|
|
4
4
|
|
|
5
5
|
function resultCard(item, entityType) {
|
|
6
6
|
const sts = item.status ? statusLabel(item.status) : '';
|
|
@@ -8,11 +8,11 @@ function resultCard(item, entityType) {
|
|
|
8
8
|
const subtitle = item.client_name || item.engagement_name || item.email || '';
|
|
9
9
|
const date = fmtDate(item.created_at);
|
|
10
10
|
const typeLabel = entityType.charAt(0).toUpperCase() + entityType.slice(1);
|
|
11
|
-
return `<div class="card-clean" style="margin-bottom:8px;cursor:pointer" data-navigate="/${entityType}/${item.id}"><div class="card-clean-body" style="padding:0.75rem"><div class="flex items-start justify-between"><div class="flex-1"><div class="flex items-center gap-2 mb-1"><span class="badge badge-sm bg-gray-100 text-gray-600">${typeLabel}</span>${sts}</div><div class="font-medium">${title}</div>${subtitle ? `<div class="text-xs text-gray-500 mt-0.5">${subtitle}</div>` : ''}</div><div class="text-xs text-gray-400">${date}</div></div></div></div>`;
|
|
11
|
+
return `<div class="card-clean" style="margin-bottom:8px;cursor:pointer" data-navigate="/${entityType}/${item.id}"><div class="card-clean-body" style="padding:0.75rem"><div class="flex items-start justify-between"><div class="flex-1"><div class="flex items-center gap-2 mb-1"><span class="badge badge-sm bg-gray-100 text-gray-600">${typeLabel}</span>${sts}</div><div class="font-medium">${esc(title)}</div>${subtitle ? `<div class="text-xs text-gray-500 mt-0.5">${esc(subtitle)}</div>` : ''}</div><div class="text-xs text-gray-400">${date}</div></div></div></div>`;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
function filterPanel(teams, stages) {
|
|
15
|
-
const teamOpts = teams.map(t => `<option value="${t.id}">${t.name}</option>`).join('');
|
|
15
|
+
const teamOpts = teams.map(t => `<option value="${esc(t.id)}">${esc(t.name)}</option>`).join('');
|
|
16
16
|
const stageOpts = stages.map(s => `<option value="${s}">${s.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}</option>`).join('');
|
|
17
17
|
return `<div class="card-clean" style="margin-bottom:1.5rem"><div class="card-clean-body"><div class="grid grid-cols-1 md:grid-cols-4 gap-3"><div><label class="text-xs font-medium text-gray-600 block mb-1" for="search-query">Search</label><input type="text" id="search-query" class="input input-bordered input-sm w-full" placeholder="Search across all entities..."/></div><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-entity">Entity Type</label><select id="filter-entity" class="select select-bordered select-sm w-full"><option value="">All Types</option><option value="engagement">Engagements</option><option value="client">Clients</option><option value="rfi">RFIs</option><option value="review">Reviews</option><option value="user">Users</option></select></div><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-status">Status</label><select id="filter-status" class="select select-bordered select-sm w-full"><option value="">All Statuses</option><option value="active">Active</option><option value="pending">Pending</option><option value="completed">Completed</option><option value="archived">Archived</option></select></div><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-stage">Stage</label><select id="filter-stage" class="select select-bordered select-sm w-full"><option value="">All Stages</option>${stageOpts}</select></div></div><div class="grid grid-cols-1 md:grid-cols-4 gap-3 mt-3"><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-team">Team</label><select id="filter-team" class="select select-bordered select-sm w-full"><option value="">All Teams</option>${teamOpts}</select></div><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-from">Date From</label><input type="date" id="filter-from" class="input input-bordered input-sm w-full"/></div><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-to">Date To</label><input type="date" id="filter-to" class="input input-bordered input-sm w-full"/></div><div class="flex items-end"><button class="btn btn-primary btn-sm w-full" data-action="doSearch">Search</button></div></div></div></div>`;
|
|
18
18
|
}
|
|
@@ -12,7 +12,7 @@ export function dataGridAdvanced(config) {
|
|
|
12
12
|
const expandCol = expandable ? '<th class="dg-expand-col"></th>' : ''
|
|
13
13
|
const renderRow = (item, rowId, groupId) => {
|
|
14
14
|
const memberAttr = groupId ? ` data-dg-member="${groupId}"` : ''
|
|
15
|
-
const expandBtn = expandable ? `<td class="dg-expand-col"><button class="dg-expand-btn" data-dg-row="${rowId}" data-action="dgToggleDetail" data-args='["${gridId}"]' data-self aria-label="Expand row details" aria-expanded="false"
|
|
15
|
+
const expandBtn = expandable ? `<td class="dg-expand-col"><button class="dg-expand-btn" data-dg-row="${rowId}" data-action="dgToggleDetail" data-args='["${gridId}"]' data-self aria-label="Expand row details" aria-expanded="false"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg></button></td>` : ''
|
|
16
16
|
const cells = columns.map(col => `<td>${item[col.field] ?? '-'}</td>`).join('')
|
|
17
17
|
const detailRow = expandable ? `<tr class="dg-detail-row"${memberAttr} data-dg-detail="${rowId}" style="display:none"><td colspan="${columns.length + 1}"><div class="dg-detail-panel">${detailRenderer ? detailRenderer(item) : ''}</div></td></tr>` : ''
|
|
18
18
|
return `<tr class="dg-data-row"${memberAttr} data-dg-values='${JSON.stringify(columns.map(c => String(item[c.field] ?? '')))}'>${expandBtn}${cells}</tr>${detailRow}`
|
|
@@ -24,7 +24,7 @@ export function dataGridAdvanced(config) {
|
|
|
24
24
|
bodyHtml = Object.entries(groups).map(([key, items]) => {
|
|
25
25
|
const gid = `${gridId}-g-${key.replace(/\W/g, '_')}`
|
|
26
26
|
const rows = items.map((item, i) => renderRow(item, `${gid}-r${i}`, gid)).join('')
|
|
27
|
-
return `<tr class="dg-group-header" data-dg-group="${gid}" tabindex="0" data-action="dgToggleGroup" data-args='["${gid}"]' onkeydown="if(event.key==='Enter')dgToggleGroup('${gid}')"><td colspan="${columns.length + (expandable ? 1 : 0)}"><span class="dg-group-arrow"
|
|
27
|
+
return `<tr class="dg-group-header" data-dg-group="${gid}" tabindex="0" data-action="dgToggleGroup" data-args='["${gid}"]' onkeydown="if(event.key==='Enter')dgToggleGroup('${gid}')"><td colspan="${columns.length + (expandable ? 1 : 0)}"><span class="dg-group-arrow"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg></span> <strong>${key}</strong> <span class="dg-group-count">(${items.length})</span></td></tr>${rows}`
|
|
28
28
|
}).join('')
|
|
29
29
|
} else {
|
|
30
30
|
bodyHtml = data.map((item, i) => renderRow(item, `${gridId}-r${i}`, null)).join('')
|
|
@@ -40,8 +40,8 @@ export function collapsibleSidebar(sections, currentPath) {
|
|
|
40
40
|
const active = currentPath === item.href
|
|
41
41
|
return `<a href="${item.href}" class="sidebar-link${active ? ' sidebar-link-active' : ''}">${item.label}</a>`
|
|
42
42
|
}).join('')
|
|
43
|
-
return `<div class="sidebar-section"><div class="sidebar-section-header" data-action="sidebarToggleSection" data-self>${section.title}<span class="sidebar-section-arrow"
|
|
43
|
+
return `<div class="sidebar-section"><div class="sidebar-section-header" data-action="sidebarToggleSection" data-self>${section.title}<span class="sidebar-section-arrow"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg></span></div><div class="sidebar-section-body">${links}</div></div>`
|
|
44
44
|
}).join('')
|
|
45
45
|
const script = `(function(){var sb=document.getElementById('${sidebarId}');var saved=localStorage.getItem('sidebar-width');if(saved)sb.style.width=saved+'px';var collapsed=localStorage.getItem('sidebar-collapsed')==='true';if(collapsed)sb.classList.add('sidebar-collapsed');window.sidebarToggleSection=function(el){el.parentElement.classList.toggle('sidebar-section-closed')};window.sidebarToggleCollapse=function(){sb.classList.toggle('sidebar-collapsed');localStorage.setItem('sidebar-collapsed',sb.classList.contains('sidebar-collapsed'))};var handle=sb.querySelector('.sidebar-resize-handle');var startX,startW;handle.addEventListener('mousedown',function(e){startX=e.clientX;startW=sb.offsetWidth;function onMove(ev){var w=startW+(ev.clientX-startX);if(w>48&&w<400){sb.style.width=w+'px';localStorage.setItem('sidebar-width',w)}}function onUp(){document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp)}document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp)})})();`
|
|
46
|
-
return `<aside id="${sidebarId}" class="sidebar-collapsible"><div class="sidebar-inner"><button class="sidebar-collapse-btn" data-action="sidebarToggleCollapse" title="Toggle sidebar"
|
|
46
|
+
return `<aside id="${sidebarId}" class="sidebar-collapsible"><div class="sidebar-inner"><button class="sidebar-collapse-btn" data-action="sidebarToggleCollapse" title="Toggle sidebar"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/></svg></button>${sectionHtml}</div><div class="sidebar-resize-handle"></div></aside><script>${script}</script>`
|
|
47
47
|
}
|
|
@@ -147,8 +147,10 @@ export const commonHandlers = {
|
|
|
147
147
|
timer = setTimeout(() => fn.apply(this, args), delay);
|
|
148
148
|
};
|
|
149
149
|
},
|
|
150
|
-
|
|
151
|
-
|
|
150
|
+
// Delegate to the styled, focus-managed gm* dialogs (native confirm/prompt are
|
|
151
|
+
// banned — blocking, unstyled, outside dialogFocus). Both return Promises.
|
|
152
|
+
confirm(message) { return window.gmConfirm({ title: 'Please confirm', message }); },
|
|
153
|
+
prompt(message, defaultValue = '') { return window.gmPrompt({ title: message, label: message, value: defaultValue }); },
|
|
152
154
|
wait(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
|
|
153
155
|
}
|
|
154
156
|
};
|