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.
Files changed (62) hide show
  1. package/package.json +18 -1
  2. package/src/app/api/debug/[[...path]]/route.js +217 -0
  3. package/src/app/api/formula/[[...path]]/route.js +142 -0
  4. package/src/cli.js +0 -1
  5. package/src/config/spec-helpers.js +6 -0
  6. package/src/engine.server.js +2 -2
  7. package/src/index.js +21 -11
  8. package/src/lib/auth-middleware.js +4 -2
  9. package/src/lib/busybase-adapter.js +162 -0
  10. package/src/lib/config-field-helpers.js +1 -1
  11. package/src/lib/config-generator-engine.js +24 -0
  12. package/src/lib/crud-handlers.js +21 -1
  13. package/src/lib/database-core.js +4 -7
  14. package/src/lib/database-migrations.js +254 -0
  15. package/src/lib/date-utils.js +164 -0
  16. package/src/lib/debug-registry.js +165 -0
  17. package/src/lib/error-handler.js +78 -0
  18. package/src/lib/export-sink.js +226 -0
  19. package/src/lib/field-iterator.js +126 -0
  20. package/src/lib/hyperformula-service.js +304 -0
  21. package/src/lib/metrics-collector.js +196 -0
  22. package/src/lib/observability-bootstrap.js +121 -0
  23. package/src/lib/perf-profiler.js +238 -0
  24. package/src/lib/query-engine.js +47 -0
  25. package/src/lib/query-string-adapter.js +97 -2
  26. package/src/lib/request-tracing.js +216 -0
  27. package/src/lib/response-formatter.js +0 -2
  28. package/src/lib/route-resolver.js +10 -0
  29. package/src/lib/status-helpers.js +128 -0
  30. package/src/lib/tracing.js +287 -0
  31. package/src/lib/utils.js +81 -0
  32. package/src/lib/validate.js +124 -1
  33. package/src/lib/xstate-workflow-engine.js +478 -0
  34. package/src/plugins/index.js +27 -0
  35. package/src/server/server.js +23 -4
  36. package/src/services/permission.service.js +6 -2
  37. package/src/ui/advanced-search-renderer.js +3 -3
  38. package/src/ui/advanced-widgets.js +4 -4
  39. package/src/ui/common-handlers.js +4 -2
  40. package/src/ui/dashboard-renderer.js +4 -4
  41. package/src/ui/engagement-cards.js +10 -10
  42. package/src/ui/engagement-grid-renderer.js +11 -8
  43. package/src/ui/entity-renderer.js +4 -2
  44. package/src/ui/event-delegation.js +348 -8
  45. package/src/ui/file-dialogs.js +9 -9
  46. package/src/ui/format-helpers.js +37 -3
  47. package/src/ui/highlight-threading-renderer.js +9 -9
  48. package/src/ui/job-management-renderer.js +6 -6
  49. package/src/ui/monitoring-dashboard-client.js +17 -3
  50. package/src/ui/notifications-renderer.js +6 -7
  51. package/src/ui/page-handler.js +9 -0
  52. package/src/ui/render-helpers.js +8 -1
  53. package/src/ui/review-comparison-renderer.js +1 -1
  54. package/src/ui/review-detail-renderer.js +6 -6
  55. package/src/ui/review-mwr-renderer.js +13 -10
  56. package/src/ui/review-widgets.js +5 -5
  57. package/src/ui/rfi-detail-renderer.js +9 -9
  58. package/src/ui/settings-renderer-advanced.js +13 -13
  59. package/src/ui/settings-renderer-teams.js +10 -9
  60. package/src/ui/settings-renderer.js +25 -24
  61. package/src/ui/spacing-system.js +8 -4
  62. package/src/ui/widgets.js +2 -2
@@ -2,7 +2,7 @@
2
2
  * Config Field Helpers - Field definition processing and validation
3
3
  */
4
4
 
5
- import { getSpec } from './spec-helpers.js';
5
+ import { getSpec } from '../config/spec-helpers.js';
6
6
 
7
7
  /**
8
8
  * Generate fields from field overrides
@@ -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
  */
@@ -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
- const result = remove(entityName, id);
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,
@@ -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
- // Placeholder for custom migration logic
220
- // Can be extended via plugins
220
+ // Delegate to the dedicated migrations module (ported from moonlanding):
221
+ // idempotent triggers, activity_log + notification tables, etc.
221
222
  try {
222
- // Create triggers for updated_at timestamps
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
+ }
@@ -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
+ }