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,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
|
+
}
|