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.
- package/functions/generic-api/user-api/helpers/data_helpers.js +223 -64
- package/functions/generic-api/user-api/helpers/subscription_helpers.js +328 -0
- package/functions/generic-api/user-api/helpers/watchlist_helpers.js +466 -0
- package/functions/generic-api/user-api/index.js +22 -1
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
687
|
-
|
|
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
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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.
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
const
|
|
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
|
|
766
|
-
const cidStr = String(
|
|
856
|
+
for (const copiedPI of copiedPIs) {
|
|
857
|
+
const cidStr = String(copiedPI.cid);
|
|
767
858
|
const rankEntry = rankingsMap.get(cidStr);
|
|
768
859
|
|
|
769
|
-
if
|
|
770
|
-
|
|
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
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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
|
-
|
|
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
|
};
|