thatcher 1.0.89 → 1.0.91

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.89",
3
+ "version": "1.0.91",
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",
@@ -277,6 +277,37 @@ function withResourceManagementDefaults(masterConfig) {
277
277
  return changed ? { ...masterConfig, entities } : masterConfig;
278
278
  }
279
279
 
280
+ // Generic outbound CRM-sync connector config, not a bespoke Salesforce/HubSpot
281
+ // integration -- provider/base_url/entity_mappings make it configurable to
282
+ // any downstream system's sync endpoint. organization_id is an EXPLICIT
283
+ // manually-defined field despite system_entity:true (same precedent as
284
+ // USER_ORGANIZATION_ENTITY_DEFAULT above) because integration credentials
285
+ // leaking cross-org via the system_entity multi-tenancy exemption would be a
286
+ // real vulnerability, not the usual global-by-design system entity case.
287
+ const INTEGRATION_CONNECTION_ENTITY_DEFAULT = {
288
+ label: 'Integration Connection',
289
+ label_plural: 'Integration Connections',
290
+ system_entity: true,
291
+ fields: {
292
+ organization_id: { type: 'ref', ref: 'organization', label: 'Organization' },
293
+ provider: { type: 'text', required: true, label: 'Provider' },
294
+ base_url: { type: 'text', required: true, label: 'Base URL' },
295
+ auth_type: { type: 'enum', required: true, label: 'Auth Type', options: [{ value: 'api_key', label: 'API Key' }, { value: 'oauth_token', label: 'OAuth Token' }] },
296
+ credential: { type: 'text', required: true, label: 'Credential', encrypted: true },
297
+ entity_mappings: { type: 'json', label: 'Entity Mappings' },
298
+ sync_direction: { type: 'enum', required: true, default: 'outbound', label: 'Sync Direction', options: [{ value: 'outbound', label: 'Outbound' }, { value: 'inbound', label: 'Inbound' }, { value: 'bidirectional', label: 'Bidirectional' }] },
299
+ enabled: { type: 'bool', default: true, label: 'Enabled' },
300
+ last_synced_at: { type: 'int', readonly: true, label: 'Last Synced At' },
301
+ },
302
+ };
303
+
304
+ function withIntegrationDefaults(masterConfig) {
305
+ const entities = { ...(masterConfig.entities || {}) };
306
+ let changed = false;
307
+ if (!entities.integration_connection) { entities.integration_connection = INTEGRATION_CONNECTION_ENTITY_DEFAULT; changed = true; }
308
+ return changed ? { ...masterConfig, entities } : masterConfig;
309
+ }
310
+
280
311
  const CUSTOM_ENTITY_DEF_ENTITY_DEFAULT = {
281
312
  label: 'Custom Entity Definition',
282
313
  label_plural: 'Custom Entity Definitions',
@@ -398,7 +429,7 @@ function withContractDefaults(masterConfig) {
398
429
  export class ConfigGeneratorEngine {
399
430
  constructor(masterConfig) {
400
431
  if (!masterConfig) throw new Error('[ConfigGeneratorEngine] masterConfig is required');
401
- this.masterConfig = deepFreeze(withCustomEntityDefaults(withResourceManagementDefaults(withContractDefaults(withTimeTrackingDefaults(withInventoryDefaults(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig))))))))));
432
+ this.masterConfig = deepFreeze(withCustomEntityDefaults(withIntegrationDefaults(withResourceManagementDefaults(withContractDefaults(withTimeTrackingDefaults(withInventoryDefaults(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig)))))))))));
402
433
  this.specCache = new LRUCache(100);
403
434
  this.debugMode = false;
404
435
  this._plugins = new Map();
