bulltrackers-module 1.0.479 → 1.0.490

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.
@@ -231,6 +231,28 @@ async function handleComputationTask(message, config, dependencies) {
231
231
 
232
232
  await db.doc(ledgerPath).update({ status: 'COMPLETED', completedAt: new Date() });
233
233
  await recordRunAttempt(db, { date, computation, pass }, 'SUCCESS', null, metrics, triggerReason, resourceTier);
234
+
235
+ // Send notification if this was an on-demand computation
236
+ if (metadata?.onDemand && metadata?.requestId && metadata?.requestingUserCid) {
237
+ try {
238
+ const { notifyComputationComplete, getComputationDisplayName } = require('../../generic-api/user-api/helpers/notification_helpers');
239
+ await notifyComputationComplete(
240
+ dependencies.db,
241
+ dependencies.logger,
242
+ metadata.requestingUserCid,
243
+ metadata.requestId,
244
+ computation,
245
+ getComputationDisplayName(computation),
246
+ true,
247
+ null
248
+ );
249
+ } catch (notifError) {
250
+ // Non-critical, log and continue
251
+ if (dependencies.logger) {
252
+ dependencies.logger.log('WARN', `[Worker] Failed to send completion notification for ${computation}`, notifError);
253
+ }
254
+ }
255
+ }
234
256
 
235
257
  } catch (err) {
236
258
  heartbeats.stop();
@@ -257,6 +279,29 @@ async function handleComputationTask(message, config, dependencies) {
257
279
  }, { merge: true });
258
280
 
259
281
  await recordRunAttempt(db, { date, computation, pass }, 'FAILURE', { message: err.message, stage: err.stage || 'FATAL' }, { peakMemoryMB: heartbeats.getPeak() }, triggerReason, resourceTier);
282
+
283
+ // Send error notification if this was an on-demand computation
284
+ if (metadata?.onDemand && metadata?.requestId && metadata?.requestingUserCid) {
285
+ try {
286
+ const { notifyComputationComplete, getComputationDisplayName } = require('../../generic-api/user-api/helpers/notification_helpers');
287
+ await notifyComputationComplete(
288
+ db,
289
+ dependencies.logger,
290
+ metadata.requestingUserCid,
291
+ metadata.requestId,
292
+ computation,
293
+ getComputationDisplayName(computation),
294
+ false,
295
+ err.message
296
+ );
297
+ } catch (notifError) {
298
+ // Non-critical, log and continue
299
+ if (dependencies.logger) {
300
+ dependencies.logger.log('WARN', `[Worker] Failed to send error notification for ${computation}`, notifError);
301
+ }
302
+ }
303
+ }
304
+
260
305
  return;
261
306
  }
262
307
 
