thatcher 1.0.6 → 1.0.33
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/README.md +4 -16
- package/package.json +12 -18
- package/src/adapters/google-auth.js +4 -43
- package/src/adapters/google-drive.js +12 -67
- package/src/app/api/audit/dashboard/route.js +14 -11
- package/src/app/api/audit/logs/route.js +2 -2
- package/src/app/api/audit/permissions/[id]/route.js +6 -3
- package/src/app/api/audit/permissions/route.js +10 -7
- package/src/app/api/audit/permissions/stats/route.js +8 -5
- package/src/app/api/audit/route.js +4 -4
- package/src/app/api/audit/stats/route.js +1 -1
- package/src/app/api/auth/google/callback/route.js +5 -2
- package/src/app/api/auth/google/route.js +4 -1
- package/src/app/api/auth/login/route.js +5 -2
- package/src/app/api/auth/mwr-bridge/route.js +11 -21
- package/src/app/api/auth/password-reset/route.js +15 -14
- package/src/app/api/cron/trigger/route.js +4 -1
- package/src/app/api/debug/[[...path]]/route.js +6 -5
- package/src/app/api/debug/sqlite/route.js +7 -12
- package/src/app/api/debug/sync/route.js +5 -5
- package/src/app/api/domains/route.js +4 -1
- package/src/app/api/email/allocate/batch/route.js +19 -29
- package/src/app/api/email/allocate/route.js +16 -17
- package/src/app/api/email/receive/route.js +31 -42
- package/src/app/api/email/send/route.js +28 -16
- package/src/app/api/email/unallocated/route.js +10 -13
- package/src/app/api/files/[id]/route.js +4 -1
- package/src/app/api/formula/[[...path]]/route.js +0 -4
- package/src/app/api/health/route.js +10 -9
- package/src/app/api/metrics/route.js +1 -1
- package/src/cli.js +4 -4
- package/src/config/config-loader.js +1 -21
- package/src/config/constants.js +0 -35
- package/src/config/env.js +0 -30
- package/src/config/spec-helpers.js +6 -95
- package/src/engine.js +23 -0
- package/src/engine.server.js +8 -78
- package/src/index.js +42 -67
- package/src/lib/alert-manager.js +7 -3
- package/src/lib/api-helpers.js +4 -3
- package/src/lib/api.js +10 -10
- package/src/lib/audit-logger-enhanced.js +77 -83
- package/src/lib/auth-middleware.js +1 -32
- package/src/lib/auth-route-helpers.js +5 -0
- package/src/lib/busybase-adapter.js +0 -5
- package/src/lib/busybase-audit-reads.js +142 -0
- package/src/lib/busybase-audit.js +41 -0
- package/src/lib/busybase-lucia-adapter.js +72 -0
- package/src/lib/busybase-store.js +338 -0
- package/src/lib/config-field-helpers.js +0 -17
- package/src/lib/config-generator-engine.js +19 -36
- package/src/lib/config-helpers.js +0 -21
- package/src/lib/crud-action-helpers.js +2 -2
- package/src/lib/crud-factory.js +1 -16
- package/src/lib/crud-handlers.js +17 -52
- package/src/lib/csrf-protection.js +5 -2
- package/src/lib/date-utils.js +3 -127
- package/src/lib/debug-registry.js +0 -5
- package/src/lib/domain-loader.js +4 -1
- package/src/lib/email-sender.js +25 -23
- package/src/lib/error-handler.js +29 -88
- package/src/lib/error-recovery.js +10 -7
- package/src/lib/error-resilience.js +7 -5
- package/src/lib/events-engine.js +9 -6
- package/src/lib/export-sink.js +0 -5
- package/src/lib/field-iterator.js +7 -76
- package/src/lib/field-registry.js +0 -22
- package/src/lib/generic-crud-handler.js +0 -16
- package/src/lib/hook-engine.js +5 -53
- package/src/lib/hot-reload/checkpoint.js +5 -2
- package/src/lib/hot-reload/promise-container.js +7 -3
- package/src/lib/hot-reload/route-wrapper.js +4 -1
- package/src/lib/hot-reload/supervisor.js +6 -3
- package/src/lib/hyperformula-service.js +0 -5
- package/src/lib/id-helpers.js +10 -0
- package/src/lib/index.js +4 -7
- package/src/lib/log-aggregator.js +0 -1
- package/src/lib/logger.js +0 -15
- package/src/lib/metrics-collector.js +2 -46
- package/src/lib/next-compat.js +15 -1
- package/src/lib/observability-bootstrap.js +0 -5
- package/src/lib/perf-monitor.js +4 -1
- package/src/lib/perf-profiler.js +0 -5
- package/src/lib/query-string-adapter.js +5 -47
- package/src/lib/realtime-server.js +4 -26
- package/src/lib/request-tracing.js +0 -5
- package/src/lib/resource-monitor.js +4 -1
- package/src/lib/response-formatter.js +0 -38
- package/src/lib/route-helpers.js +3 -1
- package/src/lib/safe-json.js +5 -1
- package/src/lib/server-bootstrap.js +10 -9
- package/src/lib/stage-pipeline.js +2 -2
- package/src/lib/state-transport-client.js +4 -1
- package/src/lib/static-server.js +2 -2
- package/src/lib/status-helpers.js +2 -51
- package/src/lib/tracing.js +0 -5
- package/src/lib/utils.js +6 -67
- package/src/lib/validate.js +5 -88
- package/src/lib/validators.js +0 -38
- package/src/lib/with-audit-logging.js +1 -1
- package/src/lib/workflow-engine.js +10 -77
- package/src/lib/xstate-workflow-engine.js +3 -9
- package/src/plugins/index.js +5 -3
- package/src/server/server.js +26 -27
- package/src/services/collaborator-role.service.js +21 -77
- package/src/services/email-sender.js +7 -34
- package/src/services/notification-engine.js +12 -33
- package/src/services/permission.service.js +10 -73
- package/src/ui/advanced-search-renderer.js +3 -2
- package/src/ui/advanced-widgets.js +1 -1
- package/src/ui/auth-pages.js +10 -9
- package/src/ui/auth-styles.js +1 -1
- package/src/ui/client.js +1 -1
- package/src/ui/component-engine.js +14 -5
- package/src/ui/dashboard-renderer.js +5 -5
- package/src/ui/dialog-engine.js +6 -9
- package/src/ui/dialog-factory.js +2 -2
- package/src/ui/engagement-cards.js +7 -7
- package/src/ui/engagement-dialogs.js +2 -3
- package/src/ui/engagement-grid-renderer.js +20 -19
- package/src/ui/entity-renderer.js +56 -42
- package/src/ui/file-dialogs.js +25 -9
- package/src/ui/flexup-report-renderer.js +21 -18
- package/src/ui/format-helpers.js +27 -28
- package/src/ui/highlight-threading-renderer.js +9 -9
- package/src/ui/job-management-renderer.js +18 -16
- package/src/ui/layout.js +51 -25
- package/src/ui/ml-console-renderer.js +15 -16
- package/src/ui/monitoring-dashboard-client.js +9 -8
- package/src/ui/notifications-renderer.js +11 -10
- package/src/ui/page-handler-admin.js +32 -23
- package/src/ui/page-handler-helpers.js +27 -23
- package/src/ui/page-handler-reviews.js +71 -35
- package/src/ui/page-handler-rfi.js +28 -14
- package/src/ui/page-handler.js +65 -48
- package/src/ui/picker-dialogs.js +26 -19
- package/src/ui/render-helpers.js +84 -46
- package/src/ui/review-comparison-renderer.js +8 -7
- package/src/ui/review-detail-panels.js +8 -9
- package/src/ui/review-detail-renderer.js +3 -3
- package/src/ui/review-detail-script.js +13 -12
- package/src/ui/review-mwr-renderer.js +6 -5
- package/src/ui/review-renderer.js +7 -6
- package/src/ui/review-widgets.js +3 -3
- package/src/ui/rfi-detail-renderer.js +37 -20
- package/src/ui/rfi-report-renderer.js +14 -9
- package/src/ui/settings-renderer-advanced.js +21 -18
- package/src/ui/settings-renderer-advanced2.js +20 -19
- package/src/ui/settings-renderer-teams.js +4 -4
- package/src/ui/settings-renderer.js +4 -4
- package/src/ui/spacing-system.js +5 -2
- package/src/ui/standalone-login.js +2 -2
- package/src/ui/styles.css +2 -1
- package/src/ui/styles2.css +75 -5
- package/src/ui/test-page.js +4 -4
- package/src/ui/widgets.js +20 -15
- package/src/lib/audit-logger.js +0 -193
- package/src/lib/database-core.js +0 -255
- package/src/lib/database-migrations.js +0 -350
- package/src/lib/db-backup.js +0 -97
- package/src/lib/error-boundary.js +0 -134
- package/src/lib/errors.js +0 -69
- package/src/lib/health-monitor.js +0 -134
- package/src/lib/index-optimizer.js +0 -96
- package/src/lib/query-engine-write.js +0 -221
- package/src/lib/query-engine.js +0 -446
- package/src/ui/collaboration-dialogs.js +0 -31
- package/src/ui/global-tags.js +0 -144
- package/src/ui/idle-logout.js +0 -156
- package/src/ui/letter-dialogs.js +0 -37
- package/src/ui/password-reset-page.js +0 -146
- package/src/ui/perf-renderer.js +0 -71
- package/src/ui/rfi-renderer.js +0 -194
- package/src/ui/validation-ui.js +0 -147
package/src/lib/error-handler.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
* Error Handling - Centralized error types and utilities
|
|
3
|
-
*/
|
|
1
|
+
import { createLogger } from './logger.js';
|
|
4
2
|
|
|
3
|
+
// AppError stays a real class (base error type; `instanceof AppError` works).
|
|
5
4
|
export class AppError extends Error {
|
|
6
5
|
constructor(message, code = 'APP_ERROR', status = 500, details = null) {
|
|
7
6
|
super(message);
|
|
@@ -9,98 +8,46 @@ export class AppError extends Error {
|
|
|
9
8
|
this.code = code;
|
|
10
9
|
this.status = status;
|
|
11
10
|
this.details = details;
|
|
12
|
-
if (Error.captureStackTrace) Error.captureStackTrace(this,
|
|
11
|
+
if (Error.captureStackTrace) Error.captureStackTrace(this, this.constructor);
|
|
13
12
|
}
|
|
14
13
|
}
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
// The specific error types are exported as FACTORY FUNCTIONS, not classes, so
|
|
16
|
+
// both `throw UnauthorizedError(x)` (the style moonlanding and ~17 callsites in
|
|
17
|
+
// this repo use) and a plain call work. A derived `class X extends AppError`
|
|
18
|
+
// cannot be invoked without `new` at all (the engine throws before the body, so
|
|
19
|
+
// a new.target guard does not help) — a factory is the portable fix. Each
|
|
20
|
+
// returns an AppError instance, so `instanceof AppError` still holds in catch.
|
|
21
|
+
export function UnauthorizedError(message = 'Authentication required') {
|
|
22
|
+
const e = new AppError(message, 'UNAUTHORIZED', 401); e.name = 'UnauthorizedError'; return e;
|
|
21
23
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
constructor(message = 'Permission denied') {
|
|
25
|
-
super(message, 'PERMISSION_DENIED', 403);
|
|
26
|
-
this.name = 'PermissionError';
|
|
27
|
-
}
|
|
24
|
+
export function PermissionError(message = 'Permission denied') {
|
|
25
|
+
const e = new AppError(message, 'PERMISSION_DENIED', 403); e.name = 'PermissionError'; return e;
|
|
28
26
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const msg = id ? `${entity} with id ${id} not found` : `${entity} not found`;
|
|
33
|
-
super(msg, 'NOT_FOUND', 404);
|
|
34
|
-
this.name = 'NotFoundError';
|
|
35
|
-
this.entity = entity;
|
|
36
|
-
this.id = id;
|
|
37
|
-
}
|
|
27
|
+
export function NotFoundError(entity = 'Resource', id = null) {
|
|
28
|
+
const msg = id ? `${entity} with id ${id} not found` : `${entity} not found`;
|
|
29
|
+
const e = new AppError(msg, 'NOT_FOUND', 404); e.name = 'NotFoundError'; e.entity = entity; e.id = id; return e;
|
|
38
30
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
constructor(message = 'Validation failed', errors = {}) {
|
|
42
|
-
super(message, 'VALIDATION_ERROR', 422);
|
|
43
|
-
this.name = 'ValidationError';
|
|
44
|
-
this.errors = errors;
|
|
45
|
-
}
|
|
31
|
+
export function ValidationError(message = 'Validation failed', errors = {}) {
|
|
32
|
+
const e = new AppError(message, 'VALIDATION_ERROR', 422); e.name = 'ValidationError'; e.errors = errors; return e;
|
|
46
33
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
super(`Database ${operation} failed: ${originalError?.message || 'unknown error'}`, 'DATABASE_ERROR', 500);
|
|
51
|
-
this.name = 'DatabaseError';
|
|
52
|
-
this.originalError = originalError;
|
|
53
|
-
}
|
|
34
|
+
export function DatabaseError(operation = 'Database operation', originalError = null) {
|
|
35
|
+
const e = new AppError(`Database ${operation} failed: ${originalError?.message || 'unknown error'}`, 'DATABASE_ERROR', 500);
|
|
36
|
+
e.name = 'DatabaseError'; e.originalError = originalError; return e;
|
|
54
37
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
constructor(message = 'Resource already exists') {
|
|
58
|
-
super(message, 'CONFLICT', 409);
|
|
59
|
-
this.name = 'ConflictError';
|
|
60
|
-
}
|
|
38
|
+
export function ConflictError(message = 'Resource already exists') {
|
|
39
|
+
const e = new AppError(message, 'CONFLICT', 409); e.name = 'ConflictError'; return e;
|
|
61
40
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
constructor(message = 'Invalid request') {
|
|
65
|
-
super(message, 'BAD_REQUEST', 400);
|
|
66
|
-
this.name = 'BadRequestError';
|
|
67
|
-
}
|
|
41
|
+
export function BadRequestError(message = 'Invalid request') {
|
|
42
|
+
const e = new AppError(message, 'BAD_REQUEST', 400); e.name = 'BadRequestError'; return e;
|
|
68
43
|
}
|
|
69
44
|
|
|
70
|
-
/**
|
|
71
|
-
* Create an error logger
|
|
72
|
-
* @param {string} context
|
|
73
|
-
* @returns {object}
|
|
74
|
-
*/
|
|
75
45
|
export function createErrorLogger(context = '') {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
console.error(`[${context}] ${msg}`, meta);
|
|
79
|
-
},
|
|
80
|
-
warn: (msg, meta = {}) => {
|
|
81
|
-
console.warn(`[${context}] ${msg}`, meta);
|
|
82
|
-
},
|
|
83
|
-
info: (_msg, _meta = {}) => {},
|
|
84
|
-
debug: (msg, meta = {}) => {
|
|
85
|
-
if (process.env.DEBUG) {
|
|
86
|
-
console.debug(`[${context}] Debug:`, msg, meta);
|
|
87
|
-
}
|
|
88
|
-
},
|
|
89
|
-
};
|
|
46
|
+
const log = createLogger(`[${context}]`);
|
|
47
|
+
return { error: log.error, warn: log.warn, info: (_msg, _meta = {}) => {}, debug: log.debug };
|
|
90
48
|
}
|
|
91
49
|
|
|
92
|
-
|
|
93
|
-
* Normalize an arbitrary thrown value into one of thatcher's AppError-class
|
|
94
|
-
* instances. AppError instances (and anything already shaped like one — a
|
|
95
|
-
* numeric `status` + a `code`) pass through untouched; common low-level
|
|
96
|
-
* errors are mapped to the appropriate thatcher error class.
|
|
97
|
-
*
|
|
98
|
-
* (Ported from moonlanding, adapted to thatcher's class-based hierarchy and
|
|
99
|
-
* its `status`/`code`/`details` shape. Kept import-free so the module stays
|
|
100
|
-
* loadable under the plain-node test runner.)
|
|
101
|
-
* @param {unknown} error
|
|
102
|
-
* @returns {AppError}
|
|
103
|
-
*/
|
|
50
|
+
// Ported from moonlanding; kept import-free so this module loads under the plain-node test runner.
|
|
104
51
|
export function normalizeError(error) {
|
|
105
52
|
if (error instanceof AppError) {
|
|
106
53
|
return error;
|
|
@@ -135,13 +82,7 @@ export function normalizeError(error) {
|
|
|
135
82
|
});
|
|
136
83
|
}
|
|
137
84
|
|
|
138
|
-
|
|
139
|
-
* Build a JSON-serializable error response body from any thrown value.
|
|
140
|
-
* (Ported from moonlanding, adapted to thatcher's error shape.)
|
|
141
|
-
* @param {unknown} error
|
|
142
|
-
* @param {boolean} [includeStack=false]
|
|
143
|
-
* @returns {object}
|
|
144
|
-
*/
|
|
85
|
+
// Ported from moonlanding.
|
|
145
86
|
export function formatErrorResponse(error, includeStack = false) {
|
|
146
87
|
const normalized = normalizeError(error);
|
|
147
88
|
const response = {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { createLogger } from './logger.js';
|
|
2
|
+
import { retryWithBackoff, withCircuitBreaker, checkpoint, restoreCheckpoint, logRecovery } from '@/lib/error-resilience';
|
|
3
|
+
|
|
4
|
+
const log = createLogger('[Recovery]');
|
|
5
|
+
import { normalizeError, AppError } from '@/lib/error-handler';
|
|
3
6
|
import { HTTP } from '@/config/constants';
|
|
4
7
|
|
|
5
8
|
const recoveryState = { supervisors: new Map(), lastHealthCheck: null };
|
|
@@ -37,7 +40,7 @@ export async function supervise(name, fn, options = {}) {
|
|
|
37
40
|
);
|
|
38
41
|
supervisor.state = 'failed';
|
|
39
42
|
supervisor.lastError = error;
|
|
40
|
-
|
|
43
|
+
log.error(`${name} failed permanently after ${supervisor.restarts.length} restarts`);
|
|
41
44
|
throw error;
|
|
42
45
|
}
|
|
43
46
|
|
|
@@ -58,7 +61,7 @@ export async function supervise(name, fn, options = {}) {
|
|
|
58
61
|
|
|
59
62
|
if (supervisor.restarts.length < supervisor.maxRestarts) {
|
|
60
63
|
const delay = Math.min(1000 * Math.pow(2, supervisor.restarts.length - 1), 30000);
|
|
61
|
-
|
|
64
|
+
log.warn(`${name} restarting in ${delay}ms (attempt ${supervisor.restarts.length})`);
|
|
62
65
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
63
66
|
return supervise(name, fn, options);
|
|
64
67
|
}
|
|
@@ -79,7 +82,7 @@ export async function degradedMode(fn, fallback, options = {}) {
|
|
|
79
82
|
]);
|
|
80
83
|
return { mode: 'normal', result };
|
|
81
84
|
} catch (error) {
|
|
82
|
-
|
|
85
|
+
log.warn('falling back to degraded mode', { error: error.message });
|
|
83
86
|
const result = await fallback();
|
|
84
87
|
return { mode: 'degraded', result };
|
|
85
88
|
}
|
|
@@ -115,7 +118,7 @@ export function isolateFailure(fn, defaultValue = null) {
|
|
|
115
118
|
try {
|
|
116
119
|
return await fn(...args);
|
|
117
120
|
} catch (error) {
|
|
118
|
-
|
|
121
|
+
log.error('failure contained', {
|
|
119
122
|
function: fn.name,
|
|
120
123
|
error: String(error.message || error)
|
|
121
124
|
});
|
|
@@ -163,7 +166,7 @@ export async function withRecovery(fn, options = {}) {
|
|
|
163
166
|
return result;
|
|
164
167
|
} catch (error) {
|
|
165
168
|
if (fallback) {
|
|
166
|
-
|
|
169
|
+
log.warn('using fallback after error', { error: error.message });
|
|
167
170
|
return fallback();
|
|
168
171
|
}
|
|
169
172
|
throw error;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { createLogger } from './logger.js';
|
|
1
2
|
import { HTTP } from '@/config/constants';
|
|
2
|
-
|
|
3
|
-
|
|
3
|
+
|
|
4
|
+
const log = createLogger('[Resilience]');
|
|
5
|
+
import { AppError, normalizeError } from '@/lib/error-handler';
|
|
4
6
|
|
|
5
7
|
const errorState = { errors: [], circuitBreakers: new Map(), checkpoints: new Map() };
|
|
6
8
|
|
|
@@ -13,12 +15,12 @@ export async function retryWithBackoff(fn, options = {}) {
|
|
|
13
15
|
} catch (error) {
|
|
14
16
|
if (attempt === maxAttempts) {
|
|
15
17
|
const normalized = normalizeError(error);
|
|
16
|
-
|
|
18
|
+
log.error('final attempt failed:', { ...context, attempt, error: normalized.toJSON() });
|
|
17
19
|
throw normalized;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
const waitTime = delay * Math.pow(backoff, attempt - 1);
|
|
21
|
-
|
|
23
|
+
log.warn(`attempt ${attempt} failed, retrying in ${waitTime}ms`, context);
|
|
22
24
|
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
23
25
|
}
|
|
24
26
|
}
|
|
@@ -66,7 +68,7 @@ export async function withCircuitBreaker(name, fn, options = {}) {
|
|
|
66
68
|
if (breaker.failures >= breaker.threshold) {
|
|
67
69
|
breaker.state = 'open';
|
|
68
70
|
breaker.nextAttempt = Date.now() + breaker.resetTimeout;
|
|
69
|
-
|
|
71
|
+
log.error(`circuit ${name} opened after ${breaker.failures} failures`);
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
throw error;
|
package/src/lib/events-engine.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { createLogger } from './logger.js';
|
|
1
2
|
import { hookEngine } from '@/lib/hook-engine.js';
|
|
3
|
+
|
|
4
|
+
const log = createLogger('[EventsEngine]');
|
|
2
5
|
import { list, get, update, create, remove } from '@/engine.js';
|
|
3
6
|
import { queueEmail } from '@/engine/notification-engine';
|
|
4
7
|
import { safeJsonParse } from '@/lib/safe-json.js';
|
|
@@ -31,7 +34,7 @@ const activateWorkflowsForStage = async (engagementId, stage, user) => {
|
|
|
31
34
|
hookEngine.execute(`feature:activated:${feature}`, { engagementId, stage, feature, user });
|
|
32
35
|
}
|
|
33
36
|
} catch (error) {
|
|
34
|
-
|
|
37
|
+
log.error(`error activating workflows for stage ${stage}:`, { message: error.message });
|
|
35
38
|
}
|
|
36
39
|
};
|
|
37
40
|
|
|
@@ -47,7 +50,7 @@ export const registerEntityHandlers = () => {
|
|
|
47
50
|
create('activity_log', { entity_type: 'engagement', entity_id: context.id, action: 'stage_transition',
|
|
48
51
|
message: `Stage transitioned: ${prev.stage} -> ${data.stage}`,
|
|
49
52
|
details: JSON.stringify({ from: prev.stage, to: data.stage, user_role: user?.role }), user_email: user?.email }, user);
|
|
50
|
-
} catch (e) {
|
|
53
|
+
} catch (e) { log.error('failed to log stage change:', { message: e.message }); }
|
|
51
54
|
return context;
|
|
52
55
|
}, { priority: 100, name: 'workflow-stage-validator' });
|
|
53
56
|
|
|
@@ -70,7 +73,7 @@ export const registerEntityHandlers = () => {
|
|
|
70
73
|
|
|
71
74
|
hookEngine.on('engagement:afterUpdate', async (engagement, changes, prev, user) => {
|
|
72
75
|
if (changes.stage && changes.stage !== prev.stage) {
|
|
73
|
-
logActivity('engagement', engagement.id, 'stage_change', `Stage: ${prev.stage}
|
|
76
|
+
logActivity('engagement', engagement.id, 'stage_change', `Stage: ${prev.stage} -> ${changes.stage}`, user, { from: prev.stage, to: changes.stage });
|
|
74
77
|
const stageActions = {
|
|
75
78
|
commencement: () => queueEmail('engagement_commencement', { engagement, recipients: 'client_users' }),
|
|
76
79
|
finalization: async () => {
|
|
@@ -85,7 +88,7 @@ export const registerEntityHandlers = () => {
|
|
|
85
88
|
await stageActions[changes.stage]?.();
|
|
86
89
|
}
|
|
87
90
|
if (changes.status && changes.status !== prev.status) {
|
|
88
|
-
logActivity('engagement', engagement.id, 'status_change', `Status: ${prev.status}
|
|
91
|
+
logActivity('engagement', engagement.id, 'status_change', `Status: ${prev.status} -> ${changes.status}`, user);
|
|
89
92
|
}
|
|
90
93
|
if (changes.commencement_date && changes.commencement_date !== prev.commencement_date) {
|
|
91
94
|
await queueEmail('engagement_date_change', { engagement, field: 'commencement_date', recipients: 'team_members' });
|
|
@@ -118,7 +121,7 @@ export const registerEntityHandlers = () => {
|
|
|
118
121
|
if (changes.client_status && changes.client_status !== prev.client_status) {
|
|
119
122
|
await queueEmail('rfi_status_change', { rfi, from: prev.client_status, to: changes.client_status, recipients: 'assigned_users' });
|
|
120
123
|
}
|
|
121
|
-
logActivity('rfi', rfi.id, 'status_change', `Status: ${prev.status}
|
|
124
|
+
logActivity('rfi', rfi.id, 'status_change', `Status: ${prev.status} -> ${changes.status}`, user);
|
|
122
125
|
}
|
|
123
126
|
if (changes.deadline && changes.deadline !== prev.deadline) {
|
|
124
127
|
await queueEmail('rfi_deadline_change', { rfi, newDeadline: changes.deadline, recipients: 'assigned_users' });
|
|
@@ -160,7 +163,7 @@ export const registerEntityHandlers = () => {
|
|
|
160
163
|
hookEngine.on('review:afterUpdate', async (review, changes, prev, user) => {
|
|
161
164
|
if (changes.status && changes.status !== prev.status) {
|
|
162
165
|
await queueEmail('review_status_change', { review, from: prev.status, to: changes.status, recipients: 'team_members' });
|
|
163
|
-
logActivity('review', review.id, 'status_change', `Status: ${prev.status}
|
|
166
|
+
logActivity('review', review.id, 'status_change', `Status: ${prev.status} -> ${changes.status}`, user);
|
|
164
167
|
}
|
|
165
168
|
if (changes.collaborators && changes.collaborators !== prev.collaborators) {
|
|
166
169
|
const added = safeJsonParse(changes.collaborators, []).filter(c => !safeJsonParse(prev.collaborators, []).find(p => p.id === c.id));
|
package/src/lib/export-sink.js
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
*
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
* @param {object} spec - Entity specification
|
|
8
|
-
* @param {Function} callback - (key, fieldDef) => void
|
|
9
|
-
* @param {object} options - { includeComputed, includeHidden }
|
|
10
|
-
*/
|
|
1
|
+
/* ------------------------------------------------------------------ *
|
|
2
|
+
* Predicate-based field query helpers (ported from moonlanding).
|
|
3
|
+
* Additive: these do not change thatcher's existing forEachField /
|
|
4
|
+
* getEditableFields / getFieldNames / getFieldsByType signatures.
|
|
5
|
+
* ------------------------------------------------------------------ */
|
|
6
|
+
|
|
11
7
|
export function forEachField(spec, callback, options = {}) {
|
|
12
8
|
const { includeComputed = false, includeHidden = false } = options;
|
|
13
9
|
|
|
@@ -18,50 +14,21 @@ export function forEachField(spec, callback, options = {}) {
|
|
|
18
14
|
}
|
|
19
15
|
}
|
|
20
16
|
|
|
21
|
-
/**
|
|
22
|
-
* Get all field names
|
|
23
|
-
* @param {object} spec
|
|
24
|
-
* @returns {string[]}
|
|
25
|
-
*/
|
|
26
17
|
export function getFieldNames(spec) {
|
|
27
18
|
return Object.keys(spec.fields || {});
|
|
28
19
|
}
|
|
29
20
|
|
|
30
|
-
/**
|
|
31
|
-
* Get only editable fields
|
|
32
|
-
* @param {object} spec
|
|
33
|
-
* @returns {string[]}
|
|
34
|
-
*/
|
|
35
21
|
export function getEditableFields(spec) {
|
|
36
22
|
return Object.entries(spec.fields || {})
|
|
37
23
|
.filter(([key, field]) => !field.readonly && !field.auto && !field.auto_generate)
|
|
38
24
|
.map(([key]) => key);
|
|
39
25
|
}
|
|
40
26
|
|
|
41
|
-
/**
|
|
42
|
-
* Get field by type
|
|
43
|
-
* @param {object} spec
|
|
44
|
-
* @param {string} type
|
|
45
|
-
* @returns {Array<{key: string, field: object}>}
|
|
46
|
-
*/
|
|
47
27
|
export function getFieldsByType(spec, type) {
|
|
48
28
|
return Object.entries(spec.fields || {})
|
|
49
29
|
.filter(([key, field]) => field.type === type);
|
|
50
30
|
}
|
|
51
31
|
|
|
52
|
-
/* ------------------------------------------------------------------ *
|
|
53
|
-
* Predicate-based field query helpers (ported from moonlanding).
|
|
54
|
-
* Additive: these do not change thatcher's existing forEachField /
|
|
55
|
-
* getEditableFields / getFieldNames / getFieldsByType signatures.
|
|
56
|
-
* ------------------------------------------------------------------ */
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Query the fields of a spec with one or more predicates.
|
|
60
|
-
* @param {object} spec
|
|
61
|
-
* @param {Function|Function[]} predicates - (field, key) => boolean
|
|
62
|
-
* @param {object} [options] - { keysOnly }
|
|
63
|
-
* @returns {Array<string|object>} keys when keysOnly, else { key, ...field }
|
|
64
|
-
*/
|
|
65
32
|
export function fieldQuery(spec, predicates, options = {}) {
|
|
66
33
|
const preds = Array.isArray(predicates) ? predicates : [predicates];
|
|
67
34
|
const results = [];
|
|
@@ -73,9 +40,6 @@ export function fieldQuery(spec, predicates, options = {}) {
|
|
|
73
40
|
return results;
|
|
74
41
|
}
|
|
75
42
|
|
|
76
|
-
/**
|
|
77
|
-
* Reusable field predicates for fieldQuery.
|
|
78
|
-
*/
|
|
79
43
|
export const is = {
|
|
80
44
|
notId: f => f.type !== 'id',
|
|
81
45
|
notHidden: f => !f.hidden,
|
|
@@ -90,85 +54,52 @@ export const is = {
|
|
|
90
54
|
hasProperty: prop => f => f[prop] !== undefined,
|
|
91
55
|
};
|
|
92
56
|
|
|
93
|
-
|
|
94
|
-
* Editable fields as full { key, ...field } objects (form rendering).
|
|
95
|
-
* Distinct from thatcher's getEditableFields (which returns names only).
|
|
96
|
-
*/
|
|
57
|
+
// Returns { key, ...field } objects, unlike getEditableFields() which returns names only.
|
|
97
58
|
export function getFormFields(spec) {
|
|
98
59
|
if (spec.system_entity) return [];
|
|
99
60
|
return fieldQuery(spec, is.editable);
|
|
100
61
|
}
|
|
101
62
|
|
|
102
|
-
/**
|
|
103
|
-
* Listable fields as full { key, ...field } objects.
|
|
104
|
-
*/
|
|
105
63
|
export function getListFields(spec) {
|
|
106
64
|
return fieldQuery(spec, is.listable);
|
|
107
65
|
}
|
|
108
66
|
|
|
109
|
-
/**
|
|
110
|
-
* Displayable fields as full { key, ...field } objects.
|
|
111
|
-
*/
|
|
112
67
|
export function getDisplayFields(spec) {
|
|
113
68
|
return fieldQuery(spec, is.displayable);
|
|
114
69
|
}
|
|
115
70
|
|
|
116
|
-
/**
|
|
117
|
-
* Required field names.
|
|
118
|
-
*/
|
|
119
71
|
export function getRequiredFields(spec) {
|
|
120
72
|
return fieldQuery(spec, is.required, { keysOnly: true });
|
|
121
73
|
}
|
|
122
74
|
|
|
123
|
-
/**
|
|
124
|
-
* Searchable field names.
|
|
125
|
-
*/
|
|
126
75
|
export function getSearchFields(spec) {
|
|
127
76
|
return fieldQuery(spec, is.searchable, { keysOnly: true });
|
|
128
77
|
}
|
|
129
78
|
|
|
130
|
-
/**
|
|
131
|
-
* Filterable fields, resolved from list.filters config.
|
|
132
|
-
*/
|
|
133
79
|
export function getFilterableFields(spec) {
|
|
134
80
|
return (spec.list?.filters || [])
|
|
135
81
|
.map(filterKey => spec.fields?.[filterKey])
|
|
136
82
|
.filter(Boolean);
|
|
137
83
|
}
|
|
138
84
|
|
|
139
|
-
/**
|
|
140
|
-
* Reference (ref) fields as full { key, ...field } objects.
|
|
141
|
-
*/
|
|
142
85
|
export function getRefFields(spec) {
|
|
143
86
|
return fieldQuery(spec, is.ref);
|
|
144
87
|
}
|
|
145
88
|
|
|
146
|
-
/**
|
|
147
|
-
* Get a single field definition by key.
|
|
148
|
-
*/
|
|
149
89
|
export function getField(spec, fieldKey) {
|
|
150
90
|
return spec.fields?.[fieldKey];
|
|
151
91
|
}
|
|
152
92
|
|
|
153
|
-
/**
|
|
154
|
-
* Get a single field's type by key.
|
|
155
|
-
*/
|
|
156
93
|
export function getFieldType(spec, fieldKey) {
|
|
157
94
|
return spec.fields?.[fieldKey]?.type;
|
|
158
95
|
}
|
|
159
96
|
|
|
160
|
-
/**
|
|
161
|
-
* Iterate editable fields on create. callback = (key, field) => void
|
|
162
|
-
*/
|
|
163
97
|
export function iterateCreateFields(spec, callback) {
|
|
164
98
|
for (const { key, ...field } of fieldQuery(spec, is.editable)) {
|
|
165
99
|
callback(key, field);
|
|
166
100
|
}
|
|
167
101
|
}
|
|
168
102
|
|
|
169
|
-
/**
|
|
170
|
-
* Iterate editable fields on update. callback = (key, field) => void
|
|
171
|
-
*/
|
|
172
103
|
export function iterateUpdateFields(spec, callback) {
|
|
173
104
|
for (const { key, ...field } of fieldQuery(spec, is.editable)) {
|
|
174
105
|
callback(key, field);
|
|
@@ -1,13 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Field Registry - Type coercion and field utilities
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Coerce value to field type
|
|
7
|
-
* @param {any} value
|
|
8
|
-
* @param {string} type
|
|
9
|
-
* @returns {any}
|
|
10
|
-
*/
|
|
11
1
|
export function coerceFieldValue(value, type) {
|
|
12
2
|
if (value === null || value === undefined) return value;
|
|
13
3
|
|
|
@@ -31,22 +21,10 @@ export function coerceFieldValue(value, type) {
|
|
|
31
21
|
}
|
|
32
22
|
}
|
|
33
23
|
|
|
34
|
-
/**
|
|
35
|
-
* Serialize field value for database
|
|
36
|
-
* @param {any} value
|
|
37
|
-
* @param {string} type
|
|
38
|
-
* @returns {any}
|
|
39
|
-
*/
|
|
40
24
|
export function serializeField(value, type) {
|
|
41
25
|
return coerceFieldValue(value, type);
|
|
42
26
|
}
|
|
43
27
|
|
|
44
|
-
/**
|
|
45
|
-
* Deserialize field value from database
|
|
46
|
-
* @param {any} value
|
|
47
|
-
* @param {string} type
|
|
48
|
-
* @returns {any}
|
|
49
|
-
*/
|
|
50
28
|
export function deserializeField(value, type) {
|
|
51
29
|
if (value === null) return null;
|
|
52
30
|
|
|
@@ -1,26 +1,10 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Generic CRUD Handler - Catch-all API handler for any entity
|
|
3
|
-
* This provides out-of-the-box CRUD for all entities defined in config
|
|
4
|
-
* Users can override by creating their own route files
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
1
|
import { createCrudHandlers } from '../lib/crud-factory.js';
|
|
8
2
|
import { getConfigEngineSync } from '../lib/config-generator-engine.js';
|
|
9
3
|
|
|
10
|
-
/**
|
|
11
|
-
* Get handler for an entity
|
|
12
|
-
* @param {string} entityName
|
|
13
|
-
* @returns {object} {GET, POST, PUT, DELETE}
|
|
14
|
-
*/
|
|
15
4
|
export function getEntityHandlers(entityName) {
|
|
16
5
|
return createCrudHandlers(entityName);
|
|
17
6
|
}
|
|
18
7
|
|
|
19
|
-
/**
|
|
20
|
-
* Check if entity exists and is accessible
|
|
21
|
-
* @param {string} entityName
|
|
22
|
-
* @returns {boolean}
|
|
23
|
-
*/
|
|
24
8
|
export function hasEntity(entityName) {
|
|
25
9
|
try {
|
|
26
10
|
const engine = getConfigEngineSync();
|
package/src/lib/hook-engine.js
CHANGED
|
@@ -1,19 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
*/
|
|
1
|
+
import { createLogger } from './logger.js';
|
|
2
|
+
|
|
3
|
+
const log = createLogger('[HookEngine]');
|
|
5
4
|
|
|
6
5
|
export class HookEngine {
|
|
7
6
|
constructor() {
|
|
8
7
|
this.hooks = new Map();
|
|
9
8
|
}
|
|
10
9
|
|
|
11
|
-
/**
|
|
12
|
-
* Register a hook handler
|
|
13
|
-
* @param {string} name - Event name
|
|
14
|
-
* @param {Function} callback - Handler function
|
|
15
|
-
* @param {object} options - { priority, once }
|
|
16
|
-
*/
|
|
17
10
|
register(name, callback, options = {}) {
|
|
18
11
|
const { priority = 0, once = false } = options;
|
|
19
12
|
if (!this.hooks.has(name)) this.hooks.set(name, []);
|
|
@@ -23,16 +16,10 @@ export class HookEngine {
|
|
|
23
16
|
return this;
|
|
24
17
|
}
|
|
25
18
|
|
|
26
|
-
/**
|
|
27
|
-
* Alias for register
|
|
28
|
-
*/
|
|
29
19
|
on(name, callback, options = {}) {
|
|
30
20
|
return this.register(name, callback, options);
|
|
31
21
|
}
|
|
32
22
|
|
|
33
|
-
/**
|
|
34
|
-
* Unregister a hook handler
|
|
35
|
-
*/
|
|
36
23
|
off(name, callback) {
|
|
37
24
|
const list = this.hooks.get(name);
|
|
38
25
|
if (!list) return this;
|
|
@@ -42,13 +29,6 @@ export class HookEngine {
|
|
|
42
29
|
return this;
|
|
43
30
|
}
|
|
44
31
|
|
|
45
|
-
/**
|
|
46
|
-
* Execute all handlers for an event (fire-and-forget style)
|
|
47
|
-
* @param {string} name
|
|
48
|
-
* @param {object} data
|
|
49
|
-
* @param {object} options - { fallthrough: true to continue on error }
|
|
50
|
-
* @returns {Promise<{success: boolean, data, errors}>}
|
|
51
|
-
*/
|
|
52
32
|
async execute(name, data = {}, options = {}) {
|
|
53
33
|
const { fallthrough = true } = options;
|
|
54
34
|
const hooks = this.hooks.get(name);
|
|
@@ -60,7 +40,7 @@ export class HookEngine {
|
|
|
60
40
|
await hook.callback(data);
|
|
61
41
|
if (hook.once) this.off(name, hook.callback);
|
|
62
42
|
} catch (error) {
|
|
63
|
-
|
|
43
|
+
log.error(`hook "${name}" error:`, { message: error.message });
|
|
64
44
|
errors.push(error);
|
|
65
45
|
if (!fallthrough) throw error;
|
|
66
46
|
}
|
|
@@ -69,12 +49,6 @@ export class HookEngine {
|
|
|
69
49
|
return { success: errors.length === 0, data, errors };
|
|
70
50
|
}
|
|
71
51
|
|
|
72
|
-
/**
|
|
73
|
-
* Pipe data through transformation hooks
|
|
74
|
-
* @param {string} name
|
|
75
|
-
* @param {any} data
|
|
76
|
-
* @returns {Promise<any>}
|
|
77
|
-
*/
|
|
78
52
|
async pipe(name, data = {}) {
|
|
79
53
|
const hooks = this.hooks.get(name);
|
|
80
54
|
if (!hooks || hooks.length === 0) return data;
|
|
@@ -86,25 +60,16 @@ export class HookEngine {
|
|
|
86
60
|
if (result !== undefined) current = result;
|
|
87
61
|
if (hook.once) this.off(name, hook.callback);
|
|
88
62
|
} catch (error) {
|
|
89
|
-
|
|
63
|
+
log.error(`pipe hook "${name}" error:`, { message: error.message });
|
|
90
64
|
}
|
|
91
65
|
}
|
|
92
66
|
return current;
|
|
93
67
|
}
|
|
94
68
|
|
|
95
|
-
/**
|
|
96
|
-
* Get all listeners for an event
|
|
97
|
-
* @param {string} name
|
|
98
|
-
* @returns {Array}
|
|
99
|
-
*/
|
|
100
69
|
listeners(name) {
|
|
101
70
|
return this.hooks.has(name) ? Array.from(this.hooks.get(name)) : [];
|
|
102
71
|
}
|
|
103
72
|
|
|
104
|
-
/**
|
|
105
|
-
* Get statistics
|
|
106
|
-
* @returns {object} Map of event -> count
|
|
107
|
-
*/
|
|
108
73
|
stats() {
|
|
109
74
|
const result = {};
|
|
110
75
|
for (const [name, list] of this.hooks.entries()) {
|
|
@@ -113,34 +78,21 @@ export class HookEngine {
|
|
|
113
78
|
return result;
|
|
114
79
|
}
|
|
115
80
|
|
|
116
|
-
/**
|
|
117
|
-
* Clear all hooks
|
|
118
|
-
*/
|
|
119
81
|
clear() {
|
|
120
82
|
this.hooks.clear();
|
|
121
83
|
}
|
|
122
84
|
}
|
|
123
85
|
|
|
124
|
-
// Global singleton
|
|
125
86
|
export const hookEngine = new HookEngine();
|
|
126
87
|
|
|
127
|
-
/**
|
|
128
|
-
* Execute hook (convenience function)
|
|
129
|
-
*/
|
|
130
88
|
export async function executeHook(name, data = {}, options = {}) {
|
|
131
89
|
return hookEngine.execute(name, data, options);
|
|
132
90
|
}
|
|
133
91
|
|
|
134
|
-
/**
|
|
135
|
-
* Pipe through hooks
|
|
136
|
-
*/
|
|
137
92
|
export async function pipeHook(name, data = {}) {
|
|
138
93
|
return hookEngine.pipe(name, data);
|
|
139
94
|
}
|
|
140
95
|
|
|
141
|
-
/**
|
|
142
|
-
* Register hook debug exposure
|
|
143
|
-
*/
|
|
144
96
|
function registerHookDebug() {
|
|
145
97
|
if (globalThis.__debug__) {
|
|
146
98
|
globalThis.__debug__.expose('hooks', {
|