bulltrackers-module 1.0.994 → 1.0.996

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.
@@ -9,6 +9,7 @@ const { requireFirebaseAuth } = require('../middleware/auth');
9
9
  const { requireVerifiedUser, sanitizeCid } = require('../middleware/identity');
10
10
 
11
11
  const { EtoroApiService } = require('../services/EtoroApiService');
12
+ const { recordAnalyticsWrite } = require('../../core/utils/analytics_registry');
12
13
 
13
14
  const router = express.Router();
14
15
 
@@ -137,7 +138,7 @@ router.get('/search', async (req, res, next) => {
137
138
  query: validated.query,
138
139
  resultCount: results.length,
139
140
  createdAt: new Date()
140
- }).catch(() => {});
141
+ }).then(() => recordAnalyticsWrite(db, 'analytics_events_search_query').catch(() => {})).catch(() => {});
141
142
  }
142
143
 
143
144
  res.json({ success: true, count: results.length, data: results });
@@ -295,7 +296,7 @@ router.post('/request-addition', async (req, res, next) => {
295
296
  requestedAt: new Date(),
296
297
  status: 'rejected_not_pi',
297
298
  etoroCid: info.realCID || info.gcid || null
298
- }).catch(() => {});
299
+ }).then(() => recordAnalyticsWrite(db, 'pi_addition_requests').catch(() => {})).catch(() => {});
299
300
  }
300
301
 
301
302
  return res.status(400).json({
@@ -355,7 +356,7 @@ router.post('/request-addition', async (req, res, next) => {
355
356
  requestedAt: new Date(),
356
357
  status: 'accepted',
357
358
  isPi: true
358
- }).catch(() => {});
359
+ }).then(() => recordAnalyticsWrite(db, 'pi_addition_requests').catch(() => {})).catch(() => {});
359
360
  }
360
361
 
361
362
  return res.json({
@@ -4,11 +4,32 @@
4
4
  */
5
5
 
6
6
  const express = require('express');
7
+ const fs = require('fs');
8
+ const path = require('path');
7
9
  const config = require('../config');
8
10
  const { requireVerifiedUser } = require('../middleware/identity');
9
11
  const { requireFirebaseAuth } = require('../middleware/auth');
10
12
  const { generateDynamicPaths } = require('./workspace-generator');
11
13
 
14
+ const FRAMEWORK_ROOT = path.join(__dirname, '../../computation-system-v3/framework');
15
+
16
+ // Read-only sample computation templates that we copy into each user's
17
+ // editable `computations/` directory on first bootstrap.
18
+ const SAMPLE_COMPUTATION_TEMPLATES = [
19
+ {
20
+ source: 'docs/authoring/sample-computations/grade1-portfolio-summary.js',
21
+ target: 'computations/Sample_PortfolioDailySummary_Grade1.js'
22
+ },
23
+ {
24
+ source: 'docs/authoring/sample-computations/grade2-entity-risk-change.js',
25
+ target: 'computations/Sample_EntityRiskChange_Grade2.js'
26
+ },
27
+ {
28
+ source: 'docs/authoring/sample-computations/grade3-alert-on-risk-spike.js',
29
+ target: 'computations/Sample_RiskSpikeAlerts_Grade3.js'
30
+ }
31
+ ];
32
+
12
33
  const MAX_PATHS_PER_USER = 500;
13
34
  const MAX_FILE_BYTES = 512 * 1024;
14
35
  const MAX_TOTAL_BYTES = 5 * 1024 * 1024;
@@ -147,21 +168,48 @@ function createWorkspaceRouter() {
147
168
  }
148
169
  });
149
170
 
