thatcher 1.0.4 → 1.0.6
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/package.json +18 -1
- package/src/app/api/debug/[[...path]]/route.js +217 -0
- package/src/app/api/formula/[[...path]]/route.js +142 -0
- package/src/cli.js +0 -1
- package/src/config/spec-helpers.js +6 -0
- package/src/engine.server.js +2 -2
- package/src/index.js +21 -11
- package/src/lib/auth-middleware.js +4 -2
- package/src/lib/busybase-adapter.js +162 -0
- package/src/lib/config-field-helpers.js +1 -1
- package/src/lib/config-generator-engine.js +24 -0
- package/src/lib/crud-handlers.js +21 -1
- package/src/lib/database-core.js +4 -7
- package/src/lib/database-migrations.js +254 -0
- package/src/lib/date-utils.js +164 -0
- package/src/lib/debug-registry.js +165 -0
- package/src/lib/error-handler.js +78 -0
- package/src/lib/export-sink.js +226 -0
- package/src/lib/field-iterator.js +126 -0
- package/src/lib/hyperformula-service.js +304 -0
- package/src/lib/metrics-collector.js +196 -0
- package/src/lib/observability-bootstrap.js +121 -0
- package/src/lib/perf-profiler.js +238 -0
- package/src/lib/query-engine.js +47 -0
- package/src/lib/query-string-adapter.js +97 -2
- package/src/lib/request-tracing.js +216 -0
- package/src/lib/response-formatter.js +0 -2
- package/src/lib/route-resolver.js +10 -0
- package/src/lib/status-helpers.js +128 -0
- package/src/lib/tracing.js +287 -0
- package/src/lib/utils.js +81 -0
- package/src/lib/validate.js +124 -1
- package/src/lib/xstate-workflow-engine.js +478 -0
- package/src/plugins/index.js +27 -0
- package/src/server/server.js +23 -4
- package/src/services/permission.service.js +6 -2
- package/src/ui/advanced-search-renderer.js +3 -3
- package/src/ui/advanced-widgets.js +4 -4
- package/src/ui/common-handlers.js +4 -2
- package/src/ui/dashboard-renderer.js +4 -4
- package/src/ui/engagement-cards.js +10 -10
- package/src/ui/engagement-grid-renderer.js +11 -8
- package/src/ui/entity-renderer.js +4 -2
- package/src/ui/event-delegation.js +348 -8
- package/src/ui/file-dialogs.js +9 -9
- package/src/ui/format-helpers.js +37 -3
- package/src/ui/highlight-threading-renderer.js +9 -9
- package/src/ui/job-management-renderer.js +6 -6
- package/src/ui/monitoring-dashboard-client.js +17 -3
- package/src/ui/notifications-renderer.js +6 -7
- package/src/ui/page-handler.js +9 -0
- package/src/ui/render-helpers.js +8 -1
- package/src/ui/review-comparison-renderer.js +1 -1
- package/src/ui/review-detail-renderer.js +6 -6
- package/src/ui/review-mwr-renderer.js +13 -10
- package/src/ui/review-widgets.js +5 -5
- package/src/ui/rfi-detail-renderer.js +9 -9
- package/src/ui/settings-renderer-advanced.js +13 -13
- package/src/ui/settings-renderer-teams.js +10 -9
- package/src/ui/settings-renderer.js +25 -24
- package/src/ui/spacing-system.js +8 -4
- package/src/ui/widgets.js +2 -2
|
@@ -13,6 +13,7 @@ export class ConfigGeneratorEngine {
|
|
|
13
13
|
if (!masterConfig) throw new Error('[ConfigGeneratorEngine] masterConfig is required');
|
|
14
14
|
this.masterConfig = deepFreeze(masterConfig);
|
|
15
15
|
this.specCache = new LRUCache(100);
|
|
16
|
+
this.debugMode = false;
|
|
16
17
|
this._plugins = new Map();
|
|
17
18
|
}
|
|
18
19
|
|
|
@@ -362,6 +363,11 @@ export class ConfigGeneratorEngine {
|
|
|
362
363
|
}
|
|
363
364
|
}
|
|
364
365
|
|
|
366
|
+
// Entity variants (ported from moonlanding: passthrough additive spec field)
|
|
367
|
+
if (entityDef.variants) {
|
|
368
|
+
spec.variants = deepClone(entityDef.variants);
|
|
369
|
+
}
|
|
370
|
+
|
|
365
371
|
// List options
|
|
366
372
|
if (entityDef.list) {
|
|
367
373
|
spec.list = {
|
|
@@ -406,6 +412,13 @@ export class ConfigGeneratorEngine {
|
|
|
406
412
|
let _singleton = null;
|
|
407
413
|
|
|
408
414
|
export function getConfigEngineSync() {
|
|
415
|
+
// tsx can instantiate this module twice (static import vs resolveModule file://
|
|
416
|
+
// URL), so _singleton set in one instance is invisible to the other. The bootstrap
|
|
417
|
+
// mirrors the engine onto globalThis.__thatcherConfigEngine; fall back to it so every
|
|
418
|
+
// module instance resolves the same engine.
|
|
419
|
+
if (!_singleton && globalThis.__thatcherConfigEngine) {
|
|
420
|
+
_singleton = globalThis.__thatcherConfigEngine;
|
|
421
|
+
}
|
|
409
422
|
if (!_singleton) {
|
|
410
423
|
throw new Error('ConfigEngine not initialized. Call initConfig() first.');
|
|
411
424
|
}
|
|
@@ -437,6 +450,17 @@ export async function initConfig(configSource) {
|
|
|
437
450
|
return _singleton;
|
|
438
451
|
}
|
|
439
452
|
|
|
453
|
+
/**
|
|
454
|
+
* Set the singleton config engine directly (used by the bootstrap in index.js,
|
|
455
|
+
* which constructs the engine from an already-parsed config object). This is what
|
|
456
|
+
* makes getConfigEngineSync() resolve for the server/plugin/spec paths.
|
|
457
|
+
* @param {ConfigGeneratorEngine} engine
|
|
458
|
+
*/
|
|
459
|
+
export function setConfigEngine(engine) {
|
|
460
|
+
_singleton = engine;
|
|
461
|
+
return _singleton;
|
|
462
|
+
}
|
|
463
|
+
|
|
440
464
|
/**
|
|
441
465
|
* Reset singleton (for hot reload)
|
|
442
466
|
*/
|
package/src/lib/crud-handlers.js
CHANGED
|
@@ -15,6 +15,7 @@ import { permissionService } from '../services/permission.service.js';
|
|
|
15
15
|
import { parse as parseQuery } from './query-string-adapter.js';
|
|
16
16
|
import { now } from './database-core.js';
|
|
17
17
|
import { getConfigEngineSync } from './config-generator-engine.js';
|
|
18
|
+
import { logAction } from './audit-logger.js';
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Build a complete set of CRUD handlers for an entity
|
|
@@ -112,6 +113,9 @@ export function createCrudHandlers(entityName, spec) {
|
|
|
112
113
|
const sanitized = sanitizeData(entityName, rawData, spec);
|
|
113
114
|
const record = create(entityName, sanitized, user);
|
|
114
115
|
|
|
116
|
+
// Audit log (ported from moon)
|
|
117
|
+
logAction(entityName, record.id, 'create', user?.id, null, record);
|
|
118
|
+
|
|
115
119
|
// Execute hooks
|
|
116
120
|
executeHook(`create:${entityName}:after`, {
|
|
117
121
|
entity: entityName,
|
|
@@ -150,6 +154,9 @@ export function createCrudHandlers(entityName, spec) {
|
|
|
150
154
|
const sanitized = sanitizeData(entityName, rawData, spec, existing);
|
|
151
155
|
const record = update(entityName, id, sanitized, user);
|
|
152
156
|
|
|
157
|
+
// Audit log (ported from moon)
|
|
158
|
+
logAction(entityName, id, 'update', user?.id, existing, record);
|
|
159
|
+
|
|
153
160
|
executeHook(`update:${entityName}:after`, {
|
|
154
161
|
entity: entityName,
|
|
155
162
|
id,
|
|
@@ -178,7 +185,20 @@ export function createCrudHandlers(entityName, spec) {
|
|
|
178
185
|
throw new AppError('Access denied', 'FORBIDDEN', HTTP.FORBIDDEN);
|
|
179
186
|
}
|
|
180
187
|
|
|
181
|
-
|
|
188
|
+
// Delete strategy (ported from moon): immutable entities are archived,
|
|
189
|
+
// entities with a status field are soft-deleted, otherwise hard-removed.
|
|
190
|
+
let result;
|
|
191
|
+
if (spec.immutable === true && spec.immutable_strategy === 'move_to_archive') {
|
|
192
|
+
const archiveData = { archived: true, archived_at: now(), archived_by: user?.id };
|
|
193
|
+
result = update(entityName, id, archiveData, user);
|
|
194
|
+
logAction(entityName, id, 'archive', user?.id, existing, archiveData);
|
|
195
|
+
} else if (spec.fields?.status) {
|
|
196
|
+
result = update(entityName, id, { status: 'deleted' }, user);
|
|
197
|
+
logAction(entityName, id, 'delete', user?.id, existing, { status: 'deleted' });
|
|
198
|
+
} else {
|
|
199
|
+
result = remove(entityName, id);
|
|
200
|
+
logAction(entityName, id, 'delete', user?.id, existing, null);
|
|
201
|
+
}
|
|
182
202
|
|
|
183
203
|
executeHook(`delete:${entityName}:after`, {
|
|
184
204
|
entity: entityName,
|
package/src/lib/database-core.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import Database from 'better-sqlite3';
|
|
7
7
|
import fs from 'fs';
|
|
8
8
|
import path from 'path';
|
|
9
|
+
import { runMigrations } from './database-migrations.js';
|
|
9
10
|
|
|
10
11
|
const SQL_TYPES = {
|
|
11
12
|
id: 'TEXT PRIMARY KEY',
|
|
@@ -216,14 +217,10 @@ export function migrate(configEngine) {
|
|
|
216
217
|
* @param {object} specs
|
|
217
218
|
*/
|
|
218
219
|
function runCustomMigrations(dbInstance, specs) {
|
|
219
|
-
//
|
|
220
|
-
//
|
|
220
|
+
// Delegate to the dedicated migrations module (ported from moonlanding):
|
|
221
|
+
// idempotent triggers, activity_log + notification tables, etc.
|
|
221
222
|
try {
|
|
222
|
-
|
|
223
|
-
for (const entityName of Object.keys(specs)) {
|
|
224
|
-
const tableName = entityName === 'user' ? 'users' : entityName;
|
|
225
|
-
// Add timestamp triggers if needed
|
|
226
|
-
}
|
|
223
|
+
runMigrations(dbInstance);
|
|
227
224
|
} catch (e) {
|
|
228
225
|
console.error('[Database] Custom migrations failed:', e.message);
|
|
229
226
|
}
|
|
@@ -13,6 +13,16 @@ export function runMigrations(db) {
|
|
|
13
13
|
createTimestampTriggers,
|
|
14
14
|
createActivityLogTable,
|
|
15
15
|
createNotificationTable,
|
|
16
|
+
createSessionsTable,
|
|
17
|
+
createChatTables,
|
|
18
|
+
createAuditLogsTable,
|
|
19
|
+
createRfiTables,
|
|
20
|
+
createPasswordResetTokensTable,
|
|
21
|
+
addHighlightColumns,
|
|
22
|
+
migrateRfiSectionTable,
|
|
23
|
+
createSystemSettingsTable,
|
|
24
|
+
createRecreationLogTable,
|
|
25
|
+
createBugReportTable,
|
|
16
26
|
];
|
|
17
27
|
|
|
18
28
|
for (const migration of migrations) {
|
|
@@ -94,3 +104,247 @@ function createNotificationTable(db) {
|
|
|
94
104
|
CREATE INDEX IF NOT EXISTS idx_notification_user ON notification(user_id, read_at);
|
|
95
105
|
`);
|
|
96
106
|
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Create sessions table (Lucia auth)
|
|
110
|
+
* @param {object} db
|
|
111
|
+
*/
|
|
112
|
+
function createSessionsTable(db) {
|
|
113
|
+
db.exec(`
|
|
114
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
115
|
+
id TEXT PRIMARY KEY,
|
|
116
|
+
user_id TEXT NOT NULL,
|
|
117
|
+
expires_at INTEGER NOT NULL,
|
|
118
|
+
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
119
|
+
);
|
|
120
|
+
`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Create chat tables (messages + mentions)
|
|
125
|
+
* @param {object} db
|
|
126
|
+
*/
|
|
127
|
+
function createChatTables(db) {
|
|
128
|
+
db.exec(`
|
|
129
|
+
CREATE TABLE IF NOT EXISTS chat_messages (
|
|
130
|
+
id TEXT PRIMARY KEY,
|
|
131
|
+
rfi_id TEXT,
|
|
132
|
+
user_id TEXT,
|
|
133
|
+
content TEXT,
|
|
134
|
+
attachments TEXT,
|
|
135
|
+
reactions TEXT DEFAULT '{}',
|
|
136
|
+
mentions TEXT DEFAULT '[]',
|
|
137
|
+
created_at INTEGER NOT NULL,
|
|
138
|
+
updated_at INTEGER,
|
|
139
|
+
FOREIGN KEY (rfi_id) REFERENCES rfi(id),
|
|
140
|
+
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
CREATE TABLE IF NOT EXISTS chat_mentions (
|
|
144
|
+
id TEXT PRIMARY KEY,
|
|
145
|
+
message_id TEXT NOT NULL,
|
|
146
|
+
user_id TEXT,
|
|
147
|
+
resolved BOOLEAN DEFAULT 0,
|
|
148
|
+
created_at INTEGER NOT NULL,
|
|
149
|
+
FOREIGN KEY (message_id) REFERENCES chat_messages(id),
|
|
150
|
+
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
CREATE INDEX IF NOT EXISTS idx_chat_messages_rfi ON chat_messages(rfi_id);
|
|
154
|
+
CREATE INDEX IF NOT EXISTS idx_chat_messages_user ON chat_messages(user_id);
|
|
155
|
+
CREATE INDEX IF NOT EXISTS idx_chat_mentions_message ON chat_mentions(message_id);
|
|
156
|
+
CREATE INDEX IF NOT EXISTS idx_chat_mentions_user ON chat_mentions(user_id);
|
|
157
|
+
`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Create audit_logs table (before/after state audit trail)
|
|
162
|
+
* Distinct from activity_log; preserved as an additive table.
|
|
163
|
+
* @param {object} db
|
|
164
|
+
*/
|
|
165
|
+
function createAuditLogsTable(db) {
|
|
166
|
+
db.exec(`
|
|
167
|
+
CREATE TABLE IF NOT EXISTS audit_logs (
|
|
168
|
+
id TEXT PRIMARY KEY,
|
|
169
|
+
entity_type TEXT NOT NULL,
|
|
170
|
+
entity_id TEXT NOT NULL,
|
|
171
|
+
action TEXT NOT NULL,
|
|
172
|
+
user_id TEXT,
|
|
173
|
+
before_state TEXT,
|
|
174
|
+
after_state TEXT,
|
|
175
|
+
created_at INTEGER NOT NULL,
|
|
176
|
+
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
CREATE INDEX IF NOT EXISTS idx_audit_logs_entity ON audit_logs(entity_type, entity_id);
|
|
180
|
+
CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs(user_id);
|
|
181
|
+
CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at);
|
|
182
|
+
`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Create RFI tables (rfis, questions, responses)
|
|
187
|
+
* @param {object} db
|
|
188
|
+
*/
|
|
189
|
+
function createRfiTables(db) {
|
|
190
|
+
db.exec(`
|
|
191
|
+
CREATE TABLE IF NOT EXISTS rfis (
|
|
192
|
+
id TEXT PRIMARY KEY,
|
|
193
|
+
engagement_id TEXT NOT NULL,
|
|
194
|
+
status TEXT DEFAULT 'draft',
|
|
195
|
+
created_at INTEGER NOT NULL,
|
|
196
|
+
updated_at INTEGER NOT NULL,
|
|
197
|
+
FOREIGN KEY (engagement_id) REFERENCES engagement(id)
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
CREATE TABLE IF NOT EXISTS rfi_questions (
|
|
201
|
+
id TEXT PRIMARY KEY,
|
|
202
|
+
rfi_id TEXT NOT NULL,
|
|
203
|
+
question TEXT NOT NULL,
|
|
204
|
+
category TEXT,
|
|
205
|
+
assigned_to TEXT,
|
|
206
|
+
due_date TEXT,
|
|
207
|
+
status TEXT DEFAULT 'pending',
|
|
208
|
+
created_at INTEGER NOT NULL,
|
|
209
|
+
updated_at INTEGER NOT NULL,
|
|
210
|
+
FOREIGN KEY (rfi_id) REFERENCES rfis(id),
|
|
211
|
+
FOREIGN KEY (assigned_to) REFERENCES users(id)
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
CREATE TABLE IF NOT EXISTS rfi_responses (
|
|
215
|
+
id TEXT PRIMARY KEY,
|
|
216
|
+
question_id TEXT NOT NULL,
|
|
217
|
+
response TEXT,
|
|
218
|
+
attachments TEXT,
|
|
219
|
+
created_at INTEGER NOT NULL,
|
|
220
|
+
updated_at INTEGER NOT NULL,
|
|
221
|
+
FOREIGN KEY (question_id) REFERENCES rfi_questions(id)
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
CREATE INDEX IF NOT EXISTS idx_rfis_engagement ON rfis(engagement_id);
|
|
225
|
+
CREATE INDEX IF NOT EXISTS idx_rfis_status ON rfis(status);
|
|
226
|
+
CREATE INDEX IF NOT EXISTS idx_rfi_questions_rfi ON rfi_questions(rfi_id);
|
|
227
|
+
CREATE INDEX IF NOT EXISTS idx_rfi_questions_status ON rfi_questions(status);
|
|
228
|
+
CREATE INDEX IF NOT EXISTS idx_rfi_responses_question ON rfi_responses(question_id);
|
|
229
|
+
`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Create password_reset_tokens table
|
|
234
|
+
* @param {object} db
|
|
235
|
+
*/
|
|
236
|
+
function createPasswordResetTokensTable(db) {
|
|
237
|
+
db.exec(`
|
|
238
|
+
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
|
239
|
+
id TEXT PRIMARY KEY,
|
|
240
|
+
user_id TEXT NOT NULL,
|
|
241
|
+
token TEXT NOT NULL UNIQUE,
|
|
242
|
+
expires_at INTEGER NOT NULL,
|
|
243
|
+
used INTEGER DEFAULT 0,
|
|
244
|
+
created_at INTEGER NOT NULL,
|
|
245
|
+
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token);
|
|
249
|
+
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user ON password_reset_tokens(user_id);
|
|
250
|
+
`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Add flags/tags columns to highlight table (idempotent — ignores duplicate-column error)
|
|
255
|
+
* @param {object} db
|
|
256
|
+
*/
|
|
257
|
+
function addHighlightColumns(db) {
|
|
258
|
+
for (const col of ['flags', 'tags']) {
|
|
259
|
+
try {
|
|
260
|
+
db.exec(`ALTER TABLE highlight ADD COLUMN ${col} TEXT`);
|
|
261
|
+
} catch (e) {
|
|
262
|
+
// Column already exists or table not present yet
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Rebuild rfi_section table if it lacks an id column (legacy shape)
|
|
269
|
+
* @param {object} db
|
|
270
|
+
*/
|
|
271
|
+
function migrateRfiSectionTable(db) {
|
|
272
|
+
let cols = [];
|
|
273
|
+
try {
|
|
274
|
+
cols = db.prepare('PRAGMA table_info(rfi_section)').all().map(c => c.name);
|
|
275
|
+
} catch (e) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (!cols.includes('id')) {
|
|
279
|
+
db.exec(`DROP TABLE IF EXISTS rfi_section`);
|
|
280
|
+
db.exec(`
|
|
281
|
+
CREATE TABLE IF NOT EXISTS rfi_section (
|
|
282
|
+
id TEXT PRIMARY KEY,
|
|
283
|
+
engagement_id TEXT NOT NULL,
|
|
284
|
+
name TEXT NOT NULL DEFAULT '',
|
|
285
|
+
sort_order INTEGER DEFAULT 0,
|
|
286
|
+
created_at INTEGER,
|
|
287
|
+
FOREIGN KEY (engagement_id) REFERENCES engagement(id)
|
|
288
|
+
);
|
|
289
|
+
CREATE INDEX IF NOT EXISTS idx_rfi_section_engagement ON rfi_section(engagement_id);
|
|
290
|
+
`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Create system_settings key/value table
|
|
296
|
+
* @param {object} db
|
|
297
|
+
*/
|
|
298
|
+
function createSystemSettingsTable(db) {
|
|
299
|
+
db.exec(`
|
|
300
|
+
CREATE TABLE IF NOT EXISTS system_settings (
|
|
301
|
+
key TEXT PRIMARY KEY,
|
|
302
|
+
value TEXT NOT NULL,
|
|
303
|
+
updated_at INTEGER
|
|
304
|
+
);
|
|
305
|
+
`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Create recreation_log table
|
|
310
|
+
* @param {object} db
|
|
311
|
+
*/
|
|
312
|
+
function createRecreationLogTable(db) {
|
|
313
|
+
db.exec(`
|
|
314
|
+
CREATE TABLE IF NOT EXISTS recreation_log (
|
|
315
|
+
id TEXT PRIMARY KEY,
|
|
316
|
+
engagement_id TEXT,
|
|
317
|
+
client_id TEXT,
|
|
318
|
+
engagement_type_id TEXT,
|
|
319
|
+
status TEXT,
|
|
320
|
+
details TEXT,
|
|
321
|
+
error TEXT,
|
|
322
|
+
created_at INTEGER NOT NULL
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
CREATE INDEX IF NOT EXISTS idx_recreation_log_created ON recreation_log(created_at);
|
|
326
|
+
CREATE INDEX IF NOT EXISTS idx_recreation_log_status ON recreation_log(status);
|
|
327
|
+
`);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Create bug_report table
|
|
332
|
+
* @param {object} db
|
|
333
|
+
*/
|
|
334
|
+
function createBugReportTable(db) {
|
|
335
|
+
db.exec(`
|
|
336
|
+
CREATE TABLE IF NOT EXISTS bug_report (
|
|
337
|
+
id TEXT PRIMARY KEY,
|
|
338
|
+
user_id TEXT,
|
|
339
|
+
summary TEXT,
|
|
340
|
+
description TEXT,
|
|
341
|
+
url TEXT,
|
|
342
|
+
user_agent TEXT,
|
|
343
|
+
viewport TEXT,
|
|
344
|
+
status TEXT DEFAULT 'open',
|
|
345
|
+
created_at INTEGER NOT NULL
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
CREATE INDEX IF NOT EXISTS idx_bug_report_created ON bug_report(created_at);
|
|
349
|
+
`);
|
|
350
|
+
}
|
package/src/lib/date-utils.js
CHANGED
|
@@ -157,3 +157,167 @@ export function formatDuration(seconds) {
|
|
|
157
157
|
const days = Math.floor(hrs / 24);
|
|
158
158
|
return `${days}d`;
|
|
159
159
|
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Additive helpers ported from moonlanding (adapted to Unix-seconds model)
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Whether a timestamp falls on a working day (Mon–Fri).
|
|
167
|
+
* @param {number} timestamp - Unix seconds
|
|
168
|
+
* @returns {boolean}
|
|
169
|
+
*/
|
|
170
|
+
export function isWorkingDay(timestamp) {
|
|
171
|
+
if (!timestamp) return false;
|
|
172
|
+
const day = new Date(timestamp * 1000).getDay();
|
|
173
|
+
return day !== 0 && day !== 6;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Count working days (inclusive) between two Unix-seconds timestamps.
|
|
178
|
+
* @param {number} startTs - Unix seconds
|
|
179
|
+
* @param {number} endTs - Unix seconds
|
|
180
|
+
* @returns {number}
|
|
181
|
+
*/
|
|
182
|
+
export function getWorkingDaysDiff(startTs, endTs) {
|
|
183
|
+
if (!startTs || !endTs) return 0;
|
|
184
|
+
const end = new Date(endTs * 1000);
|
|
185
|
+
const current = new Date(startTs * 1000);
|
|
186
|
+
let count = 0;
|
|
187
|
+
while (current <= end) {
|
|
188
|
+
if (current.getDay() !== 0 && current.getDay() !== 6) count++;
|
|
189
|
+
current.setDate(current.getDate() + 1);
|
|
190
|
+
}
|
|
191
|
+
return count;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Add a number of working days (Mon–Fri) to a Unix-seconds timestamp.
|
|
196
|
+
* @param {number} startTs - Unix seconds
|
|
197
|
+
* @param {number} numDays
|
|
198
|
+
* @returns {number} Unix seconds
|
|
199
|
+
*/
|
|
200
|
+
export function addWorkingDays(startTs, numDays) {
|
|
201
|
+
if (!startTs || numDays <= 0) return startTs;
|
|
202
|
+
const date = new Date(startTs * 1000);
|
|
203
|
+
let added = 0;
|
|
204
|
+
while (added < numDays) {
|
|
205
|
+
date.setDate(date.getDate() + 1);
|
|
206
|
+
if (date.getDay() !== 0 && date.getDay() !== 6) added++;
|
|
207
|
+
}
|
|
208
|
+
return Math.floor(date.getTime() / 1000);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Financial year (Mar 1 – Feb end) for a Unix-seconds timestamp.
|
|
213
|
+
* @param {number} [timestamp] - Unix seconds (defaults to now)
|
|
214
|
+
* @returns {number|null}
|
|
215
|
+
*/
|
|
216
|
+
export function getFinancialYear(timestamp) {
|
|
217
|
+
const ms = timestamp ? timestamp * 1000 : Date.now();
|
|
218
|
+
const date = new Date(ms);
|
|
219
|
+
if (isNaN(date.getTime())) return null;
|
|
220
|
+
const month = date.getMonth();
|
|
221
|
+
const year = date.getFullYear();
|
|
222
|
+
return month >= 2 ? year : year - 1;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Start/end Unix-seconds bounds for a financial year (Mar 1 – Feb end).
|
|
227
|
+
* @param {number} year
|
|
228
|
+
* @returns {{ start: number, end: number }}
|
|
229
|
+
*/
|
|
230
|
+
export function getFinancialYearRange(year) {
|
|
231
|
+
const start = new Date(year, 2, 1);
|
|
232
|
+
const lastDay = new Date(year + 1, 2, 0).getDate();
|
|
233
|
+
const end = new Date(year + 1, 1, lastDay);
|
|
234
|
+
return {
|
|
235
|
+
start: Math.floor(start.getTime() / 1000),
|
|
236
|
+
end: Math.floor(end.getTime() / 1000),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Format an amount as currency.
|
|
242
|
+
* @param {number|string} amount
|
|
243
|
+
* @param {string} [currency='ZAR']
|
|
244
|
+
* @param {string} [locale='en-ZA']
|
|
245
|
+
* @returns {string|null}
|
|
246
|
+
*/
|
|
247
|
+
export function formatCurrency(amount, currency = 'ZAR', locale = 'en-ZA') {
|
|
248
|
+
if (amount === null || amount === undefined) return null;
|
|
249
|
+
const num = typeof amount === 'string' ? parseFloat(amount) : amount;
|
|
250
|
+
if (isNaN(num)) return null;
|
|
251
|
+
return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(num);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Format a number with fixed decimals.
|
|
256
|
+
* @param {number|string} value
|
|
257
|
+
* @param {number} [decimals=0]
|
|
258
|
+
* @param {string} [locale='en-ZA']
|
|
259
|
+
* @returns {string|null}
|
|
260
|
+
*/
|
|
261
|
+
export function formatNumber(value, decimals = 0, locale = 'en-ZA') {
|
|
262
|
+
if (value === null || value === undefined) return null;
|
|
263
|
+
const num = typeof value === 'string' ? parseFloat(value) : value;
|
|
264
|
+
if (isNaN(num)) return null;
|
|
265
|
+
return new Intl.NumberFormat(locale, {
|
|
266
|
+
minimumFractionDigits: decimals,
|
|
267
|
+
maximumFractionDigits: decimals,
|
|
268
|
+
}).format(num);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Human-readable file size.
|
|
273
|
+
* @param {number} bytes
|
|
274
|
+
* @returns {string|null}
|
|
275
|
+
*/
|
|
276
|
+
export function formatFileSize(bytes) {
|
|
277
|
+
if (bytes === null || bytes === undefined || bytes < 0) return null;
|
|
278
|
+
if (bytes === 0) return '0 B';
|
|
279
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
280
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
281
|
+
const size = bytes / Math.pow(1024, i);
|
|
282
|
+
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Truncate text to a maximum length, appending a suffix.
|
|
287
|
+
* @param {*} text
|
|
288
|
+
* @param {number} [maxLength=100]
|
|
289
|
+
* @param {string} [suffix='...']
|
|
290
|
+
* @returns {string}
|
|
291
|
+
*/
|
|
292
|
+
export function truncateText(text, maxLength = 100, suffix = '...') {
|
|
293
|
+
if (!text) return '';
|
|
294
|
+
const str = String(text);
|
|
295
|
+
if (str.length <= maxLength) return str;
|
|
296
|
+
return str.substring(0, maxLength) + suffix;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Convert a Unix-seconds timestamp to a UTC ISO-8601 string.
|
|
301
|
+
* @param {number} timestamp - Unix seconds
|
|
302
|
+
* @returns {string|null}
|
|
303
|
+
*/
|
|
304
|
+
export function toUtcIso(timestamp) {
|
|
305
|
+
if (!timestamp) return null;
|
|
306
|
+
const date = new Date(timestamp * 1000);
|
|
307
|
+
if (isNaN(date.getTime())) return null;
|
|
308
|
+
return date.toISOString();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Normalize a Firestore timestamp (or number/date) to Unix seconds.
|
|
313
|
+
* @param {*} ts
|
|
314
|
+
* @returns {number|null}
|
|
315
|
+
*/
|
|
316
|
+
export function fromFirestoreTimestamp(ts) {
|
|
317
|
+
if (!ts) return null;
|
|
318
|
+
if (ts._seconds !== undefined) return ts._seconds;
|
|
319
|
+
if (ts.seconds !== undefined) return ts.seconds;
|
|
320
|
+
if (typeof ts === 'number') return ts;
|
|
321
|
+
const d = new Date(ts);
|
|
322
|
+
return isNaN(d.getTime()) ? null : Math.floor(d.getTime() / 1000);
|
|
323
|
+
}
|