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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thatcher",
3
- "version": "1.0.67",
3
+ "version": "1.0.68",
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",
@@ -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
- const record = await get(entityType, entityId);
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);
@@ -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;