@@ -0,0 +1,82 @@
1
+ import { list } from './busybase/store.js';
2
+ import { validateWebhookUrl } from './webhook-engine.js';
3
+ import { createLogger } from './logger.js';
4
+
5
+ const log = createLogger('[IntegrationSync]');
6
+ const REQUEST_TIMEOUT_MS = 15000;
7
+
8
+ // Records changed since the connection's last sync, read through the SAME
9
+ // {user}-scoped list() every interactive read path uses -- a sync job cannot
10
+ // reach records outside its owner's org just because it runs unattended.
11
+ export async function getRecordsChangedSince(entityName, sinceTs, user) {
12
+ const records = await list(entityName, {}, { user });
13
+ return records.filter(r => (r.updated_at || 0) > (sinceTs || 0));
14
+ }
15
+
16
+ function authHeader(connection) {
17
+ if (connection.auth_type === 'oauth_token') return { Authorization: `Bearer ${connection.credential}` };
18
+ return { 'X-Api-Key': connection.credential };
19
+ }
20
+
21
+ function mapRecordFields(record, mapping) {
22
+ if (!mapping || typeof mapping !== 'object') return record;
23
+ const mapped = {};
24
+ for (const [localField, remoteField] of Object.entries(mapping)) {
25
+ mapped[remoteField] = record[localField];
26
+ }
27
+ return mapped;
28
+ }
29
+
30
+ // connection.credential arrives here already decrypted (store.js decrypts on
31
+ // every list()/get()) -- this function only reads it into the auth header,
32
+ // never logs or persists it further.
33
+ export async function syncRecordToIntegration(connection, entityName, record) {
34
+ const mappings = typeof connection.entity_mappings === 'string'
35
+ ? JSON.parse(connection.entity_mappings || '{}')
36
+ : (connection.entity_mappings || {});
37
+ const entityMapping = mappings[entityName];
38
+ if (!entityMapping) return { success: false, error: `No entity_mapping for "${entityName}"` };
39
+
40
+ const targetUrl = `${connection.base_url.replace(/\/$/, '')}/api/sync/${entityMapping}`;
41
+ // Same SSRF guard webhook-engine.js's dispatchWebhooks applies before any
42
+ // outbound fetch -- reused verbatim, not reimplemented, so a connection
43
+ // pointed at a private/loopback/link-local target is rejected here, before
44
+ // any network call is made.
45
+ const validation = await validateWebhookUrl(targetUrl);
46
+ if (!validation.ok) {
47
+ log.error(`integration_connection ${connection.id} target rejected: ${validation.error}`);
48
+ return { success: false, error: validation.error };
49
+ }
50
+
51
+ const payload = mapRecordFields(record, mappings[`${entityName}_fields`] || mappings.fields);
52
+ const controller = new AbortController();
53
+ const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
54
+ try {
55
+ const res = await fetch(targetUrl, {
56
+ method: 'POST',
57
+ headers: { 'Content-Type': 'application/json', ...authHeader(connection) },
58
+ body: JSON.stringify(payload),
59
+ signal: controller.signal,
60
+ });
61
+ return { success: res.ok, statusCode: res.status, error: res.ok ? null : `HTTP ${res.status}` };
62
+ } catch (e) {
63
+ return { success: false, statusCode: 0, error: e.message };
64
+ } finally {
65
+ clearTimeout(timeout);
66
+ }
67
+ }
68
+
69
+ export async function syncConnection(connection, user) {
70
+ const mappings = typeof connection.entity_mappings === 'string'
71
+ ? JSON.parse(connection.entity_mappings || '{}')
72
+ : (connection.entity_mappings || {});
73
+ const entityNames = Object.keys(mappings).filter(k => !k.endsWith('_fields') && k !== 'fields');
74
+ const results = [];
75
+ for (const entityName of entityNames) {
76
+ const changed = await getRecordsChangedSince(entityName, connection.last_synced_at, user);
77
+ for (const record of changed) {
78
+ results.push({ entity: entityName, id: record.id, ...(await syncRecordToIntegration(connection, entityName, record)) });
79
+ }
80
+ }
81
+ return results;
82
+ }
@@ -47,6 +47,26 @@ async function runOneJob(job, nowTs) {
47
47
  targets = targets.filter(c => isWithinNoticeWindow(c, nowTs));
48
48
  }
49
49
 
50
+ // integration_sync is a distinct action shape from bulk field-updates --
51
+ // it fans out to an external system per connection rather than mutating
52
+ // Thatcher records, so it bypasses runBulkOperation entirely but still
53
+ // rides the SAME scheduled_job table/timer, not a new cron mechanism.
54
+ if (job.entity === 'integration_connection' && action?.type === 'integration_sync') {
55
+ const { syncConnection } = await import('./integration-sync-engine.js');
56
+ const syncResults = [];
57
+ for (const connection of targets) {
58
+ if (!connection.enabled) continue;
59
+ const results = await syncConnection(connection, owner);
60
+ syncResults.push(...results);
61
+ await update('integration_connection', connection.id, { last_synced_at: nowTs });
62
+ }
63
+ const nextRunAt = nowTs + job.interval_minutes * 60;
64
+ await update('scheduled_job', job.id, { last_run_at: nowTs, next_run_at: nextRunAt });
65
+ const succeeded = syncResults.filter(r => r.success).length;
66
+ log.info(`Job "${job.name}" ran: ${succeeded}/${syncResults.length} synced`);
67
+ return { job_id: job.id, ok: true, total: syncResults.length, succeeded, failed: syncResults.length - succeeded, results: syncResults };
68
+ }
69
+
50
70
  const ids = targets.map(r => r.id);
51
71
 
52
72
  let result = { ok: true, total: 0, succeeded: 0, failed: 0, results: [] };