150
- // POST /workspace/bootstrap — Only initializes user directories now!
171
+ // POST /workspace/bootstrap — Initializes user directories and sample computations.
151
172
  router.post('/bootstrap', requireVerifiedUser, async (req, res, next) => {
152
173
  try {
153
174
  const userId = (req.identity && req.identity.cid) || req.targetUserId;
154
175
  const ref = req.services.db.collection('users').doc(userId).collection('workspace_files');
155
176
  const snap = await ref.get();
156
177
 
157
- // We only need to bootstrap user-editable files now.
178
+ // We only need to bootstrap user-editable files now.
158
179
  // The read-only registry is automatically served in /tree and /file.
159
- const userPaths = [
160
- { path: 'computations', kind: 'directory', content: null }
161
- ];
180
+ const userPaths = [{ path: 'computations', kind: 'directory', content: null }];
181
+
182
+ // Pre-populate the computations/ directory with three fully documented,
183
+ // valid sample computations. The source of truth lives under
184
+ // computation-system-v3/framework/docs/authoring/sample-computations/*.
185
+ const sampleFiles = [];
186
+ for (const template of SAMPLE_COMPUTATION_TEMPLATES) {
187
+ const absolute = path.join(FRAMEWORK_ROOT, template.source);
188
+ try {
189
+ if (fs.existsSync(absolute)) {
190
+ const content = fs.readFileSync(absolute, 'utf8');
191
+ sampleFiles.push({
192
+ path: template.target,
193
+ kind: 'file',
194
+ content
195
+ });
196
+ }
197
+ } catch (e) {
198
+ // If a template cannot be read, skip it; bootstrap should still succeed.
199
+ // The admin can fix the underlying file and re-bootstrap later.
200
+ // eslint-disable-next-line no-console
201
+ console.error(
202
+ '[workspace.bootstrap] Failed to read sample computation template',
203
+ template.source,
204
+ e && e.message
205
+ );
206
+ }
207
+ }
162
208
 
163
209
  const now = new Date().toISOString();
164
- for (const item of userPaths) {
210
+ const bootstrapItems = [...userPaths, ...sampleFiles];
211
+
212
+ for (const item of bootstrapItems) {
165
213
  const existing = snap.docs.find((d) => (d.data().path || d.id.replace(/__/g, '/')) === item.path);
166
214
  if (existing) continue;
167
215
 
@@ -11,6 +11,7 @@ const {
11
11
  } = require('../../shared/upstashRedis');
12
12
 
13
13
  const { VALID_WATCHLIST_TAGS } = require('../constants');
14
+ const { recordAnalyticsWrite } = require('../../core/utils/analytics_registry');
14
15
 
15
16
  const LIMITS = {
16
17
  WATCHLIST_NAME_MAX: 200,
@@ -349,6 +350,7 @@ class WriteService {
349
350
 
350
351
  await this._updateWatchlistMembership(validUserId, watchlistId, oldItems, firestoreData.items || []);
351
352
 
353
+ recordAnalyticsWrite(this.db, 'watchlists').catch(() => {});
352
354
  firestoreSuccess = true;
353
355
  } catch (error) {
354
356
  console.error('[WriteService] Firestore write failed for watchlist:', error.message);
@@ -381,6 +383,7 @@ class WriteService {
381
383
  // Delete from Firestore only. Hourly analytics-load job syncs watchlist state to BigQuery.
382
384
  await docRef.delete();
383
385
 
386
+ recordAnalyticsWrite(this.db, 'watchlists').catch(() => {});
384
387
  await this._updateWatchlistMembership(validUserId, validWatchlistId, oldItems, []);
385
388
  }
386
389
 
@@ -438,6 +441,7 @@ class WriteService {
438
441
  .doc(validUserId);
439
442
  transaction.set(piReviewRef, review);
440
443
  });
444
+ recordAnalyticsWrite(this.db, 'reviews').catch(() => {});
441
445
  } catch (error) {
442
446
  // Rethrow explicit errors, log others
443
447
  if (error.message === 'Already reviewed') {
@@ -520,6 +524,7 @@ class WriteService {
520
524
  viewedAt: now,
521
525
  createdAt: now
522
526
  });
527
+ recordAnalyticsWrite(this.db, 'analytics_events_pi_page_view').catch(() => {});
523
528
  } catch (error) {
524
529
  console.error('[WriteService] analytics_events pi_page_view write failed:', error.message);
525
530
  }
@@ -544,6 +549,7 @@ class WriteService {
544
549
  new_watchlist_id: this._validateId(newWatchlistId, 'New watchlist ID'),
545
550
  createdAt: now
546
551
  });
552
+ recordAnalyticsWrite(this.db, 'analytics_events_watchlist_copied').catch(() => {});
547
553
  } catch (error) {
548
554
  console.error('[WriteService] analytics_events watchlist_copied write failed:', error.message);
549
555
  }
@@ -689,6 +695,7 @@ class WriteService {
689
695
  ...settings,
690
696
  updatedAt: new Date()
691
697
  }, { merge: true });
698
+ recordAnalyticsWrite(this.db, 'alert_subscriptions').catch(() => {});
692
699
  }
693
700
 
694
701
  /**
@@ -707,6 +714,7 @@ class WriteService {
707
714
  .collection('alert_subscriptions')
708
715
  .doc(validPiCid)
709
716
  .delete();
717
+ recordAnalyticsWrite(this.db, 'alert_subscriptions').catch(() => {});
710
718
  }
711
719
 
712
720
  // =========================================================================
@@ -743,6 +751,8 @@ class WriteService {
743
751
 
744
752
  await firestoreRef.set({ investors }, { merge: true });
745
753
 
754
+ recordAnalyticsWrite(this.db, 'pi_master_list').catch(() => {});
755
+
746
756
  // Invalidate API cache for PI master list so new PI appears quickly.
747
757
  if (this.redisEnabled) {
748
758
  const cacheKey = `api:pi-master-list:${coll}:${docId}`;
@@ -49,6 +49,13 @@ const REGISTERED_FILES = [
49
49
  'docs/modules/trades.md',
50
50
  'docs/modules/userMetrics.md',
51
51
 
52
+ // ── Authoring Samples ──────────────────────────────────────────────────────────
53
+ // These are read-only, fully documented example computations that are also used
54
+ // as templates for pre-populating the user's `computations/` directory.
55
+ 'docs/authoring/sample-computations/grade1-portfolio-summary.js',
56
+ 'docs/authoring/sample-computations/grade2-entity-risk-change.js',
57
+ 'docs/authoring/sample-computations/grade3-alert-on-risk-spike.js',
58
+
52
59
  // ── SQL Reference ─────────────────────────────────────────────────────────────
53
60
  'docs/sql/annotated-sql.md',
54
61
 
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Grade 1 sample computation — Daily portfolio value summary.
3
+ *
4
+ * This is a deliberately small, linear example that shows:
5
+ * - How to declare a minimal `config` for a user computation.
6
+ * - How to read from `ctx.data` using the keys defined in `config.requires`.
7
+ * - How to log a short, human-readable trace line via `ctx.log`.
8
+ * - How to return a safe, envelope-compatible result object (no reserved keys).
9
+ *
10
+ * The goal is to be a "perfect role model" for new authors, not to be clever.
11
+ */
12
+
13
+ /**
14
+ * Configuration object for the computation.
15
+ *
16
+ * - `name` is globally unique per tenant and used in scheduling / storage paths.
17
+ * - `skills: ['lib']` enables read-only helper libraries on `ctx.lib` (not used here,
18
+ * but included so users see the recommended default).
19
+ * - `requires.portfolio_snapshots` declares the data this computation expects:
20
+ * a recent window of portfolio snapshots with `date` + `total_value` fields.
21
+ * - `storage.bigquery: true` means the framework will persist results to BigQuery
22
+ * under the standard `computations` dataset for this tenant.
23
+ */
24
+ exports.config = {
25
+ name: 'Sample_PortfolioDailySummary_Grade1',
26
+ skills: ['lib'],
27
+ lib: [],
28
+ requires: {
29
+ portfolio_snapshots: {
30
+ // The runtime will fetch up to 7 days of snapshots ending at ctx.date.
31
+ lookback: 7,
32
+ mandatory: true,
33
+ fields: ['date', 'total_value']
34
+ }
35
+ },
36
+ storage: {
37
+ bigquery: true
38
+ }
39
+ };
40
+
41
+ /**
42
+ * Process function — called by the runtime for a single execution target.
43
+ *
44
+ * Signature:
45
+ * - ctx.computation: string → the value of config.name.
46
+ * - ctx.date: string → ISO date (YYYY-MM-DD) the run is targeting.
47
+ * - ctx.data: object → data fetched according to `config.requires`.
48
+ * - ctx.log: (message) => void → structured log helper scoped to this computation.
49
+ *
50
+ * Behaviour:
51
+ * 1. Read the `portfolio_snapshots` array from ctx.data.
52
+ * 2. Filter out rows that do not have a finite numeric `total_value`.
53
+ * 3. Compute a simple count and average total value over the window.
54
+ * 4. Log a short summary line with the number of snapshots analysed.
55
+ * 5. Return a result object with:
56
+ * - outcome: high-level status code for domain logic ("OK" or "NO_DATA").
57
+ * - asOfDate: the target date for the run.
58
+ * - snapshotCount: how many rows were actually used.
59
+ * - averagePortfolioValue: null if there was no valid data.
60
+ */
61
+ exports.process = function process(ctx) {
62
+ const snapshots = Array.isArray(ctx.data && ctx.data.portfolio_snapshots)
63
+ ? ctx.data.portfolio_snapshots
64
+ : [];
65
+
66
+ const numericValues = [];
67
+ for (const row of snapshots) {
68
+ const raw = row && row.total_value;
69
+ const value = typeof raw === 'number' ? raw : Number(raw);
70
+ if (Number.isFinite(value)) {
71
+ numericValues.push(value);
72
+ }
73
+ }
74
+
75
+ const snapshotCount = numericValues.length;
76
+ const averagePortfolioValue =
77
+ snapshotCount === 0
78
+ ? null
79
+ : numericValues.reduce((sum, value) => sum + value, 0) / snapshotCount;
80
+
81
+ ctx.log(
82
+ `[Sample_PortfolioDailySummary_Grade1] analysed ${snapshotCount} snapshot(s) for ${ctx.date}`
83
+ );
84
+
85
+ return {
86
+ // Use a domain-level outcome key; never use reserved keys like `status` or `error`.
87
+ outcome: snapshotCount === 0 ? 'NO_DATA' : 'OK',
88
+ asOfDate: ctx.date,
89
+ snapshotCount,
90
+ averagePortfolioValue
91
+ };
92
+ };
93
+
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Grade 2 sample computation — Per-entity risk change over a rolling window.
3
+ *
4
+ * This example builds on the Grade 1 sample by:
5
+ * - Operating in a clearly per-entity way (each entity is evaluated independently).
6
+ * - Working with two logical inputs (`risk_snapshots` and `portfolio_snapshots`).
7
+ * - Computing a small derived metric (risk delta) per entity.
8
+ * - Demonstrating safe handling of missing / partial data.
9
+ *
10
+ * It is still intentionally linear and readable; the comments explain each step so that
11
+ * new authors can copy this pattern when adding their own logic.
12
+ */
13
+
14
+ /**
15
+ * Configuration object for the computation.
16
+ *
17
+ * - `name` uniquely identifies this computation.
18
+ * - `type: 'per-entity'` tells the runtime to run once per entity (e.g. per portfolio).
19
+ * - `requires.risk_snapshots` and `requires.portfolio_snapshots` declare the inputs.
20
+ * - `storage.bigquery: true` persists the result into tenant-scoped BigQuery tables.
21
+ */
22
+ exports.config = {
23
+ name: 'Sample_EntityRiskChange_Grade2',
24
+ type: 'per-entity',
25
+ skills: ['lib'],
26
+ lib: [],
27
+ requires: {
28
+ risk_snapshots: {
29
+ // 30-day lookback window of per-entity risk scores.
30
+ lookback: 30,
31
+ mandatory: true,
32
+ fields: ['date', 'entity_id', 'risk_score']
33
+ },
34
+ portfolio_snapshots: {
35
+ // Optional: we only need a name/label for display purposes.
36
+ lookback: 1,
37
+ mandatory: false,
38
+ fields: ['entity_id', 'display_name']
39
+ }
40
+ },
41
+ storage: {
42
+ bigquery: true
43
+ }
44
+ };
45
+
46
+ /**
47
+ * Process function — computes the change in risk score for a single entity.
48
+ *
49
+ * For each entity on a given date:
50
+ * 1. Filter the `risk_snapshots` array down to the current entityId (if provided).
51
+ * 2. Sort snapshots by date to find the "previous" and "current" entries.
52
+ * 3. Compute a simple delta: current_risk - previous_risk.
53
+ * 4. Look up a human-readable portfolio name from `portfolio_snapshots` if present.
54
+ * 5. Return a single object that describes today's risk position for this entity.
55
+ */
56
+ exports.process = function process(ctx) {
57
+ const entityId = ctx.entityId;
58
+
59
+ const allRiskSnapshots = Array.isArray(ctx.data && ctx.data.risk_snapshots)
60
+ ? ctx.data.risk_snapshots
61
+ : [];
62
+
63
+ // Step 1: isolate this entity's rows (or all rows if entityId is not provided).
64
+ const entityRiskRows = allRiskSnapshots.filter((row) => {
65
+ if (!row) return false;
66
+ if (entityId == null) return true;
67
+ return String(row.entity_id) === String(entityId);
68
+ });
69
+
70
+ // Step 2: sort by date ascending so the last element is the most recent snapshot.
71
+ entityRiskRows.sort((a, b) => {
72
+ const ad = a && a.date ? String(a.date) : '';
73
+ const bd = b && b.date ? String(b.date) : '';
74
+ return ad.localeCompare(bd);
75
+ });
76
+
77
+ const rowCount = entityRiskRows.length;
78
+ if (rowCount === 0) {
79
+ ctx.log(
80
+ `[Sample_EntityRiskChange_Grade2] no risk_snapshots found for entity ${String(
81
+ entityId || 'GLOBAL'
82
+ )} on ${ctx.date}`
83
+ );
84
+
85
+ return {
86
+ outcome: 'NO_DATA',
87
+ asOfDate: ctx.date,
88
+ entityId: entityId || null,
89
+ currentRisk: null,
90
+ previousRisk: null,
91
+ riskDelta: null
92
+ };
93
+ }
94
+
95
+ const latest = entityRiskRows[rowCount - 1];
96
+ const previous = rowCount > 1 ? entityRiskRows[rowCount - 2] : null;
97
+
98
+ const currentRisk =
99
+ latest && typeof latest.risk_score === 'number'
100
+ ? latest.risk_score
101
+ : Number(latest && latest.risk_score);
102
+ const previousRisk =
103
+ previous && typeof previous.risk_score === 'number'
104
+ ? previous.risk_score
105
+ : Number(previous && previous.risk_score);
106
+
107
+ const safeCurrent = Number.isFinite(currentRisk) ? currentRisk : null;
108
+ const safePrevious = Number.isFinite(previousRisk) ? previousRisk : null;
109
+
110
+ let riskDelta = null;
111
+ if (safeCurrent != null && safePrevious != null) {
112
+ riskDelta = safeCurrent - safePrevious;
113
+ }
114
+
115
+ // Optional: look up a display name from portfolio_snapshots for nicer reporting.
116
+ let portfolioName = null;
117
+ const portfolioRows = Array.isArray(ctx.data && ctx.data.portfolio_snapshots)
118
+ ? ctx.data.portfolio_snapshots
119
+ : [];
120
+ const matchingPortfolio = portfolioRows.find((row) => {
121
+ if (!row) return false;
122
+ if (entityId == null) return false;
123
+ return String(row.entity_id) === String(entityId);
124
+ });
125
+ if (matchingPortfolio && typeof matchingPortfolio.display_name === 'string') {
126
+ portfolioName = matchingPortfolio.display_name;
127
+ }
128
+
129
+ ctx.log(
130
+ `[Sample_EntityRiskChange_Grade2] entity ${String(
131
+ entityId || 'GLOBAL'
132
+ )} risk=${safeCurrent} (delta=${riskDelta}) on ${ctx.date}`
133
+ );
134
+
135
+ return {
136
+ outcome: 'OK',
137
+ asOfDate: ctx.date,
138
+ entityId: entityId || null,
139
+ portfolioName,
140
+ currentRisk: safeCurrent,
141
+ previousRisk: safePrevious,
142
+ riskDelta
143
+ };
144
+ };
145
+
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Grade 3 sample computation — Simple alert surface for risk spikes.
3
+ *
4
+ * This example is intentionally still single-file and easy to read, but it
5
+ * shows a slightly more "production-like" pattern:
6
+ * - It depends on the Grade 2 computation's result by name via `dependencies`.
7
+ * - It treats the upstream result as an input table in `requires`.
8
+ * - It emits a compact list of "alerts" that could be consumed by downstream
9
+ * dashboards or notification systems.
10
+ *
11
+ * This is not wired into any real alerting system; it is a didactic example
12
+ * that demonstrates how to compose computations safely.
13
+ */
14
+
15
+ /**
16
+ * Configuration object for the computation.
17
+ *
18
+ * - `dependencies` lists another computation by name. The planner ensures that
19
+ * the dependency runs first for the target date / entity set before this one.
20
+ * - `requires.entity_risk_change` models the dependency's output as a table-like
21
+ * input; in a real deployment this would be backed by storage (e.g. BigQuery).
22
+ */
23
+ exports.config = {
24
+ name: 'Sample_RiskSpikeAlerts_Grade3',
25
+ skills: ['lib'],
26
+ lib: [],
27
+ dependencies: ['Sample_EntityRiskChange_Grade2'],
28
+ requires: {
29
+ entity_risk_change: {
30
+ // The runtime will typically resolve this from the dependency's output.
31
+ lookback: 1,
32
+ mandatory: true,
33
+ fields: [
34
+ 'asOfDate',
35
+ 'entityId',
36
+ 'portfolioName',
37
+ 'currentRisk',
38
+ 'previousRisk',
39
+ 'riskDelta'
40
+ ]
41
+ }
42
+ },
43
+ storage: {
44
+ bigquery: true
45
+ }
46
+ };
47
+
48
+ /**
49
+ * Process function — builds a list of simple alert objects when risk jumps.
50
+ *
51
+ * Behaviour:
52
+ * 1. Read the `entity_risk_change` rows for the current date.
53
+ * 2. Filter to entities whose absolute riskDelta is above a fixed threshold.
54
+ * 3. Map each row to a small, explicit alert object.
55
+ * 4. Return a payload containing:
56
+ * - outcome: "OK" or "NO_ALERTS".
57
+ * - asOfDate: the date we evaluated.
58
+ * - alerts: array of alert objects (possibly empty).
59
+ *
60
+ * NOTE: This computation does not send notifications itself; it produces clean,
61
+ * queryable data that other systems can use as a source of truth.
62
+ */
63
+ exports.process = function process(ctx) {
64
+ const rows = Array.isArray(ctx.data && ctx.data.entity_risk_change)
65
+ ? ctx.data.entity_risk_change
66
+ : [];
67
+
68
+ const THRESHOLD = 5; // Example threshold for a "meaningful" risk move.
69
+
70
+ const alerts = [];
71
+ for (const row of rows) {
72
+ if (!row) continue;
73
+
74
+ const delta = row.riskDelta;
75
+ if (!Number.isFinite(delta)) continue;
76
+
77
+ if (Math.abs(delta) < THRESHOLD) {
78
+ continue;
79
+ }
80
+
81
+ alerts.push({
82
+ entityId: row.entityId || null,
83
+ portfolioName: typeof row.portfolioName === 'string' ? row.portfolioName : null,
84
+ asOfDate: row.asOfDate || ctx.date,
85
+ currentRisk: Number.isFinite(row.currentRisk) ? row.currentRisk : null,
86
+ previousRisk: Number.isFinite(row.previousRisk) ? row.previousRisk : null,
87
+ riskDelta: delta,
88
+ direction: delta > 0 ? 'INCREASE' : 'DECREASE'
89
+ });
90
+ }
91
+
92
+ ctx.log(
93
+ `[Sample_RiskSpikeAlerts_Grade3] generated ${alerts.length} alert(s) for ${ctx.date} with threshold ${THRESHOLD}`
94
+ );
95
+
96
+ return {
97
+ outcome: alerts.length === 0 ? 'NO_ALERTS' : 'OK',
98
+ asOfDate: ctx.date,
99
+ alerts
100
+ };
101
+ };
102
+
@@ -0,0 +1,116 @@
1
+ /**
2
+ * @fileoverview Analytics load registry: time-bucketed Firestore docs for "what to sync".
3
+ * API calls recordAnalyticsWrite(db, sourceKey) after writing analytics data; the loader
4
+ * reads period docs to discover which sources had writes in the run window.
5
+ * Documents are named by time period (e.g. YYYY-MM-DD-HH for 2h buckets) to avoid 1MB limit;
6
+ * each doc has sources: { [sourceKey]: lastWrittenAt } and expire_at for TTL (30 days).
7
+ */
8
+
9
+ const BUCKET_HOURS = parseInt(process.env.REGISTRY_BUCKET_HOURS || '2', 10) || 2;
10
+ const TTL_DAYS = parseInt(process.env.REGISTRY_TTL_DAYS || '30', 10) || 30;
11
+ const REGISTRY_COLLECTION = process.env.FIRESTORE_ANALYTICS_REGISTRY_COLLECTION || 'analytics_load_registry';
12
+
13
+ /**
14
+ * Get the period document ID for a given date (e.g. "2025-03-13-00" for 2h buckets).
15
+ * @param {Date} date - Reference time
16
+ * @returns {string} Period doc ID (YYYY-MM-DD-HH)
17
+ */
18
+ function getPeriodDocId(date = new Date()) {
19
+ const y = date.getUTCFullYear();
20
+ const m = String(date.getUTCMonth() + 1).padStart(2, '0');
21
+ const d = String(date.getUTCDate()).padStart(2, '0');
22
+ const hour = date.getUTCHours();
23
+ const bucketHour = Math.floor(hour / BUCKET_HOURS) * BUCKET_HOURS;
24
+ const h = String(bucketHour).padStart(2, '0');
25
+ return `${y}-${m}-${d}-${h}`;
26
+ }
27
+
28
+ /**
29
+ * Get period start date (UTC) for expire_at calculation.
30
+ * @param {string} periodDocId - e.g. "2025-03-13-00"
31
+ * @returns {Date} Start of that period (UTC)
32
+ */
33
+ function getPeriodStartFromDocId(periodDocId) {
34
+ const [y, m, d, h] = periodDocId.split('-').map(Number);
35
+ return new Date(Date.UTC(y, m - 1, d, h, 0, 0, 0));
36
+ }
37
+
38
+ /**
39
+ * Record that a source was written to (for the analytics loader). Fire-and-forget; logs errors only.
40
+ * Updates the current period's registry doc: merge source key + writtenAt into sources, set expire_at.
41
+ * @param {object} db - Firestore instance
42
+ * @param {string} sourceKey - e.g. 'watchlists', 'analytics_events_pi_page_view'
43
+ */
44
+ async function recordAnalyticsWrite(db, sourceKey) {
45
+ const now = new Date();
46
+ const periodDocId = getPeriodDocId(now);
47
+ const periodStart = getPeriodStartFromDocId(periodDocId);
48
+ const expireAt = new Date(periodStart);
49
+ expireAt.setUTCDate(expireAt.getUTCDate() + TTL_DAYS);
50
+
51
+ try {
52
+ const col = db.collection(REGISTRY_COLLECTION);
53
+ const ref = col.doc(periodDocId);
54
+ await ref.set(
55
+ {
56
+ [`sources.${sourceKey}`]: now,
57
+ expire_at: expireAt
58
+ },
59
+ { merge: true }
60
+ );
61
+ } catch (error) {
62
+ console.error('[analytics_registry] recordAnalyticsWrite failed:', error.message);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * List period doc IDs that overlap the given time window [since, now].
68
+ * @param {Date} since - Start of window
69
+ * @param {Date} end - End of window (default now)
70
+ * @returns {string[]} Period doc IDs in range
71
+ */
72
+ function getPeriodDocIdsInWindow(since, end = new Date()) {
73
+ const ids = [];
74
+ const cursor = new Date(since);
75
+ cursor.setUTCMinutes(0, 0, 0);
76
+ const hour = cursor.getUTCHours();
77
+ const bucketHour = Math.floor(hour / BUCKET_HOURS) * BUCKET_HOURS;
78
+ cursor.setUTCHours(bucketHour, 0, 0, 0);
79
+
80
+ while (cursor <= end) {
81
+ ids.push(getPeriodDocId(cursor));
82
+ cursor.setUTCHours(cursor.getUTCHours() + BUCKET_HOURS);
83
+ }
84
+ return [...new Set(ids)];
85
+ }
86
+
87
+ /**
88
+ * Read registry docs for the given period IDs and return the union of source keys that had writes.
89
+ * @param {object} db - Firestore instance
90
+ * @param {string[]} periodDocIds - Period document IDs to read
91
+ * @returns {Promise<Set<string>>} Set of source keys
92
+ */
93
+ async function getSourceKeysFromRegistry(db, periodDocIds) {
94
+ const keys = new Set();
95
+ const col = db.collection(REGISTRY_COLLECTION);
96
+ await Promise.all(
97
+ periodDocIds.map(async (id) => {
98
+ const snap = await col.doc(id).get();
99
+ if (!snap.exists) return;
100
+ const data = snap.data();
101
+ const sources = data && data.sources && typeof data.sources === 'object' ? data.sources : {};
102
+ Object.keys(sources).forEach((k) => keys.add(k));
103
+ })
104
+ );
105
+ return keys;
106
+ }
107
+
108
+ module.exports = {
109
+ recordAnalyticsWrite,
110
+ getPeriodDocId,
111
+ getPeriodDocIdsInWindow,
112
+ getSourceKeysFromRegistry,
113
+ REGISTRY_COLLECTION,
114
+ BUCKET_HOURS,
115
+ TTL_DAYS
116
+ };