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.
Files changed (221) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +398 -0
  3. package/package.json +73 -0
  4. package/src/adapters/google-auth.js +148 -0
  5. package/src/adapters/google-drive.js +209 -0
  6. package/src/app/api/[entity]/[[...path]]/route.js +33 -0
  7. package/src/app/api/audit/dashboard/route.js +77 -0
  8. package/src/app/api/audit/logs/route.js +46 -0
  9. package/src/app/api/audit/permissions/[id]/route.js +37 -0
  10. package/src/app/api/audit/permissions/route.js +93 -0
  11. package/src/app/api/audit/permissions/stats/route.js +29 -0
  12. package/src/app/api/audit/route.js +79 -0
  13. package/src/app/api/audit/stats/route.js +18 -0
  14. package/src/app/api/auth/google/callback/route.js +94 -0
  15. package/src/app/api/auth/google/route.js +58 -0
  16. package/src/app/api/auth/login/route.js +121 -0
  17. package/src/app/api/auth/logout/route.js +52 -0
  18. package/src/app/api/auth/me/route.js +16 -0
  19. package/src/app/api/auth/mwr-bridge/route.js +77 -0
  20. package/src/app/api/auth/password-reset/route.js +65 -0
  21. package/src/app/api/cron/trigger/route.js +58 -0
  22. package/src/app/api/csrf-token/route.js +8 -0
  23. package/src/app/api/debug/config/route.js +22 -0
  24. package/src/app/api/debug/hooks/route.js +16 -0
  25. package/src/app/api/debug/plugins/route.js +20 -0
  26. package/src/app/api/debug/sqlite/route.js +21 -0
  27. package/src/app/api/debug/sync/route.js +29 -0
  28. package/src/app/api/debug/workflow/route.js +20 -0
  29. package/src/app/api/domains/[domain]/route.js +26 -0
  30. package/src/app/api/domains/route.js +21 -0
  31. package/src/app/api/email/allocate/batch/route.js +106 -0
  32. package/src/app/api/email/allocate/route.js +141 -0
  33. package/src/app/api/email/receive/route.js +158 -0
  34. package/src/app/api/email/route.js +3 -0
  35. package/src/app/api/email/send/route.js +77 -0
  36. package/src/app/api/email/unallocated/route.js +47 -0
  37. package/src/app/api/files/[id]/route.js +38 -0
  38. package/src/app/api/health/route.js +95 -0
  39. package/src/app/api/metrics/route.js +73 -0
  40. package/src/app/api/monitoring/dashboard/route.js +18 -0
  41. package/src/cli.js +243 -0
  42. package/src/config/config-loader.js +112 -0
  43. package/src/config/constants.js +127 -0
  44. package/src/config/env.js +164 -0
  45. package/src/config/spec-helpers.js +232 -0
  46. package/src/engine.server.js +212 -0
  47. package/src/index.js +368 -0
  48. package/src/lib/accessibility.js +162 -0
  49. package/src/lib/action-factory.js +34 -0
  50. package/src/lib/action-utils.js +21 -0
  51. package/src/lib/alert-manager.js +189 -0
  52. package/src/lib/api-error-wrapper.js +125 -0
  53. package/src/lib/api-helpers.js +53 -0
  54. package/src/lib/api.js +82 -0
  55. package/src/lib/audit-logger-enhanced.js +117 -0
  56. package/src/lib/audit-logger.js +193 -0
  57. package/src/lib/auth-middleware.js +102 -0
  58. package/src/lib/auth-route-helpers.js +83 -0
  59. package/src/lib/business-rules-engine.js +86 -0
  60. package/src/lib/compression.js +44 -0
  61. package/src/lib/config-field-helpers.js +91 -0
  62. package/src/lib/config-generator-engine.js +445 -0
  63. package/src/lib/config-helpers.js +120 -0
  64. package/src/lib/connection-guard.js +79 -0
  65. package/src/lib/crud-action-helpers.js +34 -0
  66. package/src/lib/crud-factory.js +83 -0
  67. package/src/lib/crud-handlers.js +244 -0
  68. package/src/lib/csrf-protection.js +63 -0
  69. package/src/lib/database-core.js +258 -0
  70. package/src/lib/database-migrations.js +96 -0
  71. package/src/lib/date-utils.js +159 -0
  72. package/src/lib/db-backup.js +97 -0
  73. package/src/lib/db-monitor.js +127 -0
  74. package/src/lib/domain-loader.js +82 -0
  75. package/src/lib/email-sender.js +100 -0
  76. package/src/lib/error-boundary.js +134 -0
  77. package/src/lib/error-handler.js +84 -0
  78. package/src/lib/error-recovery.js +190 -0
  79. package/src/lib/error-resilience.js +130 -0
  80. package/src/lib/errors.js +69 -0
  81. package/src/lib/events-engine.js +182 -0
  82. package/src/lib/field-iterator.js +50 -0
  83. package/src/lib/field-registry.js +68 -0
  84. package/src/lib/field-types.js +154 -0
  85. package/src/lib/generic-crud-handler.js +32 -0
  86. package/src/lib/health-monitor.js +134 -0
  87. package/src/lib/hook-engine.js +169 -0
  88. package/src/lib/hot-reload/cache-invalidator.js +115 -0
  89. package/src/lib/hot-reload/checkpoint.js +95 -0
  90. package/src/lib/hot-reload/debug-exposure.js +67 -0
  91. package/src/lib/hot-reload/directory-watcher.js +96 -0
  92. package/src/lib/hot-reload/index.js +50 -0
  93. package/src/lib/hot-reload/mutex.js +75 -0
  94. package/src/lib/hot-reload/promise-container.js +66 -0
  95. package/src/lib/hot-reload/route-wrapper.js +46 -0
  96. package/src/lib/hot-reload/safe-error.js +51 -0
  97. package/src/lib/hot-reload/supervisor.js +161 -0
  98. package/src/lib/hot-reload/timeout-wrapper.js +52 -0
  99. package/src/lib/http-methods-factory.js +25 -0
  100. package/src/lib/index-optimizer.js +96 -0
  101. package/src/lib/index.js +35 -0
  102. package/src/lib/list-data-transform.js +39 -0
  103. package/src/lib/log-aggregator.js +116 -0
  104. package/src/lib/logger.js +55 -0
  105. package/src/lib/metrics-collector.js +102 -0
  106. package/src/lib/minifier.js +19 -0
  107. package/src/lib/monitoring-init.js +67 -0
  108. package/src/lib/next-compat.js +80 -0
  109. package/src/lib/next-polyfills.js +135 -0
  110. package/src/lib/perf-monitor.js +91 -0
  111. package/src/lib/progress-components.js +181 -0
  112. package/src/lib/query-cache.js +126 -0
  113. package/src/lib/query-engine-write.js +221 -0
  114. package/src/lib/query-engine.js +399 -0
  115. package/src/lib/query-perf.js +117 -0
  116. package/src/lib/query-string-adapter.js +75 -0
  117. package/src/lib/realtime-server.js +67 -0
  118. package/src/lib/render-cache.js +61 -0
  119. package/src/lib/request-tracker.js +43 -0
  120. package/src/lib/resource-hints.js +29 -0
  121. package/src/lib/resource-monitor.js +117 -0
  122. package/src/lib/response-formatter.js +80 -0
  123. package/src/lib/route-helpers.js +33 -0
  124. package/src/lib/route-resolver.js +142 -0
  125. package/src/lib/safe-json.js +8 -0
  126. package/src/lib/server-bootstrap.js +71 -0
  127. package/src/lib/stage-pipeline.js +153 -0
  128. package/src/lib/state-protocol.js +171 -0
  129. package/src/lib/state-transport-client.js +169 -0
  130. package/src/lib/state-transport-reconnect.js +121 -0
  131. package/src/lib/state-transport-server.js +181 -0
  132. package/src/lib/static-server.js +97 -0
  133. package/src/lib/status-helpers.js +98 -0
  134. package/src/lib/universal-handler.js +7 -0
  135. package/src/lib/utils.js +93 -0
  136. package/src/lib/validate.js +197 -0
  137. package/src/lib/validation/business-validators.js +61 -0
  138. package/src/lib/validation/csrf.js +51 -0
  139. package/src/lib/validation/file-validators.js +34 -0
  140. package/src/lib/validation/format-validators.js +106 -0
  141. package/src/lib/validation/index.js +19 -0
  142. package/src/lib/validation/rate-limit.js +31 -0
  143. package/src/lib/validation/security-validators.js +78 -0
  144. package/src/lib/validation-middleware.js +133 -0
  145. package/src/lib/validators.js +105 -0
  146. package/src/lib/with-audit-logging.js +63 -0
  147. package/src/lib/with-error-handler.js +31 -0
  148. package/src/lib/workflow-engine.js +250 -0
  149. package/src/server/server.js +305 -0
  150. package/src/services/collaborator-role.service.js +205 -0
  151. package/src/services/email-sender.js +105 -0
  152. package/src/services/notification-engine.js +110 -0
  153. package/src/services/permission.service.js +181 -0
  154. package/src/ui/advanced-search-renderer.js +42 -0
  155. package/src/ui/advanced-widgets.js +47 -0
  156. package/src/ui/auth-pages.js +114 -0
  157. package/src/ui/auth-styles.js +53 -0
  158. package/src/ui/client.js +99 -0
  159. package/src/ui/collaboration-dialogs.js +31 -0
  160. package/src/ui/common-handlers.js +156 -0
  161. package/src/ui/component-engine.js +103 -0
  162. package/src/ui/dashboard-renderer.js +150 -0
  163. package/src/ui/dialog-engine.js +147 -0
  164. package/src/ui/dialog-factory.js +38 -0
  165. package/src/ui/engagement-cards.js +76 -0
  166. package/src/ui/engagement-dialogs.js +100 -0
  167. package/src/ui/engagement-grid-renderer.js +109 -0
  168. package/src/ui/entity-renderer.js +176 -0
  169. package/src/ui/event-delegation.js +108 -0
  170. package/src/ui/fetch-json.js +24 -0
  171. package/src/ui/file-dialogs.js +81 -0
  172. package/src/ui/flexup-report-renderer.js +173 -0
  173. package/src/ui/format-helpers.js +102 -0
  174. package/src/ui/global-tags.js +144 -0
  175. package/src/ui/highlight-threading-renderer.js +176 -0
  176. package/src/ui/idle-logout.js +156 -0
  177. package/src/ui/job-management-renderer.js +61 -0
  178. package/src/ui/layout.js +219 -0
  179. package/src/ui/letter-dialogs.js +37 -0
  180. package/src/ui/ml-console-renderer.js +108 -0
  181. package/src/ui/monitoring-dashboard-client.js +136 -0
  182. package/src/ui/monitoring-dashboard.js +134 -0
  183. package/src/ui/notifications-renderer.js +51 -0
  184. package/src/ui/page-handler-admin.js +121 -0
  185. package/src/ui/page-handler-helpers.js +111 -0
  186. package/src/ui/page-handler-reviews.js +165 -0
  187. package/src/ui/page-handler-rfi.js +42 -0
  188. package/src/ui/page-handler.js +210 -0
  189. package/src/ui/password-reset-page.js +146 -0
  190. package/src/ui/perf-helpers.js +96 -0
  191. package/src/ui/perf-renderer.js +71 -0
  192. package/src/ui/permissions-ui.js +163 -0
  193. package/src/ui/picker-dialogs.js +100 -0
  194. package/src/ui/render-helpers.js +124 -0
  195. package/src/ui/renderer.js +35 -0
  196. package/src/ui/review-comparison-renderer.js +58 -0
  197. package/src/ui/review-detail-panels.js +71 -0
  198. package/src/ui/review-detail-renderer.js +170 -0
  199. package/src/ui/review-detail-script.js +95 -0
  200. package/src/ui/review-mwr-renderer.js +113 -0
  201. package/src/ui/review-renderer.js +202 -0
  202. package/src/ui/review-widgets.js +88 -0
  203. package/src/ui/review-zone-nav.js +12 -0
  204. package/src/ui/rfi-detail-renderer.js +191 -0
  205. package/src/ui/rfi-renderer.js +194 -0
  206. package/src/ui/rfi-report-renderer.js +56 -0
  207. package/src/ui/rippleui.css +1 -0
  208. package/src/ui/settings-renderer-advanced.js +195 -0
  209. package/src/ui/settings-renderer-advanced2.js +158 -0
  210. package/src/ui/settings-renderer-teams.js +112 -0
  211. package/src/ui/settings-renderer.js +166 -0
  212. package/src/ui/spacing-system.js +155 -0
  213. package/src/ui/standalone-login.js +109 -0
  214. package/src/ui/styles.css +2530 -0
  215. package/src/ui/styles2.css +1602 -0
  216. package/src/ui/test-page.js +23 -0
  217. package/src/ui/validation-rules.js +73 -0
  218. package/src/ui/validation-ui.js +147 -0
  219. package/src/ui/virtual-scroll.js +107 -0
  220. package/src/ui/webjsx.js +61 -0
  221. package/src/ui/widgets.js +152 -0
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Database Migrations - Custom SQL migrations and schema evolution
3
+ * Runs after auto-migration completes
4
+ */
5
+
6
+ /**
7
+ * Run custom migrations
8
+ * @param {object} db - better-sqlite3 database instance
9
+ */
10
+ export function runMigrations(db) {
11
+ // All migrations defined here are idempotent
12
+ const migrations = [
13
+ createTimestampTriggers,
14
+ createActivityLogTable,
15
+ createNotificationTable,
16
+ ];
17
+
18
+ for (const migration of migrations) {
19
+ try {
20
+ migration(db);
21
+ } catch (err) {
22
+ console.error('[Migration] Failed:', err.message);
23
+ }
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Create triggers to auto-update updated_at on row changes
29
+ * @param {object} db
30
+ */
31
+ function createTimestampTriggers(db) {
32
+ const tables = ['users', 'engagements', 'rfis', 'reviews']; // Could be dynamic
33
+
34
+ for (const table of tables) {
35
+ try {
36
+ db.exec(`
37
+ CREATE TRIGGER IF NOT EXISTS set_timestamp_${table}
38
+ AFTER UPDATE ON ${table}
39
+ FOR EACH ROW
40
+ BEGIN
41
+ UPDATE ${table} SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
42
+ END;
43
+ `);
44
+ } catch (e) {
45
+ // Table might not exist yet
46
+ }
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Create activity_log table for audit trail
52
+ * @param {object} db
53
+ */
54
+ function createActivityLogTable(db) {
55
+ db.exec(`
56
+ CREATE TABLE IF NOT EXISTS activity_log (
57
+ id TEXT PRIMARY KEY,
58
+ entity_type TEXT NOT NULL,
59
+ entity_id TEXT NOT NULL,
60
+ action TEXT NOT NULL,
61
+ message TEXT,
62
+ details TEXT,
63
+ user_id TEXT,
64
+ created_at INTEGER NOT NULL,
65
+ FOREIGN KEY (user_id) REFERENCES users(id)
66
+ );
67
+ `);
68
+
69
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_activity_log_entity ON activity_log(entity_type, entity_id)`);
70
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_activity_log_user ON activity_log(user_id)`);
71
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_activity_log_created ON activity_log(created_at)`);
72
+ }
73
+
74
+ /**
75
+ * Create notification table
76
+ * @param {object} db
77
+ */
78
+ function createNotificationTable(db) {
79
+ db.exec(`
80
+ CREATE TABLE IF NOT EXISTS notification (
81
+ id TEXT PRIMARY KEY,
82
+ type TEXT NOT NULL,
83
+ user_id TEXT NOT NULL,
84
+ title TEXT NOT NULL,
85
+ message TEXT,
86
+ data TEXT,
87
+ entity_type TEXT,
88
+ entity_id TEXT,
89
+ created_at INTEGER NOT NULL,
90
+ read_at INTEGER,
91
+ FOREIGN KEY (user_id) REFERENCES users(id)
92
+ );
93
+
94
+ CREATE INDEX IF NOT EXISTS idx_notification_user ON notification(user_id, read_at);
95
+ `);
96
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Date/Time Utilities - Common date operations
3
+ */
4
+
5
+ import { now } from './database-core.js';
6
+
7
+ const SECONDS_PER_MINUTE = 60;
8
+ const SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE;
9
+ const SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR;
10
+ const SECONDS_PER_YEAR = 365.25 * SECONDS_PER_DAY;
11
+
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
+ export function formatDate(timestamp, format = 'short') {
19
+ if (!timestamp) return '—';
20
+ const date = new Date(timestamp * 1000);
21
+
22
+ if (format === 'short') {
23
+ return date.toLocaleDateString();
24
+ }
25
+ if (format === 'long') {
26
+ return date.toLocaleString();
27
+ }
28
+ if (format === 'relative') {
29
+ return formatRelative(timestamp);
30
+ }
31
+ if (format === 'iso') {
32
+ return date.toISOString();
33
+ }
34
+ return date.toLocaleDateString();
35
+ }
36
+
37
+ /**
38
+ * Format relative time (e.g., "2 hours ago")
39
+ * @param {number} timestamp - Unix seconds
40
+ * @returns {string}
41
+ */
42
+ export function formatRelative(timestamp) {
43
+ const nowSec = now();
44
+ const diff = nowSec - timestamp;
45
+
46
+ if (diff < 60) return 'just now';
47
+ if (diff < SECONDS_PER_HOUR) {
48
+ const mins = Math.floor(diff / SECONDS_PER_MINUTE);
49
+ return `${mins}m ago`;
50
+ }
51
+ if (diff < SECONDS_PER_DAY) {
52
+ const hrs = Math.floor(diff / SECONDS_PER_HOUR);
53
+ return `${hrs}h ago`;
54
+ }
55
+ if (diff < 7 * SECONDS_PER_DAY) {
56
+ const days = Math.floor(diff / SECONDS_PER_DAY);
57
+ return `${days}d ago`;
58
+ }
59
+ return formatDate(timestamp, 'short');
60
+ }
61
+
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
+ export function isWithinYears(timestamp, minYearsAgo = 10, maxYearsAhead = 5) {
70
+ const nowSec = now();
71
+ return (
72
+ timestamp > nowSec - (minYearsAgo * SECONDS_PER_YEAR) &&
73
+ timestamp < nowSec + (maxYearsAhead * SECONDS_PER_YEAR)
74
+ );
75
+ }
76
+
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
+ export function isBeforeDate(date1, date2) {
84
+ return date1 < date2;
85
+ }
86
+
87
+ /**
88
+ * Add days to current timestamp
89
+ * @param {number} days
90
+ * @returns {number}
91
+ */
92
+ export function addDays(days) {
93
+ return now() + (days * SECONDS_PER_DAY);
94
+ }
95
+
96
+ /**
97
+ * Add hours to current timestamp
98
+ * @param {number} hours
99
+ * @returns {number}
100
+ */
101
+ export function addHours(hours) {
102
+ return now() + (hours * SECONDS_PER_HOUR);
103
+ }
104
+
105
+ /**
106
+ * Start of day (midnight) for timestamp
107
+ * @param {number} [timestamp]
108
+ * @returns {number}
109
+ */
110
+ export function startOfDay(timestamp = now()) {
111
+ const date = new Date(timestamp * 1000);
112
+ date.setHours(0, 0, 0, 0);
113
+ return Math.floor(date.getTime() / 1000);
114
+ }
115
+
116
+ /**
117
+ * End of day (23:59:59) for timestamp
118
+ * @param {number} [timestamp]
119
+ * @returns {number}
120
+ */
121
+ export function endOfDay(timestamp = now()) {
122
+ return startOfDay(timestamp) + SECONDS_PER_DAY - 1;
123
+ }
124
+
125
+ /**
126
+ * Get days remaining until deadline
127
+ * @param {number} deadlineTs - Unix timestamp
128
+ * @returns {number} - negative if past due
129
+ */
130
+ export function daysRemaining(deadlineTs) {
131
+ const nowSec = now();
132
+ const startOfNextDay = startOfDay(nowSec) + SECONDS_PER_DAY;
133
+ const remaining = deadlineTs - startOfNextDay;
134
+ return Math.ceil(remaining / SECONDS_PER_DAY);
135
+ }
136
+
137
+ /**
138
+ * Check if timestamp is in the past
139
+ * @param {number} timestamp
140
+ * @returns {boolean}
141
+ */
142
+ export function isPast(timestamp) {
143
+ return now() > timestamp;
144
+ }
145
+
146
+ /**
147
+ * Format duration in seconds to human-readable
148
+ * @param {number} seconds
149
+ * @returns {string}
150
+ */
151
+ export function formatDuration(seconds) {
152
+ if (seconds < 60) return `${seconds}s`;
153
+ const mins = Math.floor(seconds / 60);
154
+ if (mins < 60) return `${mins}m`;
155
+ const hrs = Math.floor(mins / 60);
156
+ if (hrs < 24) return `${hrs}h`;
157
+ const days = Math.floor(hrs / 24);
158
+ return `${days}d`;
159
+ }
@@ -0,0 +1,97 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import Database from 'better-sqlite3';
4
+
5
+ const DEFAULT_DB_PATH = path.join(process.cwd(), 'data', 'app.db');
6
+ const DEFAULT_BACKUP_DIR = path.join(process.cwd(), 'data', 'backups');
7
+
8
+ export function createBackup(options = {}) {
9
+ const dbPath = options.dbPath || DEFAULT_DB_PATH;
10
+ const backupDir = options.backupDir || DEFAULT_BACKUP_DIR;
11
+ const label = options.label || 'backup';
12
+
13
+ if (!fs.existsSync(dbPath)) {
14
+ throw new Error(`Database not found: ${dbPath}`);
15
+ }
16
+
17
+ if (!fs.existsSync(backupDir)) {
18
+ fs.mkdirSync(backupDir, { recursive: true });
19
+ }
20
+
21
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
22
+ const backupPath = path.join(backupDir, `app-${label}-${timestamp}.db`);
23
+
24
+ const db = new Database(dbPath);
25
+ try {
26
+ db.pragma('wal_checkpoint(TRUNCATE)');
27
+ fs.copyFileSync(dbPath, backupPath);
28
+ } finally {
29
+ db.close();
30
+ }
31
+
32
+ const sourceSize = fs.statSync(dbPath).size;
33
+ const backupSize = fs.statSync(backupPath).size;
34
+
35
+ if (sourceSize !== backupSize) {
36
+ throw new Error(`Backup size mismatch: source=${sourceSize}, backup=${backupSize}`);
37
+ }
38
+
39
+ return { path: backupPath, size: backupSize, timestamp };
40
+ }
41
+
42
+ export function restoreBackup(backupPath, options = {}) {
43
+ const dbPath = options.dbPath || DEFAULT_DB_PATH;
44
+
45
+ if (!fs.existsSync(backupPath)) {
46
+ throw new Error(`Backup not found: ${backupPath}`);
47
+ }
48
+
49
+ const verifyDb = new Database(backupPath, { readonly: true });
50
+ try {
51
+ const result = verifyDb.pragma('integrity_check');
52
+ if (result[0]?.integrity_check !== 'ok') {
53
+ throw new Error(`Backup integrity check failed: ${JSON.stringify(result)}`);
54
+ }
55
+ } finally {
56
+ verifyDb.close();
57
+ }
58
+
59
+ const walPath = dbPath + '-wal';
60
+ const shmPath = dbPath + '-shm';
61
+
62
+ fs.copyFileSync(backupPath, dbPath);
63
+ if (fs.existsSync(walPath)) fs.unlinkSync(walPath);
64
+ if (fs.existsSync(shmPath)) fs.unlinkSync(shmPath);
65
+
66
+ return { restored: true, from: backupPath, to: dbPath };
67
+ }
68
+
69
+ export function listBackups(options = {}) {
70
+ const backupDir = options.backupDir || DEFAULT_BACKUP_DIR;
71
+
72
+ if (!fs.existsSync(backupDir)) return [];
73
+
74
+ return fs.readdirSync(backupDir)
75
+ .filter(f => f.endsWith('.db') && f.startsWith('app-'))
76
+ .map(f => ({
77
+ name: f,
78
+ path: path.join(backupDir, f),
79
+ size: fs.statSync(path.join(backupDir, f)).size,
80
+ created: fs.statSync(path.join(backupDir, f)).mtime,
81
+ }))
82
+ .sort((a, b) => b.created - a.created);
83
+ }
84
+
85
+ export function pruneBackups(options = {}) {
86
+ const maxBackups = options.maxBackups || 10;
87
+ const backups = listBackups(options);
88
+
89
+ if (backups.length <= maxBackups) return { pruned: 0 };
90
+
91
+ const toDelete = backups.slice(maxBackups);
92
+ for (const backup of toDelete) {
93
+ fs.unlinkSync(backup.path);
94
+ }
95
+
96
+ return { pruned: toDelete.length };
97
+ }
@@ -0,0 +1,127 @@
1
+ import { recordDatabase } from '@/lib/metrics-collector.js'
2
+
3
+ const dbStats = {
4
+ connections: 0,
5
+ activeQueries: 0,
6
+ locks: 0,
7
+ slowQueries: []
8
+ }
9
+
10
+ function wrapDatabase(db) {
11
+ const originalPrepare = db.prepare.bind(db)
12
+
13
+ db.prepare = function(sql) {
14
+ const stmt = originalPrepare(sql)
15
+ const originalRun = stmt.run.bind(stmt)
16
+ const originalGet = stmt.get.bind(stmt)
17
+ const originalAll = stmt.all.bind(stmt)
18
+
19
+ stmt.run = function(...args) {
20
+ const start = process.hrtime.bigint()
21
+ dbStats.activeQueries++
22
+
23
+ try {
24
+ const result = originalRun(...args)
25
+ const duration = Number(process.hrtime.bigint() - start) / 1000000
26
+
27
+ recordDatabase('run', duration, sql)
28
+ if (duration > 100) {
29
+ recordSlowQuery(sql, duration)
30
+ }
31
+
32
+ return result
33
+ } catch (err) {
34
+ recordDatabase('error', 0, sql)
35
+ throw err
36
+ } finally {
37
+ dbStats.activeQueries--
38
+ }
39
+ }
40
+
41
+ stmt.get = function(...args) {
42
+ const start = process.hrtime.bigint()
43
+ dbStats.activeQueries++
44
+
45
+ try {
46
+ const result = originalGet(...args)
47
+ const duration = Number(process.hrtime.bigint() - start) / 1000000
48
+
49
+ recordDatabase('get', duration, sql)
50
+ if (duration > 100) {
51
+ recordSlowQuery(sql, duration)
52
+ }
53
+
54
+ return result
55
+ } catch (err) {
56
+ recordDatabase('error', 0, sql)
57
+ throw err
58
+ } finally {
59
+ dbStats.activeQueries--
60
+ }
61
+ }
62
+
63
+ stmt.all = function(...args) {
64
+ const start = process.hrtime.bigint()
65
+ dbStats.activeQueries++
66
+
67
+ try {
68
+ const result = originalAll(...args)
69
+ const duration = Number(process.hrtime.bigint() - start) / 1000000
70
+
71
+ recordDatabase('all', duration, sql)
72
+ if (duration > 100) {
73
+ recordSlowQuery(sql, duration)
74
+ }
75
+
76
+ return result
77
+ } catch (err) {
78
+ recordDatabase('error', 0, sql)
79
+ throw err
80
+ } finally {
81
+ dbStats.activeQueries--
82
+ }
83
+ }
84
+
85
+ return stmt
86
+ }
87
+
88
+ return db
89
+ }
90
+
91
+ function recordSlowQuery(sql, duration) {
92
+ dbStats.slowQueries.push({
93
+ sql: sql.substring(0, 200),
94
+ duration,
95
+ timestamp: Date.now()
96
+ })
97
+
98
+ if (dbStats.slowQueries.length > 100) {
99
+ dbStats.slowQueries.shift()
100
+ }
101
+ }
102
+
103
+ function getDatabaseStats() {
104
+ return {
105
+ connections: dbStats.connections,
106
+ activeQueries: dbStats.activeQueries,
107
+ locks: dbStats.locks,
108
+ slowQueries: dbStats.slowQueries.slice(-10)
109
+ }
110
+ }
111
+
112
+ function clearDatabaseStats() {
113
+ dbStats.slowQueries.length = 0
114
+ }
115
+
116
+ export {
117
+ wrapDatabase,
118
+ getDatabaseStats,
119
+ clearDatabaseStats
120
+ }
121
+
122
+ if (typeof globalThis !== 'undefined') {
123
+ globalThis.__dbMonitor = {
124
+ getDatabaseStats,
125
+ clearDatabaseStats
126
+ }
127
+ }
@@ -0,0 +1,82 @@
1
+ import { getConfigEngineSync } from '@/lib/config-generator-engine';
2
+
3
+ const requireStr = (val, label) => { if (!val || typeof val !== 'string') throw new Error(`[DomainLoader] ${label} must be a non-empty string`); };
4
+
5
+ export class DomainLoader {
6
+ constructor(engine) {
7
+ if (!engine?.getEntitiesForDomain) throw new Error('[DomainLoader] requires ConfigGeneratorEngine instance');
8
+ this.engine = engine;
9
+ this.validDomains = Object.keys(engine.getDomains());
10
+ if (!this.validDomains.length) this.validDomains = ['friday', 'mwr'];
11
+ this.defaultDomain = this.validDomains[0];
12
+ }
13
+
14
+ _domain(name) {
15
+ requireStr(name, 'domainName');
16
+ const d = name.toLowerCase();
17
+ if (!this.validDomains.includes(d)) throw new Error(`[DomainLoader] Invalid domain: ${name}. Valid: ${this.validDomains.join(', ')}`);
18
+ return d;
19
+ }
20
+
21
+ getEntitiesForDomain(domain) { return [...this.engine.getEntitiesForDomain(this._domain(domain))]; }
22
+
23
+ getSpecsForDomain(domain) {
24
+ return this.getEntitiesForDomain(domain).reduce((acc, name) => {
25
+ try { acc.push(this.engine.generateEntitySpec(name)); } catch (e) { console.error(`[DomainLoader] spec failed for ${name}:`, e.message); }
26
+ return acc;
27
+ }, []);
28
+ }
29
+
30
+ getFeaturesForDomain(domain) {
31
+ const domainCfg = this.engine.getConfig().domains?.[this._domain(domain)];
32
+ if (!domainCfg) return [];
33
+ return Object.keys(domainCfg.features || {}).filter(k => domainCfg.features[k] === true);
34
+ }
35
+
36
+ isEntityInDomain(entity, domain) {
37
+ requireStr(entity, 'entityName');
38
+ try { return this.getEntitiesForDomain(domain).map(e => e.toLowerCase()).includes(entity.toLowerCase()); }
39
+ catch { return false; }
40
+ }
41
+
42
+ isFeatureInDomain(feature, domain) {
43
+ requireStr(feature, 'featureName');
44
+ try { return this.getFeaturesForDomain(domain).map(f => f.toLowerCase()).includes(feature.toLowerCase()); }
45
+ catch { return false; }
46
+ }
47
+
48
+ filterDataByDomain(data, domain, entity) {
49
+ if (!data) return data;
50
+ requireStr(domain, 'domainName'); requireStr(entity, 'entityName');
51
+ const d = this._domain(domain);
52
+ if (!this.isEntityInDomain(entity, d)) throw new Error(`[DomainLoader] Entity ${entity} not in domain ${domain}`);
53
+ return Array.isArray(data) ? data.map(item => ({ ...item })) : { ...data };
54
+ }
55
+
56
+ getDomainInfo(domain) {
57
+ const d = this._domain(domain);
58
+ const config = this.engine.getConfig();
59
+ const dc = config.domains?.[d];
60
+ if (!dc) throw new Error(`[DomainLoader] Domain ${domain} not found in config`);
61
+ return { name: d, label: dc.label || d, description: dc.description || '', enabled: dc.enabled !== false, primary_color: dc.primary_color || '#3B82F6', icon: dc.icon || 'Circle', features: this.getFeaturesForDomain(d), entities: this.getEntitiesForDomain(d) };
62
+ }
63
+
64
+ getCurrentDomain(request) {
65
+ if (!request) return this.defaultDomain;
66
+ try {
67
+ const param = new URL(request.url).searchParams.get('domain')?.toLowerCase();
68
+ return param && this.validDomains.includes(param) ? param : this.defaultDomain;
69
+ } catch { return this.defaultDomain; }
70
+ }
71
+
72
+ getApiBasePathForDomain(domain) { return `/api/${this._domain(domain)}`; }
73
+ getValidDomains() { return [...this.validDomains]; }
74
+ getDefaultDomain() { return this.defaultDomain; }
75
+ }
76
+
77
+ let globalDomainLoader = null;
78
+ export function getDomainLoader() {
79
+ if (!globalDomainLoader) globalDomainLoader = new DomainLoader(getConfigEngineSync());
80
+ return globalDomainLoader;
81
+ }
82
+ export function resetDomainLoader() { globalDomainLoader = null; }
@@ -0,0 +1,100 @@
1
+ import { now } from '@/lib/database-core';
2
+ import { sendEmail } from '@/adapters/google-gmail';
3
+ import { EMAIL_STATUS } from '@/config/constants';
4
+ import { config } from '@/config/env';
5
+
6
+ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
7
+
8
+ const validateEmail = (email) =>
9
+ !email ? { valid: false, error: 'Email address required' }
10
+ : !EMAIL_REGEX.test(email) ? { valid: false, error: 'Invalid email format' }
11
+ : { valid: true };
12
+
13
+ export function validateEmailData(emailRecord) {
14
+ const errors = [];
15
+ const rv = validateEmail(emailRecord.recipient_email);
16
+ if (!rv.valid) errors.push(`Recipient: ${rv.error}`);
17
+ const sv = validateEmail(emailRecord.sender_email);
18
+ if (!sv.valid) errors.push(`Sender: ${sv.error}`);
19
+ if (emailRecord.sender_email && config.email.from && emailRecord.sender_email !== config.email.from)
20
+ errors.push(`Sender email (${emailRecord.sender_email}) does not match configured email (${config.email.from})`);
21
+ if (!emailRecord.subject?.trim()) errors.push('Subject cannot be empty');
22
+ if (!emailRecord.body && !emailRecord.html_body) errors.push('Email must have either body or html_body');
23
+ return errors;
24
+ }
25
+
26
+ export function parseAttachments(attachmentsJson) {
27
+ if (!attachmentsJson) return [];
28
+ try {
29
+ const a = typeof attachmentsJson === 'string' ? JSON.parse(attachmentsJson) : attachmentsJson;
30
+ return Array.isArray(a) ? a : [];
31
+ } catch { return []; }
32
+ }
33
+
34
+ export function logEmailActivity(db, emailId, action, metadata = {}) {
35
+ try {
36
+ db.prepare(`INSERT INTO activity_log (id, entity_type, entity_id, action, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?)`)
37
+ .run(crypto.randomUUID?.() || `${Date.now()}_${Math.random()}`, 'email', emailId, action, JSON.stringify(metadata), now());
38
+ } catch (e) { console.error('[EMAIL] Failed to log activity:', e.message); }
39
+ }
40
+
41
+ export function checkFailureRate(db) {
42
+ try {
43
+ const stats = db.prepare(`SELECT COUNT(*) as total, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as failed FROM email WHERE created_at >= ?`)
44
+ .get(EMAIL_STATUS.FAILED, now() - 86400);
45
+ if (stats.total > 0 && stats.failed / stats.total > 0.5 && stats.total > 10)
46
+ console.warn('[EMAIL] HIGH FAILURE RATE ALERT:', { failureRate: `${((stats.failed / stats.total) * 100).toFixed(1)}%`, failed: stats.failed, total: stats.total });
47
+ } catch (e) { console.error('[EMAIL] Failed to check failure rate:', e.message); }
48
+ }
49
+
50
+ async function exponentialBackoff(attempt, maxDelayMs) {
51
+ await new Promise(r => setTimeout(r, Math.min(1000 * Math.pow(2, attempt), maxDelayMs)));
52
+ }
53
+
54
+ export async function sendSingleEmail(db, emailRecord, attempt = 1, maxRetries = 3, maxDelayMs = 30000) {
55
+ const validationErrors = validateEmailData(emailRecord);
56
+ if (validationErrors.length > 0) {
57
+ const errorMsg = validationErrors.join('; ');
58
+ db.prepare(`UPDATE email SET status=?, processing_error=?, retry_count=?, updated_at=? WHERE id=?`)
59
+ .run(EMAIL_STATUS.FAILED, errorMsg, attempt, now(), emailRecord.id);
60
+ logEmailActivity(db, emailRecord.id, 'email_send_failed', { error: errorMsg, attempt });
61
+ return { success: false, error: errorMsg, emailId: emailRecord.id };
62
+ }
63
+
64
+ try {
65
+ const emailData = {
66
+ to: emailRecord.recipient_email,
67
+ from: emailRecord.sender_email || config.email.from,
68
+ subject: emailRecord.subject,
69
+ body: emailRecord.body,
70
+ html: emailRecord.html_body,
71
+ cc: emailRecord.cc,
72
+ bcc: emailRecord.bcc,
73
+ attachments: parseAttachments(emailRecord.attachments),
74
+ ...(emailRecord.in_reply_to && { inReplyTo: emailRecord.in_reply_to }),
75
+ ...(emailRecord.references && { references: emailRecord.references }),
76
+ };
77
+
78
+ const result = await sendEmail(emailData);
79
+ db.prepare(`UPDATE email SET status=?, processed=?, message_id=?, processing_error=NULL, retry_count=?, processed_at=?, updated_at=? WHERE id=?`)
80
+ .run(EMAIL_STATUS.PROCESSED, true, result.id || result.messageId, attempt, now(), now(), emailRecord.id);
81
+ logEmailActivity(db, emailRecord.id, 'email_sent', { messageId: result.id || result.messageId, to: emailData.to, attempt });
82
+ return { success: true, messageId: result.id || result.messageId, emailId: emailRecord.id };
83
+ } catch (error) {
84
+ const isRateLimit = error.message?.includes('429') || /quota|rate limit/i.test(error.message);
85
+ const isBounce = error.message?.includes('550') || error.message?.includes('551') || /no such user|user unknown|mailbox not found/i.test(error.message);
86
+ const isPermanent = error.message?.includes('400') || /invalid|not found/i.test(error.message) || isBounce;
87
+
88
+ if (isPermanent || attempt >= maxRetries) {
89
+ const bounceStatus = isBounce ? 'bounced' : EMAIL_STATUS.FAILED;
90
+ db.prepare(`UPDATE email SET status=?, processing_error=?, retry_count=?, bounce_reason=?, bounced_at=?, bounce_permanent=?, updated_at=? WHERE id=?`)
91
+ .run(bounceStatus, error.message, attempt, isBounce ? error.message : null, isBounce ? now() : null, isBounce ? 1 : 0, now(), emailRecord.id);
92
+ logEmailActivity(db, emailRecord.id, 'email_send_failed', { error: error.message, attempt, permanent: isPermanent });
93
+ return { success: false, error: error.message, emailId: emailRecord.id, permanent: isPermanent };
94
+ }
95
+
96
+ if (isRateLimit) await exponentialBackoff(attempt, maxDelayMs);
97
+ db.prepare(`UPDATE email SET retry_count=?, processing_error=?, updated_at=? WHERE id=?`).run(attempt, error.message, now(), emailRecord.id);
98
+ return sendSingleEmail(db, emailRecord, attempt + 1, maxRetries, maxDelayMs);
99
+ }
100
+ }