bulltrackers-module 1.0.394 → 1.0.396

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.
@@ -672,7 +672,9 @@ async function getUserComputations(req, res, dependencies, config) {
672
672
 
673
673
  /**
674
674
  * POST /user/me/watchlist/auto-generate
675
- * Auto-generates watchlist based on copied PIs from SignedInUserCopiedPIs computation
675
+ * Auto-generates watchlist based on copied PIs
676
+ * Primary: Uses SignedInUserCopiedPIs computation (cheaper/faster)
677
+ * Fallback: Reads portfolio AggregatedMirrors directly if computation not available
676
678
  */
677
679
  async function autoGenerateWatchlist(req, res, dependencies, config) {
678
680
  const { db, logger } = dependencies;
@@ -683,44 +685,137 @@ async function autoGenerateWatchlist(req, res, dependencies, config) {
683
685
  }
684
686
 
685
687
  try {
686
- // 1. Fetch SignedInUserCopiedPIs computation result
687
- // Computations are stored in unified_insights/{date}/results/{category}/computations/{computationName}
688
+ let copiedPIs = [];
689
+ let dataSource = 'unknown';
690
+ const today = new Date().toISOString().split('T')[0];
691
+
692
+ // === PRIMARY: Try to fetch from computation (cheaper/faster) ===
688
693
  const insightsCollection = config.unifiedInsightsCollection || 'unified_insights';
689
694
  const category = 'signed_in_user';
690
- const today = new Date().toISOString().split('T')[0];
691
695
  const computationName = 'SignedInUserCopiedPIs';
692
696
 
693
- const computationRef = db.collection(insightsCollection)
694
- .doc(today)
695
- .collection('results')
696
- .doc(category)
697
- .collection('computations')
698
- .doc(computationName);
699
-
700
- const computationDoc = await computationRef.get();
701
-
702
- if (!computationDoc.exists) {
703
- logger.log('INFO', `[autoGenerateWatchlist] No SignedInUserCopiedPIs computation found for ${userCid} on ${today}`);
704
- return res.status(200).json({
705
- success: true,
706
- generated: 0,
707
- message: "No copied PIs found or computation not yet run"
708
- });
697
+ // Try to find latest computation date (with fallback)
698
+ const computationDate = await findLatestComputationDate(
699
+ db,
700
+ insightsCollection,
701
+ category,
702
+ computationName,
703
+ userCid,
704
+ 30
705
+ );
706
+
707
+ if (computationDate) {
708
+ const computationRef = db.collection(insightsCollection)
709
+ .doc(computationDate)
710
+ .collection('results')
711
+ .doc(category)
712
+ .collection('computations')
713
+ .doc(computationName);
714
+
715
+ const computationDoc = await computationRef.get();
716
+
717
+ if (computationDoc.exists) {
718
+ const computationData = computationDoc.data();
719
+ const userResult = computationData[String(userCid)];
720
+
721
+ if (userResult && userResult.current && userResult.current.length > 0) {
722
+ // Convert computation result to our format
723
+ copiedPIs = userResult.current.map(cid => ({
724
+ cid: Number(cid),
725
+ username: 'Unknown' // Username not in computation, will get from rankings
726
+ }));
727
+ dataSource = 'computation';
728
+ logger.log('INFO', `[autoGenerateWatchlist] Using computation data (date: ${computationDate}) for user ${userCid}, found ${copiedPIs.length} copied PIs`);
729
+ }
730
+ }
709
731
  }
710
732
 
711
- const computationData = computationDoc.data();
712
- // The computation result is stored with user CID as key
713
- const userResult = computationData[String(userCid)];
714
- const copiedPIs = userResult?.current || [];
733
+ // === FALLBACK: Read portfolio data directly if computation not available ===
734
+ if (copiedPIs.length === 0) {
735
+ logger.log('INFO', `[autoGenerateWatchlist] Computation not available, falling back to direct portfolio read for user ${userCid}`);
736
+
737
+ const { signedInUsersCollection } = config;
738
+ const CANARY_BLOCK_ID = '19M';
739
+
740
+ // Find latest available portfolio date (with fallback)
741
+ const portfolioDate = await findLatestPortfolioDate(db, signedInUsersCollection, userCid, 30);
742
+
743
+ if (!portfolioDate) {
744
+ logger.log('INFO', `[autoGenerateWatchlist] No portfolio data found for ${userCid} (checked last 30 days)`);
745
+ return res.status(200).json({
746
+ success: true,
747
+ generated: 0,
748
+ totalCopied: 0,
749
+ dataSource: 'none',
750
+ message: "No portfolio data found for this user"
751
+ });
752
+ }
753
+
754
+ if (portfolioDate !== today) {
755
+ logger.log('INFO', `[autoGenerateWatchlist] Using fallback portfolio date ${portfolioDate} for user ${userCid} (today: ${today})`);
756
+ }
757
+
758
+ // Fetch portfolio from signed_in_users/19M/snapshots/{date}/parts/part_X
759
+ const partsRef = db.collection(signedInUsersCollection)
760
+ .doc(CANARY_BLOCK_ID)
761
+ .collection('snapshots')
762
+ .doc(portfolioDate)
763
+ .collection('parts');
764
+
765
+ const partsSnapshot = await partsRef.get();
766
+
767
+ let portfolioData = null;
768
+
769
+ // Search through all parts to find the user's portfolio
770
+ for (const partDoc of partsSnapshot.docs) {
771
+ const partData = partDoc.data();
772
+ if (partData && partData[String(userCid)]) {
773
+ portfolioData = partData[String(userCid)];
774
+ break;
775
+ }
776
+ }
777
+
778
+ if (!portfolioData) {
779
+ logger.log('WARN', `[autoGenerateWatchlist] Portfolio data not found in parts for ${userCid}`);
780
+ return res.status(200).json({
781
+ success: true,
782
+ generated: 0,
783
+ totalCopied: 0,
784
+ dataSource: 'none',
785
+ message: "Portfolio data not found"
786
+ });
787
+ }
788
+
789
+ // Extract copied PIs from AggregatedMirrors
790
+ const aggregatedMirrors = portfolioData.AggregatedMirrors || [];
791
+
792
+ for (const mirror of aggregatedMirrors) {
793
+ const parentCID = mirror.ParentCID;
794
+ if (parentCID && parentCID > 0) {
795
+ copiedPIs.push({
796
+ cid: parentCID,
797
+ username: mirror.ParentUsername || 'Unknown'
798
+ });
799
+ }
800
+ }
801
+
802
+ dataSource = 'portfolio';
803
+ logger.log('INFO', `[autoGenerateWatchlist] Using portfolio data (date: ${portfolioDate}) for user ${userCid}, found ${copiedPIs.length} copied PIs`);
804
+ }
715
805
 
716
806
  if (copiedPIs.length === 0) {
807
+ logger.log('INFO', `[autoGenerateWatchlist] No copied PIs found for user ${userCid} (source: ${dataSource})`);
717
808
  return res.status(200).json({
718
809
  success: true,
719
810
  generated: 0,
811
+ totalCopied: 0,
812
+ dataSource: dataSource,
720
813
  message: "User is not currently copying any PIs"
721
814
  });
722
815
  }
723
816
 
817
+ logger.log('INFO', `[autoGenerateWatchlist] Found ${copiedPIs.length} copied PIs for user ${userCid} (source: ${dataSource}): ${copiedPIs.map(p => `${p.username} (${p.cid})`).join(', ')}`);
818
+
724
819
  // 2. Fetch latest rankings data (with fallback to latest available date)
725
820
  const rankingsCollection = config.popularInvestorRankingsCollection || process.env.FIRESTORE_COLLECTION_PI_RANKINGS || 'popular_investor_rankings';
726
821
  const rankingsDate = await findLatestRankingsDate(db, rankingsCollection, 30);
@@ -753,59 +848,123 @@ async function autoGenerateWatchlist(req, res, dependencies, config) {
753
848
  }
754
849
  }
755
850
 
756
- // 3. Match copied CIDs against rankings and create watchlist entries
757
- const watchlistsCollection = config.watchlistsCollection || 'watchlists';
758
- const watchlistRef = db.collection(watchlistsCollection).doc(String(userCid));
759
- const watchlistDoc = await watchlistRef.get();
760
- const existingWatchlist = watchlistDoc.exists ? watchlistDoc.data() : {};
761
-
762
- let generatedCount = 0;
763
- const watchlistUpdates = {};
851
+ // 3. Create watchlist items from copied PIs
852
+ // Include ALL copied PIs, even if not in rankings (user is copying them, so they should be watched)
853
+ let matchedCount = 0;
854
+ const watchlistItems = [];
764
855
 
765
- for (const copiedCID of copiedPIs) {
766
- const cidStr = String(copiedCID);
856
+ for (const copiedPI of copiedPIs) {
857
+ const cidStr = String(copiedPI.cid);
767
858
  const rankEntry = rankingsMap.get(cidStr);
768
859
 
769
- if (!rankEntry) {
770
- logger.log('INFO', `[autoGenerateWatchlist] Copied PI ${cidStr} not found in rankings, skipping`);
771
- continue;
772
- }
860
+ // Use ranking data if available, otherwise use data from portfolio/computation
861
+ const username = rankEntry?.UserName || copiedPI.username || 'Unknown';
773
862
 
774
- // Create watchlist entry ID
775
- const entryId = `pi_${cidStr}`;
776
-
777
- // Only add if not already in watchlist
778
- if (!existingWatchlist[entryId]) {
779
- watchlistUpdates[entryId] = {
780
- type: 'popular_investor',
781
- cid: Number(cidStr),
782
- username: rankEntry.UserName || 'Unknown',
783
- addedAt: FieldValue.serverTimestamp(),
784
- alertConfig: {
785
- newPositions: true,
786
- volatilityChanges: false,
787
- increasedRisk: true,
788
- newSector: false,
789
- increasedPositionSize: false,
790
- newSocialPost: true
791
- },
792
- autoGenerated: true
793
- };
794
- generatedCount++;
863
+ if (rankEntry) {
864
+ matchedCount++;
865
+ } else {
866
+ logger.log('INFO', `[autoGenerateWatchlist] Copied PI ${copiedPI.username} (${cidStr}) not found in rankings, but including in watchlist anyway`);
795
867
  }
868
+
869
+ watchlistItems.push({
870
+ cid: Number(cidStr),
871
+ username: username,
872
+ addedAt: FieldValue.serverTimestamp(),
873
+ alertConfig: {
874
+ newPositions: true,
875
+ volatilityChanges: true,
876
+ increasedRisk: true,
877
+ newSector: true,
878
+ increasedPositionSize: true,
879
+ newSocialPost: true
880
+ }
881
+ });
796
882
  }
797
883
 
798
- // 4. Write watchlist entries to Firestore
799
- if (Object.keys(watchlistUpdates).length > 0) {
800
- await watchlistRef.set(watchlistUpdates, { merge: true });
801
- logger.log('SUCCESS', `[autoGenerateWatchlist] Generated ${generatedCount} watchlist entries for user ${userCid}`);
884
+ if (watchlistItems.length === 0) {
885
+ logger.log('INFO', `[autoGenerateWatchlist] No PIs matched in rankings for user ${userCid}`);
886
+ return res.status(200).json({
887
+ success: true,
888
+ generated: 0,
889
+ totalCopied: copiedPIs.length,
890
+ matchedInRankings: 0,
891
+ dataSource: dataSource,
892
+ message: "No copied PIs found in rankings"
893
+ });
802
894
  }
803
895
 
896
+ // 4. Create or update the auto-generated watchlist using new structure
897
+ // The auto-generated watchlist should always reflect the CURRENT state of copied PIs
898
+ const watchlistsCollection = config.watchlistsCollection || 'watchlists';
899
+ const userWatchlistsRef = db.collection(watchlistsCollection)
900
+ .doc(String(userCid))
901
+ .collection('lists');
902
+
903
+ // Check if auto-generated watchlist already exists
904
+ const existingWatchlistsSnapshot = await userWatchlistsRef
905
+ .where('isAutoGenerated', '==', true)
906
+ .limit(1)
907
+ .get();
908
+
909
+ let watchlistId;
910
+ let generatedCount = watchlistItems.length;
911
+
912
+ if (!existingWatchlistsSnapshot.empty) {
913
+ // Update existing auto-generated watchlist
914
+ // Replace items entirely to match current copied PIs (sync, not append)
915
+ const existingDoc = existingWatchlistsSnapshot.docs[0];
916
+ watchlistId = existingDoc.id;
917
+ const existingData = existingDoc.data();
918
+ const existingItems = existingData.items || [];
919
+
920
+ // Calculate what changed
921
+ const existingCIDs = new Set(existingItems.map(item => item.cid));
922
+ const newCIDs = new Set(watchlistItems.map(item => item.cid));
923
+
924
+ const added = watchlistItems.filter(item => !existingCIDs.has(item.cid));
925
+ const removed = existingItems.filter(item => !newCIDs.has(item.cid));
926
+
927
+ // Replace entire items array to sync with current copied PIs
928
+ await existingDoc.ref.update({
929
+ items: watchlistItems, // Full replacement to match current state
930
+ updatedAt: FieldValue.serverTimestamp()
931
+ });
932
+
933
+ logger.log('SUCCESS', `[autoGenerateWatchlist] Synced auto-generated watchlist ${watchlistId} for user ${userCid}: ${added.length} added, ${removed.length} removed, ${watchlistItems.length} total`);
934
+ } else {
935
+ // Create new auto-generated watchlist
936
+ const crypto = require('crypto');
937
+ watchlistId = `watchlist_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
938
+
939
+ const watchlistData = {
940
+ id: watchlistId,
941
+ name: 'My Copy Watchlist', // Hardcoded name as requested
942
+ type: 'static',
943
+ visibility: 'private',
944
+ createdBy: Number(userCid),
945
+ createdAt: FieldValue.serverTimestamp(),
946
+ updatedAt: FieldValue.serverTimestamp(),
947
+ isAutoGenerated: true,
948
+ copyCount: 0,
949
+ items: watchlistItems
950
+ };
951
+
952
+ await userWatchlistsRef.doc(watchlistId).set(watchlistData);
953
+ logger.log('SUCCESS', `[autoGenerateWatchlist] Created auto-generated watchlist ${watchlistId} with ${watchlistItems.length} items for user ${userCid}`);
954
+ }
955
+
956
+ // 5. Create subscriptions for all items in the watchlist
957
+ // This will be handled by the subscription system, but we log it here
958
+ logger.log('INFO', `[autoGenerateWatchlist] Watchlist ${watchlistId} ready for subscription setup (${watchlistItems.length} items)`);
959
+
804
960
  return res.status(200).json({
805
961
  success: true,
806
962
  generated: generatedCount,
807
963
  totalCopied: copiedPIs.length,
808
- message: `Generated ${generatedCount} watchlist entries from ${copiedPIs.length} copied PIs`
964
+ matchedInRankings: matchedCount,
965
+ dataSource: dataSource,
966
+ watchlistId: watchlistId,
967
+ message: `Generated ${generatedCount} watchlist entries from ${copiedPIs.length} copied PIs (${matchedCount} matched in rankings, source: ${dataSource})`
809
968
  });
810
969
 
811
970
  } catch (error) {
@@ -0,0 +1,328 @@
1
+ /**
2
+ * @fileoverview Alert Subscription Management Helpers
3
+ * Handles subscriptions for watchlist alerts (static and dynamic)
4
+ */
5
+
6
+ const { FieldValue } = require('@google-cloud/firestore');
7
+
8
+ /**
9
+ * POST /user/me/subscriptions
10
+ * Subscribe to alerts for a PI in a watchlist
11
+ */
12
+ async function subscribeToAlerts(req, res, dependencies, config) {
13
+ const { db, logger } = dependencies;
14
+ const { userCid, watchlistId, piCid, alertTypes, thresholds } = req.body;
15
+
16
+ if (!userCid || !watchlistId || !piCid) {
17
+ return res.status(400).json({ error: "Missing required fields: userCid, watchlistId, piCid" });
18
+ }
19
+
20
+ try {
21
+ // Verify watchlist exists and belongs to user
22
+ const watchlistsCollection = config.watchlistsCollection || 'watchlists';
23
+ const watchlistRef = db.collection(watchlistsCollection)
24
+ .doc(String(userCid))
25
+ .collection('lists')
26
+ .doc(watchlistId);
27
+
28
+ const watchlistDoc = await watchlistRef.get();
29
+
30
+ if (!watchlistDoc.exists) {
31
+ return res.status(404).json({ error: "Watchlist not found" });
32
+ }
33
+
34
+ const watchlistData = watchlistDoc.data();
35
+
36
+ // Verify PI is in the watchlist
37
+ let piInWatchlist = false;
38
+ if (watchlistData.type === 'static') {
39
+ piInWatchlist = watchlistData.items?.some(item => item.cid === Number(piCid));
40
+ } else if (watchlistData.type === 'dynamic') {
41
+ // For dynamic watchlists, we'll check if the PI is in the current computation result
42
+ // This is a simplified check - in production, you'd fetch the latest computation result
43
+ piInWatchlist = true; // Allow subscriptions for dynamic watchlists
44
+ }
45
+
46
+ if (!piInWatchlist && watchlistData.type === 'static') {
47
+ return res.status(400).json({ error: "PI is not in this watchlist" });
48
+ }
49
+
50
+ // Default alert types (all enabled) if not provided
51
+ const defaultAlertTypes = {
52
+ newPositions: true,
53
+ volatilityChanges: true,
54
+ increasedRisk: true,
55
+ newSector: true,
56
+ increasedPositionSize: true,
57
+ newSocialPost: true
58
+ };
59
+
60
+ const subscriptionData = {
61
+ userCid: Number(userCid),
62
+ piCid: Number(piCid),
63
+ watchlistId: watchlistId,
64
+ alertTypes: alertTypes || defaultAlertTypes,
65
+ thresholds: thresholds || {},
66
+ subscribedAt: FieldValue.serverTimestamp(),
67
+ lastAlertAt: null
68
+ };
69
+
70
+ // Store subscription
71
+ const subscriptionsCollection = config.watchlistSubscriptionsCollection || 'watchlist_subscriptions';
72
+ const subscriptionRef = db.collection(subscriptionsCollection)
73
+ .doc(String(userCid))
74
+ .collection('alerts')
75
+ .doc(String(piCid));
76
+
77
+ await subscriptionRef.set(subscriptionData, { merge: true });
78
+
79
+ logger.log('SUCCESS', `[subscribeToAlerts] User ${userCid} subscribed to alerts for PI ${piCid} in watchlist ${watchlistId}`);
80
+
81
+ return res.status(200).json({
82
+ success: true,
83
+ subscription: subscriptionData
84
+ });
85
+
86
+ } catch (error) {
87
+ logger.log('ERROR', `[subscribeToAlerts] Error creating subscription for user ${userCid}`, error);
88
+ return res.status(500).json({ error: error.message });
89
+ }
90
+ }
91
+
92
+ /**
93
+ * PUT /user/me/subscriptions/:piCid
94
+ * Update alert subscription settings
95
+ */
96
+ async function updateSubscription(req, res, dependencies, config) {
97
+ const { db, logger } = dependencies;
98
+ const { userCid } = req.query;
99
+ const { piCid } = req.params;
100
+ const { alertTypes, thresholds } = req.body;
101
+
102
+ if (!userCid || !piCid) {
103
+ return res.status(400).json({ error: "Missing userCid or piCid" });
104
+ }
105
+
106
+ try {
107
+ const subscriptionsCollection = config.watchlistSubscriptionsCollection || 'watchlist_subscriptions';
108
+ const subscriptionRef = db.collection(subscriptionsCollection)
109
+ .doc(String(userCid))
110
+ .collection('alerts')
111
+ .doc(String(piCid));
112
+
113
+ const subscriptionDoc = await subscriptionRef.get();
114
+
115
+ if (!subscriptionDoc.exists) {
116
+ return res.status(404).json({ error: "Subscription not found" });
117
+ }
118
+
119
+ const updates = {};
120
+
121
+ if (alertTypes !== undefined) {
122
+ updates.alertTypes = alertTypes;
123
+ }
124
+
125
+ if (thresholds !== undefined) {
126
+ updates.thresholds = thresholds;
127
+ }
128
+
129
+ if (Object.keys(updates).length === 0) {
130
+ return res.status(400).json({ error: "No updates provided" });
131
+ }
132
+
133
+ updates.updatedAt = FieldValue.serverTimestamp();
134
+
135
+ await subscriptionRef.update(updates);
136
+
137
+ logger.log('SUCCESS', `[updateSubscription] Updated subscription for user ${userCid}, PI ${piCid}`);
138
+
139
+ const updatedDoc = await subscriptionRef.get();
140
+ return res.status(200).json({
141
+ success: true,
142
+ subscription: {
143
+ id: updatedDoc.id,
144
+ ...updatedDoc.data()
145
+ }
146
+ });
147
+
148
+ } catch (error) {
149
+ logger.log('ERROR', `[updateSubscription] Error updating subscription for user ${userCid}`, error);
150
+ return res.status(500).json({ error: error.message });
151
+ }
152
+ }
153
+
154
+ /**
155
+ * DELETE /user/me/subscriptions/:piCid
156
+ * Unsubscribe from alerts for a PI
157
+ */
158
+ async function unsubscribeFromAlerts(req, res, dependencies, config) {
159
+ const { db, logger } = dependencies;
160
+ const { userCid } = req.query;
161
+ const { piCid } = req.params;
162
+
163
+ if (!userCid || !piCid) {
164
+ return res.status(400).json({ error: "Missing userCid or piCid" });
165
+ }
166
+
167
+ try {
168
+ const subscriptionsCollection = config.watchlistSubscriptionsCollection || 'watchlist_subscriptions';
169
+ const subscriptionRef = db.collection(subscriptionsCollection)
170
+ .doc(String(userCid))
171
+ .collection('alerts')
172
+ .doc(String(piCid));
173
+
174
+ const subscriptionDoc = await subscriptionRef.get();
175
+
176
+ if (!subscriptionDoc.exists) {
177
+ return res.status(404).json({ error: "Subscription not found" });
178
+ }
179
+
180
+ await subscriptionRef.delete();
181
+
182
+ logger.log('SUCCESS', `[unsubscribeFromAlerts] User ${userCid} unsubscribed from alerts for PI ${piCid}`);
183
+
184
+ return res.status(200).json({
185
+ success: true,
186
+ message: "Unsubscribed successfully"
187
+ });
188
+
189
+ } catch (error) {
190
+ logger.log('ERROR', `[unsubscribeFromAlerts] Error unsubscribing user ${userCid} from PI ${piCid}`, error);
191
+ return res.status(500).json({ error: error.message });
192
+ }
193
+ }
194
+
195
+ /**
196
+ * GET /user/me/subscriptions
197
+ * Get all subscriptions for a user
198
+ */
199
+ async function getUserSubscriptions(req, res, dependencies, config) {
200
+ const { db, logger } = dependencies;
201
+ const { userCid } = req.query;
202
+
203
+ if (!userCid) {
204
+ return res.status(400).json({ error: "Missing userCid" });
205
+ }
206
+
207
+ try {
208
+ const subscriptionsCollection = config.watchlistSubscriptionsCollection || 'watchlist_subscriptions';
209
+ const subscriptionsRef = db.collection(subscriptionsCollection)
210
+ .doc(String(userCid))
211
+ .collection('alerts');
212
+
213
+ const snapshot = await subscriptionsRef.get();
214
+
215
+ const subscriptions = [];
216
+ snapshot.forEach(doc => {
217
+ subscriptions.push({
218
+ piCid: Number(doc.id),
219
+ ...doc.data()
220
+ });
221
+ });
222
+
223
+ return res.status(200).json({
224
+ subscriptions,
225
+ count: subscriptions.length
226
+ });
227
+
228
+ } catch (error) {
229
+ logger.log('ERROR', `[getUserSubscriptions] Error fetching subscriptions for user ${userCid}`, error);
230
+ return res.status(500).json({ error: error.message });
231
+ }
232
+ }
233
+
234
+ /**
235
+ * POST /user/me/watchlists/:id/subscribe-all
236
+ * Subscribe to all PIs in a watchlist with default alert settings
237
+ */
238
+ async function subscribeToWatchlist(req, res, dependencies, config) {
239
+ const { db, logger } = dependencies;
240
+ const { userCid } = req.query;
241
+ const { id } = req.params;
242
+ const { alertTypes, thresholds } = req.body;
243
+
244
+ if (!userCid || !id) {
245
+ return res.status(400).json({ error: "Missing userCid or watchlist id" });
246
+ }
247
+
248
+ try {
249
+ // Get watchlist
250
+ const watchlistsCollection = config.watchlistsCollection || 'watchlists';
251
+ const watchlistRef = db.collection(watchlistsCollection)
252
+ .doc(String(userCid))
253
+ .collection('lists')
254
+ .doc(id);
255
+
256
+ const watchlistDoc = await watchlistRef.get();
257
+
258
+ if (!watchlistDoc.exists) {
259
+ return res.status(404).json({ error: "Watchlist not found" });
260
+ }
261
+
262
+ const watchlistData = watchlistDoc.data();
263
+
264
+ // Default alert types
265
+ const defaultAlertTypes = alertTypes || {
266
+ newPositions: true,
267
+ volatilityChanges: true,
268
+ increasedRisk: true,
269
+ newSector: true,
270
+ increasedPositionSize: true,
271
+ newSocialPost: true
272
+ };
273
+
274
+ const subscriptionsCollection = config.watchlistSubscriptionsCollection || 'watchlist_subscriptions';
275
+ const subscriptionsRef = db.collection(subscriptionsCollection)
276
+ .doc(String(userCid))
277
+ .collection('alerts');
278
+
279
+ let subscribedCount = 0;
280
+
281
+ if (watchlistData.type === 'static') {
282
+ // Subscribe to all PIs in static watchlist
283
+ const items = watchlistData.items || [];
284
+
285
+ for (const item of items) {
286
+ const subscriptionData = {
287
+ userCid: Number(userCid),
288
+ piCid: item.cid,
289
+ watchlistId: id,
290
+ alertTypes: item.alertConfig || defaultAlertTypes,
291
+ thresholds: thresholds || {},
292
+ subscribedAt: FieldValue.serverTimestamp(),
293
+ lastAlertAt: null
294
+ };
295
+
296
+ await subscriptionsRef.doc(String(item.cid)).set(subscriptionData, { merge: true });
297
+ subscribedCount++;
298
+ }
299
+ } else if (watchlistData.type === 'dynamic') {
300
+ // For dynamic watchlists, we'd need to fetch the current computation result
301
+ // For now, we'll just set up the subscription structure
302
+ // The actual PIs will be determined when the computation runs
303
+ logger.log('INFO', `[subscribeToWatchlist] Dynamic watchlist subscription setup for ${id} (will be populated by computation)`);
304
+ }
305
+
306
+ logger.log('SUCCESS', `[subscribeToWatchlist] Subscribed user ${userCid} to ${subscribedCount} PIs in watchlist ${id}`);
307
+
308
+ return res.status(200).json({
309
+ success: true,
310
+ subscribed: subscribedCount,
311
+ watchlistId: id,
312
+ watchlistType: watchlistData.type
313
+ });
314
+
315
+ } catch (error) {
316
+ logger.log('ERROR', `[subscribeToWatchlist] Error subscribing to watchlist ${id} for user ${userCid}`, error);
317
+ return res.status(500).json({ error: error.message });
318
+ }
319
+ }
320
+
321
+ module.exports = {
322
+ subscribeToAlerts,
323
+ updateSubscription,
324
+ unsubscribeFromAlerts,
325
+ getUserSubscriptions,
326
+ subscribeToWatchlist
327
+ };
328
+
@@ -0,0 +1,466 @@
1
+ /**
2
+ * @fileoverview Watchlist Management Helpers
3
+ * Handles CRUD operations for user watchlists (static and dynamic)
4
+ */
5
+
6
+ const { FieldValue } = require('@google-cloud/firestore');
7
+ const crypto = require('crypto');
8
+
9
+ /**
10
+ * Generate unique watchlist ID
11
+ */
12
+ function generateWatchlistId() {
13
+ return `watchlist_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
14
+ }
15
+
16
+ /**
17
+ * GET /user/me/watchlists
18
+ * List all watchlists for a user
19
+ */
20
+ async function getUserWatchlists(req, res, dependencies, config) {
21
+ const { db, logger } = dependencies;
22
+ const { userCid } = req.query;
23
+
24
+ if (!userCid) {
25
+ return res.status(400).json({ error: "Missing userCid" });
26
+ }
27
+
28
+ try {
29
+ const watchlistsCollection = config.watchlistsCollection || 'watchlists';
30
+ const userWatchlistsRef = db.collection(watchlistsCollection)
31
+ .doc(String(userCid))
32
+ .collection('lists');
33
+
34
+ const snapshot = await userWatchlistsRef.get();
35
+
36
+ const watchlists = [];
37
+ snapshot.forEach(doc => {
38
+ watchlists.push({
39
+ id: doc.id,
40
+ ...doc.data()
41
+ });
42
+ });
43
+
44
+ // Sort by creation date (newest first)
45
+ watchlists.sort((a, b) => {
46
+ const aTime = a.createdAt?.seconds || 0;
47
+ const bTime = b.createdAt?.seconds || 0;
48
+ return bTime - aTime;
49
+ });
50
+
51
+ return res.status(200).json({
52
+ watchlists,
53
+ count: watchlists.length
54
+ });
55
+
56
+ } catch (error) {
57
+ logger.log('ERROR', `[getUserWatchlists] Error fetching watchlists for ${userCid}`, error);
58
+ return res.status(500).json({ error: error.message });
59
+ }
60
+ }
61
+
62
+ /**
63
+ * GET /user/me/watchlists/:id
64
+ * Get a specific watchlist
65
+ */
66
+ async function getWatchlist(req, res, dependencies, config) {
67
+ const { db, logger } = dependencies;
68
+ const { userCid } = req.query;
69
+ const { id } = req.params;
70
+
71
+ if (!userCid || !id) {
72
+ return res.status(400).json({ error: "Missing userCid or watchlist id" });
73
+ }
74
+
75
+ try {
76
+ const watchlistsCollection = config.watchlistsCollection || 'watchlists';
77
+ const watchlistRef = db.collection(watchlistsCollection)
78
+ .doc(String(userCid))
79
+ .collection('lists')
80
+ .doc(id);
81
+
82
+ const watchlistDoc = await watchlistRef.get();
83
+
84
+ if (!watchlistDoc.exists) {
85
+ return res.status(404).json({ error: "Watchlist not found" });
86
+ }
87
+
88
+ return res.status(200).json({
89
+ id: watchlistDoc.id,
90
+ ...watchlistDoc.data()
91
+ });
92
+
93
+ } catch (error) {
94
+ logger.log('ERROR', `[getWatchlist] Error fetching watchlist ${id} for ${userCid}`, error);
95
+ return res.status(500).json({ error: error.message });
96
+ }
97
+ }
98
+
99
+ /**
100
+ * POST /user/me/watchlists
101
+ * Create a new watchlist
102
+ */
103
+ async function createWatchlist(req, res, dependencies, config) {
104
+ const { db, logger } = dependencies;
105
+ const { userCid, name, type, visibility = 'private', items, dynamicConfig } = req.body;
106
+
107
+ if (!userCid || !name || !type) {
108
+ return res.status(400).json({ error: "Missing required fields: userCid, name, type" });
109
+ }
110
+
111
+ if (type !== 'static' && type !== 'dynamic') {
112
+ return res.status(400).json({ error: "Type must be 'static' or 'dynamic'" });
113
+ }
114
+
115
+ if (visibility !== 'public' && visibility !== 'private') {
116
+ return res.status(400).json({ error: "Visibility must be 'public' or 'private'" });
117
+ }
118
+
119
+ try {
120
+ const watchlistsCollection = config.watchlistsCollection || 'watchlists';
121
+ const watchlistId = generateWatchlistId();
122
+
123
+ const watchlistData = {
124
+ id: watchlistId,
125
+ name: name.trim(),
126
+ type,
127
+ visibility,
128
+ createdBy: Number(userCid),
129
+ createdAt: FieldValue.serverTimestamp(),
130
+ updatedAt: FieldValue.serverTimestamp(),
131
+ isAutoGenerated: false,
132
+ copyCount: 0
133
+ };
134
+
135
+ if (type === 'static') {
136
+ watchlistData.items = items || [];
137
+ } else if (type === 'dynamic') {
138
+ if (!dynamicConfig || !dynamicConfig.computationName) {
139
+ return res.status(400).json({ error: "Dynamic watchlists require dynamicConfig with computationName" });
140
+ }
141
+ watchlistData.dynamicConfig = dynamicConfig;
142
+ }
143
+
144
+ const watchlistRef = db.collection(watchlistsCollection)
145
+ .doc(String(userCid))
146
+ .collection('lists')
147
+ .doc(watchlistId);
148
+
149
+ await watchlistRef.set(watchlistData);
150
+
151
+ // If public, also add to public watchlists collection
152
+ if (visibility === 'public') {
153
+ const publicRef = db.collection('public_watchlists').doc(watchlistId);
154
+ await publicRef.set({
155
+ watchlistId,
156
+ createdBy: Number(userCid),
157
+ name: watchlistData.name,
158
+ type,
159
+ description: dynamicConfig?.description || '',
160
+ copyCount: 0,
161
+ createdAt: FieldValue.serverTimestamp(),
162
+ updatedAt: FieldValue.serverTimestamp()
163
+ });
164
+ }
165
+
166
+ logger.log('SUCCESS', `[createWatchlist] Created ${type} watchlist "${name}" for user ${userCid}`);
167
+
168
+ return res.status(201).json({
169
+ success: true,
170
+ watchlist: {
171
+ id: watchlistId,
172
+ ...watchlistData
173
+ }
174
+ });
175
+
176
+ } catch (error) {
177
+ logger.log('ERROR', `[createWatchlist] Error creating watchlist for ${userCid}`, error);
178
+ return res.status(500).json({ error: error.message });
179
+ }
180
+ }
181
+
182
+ /**
183
+ * PUT /user/me/watchlists/:id
184
+ * Update a watchlist
185
+ */
186
+ async function updateWatchlist(req, res, dependencies, config) {
187
+ const { db, logger } = dependencies;
188
+ const { userCid } = req.query;
189
+ const { id } = req.params;
190
+ const { name, visibility, items, dynamicConfig } = req.body;
191
+
192
+ if (!userCid || !id) {
193
+ return res.status(400).json({ error: "Missing userCid or watchlist id" });
194
+ }
195
+
196
+ try {
197
+ const watchlistsCollection = config.watchlistsCollection || 'watchlists';
198
+ const watchlistRef = db.collection(watchlistsCollection)
199
+ .doc(String(userCid))
200
+ .collection('lists')
201
+ .doc(id);
202
+
203
+ const watchlistDoc = await watchlistRef.get();
204
+
205
+ if (!watchlistDoc.exists) {
206
+ return res.status(404).json({ error: "Watchlist not found" });
207
+ }
208
+
209
+ const existingData = watchlistDoc.data();
210
+
211
+ // Verify ownership
212
+ if (existingData.createdBy !== Number(userCid)) {
213
+ return res.status(403).json({ error: "You can only modify your own watchlists" });
214
+ }
215
+
216
+ const updates = {
217
+ updatedAt: FieldValue.serverTimestamp()
218
+ };
219
+
220
+ if (name !== undefined) {
221
+ updates.name = name.trim();
222
+ }
223
+
224
+ if (visibility !== undefined) {
225
+ if (visibility !== 'public' && visibility !== 'private') {
226
+ return res.status(400).json({ error: "Visibility must be 'public' or 'private'" });
227
+ }
228
+ updates.visibility = visibility;
229
+
230
+ // Update public watchlists collection
231
+ const publicRef = db.collection('public_watchlists').doc(id);
232
+ if (visibility === 'public') {
233
+ await publicRef.set({
234
+ watchlistId: id,
235
+ createdBy: existingData.createdBy,
236
+ name: updates.name || existingData.name,
237
+ type: existingData.type,
238
+ description: dynamicConfig?.description || '',
239
+ copyCount: existingData.copyCount || 0,
240
+ createdAt: existingData.createdAt,
241
+ updatedAt: FieldValue.serverTimestamp()
242
+ }, { merge: true });
243
+ } else {
244
+ // Remove from public if making private
245
+ await publicRef.delete();
246
+ }
247
+ }
248
+
249
+ if (items !== undefined && existingData.type === 'static') {
250
+ updates.items = items;
251
+ }
252
+
253
+ if (dynamicConfig !== undefined && existingData.type === 'dynamic') {
254
+ updates.dynamicConfig = dynamicConfig;
255
+ }
256
+
257
+ await watchlistRef.update(updates);
258
+
259
+ logger.log('SUCCESS', `[updateWatchlist] Updated watchlist ${id} for user ${userCid}`);
260
+
261
+ const updatedDoc = await watchlistRef.get();
262
+ return res.status(200).json({
263
+ success: true,
264
+ watchlist: {
265
+ id: updatedDoc.id,
266
+ ...updatedDoc.data()
267
+ }
268
+ });
269
+
270
+ } catch (error) {
271
+ logger.log('ERROR', `[updateWatchlist] Error updating watchlist ${id} for ${userCid}`, error);
272
+ return res.status(500).json({ error: error.message });
273
+ }
274
+ }
275
+
276
+ /**
277
+ * DELETE /user/me/watchlists/:id
278
+ * Delete a watchlist
279
+ */
280
+ async function deleteWatchlist(req, res, dependencies, config) {
281
+ const { db, logger } = dependencies;
282
+ const { userCid } = req.query;
283
+ const { id } = req.params;
284
+
285
+ if (!userCid || !id) {
286
+ return res.status(400).json({ error: "Missing userCid or watchlist id" });
287
+ }
288
+
289
+ try {
290
+ const watchlistsCollection = config.watchlistsCollection || 'watchlists';
291
+ const watchlistRef = db.collection(watchlistsCollection)
292
+ .doc(String(userCid))
293
+ .collection('lists')
294
+ .doc(id);
295
+
296
+ const watchlistDoc = await watchlistRef.get();
297
+
298
+ if (!watchlistDoc.exists) {
299
+ return res.status(404).json({ error: "Watchlist not found" });
300
+ }
301
+
302
+ const watchlistData = watchlistDoc.data();
303
+
304
+ // Verify ownership
305
+ if (watchlistData.createdBy !== Number(userCid)) {
306
+ return res.status(403).json({ error: "You can only delete your own watchlists" });
307
+ }
308
+
309
+ // Delete watchlist
310
+ await watchlistRef.delete();
311
+
312
+ // Remove from public watchlists if it was public
313
+ if (watchlistData.visibility === 'public') {
314
+ const publicRef = db.collection('public_watchlists').doc(id);
315
+ await publicRef.delete();
316
+ }
317
+
318
+ // TODO: Clean up subscriptions for this watchlist
319
+ // This would require deleting entries in watchlist_subscriptions collection
320
+
321
+ logger.log('SUCCESS', `[deleteWatchlist] Deleted watchlist ${id} for user ${userCid}`);
322
+
323
+ return res.status(200).json({
324
+ success: true,
325
+ message: "Watchlist deleted successfully"
326
+ });
327
+
328
+ } catch (error) {
329
+ logger.log('ERROR', `[deleteWatchlist] Error deleting watchlist ${id} for ${userCid}`, error);
330
+ return res.status(500).json({ error: error.message });
331
+ }
332
+ }
333
+
334
+ /**
335
+ * POST /user/me/watchlists/:id/copy
336
+ * Copy a public watchlist
337
+ */
338
+ async function copyWatchlist(req, res, dependencies, config) {
339
+ const { db, logger } = dependencies;
340
+ const { userCid } = req.query;
341
+ const { id } = req.params;
342
+ const { name } = req.body; // Optional custom name
343
+
344
+ if (!userCid || !id) {
345
+ return res.status(400).json({ error: "Missing userCid or watchlist id" });
346
+ }
347
+
348
+ try {
349
+ // First, try to find in public watchlists
350
+ const publicRef = db.collection('public_watchlists').doc(id);
351
+ const publicDoc = await publicRef.get();
352
+
353
+ if (!publicDoc.exists) {
354
+ return res.status(404).json({ error: "Public watchlist not found" });
355
+ }
356
+
357
+ const publicData = publicDoc.data();
358
+
359
+ // Find the original watchlist
360
+ const originalRef = db.collection(config.watchlistsCollection || 'watchlists')
361
+ .doc(String(publicData.createdBy))
362
+ .collection('lists')
363
+ .doc(id);
364
+
365
+ const originalDoc = await originalRef.get();
366
+
367
+ if (!originalDoc.exists) {
368
+ return res.status(404).json({ error: "Original watchlist not found" });
369
+ }
370
+
371
+ const originalData = originalDoc.data();
372
+
373
+ // Create new watchlist for the copying user
374
+ const newWatchlistId = generateWatchlistId();
375
+ const watchlistData = {
376
+ ...originalData,
377
+ id: newWatchlistId,
378
+ name: name || `${originalData.name} (Copy)`,
379
+ createdBy: Number(userCid),
380
+ visibility: 'private', // Copied watchlists are always private
381
+ copiedFrom: id,
382
+ createdAt: FieldValue.serverTimestamp(),
383
+ updatedAt: FieldValue.serverTimestamp(),
384
+ isAutoGenerated: false
385
+ };
386
+
387
+ // Remove fields that shouldn't be copied
388
+ delete watchlistData.copyCount;
389
+
390
+ const newWatchlistRef = db.collection(config.watchlistsCollection || 'watchlists')
391
+ .doc(String(userCid))
392
+ .collection('lists')
393
+ .doc(newWatchlistId);
394
+
395
+ await newWatchlistRef.set(watchlistData);
396
+
397
+ // Increment copy count on original
398
+ await publicRef.update({
399
+ copyCount: FieldValue.increment(1),
400
+ updatedAt: FieldValue.serverTimestamp()
401
+ });
402
+
403
+ logger.log('SUCCESS', `[copyWatchlist] User ${userCid} copied watchlist ${id} as ${newWatchlistId}`);
404
+
405
+ return res.status(201).json({
406
+ success: true,
407
+ watchlist: {
408
+ id: newWatchlistId,
409
+ ...watchlistData
410
+ }
411
+ });
412
+
413
+ } catch (error) {
414
+ logger.log('ERROR', `[copyWatchlist] Error copying watchlist ${id} for ${userCid}`, error);
415
+ return res.status(500).json({ error: error.message });
416
+ }
417
+ }
418
+
419
+ /**
420
+ * GET /user/public-watchlists
421
+ * Browse public watchlists
422
+ */
423
+ async function getPublicWatchlists(req, res, dependencies, config) {
424
+ const { db, logger } = dependencies;
425
+ const { limit = 50, offset = 0 } = req.query;
426
+
427
+ try {
428
+ const publicRef = db.collection('public_watchlists')
429
+ .orderBy('copyCount', 'desc')
430
+ .orderBy('createdAt', 'desc')
431
+ .limit(parseInt(limit))
432
+ .offset(parseInt(offset));
433
+
434
+ const snapshot = await publicRef.get();
435
+
436
+ const watchlists = [];
437
+ snapshot.forEach(doc => {
438
+ watchlists.push({
439
+ id: doc.id,
440
+ ...doc.data()
441
+ });
442
+ });
443
+
444
+ return res.status(200).json({
445
+ watchlists,
446
+ count: watchlists.length,
447
+ limit: parseInt(limit),
448
+ offset: parseInt(offset)
449
+ });
450
+
451
+ } catch (error) {
452
+ logger.log('ERROR', `[getPublicWatchlists] Error fetching public watchlists`, error);
453
+ return res.status(500).json({ error: error.message });
454
+ }
455
+ }
456
+
457
+ module.exports = {
458
+ getUserWatchlists,
459
+ getWatchlist,
460
+ createWatchlist,
461
+ updateWatchlist,
462
+ deleteWatchlist,
463
+ copyWatchlist,
464
+ getPublicWatchlists
465
+ };
466
+
@@ -6,6 +6,8 @@ const express = require('express');
6
6
  const { submitReview, getReviews } = require('./helpers/review_helpers');
7
7
  const { getPiAnalytics, getUserRecommendations, getWatchlist, updateWatchlist, autoGenerateWatchlist, getUserDataStatus, getUserPortfolio, getUserSocialPosts, getUserComputations, getUserVerification, getInstrumentMappings } = require('./helpers/data_helpers');
8
8
  const { initiateVerification, finalizeVerification } = require('./helpers/verification_helpers');
9
+ const { getUserWatchlists, getWatchlist: getWatchlistById, createWatchlist, updateWatchlist: updateWatchlistById, deleteWatchlist, copyWatchlist, getPublicWatchlists } = require('./helpers/watchlist_helpers');
10
+ const { subscribeToAlerts, updateSubscription, unsubscribeFromAlerts, getUserSubscriptions, subscribeToWatchlist } = require('./helpers/subscription_helpers');
9
11
 
10
12
  module.exports = (dependencies, config) => {
11
13
  const router = express.Router();
@@ -33,9 +35,28 @@ module.exports = (dependencies, config) => {
33
35
  router.get('/me/instrument-mappings', (req, res) => getInstrumentMappings(req, res, dependencies, config));
34
36
 
35
37
  // --- Watchlist & Alerts ---
38
+ // Legacy single watchlist endpoints (for backward compatibility)
36
39
  router.get('/me/watchlist', (req, res) => getWatchlist(req, res, dependencies, config));
37
40
  router.post('/watchlist', (req, res) => updateWatchlist(req, res, dependencies, config));
38
41
  router.post('/me/watchlist/auto-generate', (req, res) => autoGenerateWatchlist(req, res, dependencies, config));
39
-
42
+
43
+ // New multi-watchlist endpoints
44
+ router.get('/me/watchlists', (req, res) => getUserWatchlists(req, res, dependencies, config));
45
+ router.post('/me/watchlists', (req, res) => createWatchlist(req, res, dependencies, config));
46
+ router.get('/me/watchlists/:id', (req, res) => getWatchlistById(req, res, dependencies, config));
47
+ router.put('/me/watchlists/:id', (req, res) => updateWatchlistById(req, res, dependencies, config));
48
+ router.delete('/me/watchlists/:id', (req, res) => deleteWatchlist(req, res, dependencies, config));
49
+ router.post('/me/watchlists/:id/copy', (req, res) => copyWatchlist(req, res, dependencies, config));
50
+
51
+ // Public watchlists
52
+ router.get('/public-watchlists', (req, res) => getPublicWatchlists(req, res, dependencies, config));
53
+
54
+ // --- Alert Subscriptions ---
55
+ router.post('/me/subscriptions', (req, res) => subscribeToAlerts(req, res, dependencies, config));
56
+ router.get('/me/subscriptions', (req, res) => getUserSubscriptions(req, res, dependencies, config));
57
+ router.put('/me/subscriptions/:piCid', (req, res) => updateSubscription(req, res, dependencies, config));
58
+ router.delete('/me/subscriptions/:piCid', (req, res) => unsubscribeFromAlerts(req, res, dependencies, config));
59
+ router.post('/me/watchlists/:id/subscribe-all', (req, res) => subscribeToWatchlist(req, res, dependencies, config));
60
+
40
61
  return router;
41
62
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.394",
3
+ "version": "1.0.396",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [