thatcher 1.0.7 → 1.0.34
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 +5 -41
- 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 +1 -1
- 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/crud-handlers.js
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
* This is where request/response handling, validation, auth, and business logic meet
|
|
4
|
-
*/
|
|
1
|
+
import { createLogger } from './logger.js';
|
|
2
|
+
import { get, listWithPagination, searchWithPagination, create, update, remove } from './busybase-store.js';
|
|
5
3
|
|
|
6
|
-
|
|
7
|
-
import { create, update, remove } from './query-engine-write.js';
|
|
4
|
+
const log = createLogger('[CRUD]');
|
|
8
5
|
import { validateEntity, validateUpdate, sanitizeData } from './validate.js';
|
|
9
6
|
import { requirePermission, getSessionToken } from './auth-middleware.js';
|
|
10
7
|
import { executeHook } from './hook-engine.js';
|
|
@@ -13,24 +10,16 @@ import { ok, created, paginated, noContent, error } from './response-formatter.j
|
|
|
13
10
|
import { HTTP } from '../config/constants.js';
|
|
14
11
|
import { permissionService } from '../services/permission.service.js';
|
|
15
12
|
import { parse as parseQuery } from './query-string-adapter.js';
|
|
16
|
-
import { now } from './
|
|
13
|
+
import { now } from './id-helpers.js';
|
|
17
14
|
import { getConfigEngineSync } from './config-generator-engine.js';
|
|
18
|
-
import { logAction } from './audit
|
|
15
|
+
import { logAction } from './busybase-audit.js';
|
|
19
16
|
|
|
20
|
-
/**
|
|
21
|
-
* Build a complete set of CRUD handlers for an entity
|
|
22
|
-
* @param {object} spec - Entity specification
|
|
23
|
-
* @returns {object}
|
|
24
|
-
*/
|
|
25
17
|
export function createCrudHandlers(entityName, spec) {
|
|
26
18
|
if (!spec) {
|
|
27
19
|
spec = getConfigEngineSync().generateEntitySpec(entityName);
|
|
28
20
|
}
|
|
29
21
|
|
|
30
22
|
return {
|
|
31
|
-
/**
|
|
32
|
-
* List entities (GET /api/:entity)
|
|
33
|
-
*/
|
|
34
23
|
list: async (req, context) => {
|
|
35
24
|
const { user } = context;
|
|
36
25
|
await requirePermission(user, spec, 'list');
|
|
@@ -76,16 +65,13 @@ export function createCrudHandlers(entityName, spec) {
|
|
|
76
65
|
return paginated(filteredItems, pagination);
|
|
77
66
|
},
|
|
78
67
|
|
|
79
|
-
/**
|
|
80
|
-
* Get single entity (GET /api/:entity/:id)
|
|
81
|
-
*/
|
|
82
68
|
get: async (id, req, context) => {
|
|
83
69
|
const { user } = context;
|
|
84
70
|
await requirePermission(user, spec, 'view');
|
|
85
71
|
|
|
86
72
|
if (!id) throw new AppError('ID required', 'BAD_REQUEST', HTTP.BAD_REQUEST);
|
|
87
73
|
|
|
88
|
-
const item = get(entityName, id);
|
|
74
|
+
const item = await get(entityName, id);
|
|
89
75
|
if (!item) throw NotFoundError(entityName, id);
|
|
90
76
|
|
|
91
77
|
if (!permissionService.checkRowAccess(user, spec, item)) {
|
|
@@ -95,9 +81,6 @@ export function createCrudHandlers(entityName, spec) {
|
|
|
95
81
|
return ok(permissionService.filterFields(user, spec, item));
|
|
96
82
|
},
|
|
97
83
|
|
|
98
|
-
/**
|
|
99
|
-
* Create entity (POST /api/:entity)
|
|
100
|
-
*/
|
|
101
84
|
create: async (req, context) => {
|
|
102
85
|
const { user } = context;
|
|
103
86
|
await requirePermission(user, spec, 'create');
|
|
@@ -111,32 +94,28 @@ export function createCrudHandlers(entityName, spec) {
|
|
|
111
94
|
}
|
|
112
95
|
|
|
113
96
|
const sanitized = sanitizeData(entityName, rawData, spec);
|
|
114
|
-
const record = create(entityName, sanitized, user);
|
|
97
|
+
const record = await create(entityName, sanitized, user);
|
|
115
98
|
|
|
116
99
|
// Audit log (ported from moon)
|
|
117
100
|
logAction(entityName, record.id, 'create', user?.id, null, record);
|
|
118
101
|
|
|
119
|
-
// Execute hooks
|
|
120
102
|
executeHook(`create:${entityName}:after`, {
|
|
121
103
|
entity: entityName,
|
|
122
104
|
id: record.id,
|
|
123
105
|
data: record,
|
|
124
106
|
user,
|
|
125
|
-
}).catch(
|
|
107
|
+
}).catch(e => log.error(e.message));
|
|
126
108
|
|
|
127
109
|
return created(permissionService.filterFields(user, spec, record));
|
|
128
110
|
},
|
|
129
111
|
|
|
130
|
-
/**
|
|
131
|
-
* Update entity (PUT/PATCH /api/:entity/:id)
|
|
132
|
-
*/
|
|
133
112
|
update: async (id, req, context) => {
|
|
134
113
|
const { user } = context;
|
|
135
114
|
await requirePermission(user, spec, 'edit');
|
|
136
115
|
|
|
137
116
|
if (!id) throw new AppError('ID required', 'BAD_REQUEST', HTTP.BAD_REQUEST);
|
|
138
117
|
|
|
139
|
-
const existing = get(entityName, id);
|
|
118
|
+
const existing = await get(entityName, id);
|
|
140
119
|
if (!existing) throw NotFoundError(entityName, id);
|
|
141
120
|
|
|
142
121
|
if (!permissionService.checkRowAccess(user, spec, existing)) {
|
|
@@ -152,7 +131,7 @@ export function createCrudHandlers(entityName, spec) {
|
|
|
152
131
|
}
|
|
153
132
|
|
|
154
133
|
const sanitized = sanitizeData(entityName, rawData, spec, existing);
|
|
155
|
-
const record = update(entityName, id, sanitized, user);
|
|
134
|
+
const record = await update(entityName, id, sanitized, user);
|
|
156
135
|
|
|
157
136
|
// Audit log (ported from moon)
|
|
158
137
|
logAction(entityName, id, 'update', user?.id, existing, record);
|
|
@@ -164,21 +143,18 @@ export function createCrudHandlers(entityName, spec) {
|
|
|
164
143
|
before: existing,
|
|
165
144
|
after: record,
|
|
166
145
|
user,
|
|
167
|
-
}).catch(
|
|
146
|
+
}).catch(e => log.error(e.message));
|
|
168
147
|
|
|
169
148
|
return ok(permissionService.filterFields(user, spec, record));
|
|
170
149
|
},
|
|
171
150
|
|
|
172
|
-
/**
|
|
173
|
-
* Delete entity (DELETE /api/:entity/:id)
|
|
174
|
-
*/
|
|
175
151
|
remove: async (id, req, context) => {
|
|
176
152
|
const { user } = context;
|
|
177
153
|
await requirePermission(user, spec, 'delete');
|
|
178
154
|
|
|
179
155
|
if (!id) throw new AppError('ID required', 'BAD_REQUEST', HTTP.BAD_REQUEST);
|
|
180
156
|
|
|
181
|
-
const existing = get(entityName, id);
|
|
157
|
+
const existing = await get(entityName, id);
|
|
182
158
|
if (!existing) throw NotFoundError(entityName, id);
|
|
183
159
|
|
|
184
160
|
if (!permissionService.checkRowAccess(user, spec, existing)) {
|
|
@@ -190,13 +166,13 @@ export function createCrudHandlers(entityName, spec) {
|
|
|
190
166
|
let result;
|
|
191
167
|
if (spec.immutable === true && spec.immutable_strategy === 'move_to_archive') {
|
|
192
168
|
const archiveData = { archived: true, archived_at: now(), archived_by: user?.id };
|
|
193
|
-
result = update(entityName, id, archiveData, user);
|
|
169
|
+
result = await update(entityName, id, archiveData, user);
|
|
194
170
|
logAction(entityName, id, 'archive', user?.id, existing, archiveData);
|
|
195
171
|
} else if (spec.fields?.status) {
|
|
196
|
-
result = update(entityName, id, { status: 'deleted' }, user);
|
|
172
|
+
result = await update(entityName, id, { status: 'deleted' }, user);
|
|
197
173
|
logAction(entityName, id, 'delete', user?.id, existing, { status: 'deleted' });
|
|
198
174
|
} else {
|
|
199
|
-
result = remove(entityName, id);
|
|
175
|
+
result = await remove(entityName, id);
|
|
200
176
|
logAction(entityName, id, 'delete', user?.id, existing, null);
|
|
201
177
|
}
|
|
202
178
|
|
|
@@ -205,22 +181,18 @@ export function createCrudHandlers(entityName, spec) {
|
|
|
205
181
|
id,
|
|
206
182
|
data: result,
|
|
207
183
|
user,
|
|
208
|
-
}).catch(
|
|
184
|
+
}).catch(e => log.error(e.message));
|
|
209
185
|
|
|
210
186
|
return noContent();
|
|
211
187
|
},
|
|
212
188
|
|
|
213
|
-
/**
|
|
214
|
-
* Custom action handler
|
|
215
|
-
*/
|
|
216
189
|
customAction: async (action, id, data, context) => {
|
|
217
190
|
const { user } = context;
|
|
218
191
|
await requirePermission(user, spec, 'edit'); // Simplified permission check
|
|
219
192
|
|
|
220
|
-
const record = get(entityName, id);
|
|
193
|
+
const record = await get(entityName, id);
|
|
221
194
|
if (!record) throw NotFoundError(entityName, id);
|
|
222
195
|
|
|
223
|
-
// Custom action logic (upload, manage_flags, etc.)
|
|
224
196
|
if (action === 'upload_files') {
|
|
225
197
|
const files = Array.isArray(data.files) ? data.files : [data.files];
|
|
226
198
|
executeHook(`upload_files:${entityName}:after`, {
|
|
@@ -232,18 +204,11 @@ export function createCrudHandlers(entityName, spec) {
|
|
|
232
204
|
return ok({ id, uploaded_files: files });
|
|
233
205
|
}
|
|
234
206
|
|
|
235
|
-
// More actions can be added here
|
|
236
207
|
throw new AppError(`Unknown action: ${action}`, 'BAD_REQUEST', HTTP.BAD_REQUEST);
|
|
237
208
|
},
|
|
238
209
|
};
|
|
239
210
|
}
|
|
240
211
|
|
|
241
|
-
/**
|
|
242
|
-
* Coerce field value to correct type
|
|
243
|
-
* @param {any} value
|
|
244
|
-
* @param {string} type
|
|
245
|
-
* @returns {any}
|
|
246
|
-
*/
|
|
247
212
|
function coerceFieldValue(value, type) {
|
|
248
213
|
if (value === null || value === undefined) return value;
|
|
249
214
|
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { createLogger } from './logger.js';
|
|
1
2
|
import crypto from 'crypto';
|
|
2
3
|
import { HTTP } from '@/config/constants';
|
|
3
4
|
|
|
5
|
+
const log = createLogger('[CSRF]');
|
|
6
|
+
|
|
4
7
|
const tokens = new Map();
|
|
5
8
|
const TOKEN_TTL = 3600000;
|
|
6
9
|
|
|
@@ -44,14 +47,14 @@ export async function withCsrfValidation(handler) {
|
|
|
44
47
|
const token = request.headers.get('x-csrf-token');
|
|
45
48
|
|
|
46
49
|
if (!token || !validateToken(token)) {
|
|
47
|
-
|
|
50
|
+
log.warn(`invalid token for ${method}`);
|
|
48
51
|
return new Response(JSON.stringify({ error: 'Invalid CSRF token' }), {
|
|
49
52
|
status: HTTP.FORBIDDEN,
|
|
50
53
|
headers: { 'Content-Type': 'application/json' },
|
|
51
54
|
});
|
|
52
55
|
}
|
|
53
56
|
} catch (e) {
|
|
54
|
-
|
|
57
|
+
log.error('validation error:', { message: e.message });
|
|
55
58
|
return new Response(JSON.stringify({ error: 'CSRF validation failed' }), {
|
|
56
59
|
status: HTTP.BAD_REQUEST,
|
|
57
60
|
headers: { 'Content-Type': 'application/json' },
|
package/src/lib/date-utils.js
CHANGED
|
@@ -1,20 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
* Date/Time Utilities - Common date operations
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { now } from './database-core.js';
|
|
1
|
+
import { now } from './id-helpers.js';
|
|
6
2
|
|
|
7
3
|
const SECONDS_PER_MINUTE = 60;
|
|
8
4
|
const SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE;
|
|
9
5
|
const SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR;
|
|
10
6
|
const SECONDS_PER_YEAR = 365.25 * SECONDS_PER_DAY;
|
|
11
7
|
|
|
12
|
-
/**
|
|
13
|
-
* Format Unix timestamp to human-readable date
|
|
14
|
-
* @param {number} timestamp - Unix seconds
|
|
15
|
-
* @param {string} [format='short']
|
|
16
|
-
* @returns {string}
|
|
17
|
-
*/
|
|
18
8
|
export function formatDate(timestamp, format = 'short') {
|
|
19
9
|
if (!timestamp) return '—';
|
|
20
10
|
const date = new Date(timestamp * 1000);
|
|
@@ -34,11 +24,6 @@ export function formatDate(timestamp, format = 'short') {
|
|
|
34
24
|
return date.toLocaleDateString();
|
|
35
25
|
}
|
|
36
26
|
|
|
37
|
-
/**
|
|
38
|
-
* Format relative time (e.g., "2 hours ago")
|
|
39
|
-
* @param {number} timestamp - Unix seconds
|
|
40
|
-
* @returns {string}
|
|
41
|
-
*/
|
|
42
27
|
export function formatRelative(timestamp) {
|
|
43
28
|
const nowSec = now();
|
|
44
29
|
const diff = nowSec - timestamp;
|
|
@@ -59,13 +44,6 @@ export function formatRelative(timestamp) {
|
|
|
59
44
|
return formatDate(timestamp, 'short');
|
|
60
45
|
}
|
|
61
46
|
|
|
62
|
-
/**
|
|
63
|
-
* Check if timestamp is within allowed year range
|
|
64
|
-
* @param {number} timestamp
|
|
65
|
-
* @param {number} minYearsAgo
|
|
66
|
-
* @param {number} maxYearsAhead
|
|
67
|
-
* @returns {boolean}
|
|
68
|
-
*/
|
|
69
47
|
export function isWithinYears(timestamp, minYearsAgo = 10, maxYearsAhead = 5) {
|
|
70
48
|
const nowSec = now();
|
|
71
49
|
return (
|
|
@@ -74,59 +52,28 @@ export function isWithinYears(timestamp, minYearsAgo = 10, maxYearsAhead = 5) {
|
|
|
74
52
|
);
|
|
75
53
|
}
|
|
76
54
|
|
|
77
|
-
/**
|
|
78
|
-
* Check if date1 is before date2
|
|
79
|
-
* @param {number} date1 - Unix timestamp
|
|
80
|
-
* @param {number} date2 - Unix timestamp
|
|
81
|
-
* @returns {boolean}
|
|
82
|
-
*/
|
|
83
55
|
export function isBeforeDate(date1, date2) {
|
|
84
56
|
return date1 < date2;
|
|
85
57
|
}
|
|
86
58
|
|
|
87
|
-
/**
|
|
88
|
-
* Add days to current timestamp
|
|
89
|
-
* @param {number} days
|
|
90
|
-
* @returns {number}
|
|
91
|
-
*/
|
|
92
59
|
export function addDays(days) {
|
|
93
60
|
return now() + (days * SECONDS_PER_DAY);
|
|
94
61
|
}
|
|
95
62
|
|
|
96
|
-
/**
|
|
97
|
-
* Add hours to current timestamp
|
|
98
|
-
* @param {number} hours
|
|
99
|
-
* @returns {number}
|
|
100
|
-
*/
|
|
101
63
|
export function addHours(hours) {
|
|
102
64
|
return now() + (hours * SECONDS_PER_HOUR);
|
|
103
65
|
}
|
|
104
66
|
|
|
105
|
-
/**
|
|
106
|
-
* Start of day (midnight) for timestamp
|
|
107
|
-
* @param {number} [timestamp]
|
|
108
|
-
* @returns {number}
|
|
109
|
-
*/
|
|
110
67
|
export function startOfDay(timestamp = now()) {
|
|
111
68
|
const date = new Date(timestamp * 1000);
|
|
112
69
|
date.setHours(0, 0, 0, 0);
|
|
113
70
|
return Math.floor(date.getTime() / 1000);
|
|
114
71
|
}
|
|
115
72
|
|
|
116
|
-
/**
|
|
117
|
-
* End of day (23:59:59) for timestamp
|
|
118
|
-
* @param {number} [timestamp]
|
|
119
|
-
* @returns {number}
|
|
120
|
-
*/
|
|
121
73
|
export function endOfDay(timestamp = now()) {
|
|
122
74
|
return startOfDay(timestamp) + SECONDS_PER_DAY - 1;
|
|
123
75
|
}
|
|
124
76
|
|
|
125
|
-
/**
|
|
126
|
-
* Get days remaining until deadline
|
|
127
|
-
* @param {number} deadlineTs - Unix timestamp
|
|
128
|
-
* @returns {number} - negative if past due
|
|
129
|
-
*/
|
|
130
77
|
export function daysRemaining(deadlineTs) {
|
|
131
78
|
const nowSec = now();
|
|
132
79
|
const startOfNextDay = startOfDay(nowSec) + SECONDS_PER_DAY;
|
|
@@ -134,20 +81,10 @@ export function daysRemaining(deadlineTs) {
|
|
|
134
81
|
return Math.ceil(remaining / SECONDS_PER_DAY);
|
|
135
82
|
}
|
|
136
83
|
|
|
137
|
-
/**
|
|
138
|
-
* Check if timestamp is in the past
|
|
139
|
-
* @param {number} timestamp
|
|
140
|
-
* @returns {boolean}
|
|
141
|
-
*/
|
|
142
84
|
export function isPast(timestamp) {
|
|
143
85
|
return now() > timestamp;
|
|
144
86
|
}
|
|
145
87
|
|
|
146
|
-
/**
|
|
147
|
-
* Format duration in seconds to human-readable
|
|
148
|
-
* @param {number} seconds
|
|
149
|
-
* @returns {string}
|
|
150
|
-
*/
|
|
151
88
|
export function formatDuration(seconds) {
|
|
152
89
|
if (seconds < 60) return `${seconds}s`;
|
|
153
90
|
const mins = Math.floor(seconds / 60);
|
|
@@ -162,23 +99,12 @@ export function formatDuration(seconds) {
|
|
|
162
99
|
// Additive helpers ported from moonlanding (adapted to Unix-seconds model)
|
|
163
100
|
// ---------------------------------------------------------------------------
|
|
164
101
|
|
|
165
|
-
/**
|
|
166
|
-
* Whether a timestamp falls on a working day (Mon–Fri).
|
|
167
|
-
* @param {number} timestamp - Unix seconds
|
|
168
|
-
* @returns {boolean}
|
|
169
|
-
*/
|
|
170
102
|
export function isWorkingDay(timestamp) {
|
|
171
103
|
if (!timestamp) return false;
|
|
172
104
|
const day = new Date(timestamp * 1000).getDay();
|
|
173
105
|
return day !== 0 && day !== 6;
|
|
174
106
|
}
|
|
175
107
|
|
|
176
|
-
/**
|
|
177
|
-
* Count working days (inclusive) between two Unix-seconds timestamps.
|
|
178
|
-
* @param {number} startTs - Unix seconds
|
|
179
|
-
* @param {number} endTs - Unix seconds
|
|
180
|
-
* @returns {number}
|
|
181
|
-
*/
|
|
182
108
|
export function getWorkingDaysDiff(startTs, endTs) {
|
|
183
109
|
if (!startTs || !endTs) return 0;
|
|
184
110
|
const end = new Date(endTs * 1000);
|
|
@@ -191,12 +117,6 @@ export function getWorkingDaysDiff(startTs, endTs) {
|
|
|
191
117
|
return count;
|
|
192
118
|
}
|
|
193
119
|
|
|
194
|
-
/**
|
|
195
|
-
* Add a number of working days (Mon–Fri) to a Unix-seconds timestamp.
|
|
196
|
-
* @param {number} startTs - Unix seconds
|
|
197
|
-
* @param {number} numDays
|
|
198
|
-
* @returns {number} Unix seconds
|
|
199
|
-
*/
|
|
200
120
|
export function addWorkingDays(startTs, numDays) {
|
|
201
121
|
if (!startTs || numDays <= 0) return startTs;
|
|
202
122
|
const date = new Date(startTs * 1000);
|
|
@@ -208,11 +128,7 @@ export function addWorkingDays(startTs, numDays) {
|
|
|
208
128
|
return Math.floor(date.getTime() / 1000);
|
|
209
129
|
}
|
|
210
130
|
|
|
211
|
-
|
|
212
|
-
* Financial year (Mar 1 – Feb end) for a Unix-seconds timestamp.
|
|
213
|
-
* @param {number} [timestamp] - Unix seconds (defaults to now)
|
|
214
|
-
* @returns {number|null}
|
|
215
|
-
*/
|
|
131
|
+
// Financial year runs March 1 to end of February (South African fiscal convention).
|
|
216
132
|
export function getFinancialYear(timestamp) {
|
|
217
133
|
const ms = timestamp ? timestamp * 1000 : Date.now();
|
|
218
134
|
const date = new Date(ms);
|
|
@@ -222,11 +138,6 @@ export function getFinancialYear(timestamp) {
|
|
|
222
138
|
return month >= 2 ? year : year - 1;
|
|
223
139
|
}
|
|
224
140
|
|
|
225
|
-
/**
|
|
226
|
-
* Start/end Unix-seconds bounds for a financial year (Mar 1 – Feb end).
|
|
227
|
-
* @param {number} year
|
|
228
|
-
* @returns {{ start: number, end: number }}
|
|
229
|
-
*/
|
|
230
141
|
export function getFinancialYearRange(year) {
|
|
231
142
|
const start = new Date(year, 2, 1);
|
|
232
143
|
const lastDay = new Date(year + 1, 2, 0).getDate();
|
|
@@ -237,13 +148,6 @@ export function getFinancialYearRange(year) {
|
|
|
237
148
|
};
|
|
238
149
|
}
|
|
239
150
|
|
|
240
|
-
/**
|
|
241
|
-
* Format an amount as currency.
|
|
242
|
-
* @param {number|string} amount
|
|
243
|
-
* @param {string} [currency='ZAR']
|
|
244
|
-
* @param {string} [locale='en-ZA']
|
|
245
|
-
* @returns {string|null}
|
|
246
|
-
*/
|
|
247
151
|
export function formatCurrency(amount, currency = 'ZAR', locale = 'en-ZA') {
|
|
248
152
|
if (amount === null || amount === undefined) return null;
|
|
249
153
|
const num = typeof amount === 'string' ? parseFloat(amount) : amount;
|
|
@@ -251,13 +155,6 @@ export function formatCurrency(amount, currency = 'ZAR', locale = 'en-ZA') {
|
|
|
251
155
|
return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(num);
|
|
252
156
|
}
|
|
253
157
|
|
|
254
|
-
/**
|
|
255
|
-
* Format a number with fixed decimals.
|
|
256
|
-
* @param {number|string} value
|
|
257
|
-
* @param {number} [decimals=0]
|
|
258
|
-
* @param {string} [locale='en-ZA']
|
|
259
|
-
* @returns {string|null}
|
|
260
|
-
*/
|
|
261
158
|
export function formatNumber(value, decimals = 0, locale = 'en-ZA') {
|
|
262
159
|
if (value === null || value === undefined) return null;
|
|
263
160
|
const num = typeof value === 'string' ? parseFloat(value) : value;
|
|
@@ -268,11 +165,6 @@ export function formatNumber(value, decimals = 0, locale = 'en-ZA') {
|
|
|
268
165
|
}).format(num);
|
|
269
166
|
}
|
|
270
167
|
|
|
271
|
-
/**
|
|
272
|
-
* Human-readable file size.
|
|
273
|
-
* @param {number} bytes
|
|
274
|
-
* @returns {string|null}
|
|
275
|
-
*/
|
|
276
168
|
export function formatFileSize(bytes) {
|
|
277
169
|
if (bytes === null || bytes === undefined || bytes < 0) return null;
|
|
278
170
|
if (bytes === 0) return '0 B';
|
|
@@ -282,13 +174,6 @@ export function formatFileSize(bytes) {
|
|
|
282
174
|
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
|
283
175
|
}
|
|
284
176
|
|
|
285
|
-
/**
|
|
286
|
-
* Truncate text to a maximum length, appending a suffix.
|
|
287
|
-
* @param {*} text
|
|
288
|
-
* @param {number} [maxLength=100]
|
|
289
|
-
* @param {string} [suffix='...']
|
|
290
|
-
* @returns {string}
|
|
291
|
-
*/
|
|
292
177
|
export function truncateText(text, maxLength = 100, suffix = '...') {
|
|
293
178
|
if (!text) return '';
|
|
294
179
|
const str = String(text);
|
|
@@ -296,11 +181,6 @@ export function truncateText(text, maxLength = 100, suffix = '...') {
|
|
|
296
181
|
return str.substring(0, maxLength) + suffix;
|
|
297
182
|
}
|
|
298
183
|
|
|
299
|
-
/**
|
|
300
|
-
* Convert a Unix-seconds timestamp to a UTC ISO-8601 string.
|
|
301
|
-
* @param {number} timestamp - Unix seconds
|
|
302
|
-
* @returns {string|null}
|
|
303
|
-
*/
|
|
304
184
|
export function toUtcIso(timestamp) {
|
|
305
185
|
if (!timestamp) return null;
|
|
306
186
|
const date = new Date(timestamp * 1000);
|
|
@@ -308,11 +188,7 @@ export function toUtcIso(timestamp) {
|
|
|
308
188
|
return date.toISOString();
|
|
309
189
|
}
|
|
310
190
|
|
|
311
|
-
|
|
312
|
-
* Normalize a Firestore timestamp (or number/date) to Unix seconds.
|
|
313
|
-
* @param {*} ts
|
|
314
|
-
* @returns {number|null}
|
|
315
|
-
*/
|
|
191
|
+
// Firestore Timestamp objects expose ._seconds (newer SDK) or .seconds (older); plain numbers and date strings are also accepted.
|
|
316
192
|
export function fromFirestoreTimestamp(ts) {
|
|
317
193
|
if (!ts) return null;
|
|
318
194
|
if (ts._seconds !== undefined) return ts._seconds;
|
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Debug Registry - Comprehensive globalThis.__debug__ registry for all Thatcher subsystems
|
|
3
|
-
* Modules register on import/init; supports nested namespaces and live data
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
1
|
import { createLogger } from './logger.js';
|
|
7
2
|
|
|
8
3
|
const logger = createLogger('[DebugRegistry]');
|
package/src/lib/domain-loader.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { createLogger } from './logger.js';
|
|
1
2
|
import { getConfigEngineSync } from '@/lib/config-generator-engine';
|
|
2
3
|
|
|
4
|
+
const log = createLogger('[DomainLoader]');
|
|
5
|
+
|
|
3
6
|
const requireStr = (val, label) => { if (!val || typeof val !== 'string') throw new Error(`[DomainLoader] ${label} must be a non-empty string`); };
|
|
4
7
|
|
|
5
8
|
export class DomainLoader {
|
|
@@ -22,7 +25,7 @@ export class DomainLoader {
|
|
|
22
25
|
|
|
23
26
|
getSpecsForDomain(domain) {
|
|
24
27
|
return this.getEntitiesForDomain(domain).reduce((acc, name) => {
|
|
25
|
-
try { acc.push(this.engine.generateEntitySpec(name)); } catch (e) {
|
|
28
|
+
try { acc.push(this.engine.generateEntitySpec(name)); } catch (e) { log.error(`spec failed for ${name}:`, { message: e.message }); }
|
|
26
29
|
return acc;
|
|
27
30
|
}, []);
|
|
28
31
|
}
|
package/src/lib/email-sender.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createLogger } from './logger.js';
|
|
2
|
+
import { now, genId } from '@/lib/id-helpers';
|
|
3
|
+
|
|
4
|
+
const log = createLogger('[Email]');
|
|
5
|
+
import { list, update, create } from '@/lib/busybase-store';
|
|
2
6
|
import { sendEmail } from '@/adapters/google-gmail';
|
|
3
7
|
import { EMAIL_STATUS } from '@/config/constants';
|
|
4
8
|
import { config } from '@/config/env';
|
|
@@ -31,33 +35,33 @@ export function parseAttachments(attachmentsJson) {
|
|
|
31
35
|
} catch { return []; }
|
|
32
36
|
}
|
|
33
37
|
|
|
34
|
-
export function logEmailActivity(
|
|
38
|
+
export async function logEmailActivity(emailId, action, metadata = {}) {
|
|
35
39
|
try {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
} catch (e) { console.error('[EMAIL] Failed to log activity:', e.message); }
|
|
40
|
+
await create('activity_log', { id: genId(), entity_type: 'email', entity_id: emailId, action, metadata: JSON.stringify(metadata), created_at: now() });
|
|
41
|
+
} catch (e) { log.error('failed to log activity:', { message: e.message }); }
|
|
39
42
|
}
|
|
40
43
|
|
|
41
|
-
export function checkFailureRate(
|
|
44
|
+
export async function checkFailureRate() {
|
|
42
45
|
try {
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
const cutoff = now() - 86400;
|
|
47
|
+
const recent = (await list('email', {})).filter(e => (e.created_at || 0) >= cutoff);
|
|
48
|
+
const total = recent.length;
|
|
49
|
+
const failed = recent.filter(e => e.status === EMAIL_STATUS.FAILED).length;
|
|
50
|
+
if (total > 10 && failed / total > 0.5)
|
|
51
|
+
log.warn('HIGH FAILURE RATE ALERT:', { failureRate: `${((failed / total) * 100).toFixed(1)}%`, failed, total });
|
|
52
|
+
} catch (e) { log.error('failed to check failure rate:', { message: e.message }); }
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
async function exponentialBackoff(attempt, maxDelayMs) {
|
|
51
56
|
await new Promise(r => setTimeout(r, Math.min(1000 * Math.pow(2, attempt), maxDelayMs)));
|
|
52
57
|
}
|
|
53
58
|
|
|
54
|
-
export async function sendSingleEmail(
|
|
59
|
+
export async function sendSingleEmail(emailRecord, attempt = 1, maxRetries = 3, maxDelayMs = 30000) {
|
|
55
60
|
const validationErrors = validateEmailData(emailRecord);
|
|
56
61
|
if (validationErrors.length > 0) {
|
|
57
62
|
const errorMsg = validationErrors.join('; ');
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
logEmailActivity(db, emailRecord.id, 'email_send_failed', { error: errorMsg, attempt });
|
|
63
|
+
await update('email', emailRecord.id, { status: EMAIL_STATUS.FAILED, processing_error: errorMsg, retry_count: attempt, updated_at: now() });
|
|
64
|
+
await logEmailActivity(emailRecord.id, 'email_send_failed', { error: errorMsg, attempt });
|
|
61
65
|
return { success: false, error: errorMsg, emailId: emailRecord.id };
|
|
62
66
|
}
|
|
63
67
|
|
|
@@ -76,9 +80,8 @@ export async function sendSingleEmail(db, emailRecord, attempt = 1, maxRetries =
|
|
|
76
80
|
};
|
|
77
81
|
|
|
78
82
|
const result = await sendEmail(emailData);
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
logEmailActivity(db, emailRecord.id, 'email_sent', { messageId: result.id || result.messageId, to: emailData.to, attempt });
|
|
83
|
+
await update('email', emailRecord.id, { status: EMAIL_STATUS.PROCESSED, processed: true, message_id: result.id || result.messageId, processing_error: '', retry_count: attempt, processed_at: now(), updated_at: now() });
|
|
84
|
+
await logEmailActivity(emailRecord.id, 'email_sent', { messageId: result.id || result.messageId, to: emailData.to, attempt });
|
|
82
85
|
return { success: true, messageId: result.id || result.messageId, emailId: emailRecord.id };
|
|
83
86
|
} catch (error) {
|
|
84
87
|
const isRateLimit = error.message?.includes('429') || /quota|rate limit/i.test(error.message);
|
|
@@ -87,14 +90,13 @@ export async function sendSingleEmail(db, emailRecord, attempt = 1, maxRetries =
|
|
|
87
90
|
|
|
88
91
|
if (isPermanent || attempt >= maxRetries) {
|
|
89
92
|
const bounceStatus = isBounce ? 'bounced' : EMAIL_STATUS.FAILED;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
logEmailActivity(db, emailRecord.id, 'email_send_failed', { error: error.message, attempt, permanent: isPermanent });
|
|
93
|
+
await update('email', emailRecord.id, { status: bounceStatus, processing_error: error.message, retry_count: attempt, bounce_reason: isBounce ? error.message : '', bounced_at: isBounce ? now() : 0, bounce_permanent: isBounce ? 1 : 0, updated_at: now() });
|
|
94
|
+
await logEmailActivity(emailRecord.id, 'email_send_failed', { error: error.message, attempt, permanent: isPermanent });
|
|
93
95
|
return { success: false, error: error.message, emailId: emailRecord.id, permanent: isPermanent };
|
|
94
96
|
}
|
|
95
97
|
|
|
96
98
|
if (isRateLimit) await exponentialBackoff(attempt, maxDelayMs);
|
|
97
|
-
|
|
98
|
-
return sendSingleEmail(
|
|
99
|
+
await update('email', emailRecord.id, { retry_count: attempt, processing_error: error.message, updated_at: now() });
|
|
100
|
+
return sendSingleEmail(emailRecord, attempt + 1, maxRetries, maxDelayMs);
|
|
99
101
|
}
|
|
100
102
|
}
|
package/src/lib/error-handler.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* Error Handling - Centralized error types and utilities
|
|
3
|
-
*/
|
|
1
|
+
import { createLogger } from './logger.js';
|
|
4
2
|
|
|
5
3
|
// AppError stays a real class (base error type; `instanceof AppError` works).
|
|
6
4
|
export class AppError extends Error {
|
|
@@ -44,40 +42,12 @@ export function BadRequestError(message = 'Invalid request') {
|
|
|
44
42
|
const e = new AppError(message, 'BAD_REQUEST', 400); e.name = 'BadRequestError'; return e;
|
|
45
43
|
}
|
|
46
44
|
|
|
47
|
-
/**
|
|
48
|
-
* Create an error logger
|
|
49
|
-
* @param {string} context
|
|
50
|
-
* @returns {object}
|
|
51
|
-
*/
|
|
52
45
|
export function createErrorLogger(context = '') {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
console.error(`[${context}] ${msg}`, meta);
|
|
56
|
-
},
|
|
57
|
-
warn: (msg, meta = {}) => {
|
|
58
|
-
console.warn(`[${context}] ${msg}`, meta);
|
|
59
|
-
},
|
|
60
|
-
info: (_msg, _meta = {}) => {},
|
|
61
|
-
debug: (msg, meta = {}) => {
|
|
62
|
-
if (process.env.DEBUG) {
|
|
63
|
-
console.debug(`[${context}] Debug:`, msg, meta);
|
|
64
|
-
}
|
|
65
|
-
},
|
|
66
|
-
};
|
|
46
|
+
const log = createLogger(`[${context}]`);
|
|
47
|
+
return { error: log.error, warn: log.warn, info: (_msg, _meta = {}) => {}, debug: log.debug };
|
|
67
48
|
}
|
|
68
49
|
|
|
69
|
-
|
|
70
|
-
* Normalize an arbitrary thrown value into one of thatcher's AppError-class
|
|
71
|
-
* instances. AppError instances (and anything already shaped like one — a
|
|
72
|
-
* numeric `status` + a `code`) pass through untouched; common low-level
|
|
73
|
-
* errors are mapped to the appropriate thatcher error class.
|
|
74
|
-
*
|
|
75
|
-
* (Ported from moonlanding, adapted to thatcher's class-based hierarchy and
|
|
76
|
-
* its `status`/`code`/`details` shape. Kept import-free so the module stays
|
|
77
|
-
* loadable under the plain-node test runner.)
|
|
78
|
-
* @param {unknown} error
|
|
79
|
-
* @returns {AppError}
|
|
80
|
-
*/
|
|
50
|
+
// Ported from moonlanding; kept import-free so this module loads under the plain-node test runner.
|
|
81
51
|
export function normalizeError(error) {
|
|
82
52
|
if (error instanceof AppError) {
|
|
83
53
|
return error;
|
|
@@ -112,13 +82,7 @@ export function normalizeError(error) {
|
|
|
112
82
|
});
|
|
113
83
|
}
|
|
114
84
|
|
|
115
|
-
|
|
116
|
-
* Build a JSON-serializable error response body from any thrown value.
|
|
117
|
-
* (Ported from moonlanding, adapted to thatcher's error shape.)
|
|
118
|
-
* @param {unknown} error
|
|
119
|
-
* @param {boolean} [includeStack=false]
|
|
120
|
-
* @returns {object}
|
|
121
|
-
*/
|
|
85
|
+
// Ported from moonlanding.
|
|
122
86
|
export function formatErrorResponse(error, includeStack = false) {
|
|
123
87
|
const normalized = normalizeError(error);
|
|
124
88
|
const response = {
|