thatcher 1.0.4
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/LICENSE +21 -0
- package/README.md +398 -0
- package/package.json +73 -0
- package/src/adapters/google-auth.js +148 -0
- package/src/adapters/google-drive.js +209 -0
- package/src/app/api/[entity]/[[...path]]/route.js +33 -0
- package/src/app/api/audit/dashboard/route.js +77 -0
- package/src/app/api/audit/logs/route.js +46 -0
- package/src/app/api/audit/permissions/[id]/route.js +37 -0
- package/src/app/api/audit/permissions/route.js +93 -0
- package/src/app/api/audit/permissions/stats/route.js +29 -0
- package/src/app/api/audit/route.js +79 -0
- package/src/app/api/audit/stats/route.js +18 -0
- package/src/app/api/auth/google/callback/route.js +94 -0
- package/src/app/api/auth/google/route.js +58 -0
- package/src/app/api/auth/login/route.js +121 -0
- package/src/app/api/auth/logout/route.js +52 -0
- package/src/app/api/auth/me/route.js +16 -0
- package/src/app/api/auth/mwr-bridge/route.js +77 -0
- package/src/app/api/auth/password-reset/route.js +65 -0
- package/src/app/api/cron/trigger/route.js +58 -0
- package/src/app/api/csrf-token/route.js +8 -0
- package/src/app/api/debug/config/route.js +22 -0
- package/src/app/api/debug/hooks/route.js +16 -0
- package/src/app/api/debug/plugins/route.js +20 -0
- package/src/app/api/debug/sqlite/route.js +21 -0
- package/src/app/api/debug/sync/route.js +29 -0
- package/src/app/api/debug/workflow/route.js +20 -0
- package/src/app/api/domains/[domain]/route.js +26 -0
- package/src/app/api/domains/route.js +21 -0
- package/src/app/api/email/allocate/batch/route.js +106 -0
- package/src/app/api/email/allocate/route.js +141 -0
- package/src/app/api/email/receive/route.js +158 -0
- package/src/app/api/email/route.js +3 -0
- package/src/app/api/email/send/route.js +77 -0
- package/src/app/api/email/unallocated/route.js +47 -0
- package/src/app/api/files/[id]/route.js +38 -0
- package/src/app/api/health/route.js +95 -0
- package/src/app/api/metrics/route.js +73 -0
- package/src/app/api/monitoring/dashboard/route.js +18 -0
- package/src/cli.js +243 -0
- package/src/config/config-loader.js +112 -0
- package/src/config/constants.js +127 -0
- package/src/config/env.js +164 -0
- package/src/config/spec-helpers.js +232 -0
- package/src/engine.server.js +212 -0
- package/src/index.js +368 -0
- package/src/lib/accessibility.js +162 -0
- package/src/lib/action-factory.js +34 -0
- package/src/lib/action-utils.js +21 -0
- package/src/lib/alert-manager.js +189 -0
- package/src/lib/api-error-wrapper.js +125 -0
- package/src/lib/api-helpers.js +53 -0
- package/src/lib/api.js +82 -0
- package/src/lib/audit-logger-enhanced.js +117 -0
- package/src/lib/audit-logger.js +193 -0
- package/src/lib/auth-middleware.js +102 -0
- package/src/lib/auth-route-helpers.js +83 -0
- package/src/lib/business-rules-engine.js +86 -0
- package/src/lib/compression.js +44 -0
- package/src/lib/config-field-helpers.js +91 -0
- package/src/lib/config-generator-engine.js +445 -0
- package/src/lib/config-helpers.js +120 -0
- package/src/lib/connection-guard.js +79 -0
- package/src/lib/crud-action-helpers.js +34 -0
- package/src/lib/crud-factory.js +83 -0
- package/src/lib/crud-handlers.js +244 -0
- package/src/lib/csrf-protection.js +63 -0
- package/src/lib/database-core.js +258 -0
- package/src/lib/database-migrations.js +96 -0
- package/src/lib/date-utils.js +159 -0
- package/src/lib/db-backup.js +97 -0
- package/src/lib/db-monitor.js +127 -0
- package/src/lib/domain-loader.js +82 -0
- package/src/lib/email-sender.js +100 -0
- package/src/lib/error-boundary.js +134 -0
- package/src/lib/error-handler.js +84 -0
- package/src/lib/error-recovery.js +190 -0
- package/src/lib/error-resilience.js +130 -0
- package/src/lib/errors.js +69 -0
- package/src/lib/events-engine.js +182 -0
- package/src/lib/field-iterator.js +50 -0
- package/src/lib/field-registry.js +68 -0
- package/src/lib/field-types.js +154 -0
- package/src/lib/generic-crud-handler.js +32 -0
- package/src/lib/health-monitor.js +134 -0
- package/src/lib/hook-engine.js +169 -0
- package/src/lib/hot-reload/cache-invalidator.js +115 -0
- package/src/lib/hot-reload/checkpoint.js +95 -0
- package/src/lib/hot-reload/debug-exposure.js +67 -0
- package/src/lib/hot-reload/directory-watcher.js +96 -0
- package/src/lib/hot-reload/index.js +50 -0
- package/src/lib/hot-reload/mutex.js +75 -0
- package/src/lib/hot-reload/promise-container.js +66 -0
- package/src/lib/hot-reload/route-wrapper.js +46 -0
- package/src/lib/hot-reload/safe-error.js +51 -0
- package/src/lib/hot-reload/supervisor.js +161 -0
- package/src/lib/hot-reload/timeout-wrapper.js +52 -0
- package/src/lib/http-methods-factory.js +25 -0
- package/src/lib/index-optimizer.js +96 -0
- package/src/lib/index.js +35 -0
- package/src/lib/list-data-transform.js +39 -0
- package/src/lib/log-aggregator.js +116 -0
- package/src/lib/logger.js +55 -0
- package/src/lib/metrics-collector.js +102 -0
- package/src/lib/minifier.js +19 -0
- package/src/lib/monitoring-init.js +67 -0
- package/src/lib/next-compat.js +80 -0
- package/src/lib/next-polyfills.js +135 -0
- package/src/lib/perf-monitor.js +91 -0
- package/src/lib/progress-components.js +181 -0
- package/src/lib/query-cache.js +126 -0
- package/src/lib/query-engine-write.js +221 -0
- package/src/lib/query-engine.js +399 -0
- package/src/lib/query-perf.js +117 -0
- package/src/lib/query-string-adapter.js +75 -0
- package/src/lib/realtime-server.js +67 -0
- package/src/lib/render-cache.js +61 -0
- package/src/lib/request-tracker.js +43 -0
- package/src/lib/resource-hints.js +29 -0
- package/src/lib/resource-monitor.js +117 -0
- package/src/lib/response-formatter.js +80 -0
- package/src/lib/route-helpers.js +33 -0
- package/src/lib/route-resolver.js +142 -0
- package/src/lib/safe-json.js +8 -0
- package/src/lib/server-bootstrap.js +71 -0
- package/src/lib/stage-pipeline.js +153 -0
- package/src/lib/state-protocol.js +171 -0
- package/src/lib/state-transport-client.js +169 -0
- package/src/lib/state-transport-reconnect.js +121 -0
- package/src/lib/state-transport-server.js +181 -0
- package/src/lib/static-server.js +97 -0
- package/src/lib/status-helpers.js +98 -0
- package/src/lib/universal-handler.js +7 -0
- package/src/lib/utils.js +93 -0
- package/src/lib/validate.js +197 -0
- package/src/lib/validation/business-validators.js +61 -0
- package/src/lib/validation/csrf.js +51 -0
- package/src/lib/validation/file-validators.js +34 -0
- package/src/lib/validation/format-validators.js +106 -0
- package/src/lib/validation/index.js +19 -0
- package/src/lib/validation/rate-limit.js +31 -0
- package/src/lib/validation/security-validators.js +78 -0
- package/src/lib/validation-middleware.js +133 -0
- package/src/lib/validators.js +105 -0
- package/src/lib/with-audit-logging.js +63 -0
- package/src/lib/with-error-handler.js +31 -0
- package/src/lib/workflow-engine.js +250 -0
- package/src/server/server.js +305 -0
- package/src/services/collaborator-role.service.js +205 -0
- package/src/services/email-sender.js +105 -0
- package/src/services/notification-engine.js +110 -0
- package/src/services/permission.service.js +181 -0
- package/src/ui/advanced-search-renderer.js +42 -0
- package/src/ui/advanced-widgets.js +47 -0
- package/src/ui/auth-pages.js +114 -0
- package/src/ui/auth-styles.js +53 -0
- package/src/ui/client.js +99 -0
- package/src/ui/collaboration-dialogs.js +31 -0
- package/src/ui/common-handlers.js +156 -0
- package/src/ui/component-engine.js +103 -0
- package/src/ui/dashboard-renderer.js +150 -0
- package/src/ui/dialog-engine.js +147 -0
- package/src/ui/dialog-factory.js +38 -0
- package/src/ui/engagement-cards.js +76 -0
- package/src/ui/engagement-dialogs.js +100 -0
- package/src/ui/engagement-grid-renderer.js +109 -0
- package/src/ui/entity-renderer.js +176 -0
- package/src/ui/event-delegation.js +108 -0
- package/src/ui/fetch-json.js +24 -0
- package/src/ui/file-dialogs.js +81 -0
- package/src/ui/flexup-report-renderer.js +173 -0
- package/src/ui/format-helpers.js +102 -0
- package/src/ui/global-tags.js +144 -0
- package/src/ui/highlight-threading-renderer.js +176 -0
- package/src/ui/idle-logout.js +156 -0
- package/src/ui/job-management-renderer.js +61 -0
- package/src/ui/layout.js +219 -0
- package/src/ui/letter-dialogs.js +37 -0
- package/src/ui/ml-console-renderer.js +108 -0
- package/src/ui/monitoring-dashboard-client.js +136 -0
- package/src/ui/monitoring-dashboard.js +134 -0
- package/src/ui/notifications-renderer.js +51 -0
- package/src/ui/page-handler-admin.js +121 -0
- package/src/ui/page-handler-helpers.js +111 -0
- package/src/ui/page-handler-reviews.js +165 -0
- package/src/ui/page-handler-rfi.js +42 -0
- package/src/ui/page-handler.js +210 -0
- package/src/ui/password-reset-page.js +146 -0
- package/src/ui/perf-helpers.js +96 -0
- package/src/ui/perf-renderer.js +71 -0
- package/src/ui/permissions-ui.js +163 -0
- package/src/ui/picker-dialogs.js +100 -0
- package/src/ui/render-helpers.js +124 -0
- package/src/ui/renderer.js +35 -0
- package/src/ui/review-comparison-renderer.js +58 -0
- package/src/ui/review-detail-panels.js +71 -0
- package/src/ui/review-detail-renderer.js +170 -0
- package/src/ui/review-detail-script.js +95 -0
- package/src/ui/review-mwr-renderer.js +113 -0
- package/src/ui/review-renderer.js +202 -0
- package/src/ui/review-widgets.js +88 -0
- package/src/ui/review-zone-nav.js +12 -0
- package/src/ui/rfi-detail-renderer.js +191 -0
- package/src/ui/rfi-renderer.js +194 -0
- package/src/ui/rfi-report-renderer.js +56 -0
- package/src/ui/rippleui.css +1 -0
- package/src/ui/settings-renderer-advanced.js +195 -0
- package/src/ui/settings-renderer-advanced2.js +158 -0
- package/src/ui/settings-renderer-teams.js +112 -0
- package/src/ui/settings-renderer.js +166 -0
- package/src/ui/spacing-system.js +155 -0
- package/src/ui/standalone-login.js +109 -0
- package/src/ui/styles.css +2530 -0
- package/src/ui/styles2.css +1602 -0
- package/src/ui/test-page.js +23 -0
- package/src/ui/validation-rules.js +73 -0
- package/src/ui/validation-ui.js +147 -0
- package/src/ui/virtual-scroll.js +107 -0
- package/src/ui/webjsx.js +61 -0
- package/src/ui/widgets.js +152 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { hookEngine } from '@/lib/hook-engine.js';
|
|
2
|
+
import { list, get, update, create, remove } from '@/engine.js';
|
|
3
|
+
import { queueEmail } from '@/engine/notification-engine';
|
|
4
|
+
import { safeJsonParse } from '@/lib/safe-json.js';
|
|
5
|
+
import { validateTransition } from '@/lib/workflow-engine.js';
|
|
6
|
+
import { AppError } from '@/lib/error-handler';
|
|
7
|
+
import { HTTP } from '@/config/constants';
|
|
8
|
+
|
|
9
|
+
const logActivity = (t, id, act, msg, u, d) =>
|
|
10
|
+
create('activity_log', { entity_type: t, entity_id: id, action: act, message: msg, details: d ? JSON.stringify(d) : null, user_email: u?.email }, u);
|
|
11
|
+
|
|
12
|
+
const updateEngagementProgress = (eid) => {
|
|
13
|
+
const rfis = list('rfi', { engagement_id: eid });
|
|
14
|
+
if (rfis.length > 0) {
|
|
15
|
+
const comp = rfis.filter(r => r.status === 'completed' || r.client_status === 'completed');
|
|
16
|
+
update('engagement', eid, { progress: Math.round((comp.length / rfis.length) * 100) });
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const activateWorkflowsForStage = async (engagementId, stage, user) => {
|
|
21
|
+
try {
|
|
22
|
+
const { getConfigEngine } = await import('@/lib/config-generator-engine');
|
|
23
|
+
const configEngine = await getConfigEngine();
|
|
24
|
+
const config = configEngine.getConfig();
|
|
25
|
+
|
|
26
|
+
const stageConfig = config.workflows?.engagement_lifecycle?.stages?.find(s => s.name === stage);
|
|
27
|
+
if (!stageConfig?.activates?.length) return;
|
|
28
|
+
|
|
29
|
+
for (const feature of stageConfig.activates) {
|
|
30
|
+
logActivity('engagement', engagementId, 'feature_activated', `Activated ${feature}`, user, { activated_feature: feature });
|
|
31
|
+
hookEngine.execute(`feature:activated:${feature}`, { engagementId, stage, feature, user });
|
|
32
|
+
}
|
|
33
|
+
} catch (error) {
|
|
34
|
+
console.error(`[ACTIVATION] Error activating workflows for stage ${stage}:`, error.message);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const registerEntityHandlers = () => {
|
|
39
|
+
hookEngine.register('update:engagement:before', async (context) => {
|
|
40
|
+
const { entity, data, user, prev } = context;
|
|
41
|
+
if (entity !== 'engagement' || !data.stage || data.stage === prev.stage) return context;
|
|
42
|
+
validateTransition('engagement_lifecycle', prev.stage, data.stage, user);
|
|
43
|
+
if (user?.role === 'clerk' && !(prev.clerks_can_approve === true || prev.clerks_can_approve === 1)) {
|
|
44
|
+
throw new AppError('Clerks cannot change engagement stage unless clerksCanApprove is enabled', 'INSUFFICIENT_PERMISSIONS', HTTP.FORBIDDEN);
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
create('activity_log', { entity_type: 'engagement', entity_id: context.id, action: 'stage_transition',
|
|
48
|
+
message: `Stage transitioned: ${prev.stage} -> ${data.stage}`,
|
|
49
|
+
details: JSON.stringify({ from: prev.stage, to: data.stage, user_role: user?.role }), user_email: user?.email }, user);
|
|
50
|
+
} catch (e) { console.error('[events-engine] Failed to log stage change:', e.message); }
|
|
51
|
+
return context;
|
|
52
|
+
}, { priority: 100, name: 'workflow-stage-validator' });
|
|
53
|
+
|
|
54
|
+
hookEngine.on('create:rfi_response:after', async (ctx) => {
|
|
55
|
+
if (!ctx.data?.rfi_id) return;
|
|
56
|
+
const rfi = get('rfi', ctx.data.rfi_id);
|
|
57
|
+
if (rfi) update('rfi', ctx.data.rfi_id, { response_count: (rfi.response_count || 0) + 1, last_response_date: Math.floor(Date.now() / 1000) });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
hookEngine.on('engagement:afterCreate', async (engagement, user) => {
|
|
61
|
+
if (engagement.client_id) {
|
|
62
|
+
const cnt = list('engagement', { client_id: engagement.client_id }).length;
|
|
63
|
+
update('client', engagement.client_id, { engagement_count: cnt });
|
|
64
|
+
}
|
|
65
|
+
if (engagement.stage === 'info_gathering') {
|
|
66
|
+
await queueEmail('engagement_info_gathering', { engagement, recipients: 'client_users' });
|
|
67
|
+
}
|
|
68
|
+
logActivity('engagement', engagement.id, 'create', `Engagement "${engagement.name}" created`, user);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
hookEngine.on('engagement:afterUpdate', async (engagement, changes, prev, user) => {
|
|
72
|
+
if (changes.stage && changes.stage !== prev.stage) {
|
|
73
|
+
logActivity('engagement', engagement.id, 'stage_change', `Stage: ${prev.stage} → ${changes.stage}`, user, { from: prev.stage, to: changes.stage });
|
|
74
|
+
const stageActions = {
|
|
75
|
+
commencement: () => queueEmail('engagement_commencement', { engagement, recipients: 'client_users' }),
|
|
76
|
+
finalization: async () => {
|
|
77
|
+
await queueEmail('engagement_finalization', { engagement, recipients: 'client_admin' });
|
|
78
|
+
await activateWorkflowsForStage(engagement.id, 'finalization', user);
|
|
79
|
+
},
|
|
80
|
+
closeout: () => {
|
|
81
|
+
if (engagement.letter_auditor_status !== 'accepted' && engagement.progress > 0)
|
|
82
|
+
throw new Error('Cannot close out: Letter must be accepted or progress must be 0%');
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
await stageActions[changes.stage]?.();
|
|
86
|
+
}
|
|
87
|
+
if (changes.status && changes.status !== prev.status) {
|
|
88
|
+
logActivity('engagement', engagement.id, 'status_change', `Status: ${prev.status} → ${changes.status}`, user);
|
|
89
|
+
}
|
|
90
|
+
if (changes.commencement_date && changes.commencement_date !== prev.commencement_date) {
|
|
91
|
+
await queueEmail('engagement_date_change', { engagement, field: 'commencement_date', recipients: 'team_members' });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
hookEngine.on('engagement:afterDelete', async (engagement) => {
|
|
96
|
+
if (engagement.client_id) {
|
|
97
|
+
const cnt = list('engagement', { client_id: engagement.client_id }).length;
|
|
98
|
+
update('client', engagement.client_id, { engagement_count: cnt });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
hookEngine.on('client:afterUpdate', async (client, changes, prev, user) => {
|
|
103
|
+
if (changes.status === 'inactive' && prev.status !== 'inactive') {
|
|
104
|
+
const engagements = list('engagement', { client_id: client.id });
|
|
105
|
+
for (const e of engagements) {
|
|
106
|
+
if (e.repeat_interval !== 'once') update('engagement', e.id, { repeat_interval: 'once' });
|
|
107
|
+
if (e.stage === 'info_gathering' && e.progress === 0) remove('engagement', e.id);
|
|
108
|
+
}
|
|
109
|
+
logActivity('client', client.id, 'status_change', `Client inactive. Updated ${engagements.length} engagements.`, user);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
hookEngine.on('rfi:afterUpdate', async (rfi, changes, prev, user) => {
|
|
114
|
+
if (changes.status !== undefined && changes.status !== prev.status) {
|
|
115
|
+
if (changes.status === 'completed' && !rfi.date_resolved) {
|
|
116
|
+
update('rfi', rfi.id, { date_resolved: Math.floor(Date.now() / 1000) });
|
|
117
|
+
}
|
|
118
|
+
if (changes.client_status && changes.client_status !== prev.client_status) {
|
|
119
|
+
await queueEmail('rfi_status_change', { rfi, from: prev.client_status, to: changes.client_status, recipients: 'assigned_users' });
|
|
120
|
+
}
|
|
121
|
+
logActivity('rfi', rfi.id, 'status_change', `Status: ${prev.status} → ${changes.status}`, user);
|
|
122
|
+
}
|
|
123
|
+
if (changes.deadline && changes.deadline !== prev.deadline) {
|
|
124
|
+
await queueEmail('rfi_deadline_change', { rfi, newDeadline: changes.deadline, recipients: 'assigned_users' });
|
|
125
|
+
logActivity('rfi', rfi.id, 'update', `Deadline: ${changes.deadline}`, user);
|
|
126
|
+
}
|
|
127
|
+
if (changes.status !== undefined || changes.client_status !== undefined) {
|
|
128
|
+
await updateEngagementProgress(rfi.engagement_id);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
hookEngine.on('team:afterUpdate', async (team, changes, prev) => {
|
|
133
|
+
if (changes.users && prev.users) {
|
|
134
|
+
const prevUsers = safeJsonParse(prev.users, []);
|
|
135
|
+
const newUsers = safeJsonParse(changes.users, []);
|
|
136
|
+
const removed = prevUsers.filter(u => !newUsers.includes(u));
|
|
137
|
+
if (removed.length > 0) {
|
|
138
|
+
for (const e of list('engagement', { team_id: team.id })) {
|
|
139
|
+
const users = safeJsonParse(e.users, []).filter(u => !removed.includes(u));
|
|
140
|
+
if (users.length !== safeJsonParse(e.users, []).length) {
|
|
141
|
+
update('engagement', e.id, { users: JSON.stringify(users) });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
hookEngine.on('review:afterCreate', async (review, user) => {
|
|
149
|
+
await queueEmail('review_created', { review, recipients: 'team_partners' });
|
|
150
|
+
if (review.template_id) {
|
|
151
|
+
const template = get('review_template', review.template_id);
|
|
152
|
+
for (const id of safeJsonParse(template?.default_checklists, [])) {
|
|
153
|
+
const checklist = get('checklist', id);
|
|
154
|
+
if (checklist) create('review_checklist', { review_id: review.id, checklist_id: id, items: checklist.section_items || checklist.items, status: 'pending' }, user);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
logActivity('review', review.id, 'create', `Review "${review.name}" created`, user);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
hookEngine.on('review:afterUpdate', async (review, changes, prev, user) => {
|
|
161
|
+
if (changes.status && changes.status !== prev.status) {
|
|
162
|
+
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} → ${changes.status}`, user);
|
|
164
|
+
}
|
|
165
|
+
if (changes.collaborators && changes.collaborators !== prev.collaborators) {
|
|
166
|
+
const added = safeJsonParse(changes.collaborators, []).filter(c => !safeJsonParse(prev.collaborators, []).find(p => p.id === c.id));
|
|
167
|
+
for (const c of added) await queueEmail('collaborator_added', { review, collaborator: c, recipients: 'collaborator' });
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
hookEngine.on('highlight:beforeDelete', async (highlight, user) => {
|
|
172
|
+
create('removed_highlight', { review_id: highlight.review_id, original_id: highlight.id, highlight_data: JSON.stringify(highlight) }, user);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
hookEngine.on('collaborator:afterCreate', async (collaborator, user) => {
|
|
176
|
+
const review = get('review', collaborator.review_id);
|
|
177
|
+
const collabUser = get('user', collaborator.user_id);
|
|
178
|
+
if (review && collabUser) {
|
|
179
|
+
await queueEmail('collaborator_added', { review, collaborator: collabUser, type: collaborator.type, expiresAt: collaborator.expires_at, recipients: 'collaborator' });
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field Iterator - Iterate over all fields (including nested/computed) in a spec
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Iterate over all fields in an entity spec
|
|
7
|
+
* @param {object} spec - Entity specification
|
|
8
|
+
* @param {Function} callback - (key, fieldDef) => void
|
|
9
|
+
* @param {object} options - { includeComputed, includeHidden }
|
|
10
|
+
*/
|
|
11
|
+
export function forEachField(spec, callback, options = {}) {
|
|
12
|
+
const { includeComputed = false, includeHidden = false } = options;
|
|
13
|
+
|
|
14
|
+
for (const [key, field] of Object.entries(spec.fields || {})) {
|
|
15
|
+
if (!includeHidden && field.hidden) continue;
|
|
16
|
+
if (!includeComputed && field.computed) continue;
|
|
17
|
+
callback(key, field);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get all field names
|
|
23
|
+
* @param {object} spec
|
|
24
|
+
* @returns {string[]}
|
|
25
|
+
*/
|
|
26
|
+
export function getFieldNames(spec) {
|
|
27
|
+
return Object.keys(spec.fields || {});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Get only editable fields
|
|
32
|
+
* @param {object} spec
|
|
33
|
+
* @returns {string[]}
|
|
34
|
+
*/
|
|
35
|
+
export function getEditableFields(spec) {
|
|
36
|
+
return Object.entries(spec.fields || {})
|
|
37
|
+
.filter(([key, field]) => !field.readonly && !field.auto && !field.auto_generate)
|
|
38
|
+
.map(([key]) => key);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Get field by type
|
|
43
|
+
* @param {object} spec
|
|
44
|
+
* @param {string} type
|
|
45
|
+
* @returns {Array<{key: string, field: object}>}
|
|
46
|
+
*/
|
|
47
|
+
export function getFieldsByType(spec, type) {
|
|
48
|
+
return Object.entries(spec.fields || {})
|
|
49
|
+
.filter(([key, field]) => field.type === type);
|
|
50
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
export function coerceFieldValue(value, type) {
|
|
12
|
+
if (value === null || value === undefined) return value;
|
|
13
|
+
|
|
14
|
+
switch (type) {
|
|
15
|
+
case 'int':
|
|
16
|
+
case 'decimal':
|
|
17
|
+
return Number(value);
|
|
18
|
+
case 'bool':
|
|
19
|
+
case 'boolean':
|
|
20
|
+
return value === true || value === 'true' || value === 1;
|
|
21
|
+
case 'json':
|
|
22
|
+
return typeof value === 'string' ? JSON.parse(value) : value;
|
|
23
|
+
case 'date':
|
|
24
|
+
case 'timestamp':
|
|
25
|
+
const num = Number(value);
|
|
26
|
+
return isNaN(num) ? null : num;
|
|
27
|
+
case 'ref':
|
|
28
|
+
return String(value);
|
|
29
|
+
default:
|
|
30
|
+
return String(value);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Serialize field value for database
|
|
36
|
+
* @param {any} value
|
|
37
|
+
* @param {string} type
|
|
38
|
+
* @returns {any}
|
|
39
|
+
*/
|
|
40
|
+
export function serializeField(value, type) {
|
|
41
|
+
return coerceFieldValue(value, type);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Deserialize field value from database
|
|
46
|
+
* @param {any} value
|
|
47
|
+
* @param {string} type
|
|
48
|
+
* @returns {any}
|
|
49
|
+
*/
|
|
50
|
+
export function deserializeField(value, type) {
|
|
51
|
+
if (value === null) return null;
|
|
52
|
+
|
|
53
|
+
switch (type) {
|
|
54
|
+
case 'json':
|
|
55
|
+
try {
|
|
56
|
+
return typeof value === 'string' ? JSON.parse(value) : value;
|
|
57
|
+
} catch {
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
case 'bool':
|
|
61
|
+
return Boolean(value);
|
|
62
|
+
case 'date':
|
|
63
|
+
case 'timestamp':
|
|
64
|
+
return Number(value);
|
|
65
|
+
default:
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
const TRUNCATE = { text: 50, textarea: 100, json: 80 };
|
|
2
|
+
const INDICATORS = { text: '...', multiline: '... (see more)', json: '... (see more)' };
|
|
3
|
+
const DATE_FORMATS = {
|
|
4
|
+
short: { month: 'short', day: 'numeric', year: 'numeric' },
|
|
5
|
+
long: { month: 'long', day: 'numeric', year: 'numeric' },
|
|
6
|
+
time: { hour: '2-digit', minute: '2-digit' },
|
|
7
|
+
datetime: { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' },
|
|
8
|
+
iso: 'iso',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const SECONDS_TO_MS = 1000;
|
|
12
|
+
|
|
13
|
+
export function secondsToDate(seconds) {
|
|
14
|
+
return seconds ? new Date(seconds * SECONDS_TO_MS) : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function dateToSeconds(date) {
|
|
18
|
+
return date ? Math.floor(date.getTime() / SECONDS_TO_MS) : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function formatDate(value, format = 'short') {
|
|
22
|
+
if (!value) return null;
|
|
23
|
+
const date = typeof value === 'number' ? secondsToDate(value) : new Date(value);
|
|
24
|
+
if (isNaN(date.getTime())) return null;
|
|
25
|
+
if (format === 'iso') return date.toISOString().split('T')[0];
|
|
26
|
+
const opts = DATE_FORMATS[format] || DATE_FORMATS.short;
|
|
27
|
+
return date.toLocaleDateString(undefined, opts);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function parseDate(dateString) {
|
|
31
|
+
if (!dateString) return null;
|
|
32
|
+
return dateToSeconds(new Date(dateString));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function truncateText(text, limit = TRUNCATE.text, indicator = INDICATORS.text) {
|
|
36
|
+
if (!text) return '';
|
|
37
|
+
const str = String(text);
|
|
38
|
+
return str.length > limit ? str.substring(0, limit) + indicator : str;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function truncateJson(val) {
|
|
42
|
+
const str = typeof val === 'string' ? val : JSON.stringify(val);
|
|
43
|
+
return str.length > TRUNCATE.json ? str.substring(0, TRUNCATE.json) + INDICATORS.json : str;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function truncateTextarea(text) {
|
|
47
|
+
return truncateText(text, TRUNCATE.textarea, INDICATORS.multiline);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function createSimpleType(sqlType, overrides = {}) {
|
|
51
|
+
return { sqlType, coerce: (val) => val, format: (val) => val, isValid: () => true, ...overrides };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function createNumberType(sqlType, coerceFn, formatFn) {
|
|
55
|
+
return { sqlType, coerce: coerceFn, format: formatFn, isValid: () => true };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function createDateType(sqlType, formatType) {
|
|
59
|
+
return {
|
|
60
|
+
sqlType,
|
|
61
|
+
coerce: (val) => (!val || typeof val !== 'string' ? val : dateToSeconds(new Date(val))),
|
|
62
|
+
format: (val) => formatDate(val, formatType),
|
|
63
|
+
isValid: (val) => !isNaN(new Date(val).getTime()),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const fieldRegistry = {
|
|
68
|
+
text: createSimpleType('TEXT', { format: (val) => String(val ?? '') }),
|
|
69
|
+
email: createSimpleType('TEXT', {
|
|
70
|
+
format: (val) => String(val ?? ''),
|
|
71
|
+
isValid: (val) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val),
|
|
72
|
+
}),
|
|
73
|
+
textarea: createSimpleType('TEXT', { format: (val) => truncateTextarea(String(val ?? '')) }),
|
|
74
|
+
int: createNumberType('INTEGER', (val) => {
|
|
75
|
+
if (val === undefined || val === '' || val === null) return null;
|
|
76
|
+
const num = parseInt(val, 10);
|
|
77
|
+
if (isNaN(num)) throw new Error(`Invalid integer: ${val}`);
|
|
78
|
+
return num;
|
|
79
|
+
}, (val) => String(val ?? '')),
|
|
80
|
+
number: createNumberType('REAL', (val) => {
|
|
81
|
+
if (val === undefined || val === '' || val === null) return null;
|
|
82
|
+
const num = parseFloat(val);
|
|
83
|
+
if (isNaN(num)) throw new Error(`Invalid number: ${val}`);
|
|
84
|
+
return num;
|
|
85
|
+
}, (val) => String(val ?? '')),
|
|
86
|
+
decimal: createNumberType('REAL', (val) => {
|
|
87
|
+
if (val === undefined || val === '' || val === null) return null;
|
|
88
|
+
const num = parseFloat(val);
|
|
89
|
+
if (isNaN(num)) throw new Error(`Invalid decimal: ${val}`);
|
|
90
|
+
return num;
|
|
91
|
+
}, (val) => (typeof val === 'number' ? val.toFixed(2) : val)),
|
|
92
|
+
bool: createSimpleType('INTEGER', {
|
|
93
|
+
coerce: (val) => (val === true || val === 'true' || val === 'on' || val === 1 ? 1 : 0),
|
|
94
|
+
format: (val) => (val ? 'Yes' : 'No'),
|
|
95
|
+
}),
|
|
96
|
+
date: createDateType('INTEGER', 'locale'),
|
|
97
|
+
timestamp: createDateType('INTEGER', 'datetime'),
|
|
98
|
+
enum: {
|
|
99
|
+
sqlType: 'TEXT',
|
|
100
|
+
coerce: (val) => val,
|
|
101
|
+
format: (val, field, spec) => {
|
|
102
|
+
if (!field.options || !spec || val === null || val === undefined) return String(val);
|
|
103
|
+
const opt = spec.options?.[field.options]?.find(o => String(o.value) === String(val));
|
|
104
|
+
return opt ? { label: opt.label, color: opt.color || 'gray' } : String(val);
|
|
105
|
+
},
|
|
106
|
+
isValid: () => true,
|
|
107
|
+
},
|
|
108
|
+
ref: {
|
|
109
|
+
sqlType: 'TEXT',
|
|
110
|
+
coerce: (val) => val,
|
|
111
|
+
format: (val, field, spec, row) => {
|
|
112
|
+
if (!field.display) return val;
|
|
113
|
+
if (field.display === 'avatars' && Array.isArray(val)) return { type: 'avatars', users: val };
|
|
114
|
+
return row?.[`${field.key}_display`] || val;
|
|
115
|
+
},
|
|
116
|
+
isValid: () => true,
|
|
117
|
+
},
|
|
118
|
+
json: createSimpleType('TEXT', {
|
|
119
|
+
coerce: (val) => {
|
|
120
|
+
if (val === undefined || val === '' || val === null) return null;
|
|
121
|
+
return typeof val === 'string' ? val : JSON.stringify(val);
|
|
122
|
+
},
|
|
123
|
+
format: (val) => truncateJson(val),
|
|
124
|
+
}),
|
|
125
|
+
image: createSimpleType('TEXT'),
|
|
126
|
+
id: createSimpleType('TEXT PRIMARY KEY'),
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export function getFieldHandler(type) {
|
|
130
|
+
return fieldRegistry[type] || fieldRegistry.text;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function coerceFieldValue(value, type) {
|
|
134
|
+
return getFieldHandler(type).coerce(value);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function formatFieldValue(value, field, spec, row) {
|
|
138
|
+
if (value === null || value === undefined || value === '') return null;
|
|
139
|
+
return getFieldHandler(field.type).format(value, field, spec, row);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function validateFieldValue(value, field) {
|
|
143
|
+
return getFieldHandler(field.type).isValid(value, field);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function getEnumOption(spec, optionsKey, value) {
|
|
147
|
+
return spec.options?.[optionsKey]?.find(o => String(o.value) === String(value));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function getEnumOptions(spec, optionsKey) {
|
|
151
|
+
return spec.options?.[optionsKey] || [];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export { TRUNCATE as FIELD_TRUNCATE, INDICATORS as TRUNCATION_INDICATORS, DATE_FORMATS };
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
import { createCrudHandlers } from '../lib/crud-factory.js';
|
|
8
|
+
import { getConfigEngineSync } from '../lib/config-generator-engine.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Get handler for an entity
|
|
12
|
+
* @param {string} entityName
|
|
13
|
+
* @returns {object} {GET, POST, PUT, DELETE}
|
|
14
|
+
*/
|
|
15
|
+
export function getEntityHandlers(entityName) {
|
|
16
|
+
return createCrudHandlers(entityName);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check if entity exists and is accessible
|
|
21
|
+
* @param {string} entityName
|
|
22
|
+
* @returns {boolean}
|
|
23
|
+
*/
|
|
24
|
+
export function hasEntity(entityName) {
|
|
25
|
+
try {
|
|
26
|
+
const engine = getConfigEngineSync();
|
|
27
|
+
engine.generateEntitySpec(entityName);
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { getDatabase } from '@/engine';
|
|
2
|
+
|
|
3
|
+
const healthState = {
|
|
4
|
+
checks: new Map(),
|
|
5
|
+
history: [],
|
|
6
|
+
lastCheck: null,
|
|
7
|
+
status: 'unknown'
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function registerHealthCheck(name, checkFn, options = {}) {
|
|
11
|
+
const { critical = false, timeout = 5000 } = options;
|
|
12
|
+
|
|
13
|
+
healthState.checks.set(name, {
|
|
14
|
+
name,
|
|
15
|
+
checkFn,
|
|
16
|
+
critical,
|
|
17
|
+
timeout,
|
|
18
|
+
lastResult: null,
|
|
19
|
+
lastCheck: null
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function runHealthCheck(name) {
|
|
24
|
+
const check = healthState.checks.get(name);
|
|
25
|
+
if (!check) return null;
|
|
26
|
+
|
|
27
|
+
const start = Date.now();
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
31
|
+
setTimeout(() => reject(new Error('Health check timeout')), check.timeout)
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const checkPromise = check.checkFn();
|
|
35
|
+
|
|
36
|
+
await Promise.race([checkPromise, timeoutPromise]);
|
|
37
|
+
|
|
38
|
+
const result = {
|
|
39
|
+
status: 'healthy',
|
|
40
|
+
latency: Date.now() - start,
|
|
41
|
+
timestamp: new Date().toISOString()
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
check.lastResult = result;
|
|
45
|
+
check.lastCheck = Date.now();
|
|
46
|
+
|
|
47
|
+
return result;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
const result = {
|
|
50
|
+
status: 'unhealthy',
|
|
51
|
+
error: String(error?.message || error),
|
|
52
|
+
latency: Date.now() - start,
|
|
53
|
+
timestamp: new Date().toISOString()
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
check.lastResult = result;
|
|
57
|
+
check.lastCheck = Date.now();
|
|
58
|
+
|
|
59
|
+
if (check.critical) {
|
|
60
|
+
console.error(`[HEALTH] Critical check failed: ${name}`, error);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function runAllHealthChecks() {
|
|
68
|
+
const results = {};
|
|
69
|
+
const checks = Array.from(healthState.checks.keys());
|
|
70
|
+
|
|
71
|
+
for (const name of checks) {
|
|
72
|
+
results[name] = await runHealthCheck(name);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const allHealthy = Object.values(results).every(r => r.status === 'healthy');
|
|
76
|
+
const criticalUnhealthy = Array.from(healthState.checks.values())
|
|
77
|
+
.filter(c => c.critical && c.lastResult?.status === 'unhealthy')
|
|
78
|
+
.length > 0;
|
|
79
|
+
|
|
80
|
+
const status = criticalUnhealthy ? 'critical' : allHealthy ? 'healthy' : 'degraded';
|
|
81
|
+
|
|
82
|
+
const summary = {
|
|
83
|
+
status,
|
|
84
|
+
timestamp: new Date().toISOString(),
|
|
85
|
+
checks: results,
|
|
86
|
+
uptime: process.uptime()
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
healthState.lastCheck = summary;
|
|
90
|
+
healthState.status = status;
|
|
91
|
+
healthState.history.push(summary);
|
|
92
|
+
|
|
93
|
+
if (healthState.history.length > 100) {
|
|
94
|
+
healthState.history.shift();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return summary;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function getHealthStatus() {
|
|
101
|
+
return healthState.lastCheck || { status: 'unknown', checks: {} };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function getHealthHistory() {
|
|
105
|
+
return healthState.history.slice(-20);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
registerHealthCheck('database', async () => {
|
|
109
|
+
const db = getDatabase();
|
|
110
|
+
db.prepare('SELECT 1').get();
|
|
111
|
+
}, { critical: true, timeout: 2000 });
|
|
112
|
+
|
|
113
|
+
registerHealthCheck('memory', async () => {
|
|
114
|
+
const usage = process.memoryUsage();
|
|
115
|
+
const heapUsedMB = usage.heapUsed / 1024 / 1024;
|
|
116
|
+
|
|
117
|
+
if (heapUsedMB > 512) {
|
|
118
|
+
throw new Error(`High memory usage: ${heapUsedMB.toFixed(0)}MB`);
|
|
119
|
+
}
|
|
120
|
+
}, { critical: false, timeout: 1000 });
|
|
121
|
+
|
|
122
|
+
registerHealthCheck('uptime', async () => {
|
|
123
|
+
const uptime = process.uptime();
|
|
124
|
+
if (uptime < 5) {
|
|
125
|
+
throw new Error('Recently restarted');
|
|
126
|
+
}
|
|
127
|
+
}, { critical: false, timeout: 100 });
|
|
128
|
+
|
|
129
|
+
if (typeof global !== 'undefined') {
|
|
130
|
+
global.healthState = healthState;
|
|
131
|
+
global.runHealthCheck = runHealthCheck;
|
|
132
|
+
global.runAllHealthChecks = runAllHealthChecks;
|
|
133
|
+
global.getHealthStatus = getHealthStatus;
|
|
134
|
+
}
|