@@ -493,6 +493,14 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
493
493
  return;
494
494
  }
495
495
  permissionService.enforceEditPermissions(user, spec, body);
496
+ // The edit form renders encrypted:true fields blank with a "leave
497
+ // blank to keep unchanged" note -- a blank submission must not
498
+ // overwrite the stored secret, since update() spreads body directly
499
+ // into the patch and encryptFields only skips ABSENT keys, not
500
+ // empty-string values actually present in the object.
501
+ for (const [fieldKey, fieldDef] of Object.entries(spec.fields || {})) {
502
+ if (fieldDef.encrypted && body[fieldKey] === '') delete body[fieldKey];
503
+ }
496
504
  {
497
505
  // validateUpdate is the authoritative server-side check for both
498
506
  // generic field-type constraints AND entity-specific business
@@ -65,6 +65,11 @@ function roleLabel(r) {
65
65
  }
66
66
 
67
67
  function formatFieldValue(k, v, entityName, f) {
68
+ // encrypted:true fields decrypt on every list()/get() read (store.js) so
69
+ // the plaintext DOES reach this renderer -- redact here, the display
70
+ // boundary, rather than in store.js which legitimately serves the real
71
+ // value to business logic that needs it.
72
+ if (f?.encrypted) return v ? '<span class="pill pill-neutral">••••••••</span>' : '-'
68
73
  if (entityName === 'user' && k === 'role') return `<span class="pill pill-neutral">${roleLabel(v)}</span>`
69
74
  if (entityName === 'user' && k === 'status') {
70
75
  const cls = v === 'active' ? 'pill-success' : v === 'deleted' ? 'pill-danger' : 'pill-neutral'
@@ -346,6 +351,10 @@ export function renderEntityForm(entityName, item, spec, user, isNew = false, re
346
351
  const existingNote = existing?.filename ? `<div style="font-size:0.8rem;color:var(--color-text-muted)" data-existing-file="${k}">Current: ${esc(existing.filename)}</div>` : ''
347
352
  return `<div class="form-field">${lbl(k,f,f.required)}<input type="file" id="field-${k}" data-attachment="${k}" class="form-input" ${existing ? '' : req}/>${existingNote}<input type="hidden" id="field-${k}-value" name="${k}" value="${existing ? esc(JSON.stringify(existing)) : ''}"/></div>`
348
353
  }
354
+ if (f.encrypted) {
355
+ const encNote = val ? `<div style="font-size:0.8rem;color:var(--color-text-muted)">Currently set. Leave blank to keep unchanged.</div>` : ''
356
+ return `<div class="form-field">${lbl(k,f,f.required)}<input type="password" id="field-${k}" name="${k}" value="" class="form-input" autocomplete="new-password" placeholder="Enter ${esc((f.label||k).toLowerCase())}"/>${encNote}</div>`
357
+ }
349
358
  return `<div class="form-field">${lbl(k,f,f.required)}<input type="${type}" id="field-${k}" name="${k}" value="${esc(val)}" class="form-input" ${req} ${placeholder}/></div>`
350
359
  }).join('\n')
351
360
 
@@ -9,7 +9,7 @@ import { renderEngagementGrid } from '@/ui/engagement-grid-renderer.js';
9
9
  import { renderBoardView } from '@/ui/board-view-renderer.js';
10
10
  import { renderGridView } from '@/ui/grid-view-renderer.js';
11
11
  import { renderCalendarView, renderTimelineView } from '@/ui/calendar-view-renderer.js';
12
- import { renderCountByFieldReport, renderCountOverTimeReport, renderSumByFieldReport, renderRollupReport, renderInventoryForecastReport, renderDemandForecastReport, renderPivotReport } from '@/ui/report-renderer.js';
12
+ import { renderCountByFieldReport, renderCountOverTimeReport, renderSumByFieldReport, renderRollupReport, renderInventoryForecastReport, renderDemandForecastReport, renderPivotReport, renderCohortOverTimeReport } from '@/ui/report-renderer.js';
13
13
  import { renderClientProgress } from '@/ui/client-progress-renderer.js';
14
14
  import { renderLetterWorkflow } from '@/ui/letter-workflow-renderer.js';
15
15
  import { renderAdvancedSearch } from '@/ui/advanced-search-renderer.js';
@@ -377,6 +377,12 @@ export async function handlePage(pathname, req, res) {
377
377
  const agg = params.get('agg') || 'count';
378
378
  return renderPivotReport(user, entityName, spec, items, rowField, colField, valueField, agg);
379
379
  }
380
+ if (report === 'cohort-over-time') {
381
+ const dateField = params.get('date_field') || '';
382
+ const cohortField = params.get('cohort_field') || '';
383
+ const granularity = params.get('granularity') || 'month';
384
+ return renderCohortOverTimeReport(user, entityName, spec, items, dateField, cohortField, granularity);
385
+ }
380
386
  if (report === 'rollup') {
381
387
  const refField = params.get('ref_field') || '';
382
388
  const rollupField = params.get('rollup_field') || '';
@@ -329,6 +329,62 @@ export function renderPivotReport(user, entityName, spec, records, rowField, col
329
329
  return page(user, `${label} Report | Thatcher`, null, content);
330
330
  }
331
331
 
332
+ // Trend-by-cohort: how a metric evolves over time (rows = date periods),
333
+ // broken down by a cohort dimension (cols = cohort values) -- a genuinely
334
+ // distinct shape from pivotByFields' point-in-time cross-tab and
335
+ // countOverTime's ungrouped time series. Reuses pivotByFields directly
336
+ // rather than a parallel implementation: a synthetic '__period' field is
337
+ // pre-computed onto cloned records via the SAME bucketDateKey granularity
338
+ // logic countOverTime already uses, and a matching plain-text spec.fields
339
+ // entry is added so bucketLabel's enum-lookup branch is skipped (falls
340
+ // through to String(rawValue), exactly what a period string needs) -- then
341
+ // it's an ordinary two-field pivot. ISO-formatted period keys (YYYY-MM,
342
+ // YYYY-MM-DD, YYYY-Www) sort correctly under pivotByFields' existing
343
+ // lexical row sort, so no separate chronological sort is needed.
344
+ export function cohortOverTime(records, spec, dateField, cohortField, granularity = 'month') {
345
+ const periodField = '__period';
346
+ const augmentedRecords = [];
347
+ let unresolved = 0;
348
+ for (const r of records) {
349
+ const key = bucketDateKey(r[dateField], granularity);
350
+ if (key === null) { unresolved++; continue; }
351
+ augmentedRecords.push({ ...r, [periodField]: key });
352
+ }
353
+ const augmentedSpec = { ...spec, fields: { ...spec.fields, [periodField]: { type: 'text', label: 'Period' } } };
354
+ const { rows, cols, cells } = pivotByFields(augmentedRecords, augmentedSpec, periodField, cohortField, null, 'count');
355
+ return { rows, cols, cells, unresolved };
356
+ }
357
+
358
+ export function renderCohortOverTimeReport(user, entityName, spec, records, dateField, cohortField, granularity) {
359
+ const label = getEntityLabel(spec, true) || entityName;
360
+ const dateFieldDef = spec.fields?.[dateField];
361
+ const cohortFieldDef = spec.fields?.[cohortField];
362
+ if (!dateFieldDef || !cohortFieldDef) {
363
+ const badField = !dateFieldDef ? dateField : cohortField;
364
+ return page(user, `${label} | Report`, null,
365
+ `<div class="page-header"><h1 class="page-title">${esc(label)}</h1></div>
366
+ <div class="report-empty-state">Unknown field "${esc(badField)}" for this entity.</div>`);
367
+ }
368
+ const { rows, cols, cells, unresolved } = cohortOverTime(records, spec, dateField, cohortField, granularity);
369
+ const notice = unresolved
370
+ ? `<div class="report-notice">${unresolved} record${unresolved === 1 ? '' : 's'} without a resolvable date excluded</div>`
371
+ : '';
372
+ const headerCells = cols.map(c => `<th>${esc(c)}</th>`).join('');
373
+ const bodyRows = rows.map((r, i) => {
374
+ const dataCells = cells[i].map(v => `<td style="text-align:right">${esc(String(v))}</td>`).join('');
375
+ return `<tr><td>${esc(r)}</td>${dataCells}</tr>`;
376
+ }).join('') || emptyState('No data to report on', 'bar-chart');
377
+ const content = `<div class="page-header">
378
+ <div><h1 class="page-title">${esc(label)}: ${esc(cohortFieldDef.label || cohortField)} over time</h1><p class="page-subtitle">${records.length} total records, grouped by ${esc(granularity)}</p></div>
379
+ </div>
380
+ ${notice}
381
+ <div class="table-wrap"><table class="data-table">
382
+ <thead><tr><th>Period</th>${headerCells}</tr></thead>
383
+ <tbody>${bodyRows}</tbody>
384
+ </table></div>`;
385
+ return page(user, `${label} Report | Thatcher`, null, content);
386
+ }
387
+
332
388
  export function renderDemandForecastReport(user, spec, buckets, totalOpportunities) {
333
389
  const label = getEntityLabel(spec, true) || 'Opportunities';
334
390
  const valueFieldDef = spec.fields?.value || { type: 'currency' };