@@ -0,0 +1,131 @@
1
+ /**
2
+ * @fileoverview Notification Helpers for On-Demand Requests
3
+ * Sends notifications to users when their on-demand sync/computation requests complete
4
+ */
5
+
6
+ const { FieldValue } = require('@google-cloud/firestore');
7
+
8
+ /**
9
+ * Send a notification to a user about their on-demand request
10
+ * @param {Firestore} db - Firestore instance
11
+ * @param {Object} logger - Logger instance
12
+ * @param {number} userCid - User CID to notify
13
+ * @param {string} type - Notification type ('success', 'error', 'progress')
14
+ * @param {string} title - Notification title
15
+ * @param {string} message - Notification message
16
+ * @param {Object} metadata - Additional metadata (requestId, computationName, etc.)
17
+ */
18
+ async function sendOnDemandNotification(db, logger, userCid, type, title, message, metadata = {}) {
19
+ try {
20
+ const notificationId = `ondemand_${Date.now()}_${userCid}_${Math.random().toString(36).substring(2, 9)}`;
21
+ const notificationRef = db.collection('user_notifications')
22
+ .doc(String(userCid))
23
+ .collection('notifications')
24
+ .doc(notificationId);
25
+
26
+ const notificationData = {
27
+ id: notificationId,
28
+ type: 'on_demand',
29
+ subType: type, // 'success', 'error', 'progress'
30
+ title,
31
+ message,
32
+ read: false,
33
+ createdAt: FieldValue.serverTimestamp(),
34
+ metadata: {
35
+ ...metadata,
36
+ userCid: Number(userCid)
37
+ }
38
+ };
39
+
40
+ await notificationRef.set(notificationData);
41
+
42
+ // Update notification counter
43
+ const today = new Date().toISOString().split('T')[0];
44
+ const counterRef = db.collection('user_notifications')
45
+ .doc(String(userCid))
46
+ .collection('counters')
47
+ .doc(today);
48
+
49
+ await counterRef.set({
50
+ date: today,
51
+ unreadCount: FieldValue.increment(1),
52
+ totalCount: FieldValue.increment(1),
53
+ lastUpdated: FieldValue.serverTimestamp()
54
+ }, { merge: true });
55
+
56
+ logger.log('INFO', `[sendOnDemandNotification] Sent ${type} notification to user ${userCid}: ${title}`);
57
+
58
+ } catch (error) {
59
+ logger.log('ERROR', `[sendOnDemandNotification] Failed to send notification to user ${userCid}`, error);
60
+ // Don't throw - notifications are non-critical
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Send notification when task engine completes data fetch
66
+ */
67
+ async function notifyTaskEngineComplete(db, logger, requestingUserCid, requestId, username, success, error = null) {
68
+ if (!requestingUserCid) return; // No user to notify
69
+
70
+ const type = success ? 'success' : 'error';
71
+ const title = success
72
+ ? 'Data Sync Complete'
73
+ : 'Data Sync Failed';
74
+ const message = success
75
+ ? `Your data sync for ${username} has completed. Portfolio, history, and social data have been stored.`
76
+ : (error || 'An error occurred while syncing your data. Please try again.');
77
+
78
+ await sendOnDemandNotification(db, logger, requestingUserCid, type, title, message, {
79
+ requestId,
80
+ username,
81
+ stage: 'task_engine',
82
+ success
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Send notification when computation completes
88
+ */
89
+ async function notifyComputationComplete(db, logger, requestingUserCid, requestId, computationName, displayName, success, error = null) {
90
+ if (!requestingUserCid) return; // No user to notify
91
+
92
+ const type = success ? 'success' : 'error';
93
+ const title = success
94
+ ? 'Computation Complete'
95
+ : 'Computation Failed';
96
+ const message = success
97
+ ? `${displayName || computationName} has been computed and stored.`
98
+ : (error || `Failed to compute ${displayName || computationName}. Please try again.`);
99
+
100
+ await sendOnDemandNotification(db, logger, requestingUserCid, type, title, message, {
101
+ requestId,
102
+ computationName,
103
+ displayName,
104
+ stage: 'computation',
105
+ success
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Get human-readable name for a computation
111
+ */
112
+ function getComputationDisplayName(computationName) {
113
+ const displayNames = {
114
+ 'SignedInUserProfileMetrics': 'Profile Metrics',
115
+ 'SignedInUserCopiedList': 'Copied Investors List',
116
+ 'SignedInUserCopiedPIs': 'Copied Popular Investors',
117
+ 'SignedInUserPastCopies': 'Past Copy History',
118
+ 'PopularInvestorProfileMetrics': 'Popular Investor Profile',
119
+ 'SignedInUserPIPersonalizedMetrics': 'Personalized Metrics'
120
+ };
121
+
122
+ return displayNames[computationName] || computationName;
123
+ }
124
+
125
+ module.exports = {
126
+ sendOnDemandNotification,
127
+ notifyTaskEngineComplete,
128
+ notifyComputationComplete,
129
+ getComputationDisplayName
130
+ };
131
+
@@ -163,11 +163,16 @@ async function requestUserSync(req, res, dependencies, config) {
163
163
  requestId,
164
164
  requestedBy: Number(requestingUserCid),
165
165
  effectiveRequestedBy: effectiveCid,
166
+ data: {
167
+ includeSocial: true, // Always include social data for on-demand syncs
168
+ since: new Date(Date.now() - (7 * 24 * 60 * 60 * 1000)).toISOString() // Last 7 days
169
+ },
166
170
  metadata: {
167
171
  onDemand: true,
168
172
  targetCid: targetCidNum, // Target specific user for optimization
169
173
  requestedAt: now.toISOString(),
170
- isImpersonating: isImpersonating || false
174
+ isImpersonating: isImpersonating || false,
175
+ requestingUserCid: Number(requestingUserCid) // Store for notifications
171
176
  }
172
177
  };
173
178
 
@@ -245,12 +250,15 @@ async function getUserSyncStatus(req, res, dependencies, config) {
245
250
  const compsSub = config.computationsSubcollection || 'computations';
246
251
 
247
252
  // Determine category and computation name based on user type
253
+ // NOTE: ResultCommitter ignores category metadata if computation is in a non-core folder
254
+ // SignedInUserProfileMetrics is in popular-investor folder, so it's stored in popular-investor category
248
255
  let category, computationName;
249
256
  if (userType === 'POPULAR_INVESTOR') {
250
257
  category = 'popular-investor';
251
258
  computationName = 'PopularInvestorProfileMetrics';
252
259
  } else {
253
- category = 'signed_in_user';
260
+ // SignedInUserProfileMetrics is in popular-investor folder, so stored in popular-investor category
261
+ category = 'popular-investor';
254
262
  computationName = 'SignedInUserProfileMetrics';
255
263
  }
256
264
 
@@ -318,7 +326,8 @@ async function getUserSyncStatus(req, res, dependencies, config) {
318
326
  }
319
327
 
320
328
  // Check if request is stale (stuck in processing state for too long)
321
- const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
329
+ // Increased timeout to 15 minutes to account for computation time
330
+ const STALE_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes
322
331
  const processingStates = ['processing', 'dispatched', 'indexing', 'computing', 'queued'];
323
332
  const isProcessingState = processingStates.includes(status);
324
333
 
@@ -336,8 +345,22 @@ async function getUserSyncStatus(req, res, dependencies, config) {
336
345
  const referenceTime = dispatchedAt || createdAt || updatedAt;
337
346
 
338
347
  if (referenceTime && (now - referenceTime) > STALE_THRESHOLD_MS) {
339
- isStale = true;
340
- logger.log('WARN', `[getUserSyncStatus] Detected stale request ${latestRequest.requestId} for user ${targetCidNum}. Status: ${status}, Age: ${Math.round((now - referenceTime) / 60000)} minutes`);
348
+ // Before marking as stale, do one final check for computation results
349
+ // Sometimes computation completes but status wasn't updated
350
+ const finalCheck = await checkComputationResults(db, targetCidNum, userType, insightsCollection, resultsSub, compsSub, logger);
351
+ if (!finalCheck) {
352
+ isStale = true;
353
+ logger.log('WARN', `[getUserSyncStatus] Detected stale request ${latestRequest.requestId} for user ${targetCidNum}. Status: ${status}, Age: ${Math.round((now - referenceTime) / 60000)} minutes`);
354
+ } else {
355
+ // Found results, update status to completed
356
+ status = 'completed';
357
+ await requestsSnapshot.docs[0].ref.update({
358
+ status: 'completed',
359
+ completedAt: FieldValue.serverTimestamp(),
360
+ updatedAt: FieldValue.serverTimestamp()
361
+ });
362
+ logger.log('INFO', `[getUserSyncStatus] Found computation results on stale check, marked as completed`);
363
+ }
341
364
  }
342
365
  }
343
366
 
@@ -489,6 +512,80 @@ async function checkRateLimits(db, targetUserCid, logger) {
489
512
  };
490
513
  }
491
514
 
515
+ /**
516
+ * Helper function to check for computation results
517
+ */
518
+ async function checkComputationResults(db, targetCidNum, userType, insightsCollection, resultsSub, compsSub, logger) {
519
+ try {
520
+ // Determine category and computation name based on user type
521
+ // NOTE: ResultCommitter ignores category metadata if computation is in a non-core folder
522
+ // SignedInUserProfileMetrics is in popular-investor folder, so it's stored in popular-investor category
523
+ let category, computationName;
524
+ if (userType === 'POPULAR_INVESTOR') {
525
+ category = 'popular-investor';
526
+ computationName = 'PopularInvestorProfileMetrics';
527
+ } else {
528
+ // SignedInUserProfileMetrics is in popular-investor folder, so stored in popular-investor category
529
+ category = 'popular-investor';
530
+ computationName = 'SignedInUserProfileMetrics';
531
+ }
532
+
533
+ // Check today and yesterday for computation results
534
+ const checkDate = new Date();
535
+ for (let i = 0; i < 2; i++) {
536
+ const dateStr = new Date(checkDate);
537
+ dateStr.setDate(checkDate.getDate() - i);
538
+ const dateStrFormatted = dateStr.toISOString().split('T')[0];
539
+
540
+ const docRef = db.collection(insightsCollection)
541
+ .doc(dateStrFormatted)
542
+ .collection(resultsSub)
543
+ .doc(category)
544
+ .collection(compsSub)
545
+ .doc(computationName);
546
+
547
+ const doc = await docRef.get();
548
+ if (doc.exists) {
549
+ const docData = doc.data();
550
+ let mergedData = null;
551
+
552
+ // Check if data is sharded
553
+ if (docData._sharded === true && docData._shardCount) {
554
+ const shardsCol = docRef.collection('_shards');
555
+ const shardsSnapshot = await shardsCol.get();
556
+
557
+ if (!shardsSnapshot.empty) {
558
+ mergedData = {};
559
+ for (const shardDoc of shardsSnapshot.docs) {
560
+ const shardData = shardDoc.data();
561
+ Object.assign(mergedData, shardData);
562
+ }
563
+ }
564
+ } else {
565
+ const { tryDecompress } = require('./data_helpers');
566
+ mergedData = tryDecompress(docData);
567
+
568
+ if (typeof mergedData === 'string') {
569
+ try {
570
+ mergedData = JSON.parse(mergedData);
571
+ } catch (e) {
572
+ mergedData = null;
573
+ }
574
+ }
575
+ }
576
+
577
+ if (mergedData && typeof mergedData === 'object' && mergedData[String(targetCidNum)]) {
578
+ return true; // Found results
579
+ }
580
+ }
581
+ }
582
+ return false; // No results found
583
+ } catch (error) {
584
+ logger.log('WARN', `[checkComputationResults] Error checking computation results`, error);
585
+ return false;
586
+ }
587
+ }
588
+
492
589
  module.exports = {
493
590
  requestUserSync,
494
591
  getUserSyncStatus
@@ -761,6 +761,26 @@ async function handleOnDemandUserUpdate(taskData, config, dependencies) {
761
761
 
762
762
  logger.log('SUCCESS', `[On-Demand Update] Complete for ${username} (Portfolio: ${portfolioFetched ? '✓' : '✗'}, History: ${historyFetched ? '✓' : '✗'}, Social: ${socialFetched ? '✓' : '✗'})`);
763
763
 
764
+ // Send notification to requesting user if this is an on-demand sync
765
+ if (requestId && source === 'on_demand_sync' && metadata?.requestingUserCid) {
766
+ try {
767
+ const { notifyTaskEngineComplete } = require('../../generic-api/user-api/helpers/notification_helpers');
768
+ const success = portfolioFetched || historyFetched; // At least one should succeed
769
+ await notifyTaskEngineComplete(
770
+ db,
771
+ logger,
772
+ metadata.requestingUserCid,
773
+ requestId,
774
+ username,
775
+ success,
776
+ success ? null : 'Failed to fetch portfolio or history data'
777
+ );
778
+ } catch (notifError) {
779
+ logger.log('WARN', `[On-Demand Update] Failed to send notification`, notifError);
780
+ // Non-critical, continue
781
+ }
782
+ }
783
+
764
784
  // Update root data indexer after all data is fetched
765
785
  // Then trigger computations for both sync requests AND user signups
766
786
  const shouldTriggerComputations = (requestId && (source === 'on_demand_sync' || source === 'user_signup')) || isNewUser;
@@ -834,7 +854,9 @@ async function handleOnDemandUserUpdate(taskData, config, dependencies) {
834
854
  requestId: requestId,
835
855
  userCid: cid,
836
856
  username: username,
837
- targetCid: targetCid // Pass targetCid for optimization
857
+ targetCid: targetCid, // Pass targetCid for optimization
858
+ requestingUserCid: metadata?.requestingUserCid || null, // Pass for notifications
859
+ onDemand: true
838
860
  }
839
861
  );
840
862
  triggeredMessages.push(...messages);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.479",
3
+ "version": "1.0.490",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [