thatcher 1.0.67 → 1.0.68
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
|
@@ -106,10 +106,64 @@ function withWebhookDefaults(masterConfig) {
|
|
|
106
106
|
return changed ? { ...masterConfig, entities } : masterConfig;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
const LEAD_ENTITY_DEFAULT = {
|
|
110
|
+
label: 'Lead',
|
|
111
|
+
label_plural: 'Leads',
|
|
112
|
+
system_entity: true,
|
|
113
|
+
fields: {
|
|
114
|
+
name: { type: 'text', required: true, label: 'Name' },
|
|
115
|
+
company: { type: 'text', label: 'Company' },
|
|
116
|
+
email: { type: 'email', label: 'Email' },
|
|
117
|
+
phone: { type: 'text', label: 'Phone' },
|
|
118
|
+
source: { type: 'text', label: 'Source' },
|
|
119
|
+
status: { type: 'enum', options: ['new', 'contacted', 'qualified', 'disqualified'], default: 'new', label: 'Status' },
|
|
120
|
+
owner_id: { type: 'ref', ref: 'user', label: 'Owner' },
|
|
121
|
+
notes: { type: 'textarea', label: 'Notes' },
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const OPPORTUNITY_PIPELINE_WORKFLOW = {
|
|
126
|
+
state_field: 'stage',
|
|
127
|
+
stages: [
|
|
128
|
+
{ name: 'prospecting', label: 'Prospecting', forward: ['qualification'], backward: [] },
|
|
129
|
+
{ name: 'qualification', label: 'Qualification', forward: ['proposal'], backward: ['prospecting'] },
|
|
130
|
+
{ name: 'proposal', label: 'Proposal', forward: ['negotiation'], backward: ['qualification'] },
|
|
131
|
+
{ name: 'negotiation', label: 'Negotiation', forward: ['won', 'lost'], backward: ['proposal'] },
|
|
132
|
+
{ name: 'won', label: 'Won', forward: [], backward: [] },
|
|
133
|
+
{ name: 'lost', label: 'Lost', forward: [], backward: [] },
|
|
134
|
+
],
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const OPPORTUNITY_ENTITY_DEFAULT = {
|
|
138
|
+
label: 'Opportunity',
|
|
139
|
+
label_plural: 'Opportunities',
|
|
140
|
+
system_entity: true,
|
|
141
|
+
workflow: 'opportunity_pipeline',
|
|
142
|
+
fields: {
|
|
143
|
+
name: { type: 'text', required: true, label: 'Name' },
|
|
144
|
+
lead_id: { type: 'ref', ref: 'lead', label: 'Lead' },
|
|
145
|
+
value: { type: 'currency', label: 'Value' },
|
|
146
|
+
stage: { type: 'enum', options: ['prospecting', 'qualification', 'proposal', 'negotiation', 'won', 'lost'], default: 'prospecting', label: 'Stage' },
|
|
147
|
+
expected_close_date: { type: 'date', label: 'Expected Close Date' },
|
|
148
|
+
owner_id: { type: 'ref', ref: 'user', label: 'Owner' },
|
|
149
|
+
probability: { type: 'number', min: 0, max: 100, label: 'Probability (%)' },
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
function withCrmDefaults(masterConfig) {
|
|
154
|
+
const entities = { ...(masterConfig.entities || {}) };
|
|
155
|
+
const workflows = { ...(masterConfig.workflows || {}) };
|
|
156
|
+
let changed = false;
|
|
157
|
+
if (!entities.lead) { entities.lead = LEAD_ENTITY_DEFAULT; changed = true; }
|
|
158
|
+
if (!entities.opportunity) { entities.opportunity = OPPORTUNITY_ENTITY_DEFAULT; changed = true; }
|
|
159
|
+
if (!workflows.opportunity_pipeline) { workflows.opportunity_pipeline = OPPORTUNITY_PIPELINE_WORKFLOW; changed = true; }
|
|
160
|
+
return changed ? { ...masterConfig, entities, workflows } : masterConfig;
|
|
161
|
+
}
|
|
162
|
+
|
|
109
163
|
export class ConfigGeneratorEngine {
|
|
110
164
|
constructor(masterConfig) {
|
|
111
165
|
if (!masterConfig) throw new Error('[ConfigGeneratorEngine] masterConfig is required');
|
|
112
|
-
this.masterConfig = deepFreeze(withWebhookDefaults(withMultiTenancyDefaults(masterConfig)));
|
|
166
|
+
this.masterConfig = deepFreeze(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig))));
|
|
113
167
|
this.specCache = new LRUCache(100);
|
|
114
168
|
this.debugMode = false;
|
|
115
169
|
this._plugins = new Map();
|
|
@@ -147,7 +147,12 @@ export function getTransitionStatus(record) {
|
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
export async function transition(entityType, entityId, workflowName, toState, user, reason = '') {
|
|
150
|
-
|
|
150
|
+
// get(...,{user}) enforces the same row/org access scoping every other read
|
|
151
|
+
// path applies -- without it, a transition could read and act on a record
|
|
152
|
+
// outside the caller's org/row-access simply because this is a state-machine
|
|
153
|
+
// write rather than a plain field update, the exact bypass class bulk-ops'
|
|
154
|
+
// delete/set_field actions already close via the same call shape.
|
|
155
|
+
const record = await get(entityType, entityId, { user });
|
|
151
156
|
if (!record) throw new AppError('Record not found', 'NOT_FOUND', HTTP.NOT_FOUND);
|
|
152
157
|
|
|
153
158
|
validateTransition(workflowName, record.status || record.stage, toState, user);
|
package/src/server/server.js
CHANGED
|
@@ -127,6 +127,10 @@ export function createServer(options) {
|
|
|
127
127
|
return await handleOAuthGoogleCallback(req, res);
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
if (req.method === 'POST' && id && action === 'transition') {
|
|
131
|
+
return await handleEntityTransition(req, res, entity, id, thatcher, configEngine);
|
|
132
|
+
}
|
|
133
|
+
|
|
130
134
|
if (req.method === 'POST' && id === 'import' && !action) {
|
|
131
135
|
return await handleCsvImport(req, res, entity, thatcher, configEngine);
|
|
132
136
|
}
|
|
@@ -506,6 +510,61 @@ async function handleBulkOperation(req, res, entityName, thatcher, configEngineA
|
|
|
506
510
|
}
|
|
507
511
|
}
|
|
508
512
|
|
|
513
|
+
async function handleEntityTransition(req, res, entityName, id, thatcher, configEngineArg) {
|
|
514
|
+
const user = await resolveRequestUser(req);
|
|
515
|
+
if (!user) {
|
|
516
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
517
|
+
res.end(JSON.stringify({ error: 'Authentication required' }));
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
let configEngine = configEngineArg || thatcher?.configEngine || globalThis.__thatcherConfigEngine;
|
|
522
|
+
if (!configEngine) {
|
|
523
|
+
const { getConfigEngineSync } = await import('../lib/config-generator-engine.js');
|
|
524
|
+
configEngine = getConfigEngineSync();
|
|
525
|
+
}
|
|
526
|
+
let spec;
|
|
527
|
+
try {
|
|
528
|
+
spec = configEngine.generateEntitySpec(entityName);
|
|
529
|
+
} catch {
|
|
530
|
+
res.writeHead(404);
|
|
531
|
+
res.end(JSON.stringify({ error: `Entity "${entityName}" not found` }));
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
let body;
|
|
536
|
+
try {
|
|
537
|
+
body = await readBody(req);
|
|
538
|
+
} catch (e) {
|
|
539
|
+
res.writeHead(400);
|
|
540
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const workflowName = body?.workflow || spec.workflow;
|
|
544
|
+
const toState = body?.toState;
|
|
545
|
+
if (!workflowName || !toState) {
|
|
546
|
+
res.writeHead(400);
|
|
547
|
+
res.end(JSON.stringify({ error: 'workflow and toState required' }));
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
try {
|
|
552
|
+
const { requirePermission } = await import('../lib/auth-middleware.js');
|
|
553
|
+
await requirePermission(user, spec, 'edit');
|
|
554
|
+
const { transition } = await import('../lib/workflow-engine.js');
|
|
555
|
+
// transition() itself now reads via get(...,{user}) -- the same
|
|
556
|
+
// row/org-access-scoped path every other read uses -- so a caller
|
|
557
|
+
// cannot drag-drop a record outside their access into a new stage.
|
|
558
|
+
const updated = await transition(entityName, id, workflowName, toState, user, body?.reason || '');
|
|
559
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
560
|
+
res.end(JSON.stringify({ ok: true, data: updated }));
|
|
561
|
+
} catch (err) {
|
|
562
|
+
apiLog.error(err.message);
|
|
563
|
+
res.writeHead(err.status || 400);
|
|
564
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
509
568
|
async function handleCreateEntityTemplate(req, res, thatcher, configEngineArg) {
|
|
510
569
|
const user = await requireAuthedPartner(req, res);
|
|
511
570
|
if (!user) return;
|