bulltrackers-module 1.0.421 → 1.0.423

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.
@@ -154,6 +154,67 @@ async function findLatestComputationDate(db, insightsCollection, resultsSub, com
154
154
  return null; // No document found in the last maxDaysBack days
155
155
  }
156
156
 
157
+ /**
158
+ * Check if a signed-in user is also a Popular Investor
159
+ * Returns ranking entry if found, null otherwise
160
+ * Checks dev overrides first for pretendToBePI flag
161
+ */
162
+ async function checkIfUserIsPI(db, userCid, config, logger = null) {
163
+ try {
164
+ // Check dev override first (for developer accounts)
165
+ const { getDevOverride } = require('./dev_helpers');
166
+ const devOverride = await getDevOverride(db, userCid, config, logger);
167
+
168
+ if (devOverride && devOverride.pretendToBePI) {
169
+ // Generate fake ranking entry for dev testing
170
+ const fakeRankEntry = {
171
+ CustomerId: Number(userCid),
172
+ UserName: 'Dev Test PI',
173
+ AUMValue: 500000 + Math.floor(Math.random() * 1000000), // Random AUM between 500k-1.5M
174
+ Copiers: 150 + Math.floor(Math.random() * 200), // Random copiers between 150-350
175
+ RiskScore: 3 + Math.floor(Math.random() * 3), // Random risk score 3-5
176
+ Gain: 25 + Math.floor(Math.random() * 50), // Random gain 25-75%
177
+ WinRatio: 50 + Math.floor(Math.random() * 20), // Random win ratio 50-70%
178
+ Trades: 500 + Math.floor(Math.random() * 1000) // Random trades 500-1500
179
+ };
180
+
181
+ if (logger && logger.log) {
182
+ logger.log('INFO', `[checkIfUserIsPI] DEV OVERRIDE: User ${userCid} pretending to be PI with fake ranking data`);
183
+ } else {
184
+ console.log(`[checkIfUserIsPI] DEV OVERRIDE: User ${userCid} pretending to be PI with fake ranking data`);
185
+ }
186
+
187
+ return fakeRankEntry;
188
+ }
189
+
190
+ // Otherwise, check real rankings
191
+ const rankingsCollection = config.popularInvestorRankingsCollection || process.env.FIRESTORE_COLLECTION_PI_RANKINGS || 'popular_investor_rankings';
192
+ const rankingsDate = await findLatestRankingsDate(db, rankingsCollection, 30);
193
+
194
+ if (!rankingsDate) {
195
+ return null;
196
+ }
197
+
198
+ const rankingsRef = db.collection(rankingsCollection).doc(rankingsDate);
199
+ const rankingsDoc = await rankingsRef.get();
200
+
201
+ if (!rankingsDoc.exists) {
202
+ return null;
203
+ }
204
+
205
+ const rankingsData = rankingsDoc.data();
206
+ const rankingsItems = rankingsData.Items || [];
207
+
208
+ // Find user in rankings
209
+ const userRankEntry = rankingsItems.find(item => String(item.CustomerId) === String(userCid));
210
+
211
+ return userRankEntry || null;
212
+ } catch (error) {
213
+ console.error('[checkIfUserIsPI] Error checking if user is PI:', error);
214
+ return null;
215
+ }
216
+ }
217
+
157
218
  /**
158
219
  * Helper function to find the latest available date for Popular Investor rankings
159
220
  * Searches backwards from today up to 30 days
@@ -546,12 +607,30 @@ async function getUserPortfolio(req, res, dependencies, config) {
546
607
  return res.status(404).json({ error: "Portfolio data not found for this user" });
547
608
  }
548
609
 
610
+ // Apply dev override to AggregatedMirrors if dev override is active
611
+ const { getDevOverride } = require('./dev_helpers');
612
+ const devOverride = await getDevOverride(db, userCid, config, logger);
613
+ if (devOverride && devOverride.enabled && devOverride.fakeCopiedPIs.length > 0 && portfolioData.AggregatedMirrors) {
614
+ logger.log('INFO', `[getUserPortfolio] Applying DEV OVERRIDE to AggregatedMirrors for user ${userCid}`);
615
+
616
+ // Replace AggregatedMirrors with fake ones from dev override
617
+ portfolioData.AggregatedMirrors = devOverride.fakeCopiedPIs.map(cid => ({
618
+ ParentCID: Number(cid),
619
+ ParentUsername: `PI-${cid}`, // Placeholder, will be resolved if needed
620
+ Invested: 0,
621
+ NetProfit: 0,
622
+ Value: 0,
623
+ PendingForClosure: false
624
+ }));
625
+ }
626
+
549
627
  return res.status(200).json({
550
628
  portfolio: portfolioData,
551
629
  date: dataDate,
552
630
  isFallback: isFallback,
553
631
  requestedDate: today,
554
- userCid: String(userCid)
632
+ userCid: String(userCid),
633
+ devOverrideActive: devOverride && devOverride.enabled
555
634
  });
556
635
 
557
636
  } catch (error) {
@@ -710,6 +789,11 @@ async function getUserComputations(req, res, dependencies, config) {
710
789
  return res.status(400).json({ error: "Please specify at least one computation name" });
711
790
  }
712
791
 
792
+ // Check for dev override (for developer accounts)
793
+ const { getDevOverride } = require('./dev_helpers');
794
+ const devOverride = await getDevOverride(db, userCid, config, logger);
795
+ const isDevOverrideActive = devOverride && devOverride.enabled && devOverride.fakeCopiedPIs.length > 0;
796
+
713
797
  let datesToCheck = [today];
714
798
 
715
799
  // If mode is 'latest', try to find the latest available date for the first computation
@@ -777,7 +861,42 @@ async function getUserComputations(req, res, dependencies, config) {
777
861
  const rawData = doc.data();
778
862
  const data = tryDecompress(rawData);
779
863
  // Filter by user CID - computation results are stored as { cid: result }
780
- const userResult = data[String(userCid)];
864
+ let userResult = data[String(userCid)];
865
+
866
+ // Apply dev override for computations that include copied PIs
867
+ if (isDevOverrideActive && (compName === 'SignedInUserProfileMetrics' || compName === 'SignedInUserCopiedPIs')) {
868
+ if (compName === 'SignedInUserCopiedPIs') {
869
+ // Override the copied PIs list
870
+ userResult = {
871
+ current: devOverride.fakeCopiedPIs,
872
+ past: [],
873
+ all: devOverride.fakeCopiedPIs
874
+ };
875
+ logger.log('INFO', `[getUserComputations] Applied DEV OVERRIDE to SignedInUserCopiedPIs for user ${userCid}`);
876
+ } else if (compName === 'SignedInUserProfileMetrics' && userResult && userResult.copiedPIs) {
877
+ // Override the copiedPIs data in SignedInUserProfileMetrics
878
+ // We need to construct fake mirror data from the dev override PIs
879
+ const fakeMirrors = devOverride.fakeCopiedPIs.map(cid => ({
880
+ cid: Number(cid),
881
+ username: `PI-${cid}`, // Placeholder, will be resolved from rankings if available
882
+ invested: 0,
883
+ netProfit: 0,
884
+ value: 0,
885
+ pendingClosure: false,
886
+ isRanked: false
887
+ }));
888
+
889
+ userResult = {
890
+ ...userResult,
891
+ copiedPIs: {
892
+ chartType: 'cards',
893
+ data: fakeMirrors
894
+ }
895
+ };
896
+ logger.log('INFO', `[getUserComputations] Applied DEV OVERRIDE to SignedInUserProfileMetrics.copiedPIs for user ${userCid}`);
897
+ }
898
+ }
899
+
781
900
  if (userResult) {
782
901
  results[date][compName] = userResult;
783
902
  }
@@ -809,7 +928,8 @@ async function getUserComputations(req, res, dependencies, config) {
809
928
  computations: computationNames,
810
929
  data: cleanedResults,
811
930
  isFallback: isFallback,
812
- requestedDate: today
931
+ requestedDate: today,
932
+ devOverrideActive: isDevOverrideActive
813
933
  });
814
934
 
815
935
  } catch (error) {
@@ -1619,6 +1739,609 @@ async function getPiProfile(req, res, dependencies, config) {
1619
1739
  }
1620
1740
  }
1621
1741
 
1742
+ /**
1743
+ * GET /user/me/is-popular-investor
1744
+ * Check if signed-in user is also a Popular Investor
1745
+ */
1746
+ async function checkIfUserIsPopularInvestor(req, res, dependencies, config) {
1747
+ const { db, logger } = dependencies;
1748
+ const { userCid } = req.query;
1749
+
1750
+ if (!userCid) {
1751
+ return res.status(400).json({ error: "Missing userCid" });
1752
+ }
1753
+
1754
+ try {
1755
+ const rankEntry = await checkIfUserIsPI(db, userCid, config, logger);
1756
+
1757
+ if (!rankEntry) {
1758
+ return res.status(200).json({
1759
+ isPopularInvestor: false,
1760
+ rankingData: null
1761
+ });
1762
+ }
1763
+
1764
+ // Check if this is a dev override
1765
+ const { getDevOverride } = require('./dev_helpers');
1766
+ const devOverride = await getDevOverride(db, userCid, config, logger);
1767
+ const isDevOverride = devOverride && devOverride.pretendToBePI;
1768
+
1769
+ // Return ranking data
1770
+ return res.status(200).json({
1771
+ isPopularInvestor: true,
1772
+ rankingData: {
1773
+ cid: rankEntry.CustomerId,
1774
+ username: rankEntry.UserName,
1775
+ aum: rankEntry.AUMValue || 0,
1776
+ copiers: rankEntry.Copiers || 0,
1777
+ riskScore: rankEntry.RiskScore || 0,
1778
+ gain: rankEntry.Gain || 0,
1779
+ winRatio: rankEntry.WinRatio || 0,
1780
+ trades: rankEntry.Trades || 0
1781
+ },
1782
+ isDevOverride: isDevOverride || false
1783
+ });
1784
+
1785
+ } catch (error) {
1786
+ logger.log('ERROR', `[checkIfUserIsPopularInvestor] Error checking PI status for ${userCid}:`, error);
1787
+ return res.status(500).json({ error: error.message });
1788
+ }
1789
+ }
1790
+
1791
+ /**
1792
+ * Track profile page view for a Popular Investor
1793
+ * Stores view data for analytics
1794
+ */
1795
+ async function trackProfileView(req, res, dependencies, config) {
1796
+ const { db, logger } = dependencies;
1797
+ const { piCid } = req.params;
1798
+ const { viewerCid, viewerType = 'anonymous' } = req.body; // viewerType: 'anonymous', 'signed_in', 'copier'
1799
+
1800
+ if (!piCid) {
1801
+ return res.status(400).json({ error: "Missing piCid" });
1802
+ }
1803
+
1804
+ try {
1805
+ const profileViewsCollection = config.profileViewsCollection || 'profile_views';
1806
+ const today = new Date().toISOString().split('T')[0];
1807
+
1808
+ // Create/update daily view document
1809
+ const viewDocId = `${piCid}_${today}`;
1810
+ const viewRef = db.collection(profileViewsCollection).doc(viewDocId);
1811
+
1812
+ // Get existing document to merge unique viewers properly
1813
+ const existingDoc = await viewRef.get();
1814
+ const existingData = existingDoc.exists ? existingDoc.data() : {};
1815
+ const existingUniqueViewers = Array.isArray(existingData.uniqueViewers) ? existingData.uniqueViewers : [];
1816
+
1817
+ // Add new viewer if provided and not already in list
1818
+ const updatedUniqueViewers = viewerCid && !existingUniqueViewers.includes(String(viewerCid))
1819
+ ? [...existingUniqueViewers, String(viewerCid)]
1820
+ : existingUniqueViewers;
1821
+
1822
+ const viewData = {
1823
+ piCid: Number(piCid),
1824
+ date: today,
1825
+ totalViews: FieldValue.increment(1),
1826
+ uniqueViewers: updatedUniqueViewers, // Store as array, not using arrayUnion to avoid duplicates
1827
+ lastUpdated: FieldValue.serverTimestamp()
1828
+ };
1829
+
1830
+ // Set with merge to update if exists
1831
+ await viewRef.set(viewData, { merge: true });
1832
+
1833
+ // Also track individual view for detailed analytics (optional, lightweight)
1834
+ if (viewerCid) {
1835
+ const individualViewId = `${piCid}_${viewerCid}_${Date.now()}`;
1836
+ await db.collection(profileViewsCollection)
1837
+ .doc('individual_views')
1838
+ .collection('views')
1839
+ .doc(individualViewId)
1840
+ .set({
1841
+ piCid: Number(piCid),
1842
+ viewerCid: Number(viewerCid),
1843
+ viewerType: viewerType,
1844
+ viewedAt: FieldValue.serverTimestamp(),
1845
+ date: today
1846
+ }, { merge: true });
1847
+ }
1848
+
1849
+ return res.status(200).json({ success: true, message: "View tracked" });
1850
+
1851
+ } catch (error) {
1852
+ logger.log('ERROR', `[trackProfileView] Error tracking view for PI ${piCid}:`, error);
1853
+ // Don't fail the request if tracking fails
1854
+ return res.status(200).json({ success: false, message: "View tracking failed but request succeeded" });
1855
+ }
1856
+ }
1857
+
1858
+ /**
1859
+ * Generate sample PI personalized metrics data for dev testing
1860
+ * Creates realistic sample data that matches the computation structure 1:1
1861
+ */
1862
+ function generateSamplePIPersonalizedMetrics(userCid, rankEntry) {
1863
+ const today = new Date().toISOString().split('T')[0];
1864
+ const yesterday = new Date();
1865
+ yesterday.setDate(yesterday.getDate() - 1);
1866
+ const yesterdayStr = yesterday.toISOString().split('T')[0];
1867
+
1868
+ // Generate sample base metrics (from PopularInvestorProfileMetrics)
1869
+ const baseMetrics = {
1870
+ socialEngagement: {
1871
+ chartType: 'line',
1872
+ data: Array.from({ length: 30 }, (_, i) => {
1873
+ const date = new Date();
1874
+ date.setDate(date.getDate() - (29 - i));
1875
+ return {
1876
+ date: date.toISOString().split('T')[0],
1877
+ likes: Math.floor(Math.random() * 50) + 10,
1878
+ comments: Math.floor(Math.random() * 20) + 5
1879
+ };
1880
+ })
1881
+ },
1882
+ profitablePositions: {
1883
+ chartType: 'bar',
1884
+ data: Array.from({ length: 30 }, (_, i) => {
1885
+ const date = new Date();
1886
+ date.setDate(date.getDate() - (29 - i));
1887
+ return {
1888
+ date: date.toISOString().split('T')[0],
1889
+ profitableCount: Math.floor(Math.random() * 10) + 5,
1890
+ totalCount: Math.floor(Math.random() * 15) + 10
1891
+ };
1892
+ })
1893
+ },
1894
+ topWinningPositions: {
1895
+ chartType: 'table',
1896
+ data: Array.from({ length: 10 }, (_, i) => ({
1897
+ instrumentId: 1000 + i,
1898
+ ticker: ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'TSLA', 'META', 'NVDA', 'NFLX', 'AMD', 'INTC'][i],
1899
+ netProfit: Math.floor(Math.random() * 100) + 20,
1900
+ invested: Math.floor(Math.random() * 5000) + 1000,
1901
+ value: Math.floor(Math.random() * 6000) + 1000,
1902
+ isCurrent: Math.random() > 0.5,
1903
+ closeDate: Math.random() > 0.5 ? new Date().toISOString() : null
1904
+ }))
1905
+ },
1906
+ sectorPerformance: {
1907
+ bestSector: 'Technology',
1908
+ worstSector: 'Energy',
1909
+ bestSectorProfit: 15.5,
1910
+ worstSectorProfit: -3.2
1911
+ },
1912
+ sectorExposure: {
1913
+ chartType: 'pie',
1914
+ data: {
1915
+ 'Technology': 35.5,
1916
+ 'Healthcare': 20.3,
1917
+ 'Finance': 15.2,
1918
+ 'Energy': 10.1,
1919
+ 'Consumer': 18.9
1920
+ }
1921
+ },
1922
+ assetExposure: {
1923
+ chartType: 'pie',
1924
+ data: {
1925
+ 'AAPL': 12.5,
1926
+ 'MSFT': 10.3,
1927
+ 'GOOGL': 8.7,
1928
+ 'AMZN': 7.2,
1929
+ 'TSLA': 6.1
1930
+ }
1931
+ },
1932
+ portfolioSummary: {
1933
+ totalInvested: 850000,
1934
+ totalProfit: 125000,
1935
+ profitPercent: 14.7
1936
+ },
1937
+ rankingsData: {
1938
+ aum: rankEntry.AUMValue || 0,
1939
+ riskScore: rankEntry.RiskScore || 0,
1940
+ gain: rankEntry.Gain || 0,
1941
+ copiers: rankEntry.Copiers || 0,
1942
+ winRatio: rankEntry.WinRatio || 0,
1943
+ trades: rankEntry.Trades || 0
1944
+ }
1945
+ };
1946
+
1947
+ // Generate sample review metrics
1948
+ const reviewMetricsOverTime = Array.from({ length: 30 }, (_, i) => {
1949
+ const date = new Date();
1950
+ date.setDate(date.getDate() - (29 - i));
1951
+ return {
1952
+ date: date.toISOString().split('T')[0],
1953
+ averageRating: Number((3.5 + Math.random() * 1.5).toFixed(2)),
1954
+ count: Math.floor(Math.random() * 5) + 1
1955
+ };
1956
+ });
1957
+
1958
+ const totalReviews = reviewMetricsOverTime.reduce((sum, r) => sum + r.count, 0);
1959
+ const avgRating = reviewMetricsOverTime.reduce((sum, r) => sum + (r.averageRating * r.count), 0) / totalReviews;
1960
+
1961
+ // Generate sample similar PIs
1962
+ const similarPIs = Array.from({ length: 5 }, (_, i) => ({
1963
+ cid: 1000000 + i,
1964
+ username: `SimilarPI${i + 1}`,
1965
+ similarityScore: Number((85 - (i * 5)).toFixed(2)),
1966
+ aum: Math.floor(Math.random() * 500000) + 300000,
1967
+ copiers: Math.floor(Math.random() * 200) + 100,
1968
+ riskScore: Math.floor(Math.random() * 3) + 2,
1969
+ gain: Number((20 + Math.random() * 30).toFixed(2))
1970
+ }));
1971
+
1972
+ // Calculate leaderboard position (sample)
1973
+ const totalPIs = 1000;
1974
+ const currentRank = Math.floor(Math.random() * 200) + 50; // Rank 50-250
1975
+ const percentile = Number((100 - ((currentRank / totalPIs) * 100)).toFixed(2));
1976
+
1977
+ // Generate sample asset investments
1978
+ const topAssets = Array.from({ length: 10 }, (_, i) => ({
1979
+ instrumentId: 1000 + i,
1980
+ ticker: ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'TSLA', 'META', 'NVDA', 'NFLX', 'AMD', 'INTC'][i],
1981
+ aum: Math.floor(Math.random() * 100000) + 50000,
1982
+ percentage: Number((Math.random() * 15 + 5).toFixed(2))
1983
+ }));
1984
+
1985
+ // Generate sample profile views
1986
+ const profileViewsOverTime = Array.from({ length: 30 }, (_, i) => {
1987
+ const date = new Date();
1988
+ date.setDate(date.getDate() - (29 - i));
1989
+ return {
1990
+ date: date.toISOString().split('T')[0],
1991
+ views: Math.floor(Math.random() * 50) + 10,
1992
+ uniqueViews: Math.floor(Math.random() * 30) + 5
1993
+ };
1994
+ });
1995
+
1996
+ const totalViews = profileViewsOverTime.reduce((sum, v) => sum + v.views, 0);
1997
+ const totalUniqueViews = new Set(profileViewsOverTime.flatMap(v => Array.from({ length: v.uniqueViews }, (_, i) => `viewer_${i}`))).size;
1998
+ const averageDailyViews = Number((totalViews / 30).toFixed(2));
1999
+
2000
+ // Calculate copier growth
2001
+ const copiersToday = rankEntry.Copiers || 250;
2002
+ const copiersYesterday = copiersToday - Math.floor(Math.random() * 20) + 5; // Random change
2003
+ const diff = copiersToday - copiersYesterday;
2004
+ const growthRate = copiersYesterday > 0 ? Number(((diff / copiersYesterday) * 100).toFixed(2)) : 0;
2005
+ const trend = diff > 0 ? 'increasing' : diff < 0 ? 'decreasing' : 'stable';
2006
+
2007
+ // Calculate engagement score
2008
+ const reviewScore = avgRating * 20; // 0-100 scale
2009
+ const viewScore = Math.min(100, (averageDailyViews / 10) * 100);
2010
+ const copierScore = Math.min(100, (copiersToday / 1000) * 100);
2011
+ const socialScore = Math.min(100, (baseMetrics.socialEngagement.data.length / 10) * 100);
2012
+ const engagementScore = (reviewScore * 0.3 + viewScore * 0.2 + copierScore * 0.3 + socialScore * 0.2);
2013
+
2014
+ return {
2015
+ baseMetrics: baseMetrics,
2016
+ reviewMetrics: {
2017
+ overTime: reviewMetricsOverTime,
2018
+ currentStats: {
2019
+ averageRating: Number(avgRating.toFixed(2)),
2020
+ totalReviews: totalReviews,
2021
+ distribution: {
2022
+ 1: Math.floor(totalReviews * 0.1),
2023
+ 2: Math.floor(totalReviews * 0.15),
2024
+ 3: Math.floor(totalReviews * 0.25),
2025
+ 4: Math.floor(totalReviews * 0.3),
2026
+ 5: Math.floor(totalReviews * 0.2)
2027
+ }
2028
+ },
2029
+ sentimentTrend: []
2030
+ },
2031
+ similarPIs: similarPIs,
2032
+ leaderboardPosition: {
2033
+ currentRank: currentRank,
2034
+ totalPIs: totalPIs,
2035
+ percentile: percentile,
2036
+ rankByAUM: currentRank + Math.floor(Math.random() * 50) - 25,
2037
+ rankByCopiers: currentRank
2038
+ },
2039
+ assetInvestments: {
2040
+ byAUM: topAssets.reduce((acc, asset) => {
2041
+ acc[asset.instrumentId] = asset.aum;
2042
+ return acc;
2043
+ }, {}),
2044
+ topAssets: topAssets
2045
+ },
2046
+ profileViews: {
2047
+ overTime: profileViewsOverTime,
2048
+ totalViews: totalViews,
2049
+ totalUniqueViews: totalUniqueViews,
2050
+ averageDailyViews: averageDailyViews
2051
+ },
2052
+ copierGrowth: {
2053
+ overTime: [
2054
+ { date: yesterdayStr, copiers: copiersYesterday },
2055
+ { date: today, copiers: copiersToday }
2056
+ ],
2057
+ growthRate: growthRate,
2058
+ diff: diff,
2059
+ trend: trend
2060
+ },
2061
+ engagementScore: {
2062
+ current: Number(engagementScore.toFixed(2)),
2063
+ components: {
2064
+ reviewScore: Number(reviewScore.toFixed(2)),
2065
+ viewScore: Number(viewScore.toFixed(2)),
2066
+ copierScore: Number(copierScore.toFixed(2)),
2067
+ socialScore: Number(socialScore.toFixed(2))
2068
+ }
2069
+ }
2070
+ };
2071
+ }
2072
+
2073
+ /**
2074
+ * GET /user/me/pi-personalized-metrics
2075
+ * Get personalized metrics for signed-in user who is a Popular Investor
2076
+ * Includes review metrics, page views, similar PIs, leaderboard position, etc.
2077
+ */
2078
+ async function getSignedInUserPIPersonalizedMetrics(req, res, dependencies, config) {
2079
+ const { db, logger } = dependencies;
2080
+ const { userCid } = req.query;
2081
+
2082
+ if (!userCid) {
2083
+ return res.status(400).json({ error: "Missing userCid" });
2084
+ }
2085
+
2086
+ try {
2087
+ // First, verify user is a PI (checks dev override)
2088
+ const rankEntry = await checkIfUserIsPI(db, userCid, config, logger);
2089
+ if (!rankEntry) {
2090
+ return res.status(404).json({
2091
+ error: "Not a Popular Investor",
2092
+ message: "This endpoint is only available for users who are Popular Investors"
2093
+ });
2094
+ }
2095
+
2096
+ // Check if this is a dev override
2097
+ const { getDevOverride } = require('./dev_helpers');
2098
+ const devOverride = await getDevOverride(db, userCid, config, logger);
2099
+ const isDevOverride = devOverride && devOverride.pretendToBePI;
2100
+
2101
+ // If dev override, generate sample data
2102
+ if (isDevOverride) {
2103
+ logger.log('INFO', `[getSignedInUserPIPersonalizedMetrics] DEV OVERRIDE: Generating sample PI metrics for user ${userCid}`);
2104
+ const sampleData = generateSamplePIPersonalizedMetrics(userCid, rankEntry);
2105
+ const today = new Date().toISOString().split('T')[0];
2106
+
2107
+ return res.status(200).json({
2108
+ status: 'success',
2109
+ userCid: String(userCid),
2110
+ data: sampleData,
2111
+ dataDate: today,
2112
+ requestedDate: today,
2113
+ isFallback: false,
2114
+ isDevOverride: true
2115
+ });
2116
+ }
2117
+
2118
+ const insightsCollection = config.unifiedInsightsCollection || 'unified_insights';
2119
+ const resultsSub = config.resultsSubcollection || 'results';
2120
+ const compsSub = config.computationsSubcollection || 'computations';
2121
+ const category = 'popular-investor';
2122
+ const computationName = 'SignedInUserPIPersonalizedMetrics';
2123
+ const today = new Date().toISOString().split('T')[0];
2124
+
2125
+ // Try to find computation with user-specific fallback logic
2126
+ let foundDate = null;
2127
+ let metricsData = null;
2128
+ let checkedDates = [];
2129
+
2130
+ // Step 1: Try today, then look back 30 days for computation document
2131
+ const latestComputationDate = await findLatestComputationDate(
2132
+ db,
2133
+ insightsCollection,
2134
+ resultsSub,
2135
+ compsSub,
2136
+ category,
2137
+ computationName,
2138
+ null, // Don't check for specific user yet
2139
+ 30
2140
+ );
2141
+
2142
+ if (!latestComputationDate) {
2143
+ // No computation exists at all - will fallback to frontend
2144
+ logger.log('INFO', `[getSignedInUserPIPersonalizedMetrics] No computation document found, will use frontend fallback`);
2145
+ return res.status(200).json({
2146
+ status: 'fallback',
2147
+ message: 'Computation not available, use frontend fallback',
2148
+ data: null,
2149
+ useFrontendFallback: true
2150
+ });
2151
+ }
2152
+
2153
+ // Step 2: Check if user exists in latest computation, if not, look back 7 days
2154
+ const latestDateObj = new Date(latestComputationDate + 'T00:00:00Z');
2155
+
2156
+ for (let daysBack = 0; daysBack <= 7; daysBack++) {
2157
+ const checkDate = new Date(latestDateObj);
2158
+ checkDate.setUTCDate(latestDateObj.getUTCDate() - daysBack);
2159
+ const dateStr = checkDate.toISOString().split('T')[0];
2160
+ checkedDates.push(dateStr);
2161
+
2162
+ const computationRef = db.collection(insightsCollection)
2163
+ .doc(dateStr)
2164
+ .collection(resultsSub)
2165
+ .doc(category)
2166
+ .collection(compsSub)
2167
+ .doc(computationName);
2168
+
2169
+ const computationDoc = await computationRef.get();
2170
+
2171
+ if (computationDoc.exists) {
2172
+ const rawData = computationDoc.data();
2173
+ let computationData = tryDecompress(rawData);
2174
+
2175
+ if (typeof computationData === 'string') {
2176
+ try {
2177
+ computationData = JSON.parse(computationData);
2178
+ } catch (e) {
2179
+ continue;
2180
+ }
2181
+ }
2182
+
2183
+ // Check if user exists in this computation
2184
+ if (computationData && typeof computationData === 'object' && !Array.isArray(computationData)) {
2185
+ const userData = computationData[String(userCid)];
2186
+ if (userData) {
2187
+ foundDate = dateStr;
2188
+ metricsData = userData;
2189
+ logger.log('INFO', `[getSignedInUserPIPersonalizedMetrics] Found user ${userCid} in computation date ${dateStr}`);
2190
+ break;
2191
+ }
2192
+ }
2193
+ }
2194
+ }
2195
+
2196
+ // Step 3: If user not found in any computation, return fallback flag
2197
+ if (!foundDate || !metricsData) {
2198
+ logger.log('INFO', `[getSignedInUserPIPersonalizedMetrics] User ${userCid} not found in any computation, will use frontend fallback`);
2199
+ return res.status(200).json({
2200
+ status: 'fallback',
2201
+ message: 'User not found in computation, use frontend fallback',
2202
+ data: null,
2203
+ useFrontendFallback: true,
2204
+ checkedDates: checkedDates
2205
+ });
2206
+ }
2207
+
2208
+ // Step 4: Enhance with review metrics and page views from Firestore
2209
+ // These require cross-collection queries that aren't in computation
2210
+
2211
+ // Fetch review metrics over time
2212
+ const reviewsCollection = config.reviewsCollection || 'pi_reviews';
2213
+ const reviewsSnapshot = await db.collection(reviewsCollection)
2214
+ .where('piCid', '==', Number(userCid))
2215
+ .orderBy('createdAt', 'desc')
2216
+ .limit(1000) // Get enough for time series
2217
+ .get();
2218
+
2219
+ const reviewTimeMap = new Map();
2220
+ let totalReviews = 0;
2221
+ let totalRating = 0;
2222
+ const ratingDistribution = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
2223
+
2224
+ reviewsSnapshot.forEach(doc => {
2225
+ const review = doc.data();
2226
+ const rating = review.rating || 0;
2227
+ totalReviews++;
2228
+ totalRating += rating;
2229
+ ratingDistribution[rating] = (ratingDistribution[rating] || 0) + 1;
2230
+
2231
+ if (review.createdAt) {
2232
+ const reviewDate = review.createdAt.toDate ? review.createdAt.toDate().toISOString().split('T')[0] : review.createdAt;
2233
+ const existing = reviewTimeMap.get(reviewDate) || { date: reviewDate, count: 0, totalRating: 0 };
2234
+ existing.count++;
2235
+ existing.totalRating += rating;
2236
+ reviewTimeMap.set(reviewDate, existing);
2237
+ }
2238
+ });
2239
+
2240
+ const reviewMetricsOverTime = Array.from(reviewTimeMap.values())
2241
+ .map(entry => ({
2242
+ date: entry.date,
2243
+ averageRating: entry.count > 0 ? Number((entry.totalRating / entry.count).toFixed(2)) : 0,
2244
+ count: entry.count
2245
+ }))
2246
+ .sort((a, b) => a.date.localeCompare(b.date));
2247
+
2248
+ metricsData.reviewMetrics = {
2249
+ overTime: reviewMetricsOverTime,
2250
+ currentStats: {
2251
+ averageRating: totalReviews > 0 ? Number((totalRating / totalReviews).toFixed(2)) : 0,
2252
+ totalReviews: totalReviews,
2253
+ distribution: ratingDistribution
2254
+ },
2255
+ sentimentTrend: [] // Could analyze review text sentiment if needed
2256
+ };
2257
+
2258
+ // Fetch profile views over time
2259
+ const profileViewsCollection = config.profileViewsCollection || 'profile_views';
2260
+ // Query without orderBy first, then sort in memory (to avoid index requirement)
2261
+ const viewsSnapshot = await db.collection(profileViewsCollection)
2262
+ .where('piCid', '==', Number(userCid))
2263
+ .limit(90) // Last 90 days worth of documents
2264
+ .get();
2265
+
2266
+ const viewsOverTime = [];
2267
+ let totalViews = 0;
2268
+ let totalUniqueViews = 0;
2269
+ const uniqueViewersSet = new Set();
2270
+ const viewsByDate = new Map();
2271
+
2272
+ viewsSnapshot.forEach(doc => {
2273
+ const viewData = doc.data();
2274
+ const date = viewData.date;
2275
+ if (!date) return;
2276
+
2277
+ const views = typeof viewData.totalViews === 'number' ? viewData.totalViews : 0;
2278
+ const uniqueViewers = Array.isArray(viewData.uniqueViewers) ? viewData.uniqueViewers : [];
2279
+
2280
+ // Aggregate by date (in case there are multiple docs per date)
2281
+ const existing = viewsByDate.get(date) || { date, views: 0, uniqueViewers: [] };
2282
+ existing.views += views;
2283
+ existing.uniqueViewers = [...new Set([...existing.uniqueViewers, ...uniqueViewers])];
2284
+ viewsByDate.set(date, existing);
2285
+ });
2286
+
2287
+ // Convert map to array and calculate totals
2288
+ for (const [date, data] of viewsByDate.entries()) {
2289
+ totalViews += data.views;
2290
+ data.uniqueViewers.forEach(v => uniqueViewersSet.add(v));
2291
+
2292
+ viewsOverTime.push({
2293
+ date: date,
2294
+ views: data.views,
2295
+ uniqueViews: data.uniqueViewers.length
2296
+ });
2297
+ }
2298
+
2299
+ totalUniqueViews = uniqueViewersSet.size;
2300
+ const averageDailyViews = viewsOverTime.length > 0 ? Number((totalViews / viewsOverTime.length).toFixed(2)) : 0;
2301
+
2302
+ metricsData.profileViews = {
2303
+ overTime: viewsOverTime.sort((a, b) => a.date.localeCompare(b.date)),
2304
+ totalViews: totalViews,
2305
+ totalUniqueViews: totalUniqueViews,
2306
+ averageDailyViews: averageDailyViews
2307
+ };
2308
+
2309
+ // Calculate engagement score
2310
+ const reviewScore = metricsData.reviewMetrics.currentStats.averageRating * 20; // 0-100 scale
2311
+ const viewScore = Math.min(100, (averageDailyViews / 10) * 100); // Cap at 100 for 10+ daily views
2312
+ const copierScore = rankEntry.Copiers ? Math.min(100, (rankEntry.Copiers / 1000) * 100) : 0; // Cap at 100 for 1000+ copiers
2313
+ const socialScore = metricsData.baseMetrics?.socialEngagement?.data?.length > 0 ? Math.min(100, (metricsData.baseMetrics.socialEngagement.data.length / 10) * 100) : 0;
2314
+
2315
+ const engagementScore = (reviewScore * 0.3 + viewScore * 0.2 + copierScore * 0.3 + socialScore * 0.2);
2316
+
2317
+ metricsData.engagementScore = {
2318
+ current: Number(engagementScore.toFixed(2)),
2319
+ components: {
2320
+ reviewScore: Number(reviewScore.toFixed(2)),
2321
+ viewScore: Number(viewScore.toFixed(2)),
2322
+ copierScore: Number(copierScore.toFixed(2)),
2323
+ socialScore: Number(socialScore.toFixed(2))
2324
+ }
2325
+ };
2326
+
2327
+ logger.log('SUCCESS', `[getSignedInUserPIPersonalizedMetrics] Returning personalized metrics for user ${userCid} from date ${foundDate}`);
2328
+
2329
+ return res.status(200).json({
2330
+ status: 'success',
2331
+ userCid: String(userCid),
2332
+ data: metricsData,
2333
+ dataDate: foundDate,
2334
+ requestedDate: today,
2335
+ isFallback: foundDate !== today,
2336
+ daysBackFromLatest: checkedDates.indexOf(foundDate)
2337
+ });
2338
+
2339
+ } catch (error) {
2340
+ logger.log('ERROR', `[getSignedInUserPIPersonalizedMetrics] Error fetching personalized metrics for ${userCid}:`, error);
2341
+ return res.status(500).json({ error: error.message });
2342
+ }
2343
+ }
2344
+
1622
2345
  module.exports = {
1623
2346
  getPiAnalytics,
1624
2347
  getUserRecommendations,
@@ -1635,5 +2358,8 @@ module.exports = {
1635
2358
  requestPiAddition,
1636
2359
  getWatchlistTriggerCounts,
1637
2360
  checkPisInRankings,
1638
- getPiProfile
2361
+ getPiProfile,
2362
+ checkIfUserIsPopularInvestor,
2363
+ trackProfileView,
2364
+ getSignedInUserPIPersonalizedMetrics
1639
2365
  };
@@ -21,7 +21,7 @@ function isDeveloperAccount(userCid) {
21
21
  /**
22
22
  * Get developer override data for a user
23
23
  * Auto-creates default document if it doesn't exist (for developer accounts only)
24
- * Returns null if not a developer
24
+ * Returns null if not a developerg,
25
25
  */
26
26
  async function getDevOverride(db, userCid, config, logger = null) {
27
27
  if (!isDeveloperAccount(userCid)) {
@@ -39,6 +39,7 @@ async function getDevOverride(db, userCid, config, logger = null) {
39
39
  userCid: Number(userCid),
40
40
  enabled: false,
41
41
  fakeCopiedPIs: [],
42
+ pretendToBePI: false,
42
43
  createdAt: FieldValue.serverTimestamp(),
43
44
  lastUpdated: FieldValue.serverTimestamp()
44
45
  };
@@ -56,6 +57,7 @@ async function getDevOverride(db, userCid, config, logger = null) {
56
57
  return {
57
58
  enabled: false,
58
59
  fakeCopiedPIs: [],
60
+ pretendToBePI: false,
59
61
  lastUpdated: null,
60
62
  wasAutoCreated: true
61
63
  };
@@ -67,6 +69,7 @@ async function getDevOverride(db, userCid, config, logger = null) {
67
69
  return {
68
70
  enabled: data.enabled === true,
69
71
  fakeCopiedPIs: data.fakeCopiedPIs || [],
72
+ pretendToBePI: data.pretendToBePI === true,
70
73
  lastUpdated: data.lastUpdated,
71
74
  wasAutoCreated: false
72
75
  };
@@ -151,7 +154,7 @@ async function hasUserCopiedWithDevOverride(db, userCid, piCid, config, logger)
151
154
  */
152
155
  async function setDevOverride(req, res, dependencies, config) {
153
156
  const { db, logger } = dependencies;
154
- const { userCid, enabled, fakeCopiedPIs } = req.body;
157
+ const { userCid, enabled, fakeCopiedPIs, pretendToBePI } = req.body;
155
158
 
156
159
  if (!userCid) {
157
160
  return res.status(400).json({ error: "Missing userCid" });
@@ -166,7 +169,7 @@ async function setDevOverride(req, res, dependencies, config) {
166
169
  });
167
170
  }
168
171
 
169
- // Validate fakeCopiedPIs
172
+ // Validate fakeCopiedPIs (only required if enabled is true)
170
173
  if (enabled && (!Array.isArray(fakeCopiedPIs) || fakeCopiedPIs.length === 0)) {
171
174
  return res.status(400).json({
172
175
  error: "Invalid request",
@@ -181,16 +184,21 @@ async function setDevOverride(req, res, dependencies, config) {
181
184
  const devOverridesCollection = config.devOverridesCollection || 'dev_overrides';
182
185
  const overrideRef = db.collection(devOverridesCollection).doc(String(userCid));
183
186
 
187
+ // Get existing override to preserve values not being updated
188
+ const existingDoc = await overrideRef.get();
189
+ const existingData = existingDoc.exists ? existingDoc.data() : {};
190
+
184
191
  const overrideData = {
185
192
  userCid: Number(userCid),
186
- enabled: enabled === true,
187
- fakeCopiedPIs: validatedPIs,
193
+ enabled: enabled !== undefined ? (enabled === true) : (existingData.enabled || false),
194
+ fakeCopiedPIs: enabled !== undefined ? validatedPIs : (existingData.fakeCopiedPIs || []),
195
+ pretendToBePI: pretendToBePI !== undefined ? (pretendToBePI === true) : (existingData.pretendToBePI || false),
188
196
  lastUpdated: FieldValue.serverTimestamp()
189
197
  };
190
198
 
191
199
  await overrideRef.set(overrideData, { merge: true });
192
200
 
193
- logger.log('SUCCESS', `[setDevOverride] Updated dev override for user ${userCid}: enabled=${enabled}, ${validatedPIs.length} fake PIs`);
201
+ logger.log('SUCCESS', `[setDevOverride] Updated dev override for user ${userCid}: enabled=${overrideData.enabled}, pretendToBePI=${overrideData.pretendToBePI}, ${validatedPIs.length} fake PIs`);
194
202
 
195
203
  return res.status(200).json({
196
204
  success: true,
@@ -239,6 +247,7 @@ async function getDevOverrideStatus(req, res, dependencies, config) {
239
247
  return res.status(200).json({
240
248
  enabled: devOverride.enabled,
241
249
  fakeCopiedPIs: devOverride.fakeCopiedPIs,
250
+ pretendToBePI: devOverride.pretendToBePI || false,
242
251
  lastUpdated: devOverride.lastUpdated,
243
252
  wasAutoCreated: devOverride.wasAutoCreated || false
244
253
  });
@@ -4,7 +4,7 @@
4
4
  */
5
5
  const express = require('express');
6
6
  const { submitReview, getReviews, getUserReview, checkReviewEligibility } = require('./helpers/review_helpers');
7
- const { getPiAnalytics, getUserRecommendations, getWatchlist, updateWatchlist, autoGenerateWatchlist, getUserDataStatus, getUserPortfolio, getUserSocialPosts, getUserComputations, getUserVerification, getInstrumentMappings, searchPopularInvestors, requestPiAddition, getWatchlistTriggerCounts, checkPisInRankings, getPiProfile } = require('./helpers/data_helpers');
7
+ const { getPiAnalytics, getUserRecommendations, getWatchlist, updateWatchlist, autoGenerateWatchlist, getUserDataStatus, getUserPortfolio, getUserSocialPosts, getUserComputations, getUserVerification, getInstrumentMappings, searchPopularInvestors, requestPiAddition, getWatchlistTriggerCounts, checkPisInRankings, getPiProfile, checkIfUserIsPopularInvestor, trackProfileView, getSignedInUserPIPersonalizedMetrics } = require('./helpers/data_helpers');
8
8
  const { initiateVerification, finalizeVerification } = require('./helpers/verification_helpers');
9
9
  const { getUserWatchlists, getWatchlist: getWatchlistById, createWatchlist, updateWatchlist: updateWatchlistById, deleteWatchlist, copyWatchlist, getPublicWatchlists } = require('./helpers/watchlist_helpers');
10
10
  const { subscribeToAlerts, updateSubscription, unsubscribeFromAlerts, getUserSubscriptions, subscribeToWatchlist } = require('./helpers/subscription_helpers');
@@ -26,6 +26,7 @@ module.exports = (dependencies, config) => {
26
26
  // --- Data Serving ---
27
27
  router.get('/pi/:cid/analytics', (req, res) => getPiAnalytics(req, res, dependencies, config));
28
28
  router.get('/pi/:cid/profile', (req, res) => getPiProfile(req, res, dependencies, config));
29
+ router.post('/pi/:piCid/track-view', (req, res) => trackProfileView(req, res, dependencies, config));
29
30
 
30
31
  // Signed-In User Personalized Routes
31
32
  // Note: Router is mounted at /user, so these routes should be /me/* not /user/me/*
@@ -37,6 +38,8 @@ module.exports = (dependencies, config) => {
37
38
  router.get('/me/computations', (req, res) => getUserComputations(req, res, dependencies, config));
38
39
  router.get('/me/verification', (req, res) => getUserVerification(req, res, dependencies, config));
39
40
  router.get('/me/instrument-mappings', (req, res) => getInstrumentMappings(req, res, dependencies, config));
41
+ router.get('/me/is-popular-investor', (req, res) => checkIfUserIsPopularInvestor(req, res, dependencies, config));
42
+ router.get('/me/pi-personalized-metrics', (req, res) => getSignedInUserPIPersonalizedMetrics(req, res, dependencies, config));
40
43
 
41
44
  // --- Watchlist & Alerts ---
42
45
  // Legacy single watchlist endpoints (for backward compatibility)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.421",
3
+ "version": "1.0.423",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [