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.
Files changed (62) hide show
  1. package/package.json +18 -1
  2. package/src/app/api/debug/[[...path]]/route.js +217 -0
  3. package/src/app/api/formula/[[...path]]/route.js +142 -0
  4. package/src/cli.js +0 -1
  5. package/src/config/spec-helpers.js +6 -0
  6. package/src/engine.server.js +2 -2
  7. package/src/index.js +21 -11
  8. package/src/lib/auth-middleware.js +4 -2
  9. package/src/lib/busybase-adapter.js +162 -0
  10. package/src/lib/config-field-helpers.js +1 -1
  11. package/src/lib/config-generator-engine.js +24 -0
  12. package/src/lib/crud-handlers.js +21 -1
  13. package/src/lib/database-core.js +4 -7
  14. package/src/lib/database-migrations.js +254 -0
  15. package/src/lib/date-utils.js +164 -0
  16. package/src/lib/debug-registry.js +165 -0
  17. package/src/lib/error-handler.js +78 -0
  18. package/src/lib/export-sink.js +226 -0
  19. package/src/lib/field-iterator.js +126 -0
  20. package/src/lib/hyperformula-service.js +304 -0
  21. package/src/lib/metrics-collector.js +196 -0
  22. package/src/lib/observability-bootstrap.js +121 -0
  23. package/src/lib/perf-profiler.js +238 -0
  24. package/src/lib/query-engine.js +47 -0
  25. package/src/lib/query-string-adapter.js +97 -2
  26. package/src/lib/request-tracing.js +216 -0
  27. package/src/lib/response-formatter.js +0 -2
  28. package/src/lib/route-resolver.js +10 -0
  29. package/src/lib/status-helpers.js +128 -0
  30. package/src/lib/tracing.js +287 -0
  31. package/src/lib/utils.js +81 -0
  32. package/src/lib/validate.js +124 -1
  33. package/src/lib/xstate-workflow-engine.js +478 -0
  34. package/src/plugins/index.js +27 -0
  35. package/src/server/server.js +23 -4
  36. package/src/services/permission.service.js +6 -2
  37. package/src/ui/advanced-search-renderer.js +3 -3
  38. package/src/ui/advanced-widgets.js +4 -4
  39. package/src/ui/common-handlers.js +4 -2
  40. package/src/ui/dashboard-renderer.js +4 -4
  41. package/src/ui/engagement-cards.js +10 -10
  42. package/src/ui/engagement-grid-renderer.js +11 -8
  43. package/src/ui/entity-renderer.js +4 -2
  44. package/src/ui/event-delegation.js +348 -8
  45. package/src/ui/file-dialogs.js +9 -9
  46. package/src/ui/format-helpers.js +37 -3
  47. package/src/ui/highlight-threading-renderer.js +9 -9
  48. package/src/ui/job-management-renderer.js +6 -6
  49. package/src/ui/monitoring-dashboard-client.js +17 -3
  50. package/src/ui/notifications-renderer.js +6 -7
  51. package/src/ui/page-handler.js +9 -0
  52. package/src/ui/render-helpers.js +8 -1
  53. package/src/ui/review-comparison-renderer.js +1 -1
  54. package/src/ui/review-detail-renderer.js +6 -6
  55. package/src/ui/review-mwr-renderer.js +13 -10
  56. package/src/ui/review-widgets.js +5 -5
  57. package/src/ui/rfi-detail-renderer.js +9 -9
  58. package/src/ui/settings-renderer-advanced.js +13 -13
  59. package/src/ui/settings-renderer-teams.js +10 -9
  60. package/src/ui/settings-renderer.js +25 -24
  61. package/src/ui/spacing-system.js +8 -4
  62. package/src/ui/widgets.js +2 -2
@@ -3,6 +3,8 @@
3
3
  * These are the standard status values used by moonlanding parity entities
4
4
  */
5
5
 
6
+ import { getConfigEngineSync } from './config-generator-engine.js';
7
+
6
8
  // Engagement statuses
7
9
  export const ENGAGEMENT_STATUS = {
8
10
  DRAFT: 'draft',
@@ -96,3 +98,129 @@ export function getValidTransitions(currentStage) {
96
98
  }
97
99
  return [...new Set(transitions)];
98
100
  }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Config-driven enrichment (ported from moonlanding status-helpers).
104
+ // These are ADDITIVE: each falls back to thatcher's static enums / transition
105
+ // maps above when no config engine is initialized, so thatcher's architecture
106
+ // and enum model remain authoritative.
107
+ // ---------------------------------------------------------------------------
108
+
109
+ let _cachedConfig = null;
110
+
111
+ function getCachedConfig() {
112
+ if (!_cachedConfig) {
113
+ try {
114
+ const engine = getConfigEngineSync();
115
+ _cachedConfig = engine.getConfig();
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+ return _cachedConfig;
121
+ }
122
+
123
+ function buildEnumFromWorkflow(workflowName) {
124
+ const stages = getCachedConfig()?.workflows?.[workflowName]?.stages;
125
+ if (!stages) return null;
126
+ const result = {};
127
+ for (const stage of stages) {
128
+ const name = typeof stage === 'string' ? stage : stage.name;
129
+ result[name.toUpperCase()] = name;
130
+ }
131
+ return result;
132
+ }
133
+
134
+ /**
135
+ * Human-readable labels for status/stage values.
136
+ * Built from thatcher's own enums so labels track thatcher's enum model.
137
+ */
138
+ function titleCase(v) {
139
+ return String(v).replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
140
+ }
141
+
142
+ export const STATUS_LABELS = {
143
+ ...Object.fromEntries(Object.values(ENGAGEMENT_STATUS).map((v) => [v, titleCase(v)])),
144
+ ...Object.fromEntries(Object.values(ENGAGEMENT_STAGE).map((v) => [v, titleCase(v)])),
145
+ ...Object.fromEntries(Object.values(RFI_STATUS).map((v) => [v, titleCase(v)])),
146
+ ...Object.fromEntries(Object.values(RFI_CLIENT_STATUS).map((v) => [v, titleCase(v)])),
147
+ ...Object.fromEntries(Object.values(RFI_AUDITOR_STATUS).map((v) => [v, titleCase(v)])),
148
+ ...Object.fromEntries(Object.values(REVIEW_STATUS).map((v) => [v, titleCase(v)])),
149
+ ...Object.fromEntries(Object.values(HIGHLIGHT_STATUS).map((v) => [v, titleCase(v)])),
150
+ };
151
+
152
+ /**
153
+ * Get the display label for a status/stage value.
154
+ * @param {string} value
155
+ * @returns {string}
156
+ */
157
+ export function getStatusLabel(value) {
158
+ return STATUS_LABELS[value] || value;
159
+ }
160
+
161
+ /**
162
+ * Resolve engagement stages from config workflow, falling back to ENGAGEMENT_STAGE.
163
+ * @returns {Object}
164
+ */
165
+ export function getEngagementStages() {
166
+ return buildEnumFromWorkflow('engagement_lifecycle') || ENGAGEMENT_STAGE;
167
+ }
168
+
169
+ /**
170
+ * Resolve RFI states from config workflow, falling back to RFI_STATUS.
171
+ * @returns {Object}
172
+ */
173
+ export function getRfiStates() {
174
+ return buildEnumFromWorkflow('rfi_type_standard') || RFI_STATUS;
175
+ }
176
+
177
+ /**
178
+ * Resolve review stages from config workflow, falling back to REVIEW_STATUS.
179
+ * @returns {Object}
180
+ */
181
+ export function getReviewStages() {
182
+ return buildEnumFromWorkflow('review_lifecycle') || REVIEW_STATUS;
183
+ }
184
+
185
+ /**
186
+ * Resolve the engagement stage transition map from config, falling back to
187
+ * thatcher's static STAGE_TRANSITIONS.
188
+ * @returns {Object<string,string>}
189
+ */
190
+ export function getStageTransitions() {
191
+ const stages = getCachedConfig()?.workflows?.engagement_lifecycle?.stages;
192
+ if (!stages) return STAGE_TRANSITIONS;
193
+ const result = {};
194
+ for (let i = 0; i < stages.length - 1; i++) {
195
+ const cur = typeof stages[i] === 'string' ? stages[i] : stages[i].name;
196
+ const nxt = typeof stages[i + 1] === 'string' ? stages[i + 1] : stages[i + 1].name;
197
+ result[cur] = nxt;
198
+ }
199
+ return result;
200
+ }
201
+
202
+ /**
203
+ * Check whether a transition between two states is valid for an entity type.
204
+ * @param {string} entityType
205
+ * @param {string} from
206
+ * @param {string} to
207
+ * @returns {boolean}
208
+ */
209
+ export function isValidTransition(entityType, from, to) {
210
+ if (entityType === 'engagement') {
211
+ const transitions = getStageTransitions();
212
+ return transitions[from] === to;
213
+ }
214
+ if (entityType === 'review') {
215
+ const allowed = getValidTransitions(from) || [];
216
+ return allowed.includes(to);
217
+ }
218
+ return false;
219
+ }
220
+
221
+ /**
222
+ * Clear the cached config snapshot (call after a config hot-reload).
223
+ */
224
+ export function clearCachedConfig() {
225
+ _cachedConfig = null;
226
+ }
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Observability Tracing Core - OpenTelemetry-style distributed tracing for Thatcher
3
+ * Provides trace/span lifecycle, async context propagation, and trace export
4
+ */
5
+
6
+ import { AsyncLocalStorage } from 'async_hooks';
7
+ import { createLogger } from './logger.js';
8
+
9
+ const logger = createLogger('[Tracing]');
10
+
11
+ const _asyncLocalStorage = new AsyncLocalStorage();
12
+ const _traceBuffer = [];
13
+ const _activeSpans = new Map();
14
+ const MAX_BUFFER_SIZE = 5000;
15
+ const SLOW_THRESHOLD_MS = 100;
16
+
17
+ let _traceIdCounter = 0;
18
+ let _spanIdCounter = 0;
19
+
20
+ function generateTraceId() {
21
+ _traceIdCounter++;
22
+ return `trace-${Date.now()}-${_traceIdCounter.toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
23
+ }
24
+
25
+ function generateSpanId() {
26
+ _spanIdCounter++;
27
+ return `span-${_spanIdCounter.toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
28
+ }
29
+
30
+ export class Span {
31
+ constructor(options) {
32
+ this.traceId = options.traceId;
33
+ this.spanId = options.spanId;
34
+ this.parentSpanId = options.parentSpanId || null;
35
+ this.name = options.name;
36
+ this.kind = options.kind || 'internal';
37
+ this.startTime = options.startTime || Date.now();
38
+ this.endTime = null;
39
+ this.duration = null;
40
+ this.status = options.status || 'ok';
41
+ this.statusCode = options.statusCode || null;
42
+ this.attributes = new Map(Object.entries(options.attributes || {}));
43
+ this.events = [];
44
+ this._isRecording = true;
45
+ }
46
+
47
+ setAttribute(key, value) {
48
+ if (!this._isRecording) return this;
49
+ this.attributes.set(key, value);
50
+ return this;
51
+ }
52
+
53
+ setAttributes(attrs) {
54
+ if (!this._isRecording) return this;
55
+ for (const [key, value] of Object.entries(attrs)) {
56
+ this.attributes.set(key, value);
57
+ }
58
+ return this;
59
+ }
60
+
61
+ addEvent(name, attributes = {}) {
62
+ if (!this._isRecording) return this;
63
+ this.events.push({
64
+ name,
65
+ timestamp: Date.now(),
66
+ attributes,
67
+ });
68
+ return this;
69
+ }
70
+
71
+ setStatus(status, message) {
72
+ if (!this._isRecording) return this;
73
+ this.status = status;
74
+ if (message) {
75
+ this.attributes.set('status.message', message);
76
+ }
77
+ return this;
78
+ }
79
+
80
+ end() {
81
+ if (!this._isRecording) return;
82
+ this.endTime = Date.now();
83
+ this.duration = this.endTime - this.startTime;
84
+
85
+ if (this.duration > SLOW_THRESHOLD_MS) {
86
+ this.setAttribute('slow', true);
87
+ this.setAttribute('threshold.exceeded', SLOW_THRESHOLD_MS);
88
+ }
89
+
90
+ this._isRecording = false;
91
+ _activeSpans.delete(this.spanId);
92
+ }
93
+
94
+ recordException(error) {
95
+ if (!this._isRecording) return this;
96
+ this.setStatus('error', error.message);
97
+ this.addEvent('exception', {
98
+ 'exception.type': error.constructor?.name || 'Error',
99
+ 'exception.message': error.message,
100
+ 'exception.stacktrace': error.stack,
101
+ });
102
+ return this;
103
+ }
104
+
105
+ toJSON() {
106
+ return {
107
+ traceId: this.traceId,
108
+ spanId: this.spanId,
109
+ parentSpanId: this.parentSpanId,
110
+ name: this.name,
111
+ kind: this.kind,
112
+ startTime: this.startTime,
113
+ endTime: this.endTime,
114
+ duration: this.duration,
115
+ status: this.status,
116
+ statusCode: this.statusCode,
117
+ attributes: Object.fromEntries(this.attributes),
118
+ events: this.events,
119
+ };
120
+ }
121
+ }
122
+
123
+ export class Tracer {
124
+ constructor(name = 'thatcher') {
125
+ this.name = name;
126
+ }
127
+
128
+ startSpan(name, options = {}) {
129
+ const parentContext = _asyncLocalStorage.getStore();
130
+ const parentSpan = parentContext?.span || null;
131
+
132
+ const traceId = options.traceId || parentContext?.traceId || generateTraceId();
133
+ const spanId = generateSpanId();
134
+
135
+ const span = new Span({
136
+ traceId,
137
+ spanId,
138
+ parentSpanId: parentSpan?.spanId,
139
+ name,
140
+ kind: options.kind,
141
+ attributes: options.attributes,
142
+ });
143
+
144
+ _activeSpans.set(spanId, span);
145
+
146
+ const context = { traceId, span };
147
+ return { span, context };
148
+ }
149
+
150
+ async withSpan(name, fn, options = {}) {
151
+ const { span, context } = this.startSpan(name, options);
152
+
153
+ try {
154
+ const result = await _asyncLocalStorage.run(context, () => fn(span));
155
+ span.setStatus('ok');
156
+ return result;
157
+ } catch (error) {
158
+ span.recordException(error);
159
+ throw error;
160
+ } finally {
161
+ span.end();
162
+ this._exportSpan(span);
163
+ }
164
+ }
165
+
166
+ getCurrentContext() {
167
+ return _asyncLocalStorage.getStore();
168
+ }
169
+
170
+ getCurrentTraceId() {
171
+ return _asyncLocalStorage.getStore()?.traceId || null;
172
+ }
173
+
174
+ getCurrentSpan() {
175
+ return _asyncLocalStorage.getStore()?.span || null;
176
+ }
177
+
178
+ getTraceparentHeader() {
179
+ const ctx = this.getCurrentContext();
180
+ if (!ctx?.span) return null;
181
+ return `00-${ctx.traceId}-${ctx.span.spanId.replace('span-', '').replace(/-/g, '').padStart(16, '0')}-01`;
182
+ }
183
+
184
+ extractTraceparent(header) {
185
+ if (!header) return null;
186
+ const parts = header.split('-');
187
+ if (parts.length !== 4) return null;
188
+ return {
189
+ version: parts[0],
190
+ traceId: parts[1],
191
+ parentSpanId: parts[2],
192
+ flags: parts[3],
193
+ };
194
+ }
195
+
196
+ getActiveSpans() {
197
+ return Array.from(_activeSpans.values()).map(s => s.toJSON());
198
+ }
199
+
200
+ getRecentTraces(limit = 100) {
201
+ return _traceBuffer.slice(-limit);
202
+ }
203
+
204
+ getTraceById(traceId) {
205
+ const spans = _traceBuffer.filter(s => s.traceId === traceId);
206
+ if (spans.length === 0) return null;
207
+
208
+ const spanMap = new Map();
209
+ for (const span of spans) {
210
+ spanMap.set(span.spanId, { ...span, children: [] });
211
+ }
212
+
213
+ let root = null;
214
+ for (const span of spanMap.values()) {
215
+ if (span.parentSpanId && spanMap.has(span.parentSpanId)) {
216
+ spanMap.get(span.parentSpanId).children.push(span);
217
+ } else {
218
+ root = span;
219
+ }
220
+ }
221
+
222
+ return root;
223
+ }
224
+
225
+ getStats() {
226
+ return {
227
+ activeSpans: _activeSpans.size,
228
+ bufferedTraces: _traceBuffer.length,
229
+ maxBufferSize: MAX_BUFFER_SIZE,
230
+ };
231
+ }
232
+
233
+ _exportSpan(span) {
234
+ const json = span.toJSON();
235
+ _traceBuffer.push(json);
236
+
237
+ while (_traceBuffer.length > MAX_BUFFER_SIZE) {
238
+ _traceBuffer.shift();
239
+ }
240
+
241
+ if (globalThis.__trace_export__) {
242
+ globalThis.__trace_export__.export(json).catch(() => {});
243
+ }
244
+ }
245
+
246
+ clear() {
247
+ _traceBuffer.length = 0;
248
+ _activeSpans.clear();
249
+ }
250
+ }
251
+
252
+ export const tracer = new Tracer('thatcher');
253
+
254
+ export function createTracer(name) {
255
+ return new Tracer(name);
256
+ }
257
+
258
+ export function getTracer() {
259
+ return tracer;
260
+ }
261
+
262
+ export async function traceOperation(name, fn, attributes = {}) {
263
+ return tracer.withSpan(name, async (span) => {
264
+ span.setAttributes(attributes);
265
+ return fn(span);
266
+ }, { attributes });
267
+ }
268
+
269
+ export function getCurrentTraceId() {
270
+ return tracer.getCurrentTraceId();
271
+ }
272
+
273
+ export function getTraceparentHeader() {
274
+ return tracer.getTraceparentHeader();
275
+ }
276
+
277
+ export default Tracer;
278
+
279
+ if (globalThis.__debug__) {
280
+ globalThis.__debug__.expose('tracing', {
281
+ activeSpans: () => tracer.getActiveSpans(),
282
+ recentTraces: (limit) => tracer.getRecentTraces(limit),
283
+ traceById: (id) => tracer.getTraceById(id),
284
+ stats: () => tracer.getStats(),
285
+ currentTraceId: () => tracer.getCurrentTraceId(),
286
+ }, 'Tracing Core');
287
+ }
package/src/lib/utils.js CHANGED
@@ -2,6 +2,72 @@
2
2
  * Utility Functions - Common helpers
3
3
  */
4
4
 
5
+ import { list } from './query-engine.js';
6
+ import { getSpec } from '../config/spec-helpers.js';
7
+
8
+ /**
9
+ * Load ref-field options for a spec's form (ported from moonlanding).
10
+ * For each `ref` field, fetch the referenced entity's rows and build
11
+ * `{ value, label }` option lists. `engagement` refs get a richer label
12
+ * and skip archived rows. Failures degrade to an empty option list.
13
+ * @param {object} spec - Entity spec with a `fields` map
14
+ * @returns {Promise<Record<string, Array<{value:any,label:string}>>>}
15
+ */
16
+ export async function loadFormOptions(spec) {
17
+ const options = {};
18
+ for (const [key, field] of Object.entries(spec.fields || {})) {
19
+ if (field.type === 'ref' && field.ref) {
20
+ try {
21
+ const data = list(field.ref);
22
+ if (field.ref === 'engagement') {
23
+ options[key] = data
24
+ .filter(r => r.status !== 'archived')
25
+ .map(r => ({
26
+ value: r.id,
27
+ label: `${r.client_name || 'No Client'} - ${r.name} (${r.financial_year || r.year || 'N/A'})`,
28
+ }));
29
+ } else {
30
+ options[key] = data.map(r => ({
31
+ value: r.id,
32
+ label: r.name || r.email || r.id,
33
+ }));
34
+ }
35
+ } catch {
36
+ options[key] = [];
37
+ }
38
+ }
39
+ }
40
+ return options;
41
+ }
42
+
43
+ /**
44
+ * Error thrown when an entity spec cannot be resolved (ported from moonlanding).
45
+ */
46
+ export class SpecError extends Error {
47
+ constructor(entity) {
48
+ super(`Unknown entity: ${entity}`);
49
+ this.code = 'UNKNOWN_ENTITY';
50
+ this.name = 'SpecError';
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Resolve an entity spec, throwing a typed SpecError on failure (ported from
56
+ * moonlanding). Uses thatcher's single-arg getSpec convention.
57
+ * @param {string} entity
58
+ * @returns {Promise<object>}
59
+ */
60
+ export async function resolveSpec(entity) {
61
+ try {
62
+ const spec = getSpec(entity);
63
+ if (!spec) throw new SpecError(entity);
64
+ return spec;
65
+ } catch (e) {
66
+ if (e instanceof SpecError) throw e;
67
+ throw new SpecError(entity);
68
+ }
69
+ }
70
+
5
71
  /**
6
72
  * Get display name for user
7
73
  * @param {object} user - User with name or email
@@ -91,3 +157,18 @@ export function randomColor() {
91
157
  ];
92
158
  return colors[Math.floor(Math.random() * colors.length)];
93
159
  }
160
+
161
+ /**
162
+ * Determine whether a user record is active (ported from moonlanding).
163
+ * Treats explicit inactive/disabled/suspended status, falsy `is_active`,
164
+ * or a set `deleted_at` as inactive.
165
+ * @param {object} user
166
+ * @returns {boolean}
167
+ */
168
+ export function isUserActive(user) {
169
+ if (!user) return false;
170
+ if (user.is_active === false || user.is_active === 0) return false;
171
+ if (user.status === 'inactive' || user.status === 'disabled' || user.status === 'suspended') return false;
172
+ if (user.deleted_at) return false;
173
+ return true;
174
+ }
@@ -4,7 +4,12 @@
4
4
  */
5
5
 
6
6
  import { getSpec } from '../config/spec-helpers.js';
7
- import { isValidEmail as checkEmailFormat } from './validators.js';
7
+ import { isValidEmail as checkEmailFormat, isValidEmail } from './validators.js';
8
+ import { getValidTransitions } from './status-helpers.js';
9
+ import { isBeforeDate } from './date-utils.js';
10
+
11
+ // Re-export so consumers can reach the email validator from validate.js (parity with moon)
12
+ export { isValidEmail };
8
13
 
9
14
  const HTML_ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
10
15
 
@@ -153,11 +158,44 @@ export async function validateEntity(entityName, data, existingRecord = null) {
153
158
  if (!result.valid && result.error) {
154
159
  errors[fieldName] = result.error;
155
160
  }
161
+
162
+ const uniqueErr = await checkUnique(fieldDef, value, {
163
+ fieldName,
164
+ entityName,
165
+ existingRecord,
166
+ });
167
+ if (uniqueErr && !errors[fieldName]) errors[fieldName] = uniqueErr;
156
168
  }
157
169
 
158
170
  return errors;
159
171
  }
160
172
 
173
+ /**
174
+ * Enforce a field's `unique` constraint against the datastore.
175
+ * Returns an error string if a duplicate exists, otherwise null.
176
+ * @param {object} fieldDef
177
+ * @param {any} value
178
+ * @param {object} options - { fieldName, entityName, existingRecord }
179
+ * @returns {Promise<string|null>}
180
+ */
181
+ async function checkUnique(fieldDef, value, { fieldName, entityName, existingRecord }) {
182
+ if (!fieldDef.unique) return null;
183
+ if (value == null || value === '') return null;
184
+ const existingValue = existingRecord?.[fieldName];
185
+ if (value === existingValue) return null;
186
+ try {
187
+ const { get } = await import('./query-engine.js');
188
+ const table = entityName === 'user' ? 'users' : entityName;
189
+ const dup = get(table, undefined, { [fieldName]: value });
190
+ if (dup && (!existingRecord || dup.id !== existingRecord.id)) {
191
+ return `Field '${fieldName}' must be unique`;
192
+ }
193
+ } catch {
194
+ // Table might not exist yet; skip uniqueness enforcement
195
+ }
196
+ return null;
197
+ }
198
+
161
199
  /**
162
200
  * Validate update (only changed fields)
163
201
  * @param {string} entityName
@@ -182,6 +220,13 @@ export async function validateUpdate(entityName, changes, existingRecord) {
182
220
  if (!result.valid && result.error) {
183
221
  errors[fieldName] = result.error;
184
222
  }
223
+
224
+ const uniqueErr = await checkUnique(fieldDef, value, {
225
+ fieldName,
226
+ entityName,
227
+ existingRecord,
228
+ });
229
+ if (uniqueErr && !errors[fieldName]) errors[fieldName] = uniqueErr;
185
230
  }
186
231
 
187
232
  return errors;
@@ -195,3 +240,81 @@ export async function validateUpdate(entityName, changes, existingRecord) {
195
240
  export function hasErrors(errors) {
196
241
  return errors && Object.keys(errors).length > 0;
197
242
  }
243
+
244
+ /**
245
+ * Validate a status/stage transition is permitted.
246
+ * Uses thatcher's STAGE_TRANSITIONS graph via getValidTransitions.
247
+ * @param {string} entityType
248
+ * @param {string} currentStatus
249
+ * @param {string} newStatus
250
+ * @returns {{valid: boolean, reason?: string}}
251
+ */
252
+ export function validateStatusTransition(entityType, currentStatus, newStatus) {
253
+ if (!currentStatus || !newStatus) {
254
+ return { valid: false, reason: 'Status values required' };
255
+ }
256
+ if (currentStatus === newStatus) return { valid: true };
257
+ const allowed = getValidTransitions(currentStatus) || [];
258
+ if (!allowed.includes(newStatus)) {
259
+ return {
260
+ valid: false,
261
+ reason: `Cannot transition ${entityType} from '${currentStatus}' to '${newStatus}'`,
262
+ };
263
+ }
264
+ return { valid: true };
265
+ }
266
+
267
+ /**
268
+ * Validate that an end date is not before a start date.
269
+ * @param {number} startSeconds - Unix timestamp (seconds)
270
+ * @param {number} endSeconds - Unix timestamp (seconds)
271
+ * @param {string} label
272
+ * @returns {{valid: boolean, reason?: string}}
273
+ */
274
+ export function validateDateRange(startSeconds, endSeconds, label = 'date') {
275
+ if (!startSeconds || !endSeconds) return { valid: true };
276
+ if (isBeforeDate(endSeconds, startSeconds)) {
277
+ return { valid: false, reason: `End ${label} cannot be before start ${label}` };
278
+ }
279
+ return { valid: true };
280
+ }
281
+
282
+ /**
283
+ * Validate a deadline: required, not before a reference date, within maxYears.
284
+ * @param {number} deadlineSeconds - Unix timestamp (seconds)
285
+ * @param {number} [referenceSeconds] - Unix timestamp (seconds)
286
+ * @param {number} [maxYears=2]
287
+ * @returns {{valid: boolean, reason?: string}}
288
+ */
289
+ export function validateDeadline(deadlineSeconds, referenceSeconds, maxYears = 2) {
290
+ if (!deadlineSeconds) return { valid: false, reason: 'Deadline is required' };
291
+ if (referenceSeconds && isBeforeDate(deadlineSeconds, referenceSeconds)) {
292
+ return { valid: false, reason: 'Deadline cannot be before the reference date' };
293
+ }
294
+ // Upper-bound window check (moon isWithinYears semantics: deadline must be within maxYears ahead).
295
+ // Inlined rather than reusing thatcher's isWithinYears(ts, minYearsAgo, maxYearsAhead) whose
296
+ // 2nd positional arg differs in meaning.
297
+ const limit = new Date();
298
+ limit.setFullYear(limit.getFullYear() + maxYears);
299
+ if (new Date(deadlineSeconds * 1000) > limit) {
300
+ return { valid: false, reason: `Deadline must be within ${maxYears} years` };
301
+ }
302
+ return { valid: true };
303
+ }
304
+
305
+ /**
306
+ * Sanitize string/text fields of a record using the entity spec.
307
+ * @param {object} data
308
+ * @param {object} spec
309
+ * @returns {object} Sanitized shallow copy
310
+ */
311
+ export function sanitizeData(data, spec) {
312
+ const sanitized = { ...data };
313
+ for (const [fieldName, value] of Object.entries(sanitized)) {
314
+ const fieldDef = spec?.fields?.[fieldName];
315
+ if (fieldDef && (fieldDef.type === 'string' || fieldDef.type === 'text') && typeof value === 'string') {
316
+ sanitized[fieldName] = sanitizeHtml(value);
317
+ }
318
+ }
319
+ return sanitized;
320
+ }