bulltrackers-module 1.0.418 → 1.0.419

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.
@@ -1422,6 +1422,55 @@ async function checkPisInRankings(req, res, dependencies, config) {
1422
1422
  * Fetches Popular Investor profile data from computation
1423
1423
  * Falls back to latest available date if today's data doesn't exist
1424
1424
  */
1425
+ /**
1426
+ * Helper function to fetch and check if a specific CID exists in a computation document for a given date
1427
+ * Returns { found: boolean, profileData: object | null, computationData: object | null }
1428
+ */
1429
+ async function checkPiInComputationDate(db, insightsCollection, resultsSub, compsSub, category, computationName, dateStr, cidStr, logger) {
1430
+ try {
1431
+ const computationRef = db.collection(insightsCollection)
1432
+ .doc(dateStr)
1433
+ .collection(resultsSub)
1434
+ .doc(category)
1435
+ .collection(compsSub)
1436
+ .doc(computationName);
1437
+
1438
+ const computationDoc = await computationRef.get();
1439
+
1440
+ if (!computationDoc.exists) {
1441
+ return { found: false, profileData: null, computationData: null };
1442
+ }
1443
+
1444
+ // Decompress if needed
1445
+ const rawData = computationDoc.data();
1446
+ let computationData = tryDecompress(rawData);
1447
+
1448
+ // Handle string decompression result
1449
+ if (typeof computationData === 'string') {
1450
+ try {
1451
+ computationData = JSON.parse(computationData);
1452
+ } catch (e) {
1453
+ logger.log('WARN', `[checkPiInComputationDate] Failed to parse decompressed string for date ${dateStr}:`, e.message);
1454
+ return { found: false, profileData: null, computationData: null };
1455
+ }
1456
+ }
1457
+
1458
+ // Check if CID exists in the computation data
1459
+ if (computationData && typeof computationData === 'object' && !Array.isArray(computationData)) {
1460
+ const profileData = computationData[cidStr];
1461
+ if (profileData) {
1462
+ return { found: true, profileData, computationData };
1463
+ }
1464
+ }
1465
+
1466
+ return { found: false, profileData: null, computationData };
1467
+
1468
+ } catch (error) {
1469
+ logger.log('WARN', `[checkPiInComputationDate] Error checking date ${dateStr}:`, error.message);
1470
+ return { found: false, profileData: null, computationData: null };
1471
+ }
1472
+ }
1473
+
1425
1474
  async function getPiProfile(req, res, dependencies, config) {
1426
1475
  const { db, logger } = dependencies;
1427
1476
  const { cid } = req.params;
@@ -1437,6 +1486,8 @@ async function getPiProfile(req, res, dependencies, config) {
1437
1486
  const computationName = 'PopularInvestorProfileMetrics';
1438
1487
  const category = 'popular-investor'; // Use hyphen to match Firestore path
1439
1488
  const today = new Date().toISOString().split('T')[0];
1489
+ const cidStr = String(cid);
1490
+ const maxDaysBackForPi = 7; // Maximum days to search back for a specific PI
1440
1491
 
1441
1492
  logger.log('INFO', `[getPiProfile] Starting search for PI CID: ${cid}`);
1442
1493
 
@@ -1462,114 +1513,89 @@ async function getPiProfile(req, res, dependencies, config) {
1462
1513
  });
1463
1514
  }
1464
1515
 
1465
- // Fetch computation result
1466
- const computationRef = db.collection(insightsCollection)
1467
- .doc(latestDate)
1468
- .collection(resultsSub)
1469
- .doc(category)
1470
- .collection(compsSub)
1471
- .doc(computationName);
1472
-
1473
- logger.log('INFO', `[getPiProfile] Fetching document at path: ${insightsCollection}/${latestDate}/${resultsSub}/${category}/${compsSub}/${computationName}`);
1474
-
1475
- const computationDoc = await computationRef.get();
1476
-
1477
- if (!computationDoc.exists) {
1478
- logger.log('WARN', `[getPiProfile] Document does not exist at expected path`);
1479
- return res.status(404).json({
1480
- error: "Profile data not found",
1481
- message: "Computation document does not exist"
1482
- });
1483
- }
1484
-
1485
- // Decompress if needed (handles byte string storage)
1486
- const rawData = computationDoc.data();
1487
- const wasCompressed = rawData && rawData._compressed === true;
1488
- logger.log('INFO', `[getPiProfile] Document exists. Was compressed: ${wasCompressed}, CID being searched: ${cid}`);
1489
- logger.log('INFO', `[getPiProfile] Raw data keys:`, rawData ? Object.keys(rawData).slice(0, 10) : 'NO DATA');
1490
-
1491
- if (wasCompressed && rawData.payload) {
1492
- logger.log('INFO', `[getPiProfile] Payload type: ${typeof rawData.payload}, isBuffer: ${Buffer.isBuffer(rawData.payload)}`);
1493
- }
1516
+ // Try to find the PI starting from the latest date, then going back up to 7 days
1517
+ let foundDate = null;
1518
+ let profileData = null;
1519
+ let checkedDates = [];
1494
1520
 
1495
- let computationData = tryDecompress(rawData);
1521
+ // Parse the latest date to calculate earlier dates
1522
+ const latestDateObj = new Date(latestDate + 'T00:00:00Z');
1496
1523
 
1497
- // Check if decompression returned a string (which would indicate a problem)
1498
- if (typeof computationData === 'string') {
1499
- logger.log('WARN', `[getPiProfile] Decompression returned a STRING instead of an object. Length: ${computationData.length}`);
1500
- logger.log('WARN', `[getPiProfile] First 500 chars of decompressed string:`, computationData.substring(0, 500));
1501
- // Try to parse it as JSON
1502
- try {
1503
- computationData = JSON.parse(computationData);
1504
- logger.log('INFO', `[getPiProfile] Successfully parsed string as JSON`);
1505
- } catch (e) {
1506
- logger.log('ERROR', `[getPiProfile] Failed to parse decompressed string as JSON:`, e.message);
1507
- return res.status(500).json({
1508
- error: "Data parsing failed",
1509
- message: "Failed to parse decompressed computation data"
1510
- });
1524
+ for (let daysBack = 0; daysBack <= maxDaysBackForPi; daysBack++) {
1525
+ const checkDate = new Date(latestDateObj);
1526
+ checkDate.setUTCDate(latestDateObj.getUTCDate() - daysBack);
1527
+ const dateStr = checkDate.toISOString().split('T')[0];
1528
+ checkedDates.push(dateStr);
1529
+
1530
+ logger.log('INFO', `[getPiProfile] Checking date ${dateStr} for CID ${cid} (${daysBack} days back from latest)`);
1531
+
1532
+ const result = await checkPiInComputationDate(
1533
+ db,
1534
+ insightsCollection,
1535
+ resultsSub,
1536
+ compsSub,
1537
+ category,
1538
+ computationName,
1539
+ dateStr,
1540
+ cidStr,
1541
+ logger
1542
+ );
1543
+
1544
+ if (result.found) {
1545
+ foundDate = dateStr;
1546
+ profileData = result.profileData;
1547
+ logger.log('SUCCESS', `[getPiProfile] Found profile data for CID ${cid} in date ${dateStr} (${daysBack} days back from latest)`);
1548
+ break;
1549
+ } else {
1550
+ logger.log('INFO', `[getPiProfile] CID ${cid} not found in date ${dateStr}`);
1511
1551
  }
1512
1552
  }
1513
1553
 
1514
- // Debug logging: Show full decompressed document structure
1515
- logger.log('INFO', `[getPiProfile] Decompressed data structure:`, {
1516
- hasData: !!computationData,
1517
- dataType: typeof computationData,
1518
- isArray: Array.isArray(computationData),
1519
- isString: typeof computationData === 'string',
1520
- keys: computationData && typeof computationData === 'object' && !Array.isArray(computationData) ? Object.keys(computationData).slice(0, 50) : 'N/A', // First 50 keys
1521
- totalKeys: computationData && typeof computationData === 'object' && !Array.isArray(computationData) ? Object.keys(computationData).length : 0,
1522
- searchingForCid: String(cid),
1523
- cidExists: computationData && typeof computationData === 'object' && !Array.isArray(computationData) ? computationData[String(cid)] !== undefined : false,
1524
- sampleCids: computationData && typeof computationData === 'object' && !Array.isArray(computationData) ? Object.keys(computationData).slice(0, 10) : []
1525
- });
1526
-
1527
- // Log full decompressed data (will be large but needed for debugging)
1528
- // Only stringify if it's an object, not a string
1529
- if (typeof computationData === 'object' && !Array.isArray(computationData)) {
1530
- logger.log('INFO', `[getPiProfile] FULL DECOMPRESSED DOCUMENT CONTENTS (first 2000 chars):`, JSON.stringify(computationData, null, 2).substring(0, 2000));
1531
- } else {
1532
- logger.log('INFO', `[getPiProfile] FULL DECOMPRESSED DOCUMENT CONTENTS (first 2000 chars):`, String(computationData).substring(0, 2000));
1533
- }
1534
-
1535
- const cidStr = String(cid);
1536
- const profileData = computationData && typeof computationData === 'object' && !Array.isArray(computationData) ? computationData[cidStr] : undefined;
1537
-
1538
- if (!profileData) {
1539
- // Get ALL available CIDs (not just a sample)
1540
- const allAvailableCids = computationData && typeof computationData === 'object' && !Array.isArray(computationData) ? Object.keys(computationData).sort() : [];
1541
- const totalCids = allAvailableCids.length;
1542
- const cidExists = allAvailableCids.includes(cidStr);
1554
+ // If not found in any checked date, return 404
1555
+ if (!foundDate || !profileData) {
1556
+ logger.log('WARN', `[getPiProfile] CID ${cid} not found in any checked dates: ${checkedDates.join(', ')}`);
1557
+
1558
+ // Try to get sample data from the latest date to show what CIDs are available
1559
+ const latestResult = await checkPiInComputationDate(
1560
+ db,
1561
+ insightsCollection,
1562
+ resultsSub,
1563
+ compsSub,
1564
+ category,
1565
+ computationName,
1566
+ latestDate,
1567
+ cidStr,
1568
+ logger
1569
+ );
1543
1570
 
1544
- logger.log('WARN', `[getPiProfile] CID ${cid} (as string: "${cidStr}") NOT FOUND in computation data.`);
1545
- logger.log('WARN', `[getPiProfile] Total CIDs in document: ${totalCids}`);
1546
- logger.log('WARN', `[getPiProfile] ALL Available CIDs:`, allAvailableCids);
1547
- logger.log('WARN', `[getPiProfile] CID exists check: ${cidExists}`);
1571
+ const allAvailableCids = latestResult.computationData && typeof latestResult.computationData === 'object' && !Array.isArray(latestResult.computationData)
1572
+ ? Object.keys(latestResult.computationData).sort()
1573
+ : [];
1548
1574
 
1549
1575
  return res.status(404).json({
1550
1576
  error: "Profile data not found",
1551
- message: `Popular Investor ${cid} does not exist in computation results for ${latestDate}. This PI may not have been processed on this date.`,
1577
+ message: `Popular Investor ${cid} does not exist in computation results for the last ${maxDaysBackForPi + 1} days. This PI may not have been processed recently.`,
1552
1578
  debug: {
1553
1579
  searchedCid: cidStr,
1554
- wasCompressed: wasCompressed,
1555
- totalCidsInDocument: totalCids,
1556
- allAvailableCids: allAvailableCids,
1557
- cidExists: cidExists,
1558
- dataDate: latestDate,
1559
- documentPath: `${insightsCollection}/${latestDate}/${resultsSub}/${category}/${compsSub}/${computationName}`
1580
+ checkedDates: checkedDates,
1581
+ totalCidsInLatestDocument: allAvailableCids.length,
1582
+ sampleAvailableCids: allAvailableCids.slice(0, 20), // First 20 for reference
1583
+ latestDate: latestDate
1560
1584
  }
1561
1585
  });
1562
1586
  }
1563
1587
 
1564
- logger.log('SUCCESS', `[getPiProfile] Found profile data for CID ${cid} from date ${latestDate}`);
1588
+ logger.log('SUCCESS', `[getPiProfile] Returning profile data for CID ${cid} from date ${foundDate} (requested: ${today}, latest available: ${latestDate})`);
1565
1589
 
1566
1590
  return res.status(200).json({
1567
1591
  status: 'success',
1568
- cid: String(cid),
1592
+ cid: cidStr,
1569
1593
  data: profileData,
1570
- isFallback: latestDate !== today,
1571
- dataDate: latestDate,
1572
- requestedDate: today
1594
+ isFallback: foundDate !== latestDate || foundDate !== today,
1595
+ dataDate: foundDate,
1596
+ latestComputationDate: latestDate,
1597
+ requestedDate: today,
1598
+ daysBackFromLatest: checkedDates.indexOf(foundDate)
1573
1599
  });
1574
1600
 
1575
1601
  } catch (error) {
@@ -4,38 +4,161 @@
4
4
  */
5
5
 
6
6
  const { FieldValue } = require('@google-cloud/firestore');
7
+ const zlib = require('zlib');
8
+
9
+ /**
10
+ * Helper to decompress computation results
11
+ */
12
+ function tryDecompress(data) {
13
+ if (data && data._compressed === true && data.payload) {
14
+ try {
15
+ let buffer;
16
+ if (Buffer.isBuffer(data.payload)) {
17
+ buffer = data.payload;
18
+ } else if (typeof data.payload === 'string') {
19
+ try {
20
+ buffer = Buffer.from(data.payload, 'base64');
21
+ } catch (e) {
22
+ try {
23
+ return JSON.parse(data.payload);
24
+ } catch (e2) {
25
+ buffer = Buffer.from(data.payload);
26
+ }
27
+ }
28
+ } else {
29
+ buffer = Buffer.from(data.payload);
30
+ }
31
+ const decompressed = zlib.gunzipSync(buffer);
32
+ const jsonString = decompressed.toString('utf8');
33
+ const parsed = JSON.parse(jsonString);
34
+ if (typeof parsed === 'string') {
35
+ return JSON.parse(parsed);
36
+ }
37
+ return parsed;
38
+ } catch (e) {
39
+ console.error('[ReviewHelpers] Decompression failed:', e.message);
40
+ return {};
41
+ }
42
+ }
43
+ return data;
44
+ }
45
+
46
+ /**
47
+ * Finds the latest computation date for a given computation
48
+ */
49
+ async function findLatestComputationDate(db, insightsCollection, resultsSub, compsSub, category, computationName, userCid, maxDays = 30) {
50
+ const today = new Date();
51
+ for (let i = 0; i < maxDays; i++) {
52
+ const checkDate = new Date(today);
53
+ checkDate.setDate(today.getDate() - i);
54
+ const dateStr = checkDate.toISOString().split('T')[0];
55
+
56
+ const computationRef = db.collection(insightsCollection)
57
+ .doc(dateStr)
58
+ .collection(resultsSub)
59
+ .doc(category)
60
+ .collection(compsSub)
61
+ .doc(computationName);
62
+
63
+ const doc = await computationRef.get();
64
+ if (doc.exists) {
65
+ return dateStr;
66
+ }
67
+ }
68
+ return null;
69
+ }
7
70
 
8
71
  /**
9
72
  * Checks if a Signed-In User has ever copied a specific PI.
10
- * Checks both active portfolio (AggregatedMirrors) and Trade History.
73
+ * Uses SignedInUserCopiedPIs computation (primary) with fallback to direct portfolio check.
11
74
  */
12
75
  async function hasUserCopied(db, userCid, piCid, config) {
13
- const { signedInUsersCollection, signedInHistoryCollection } = config;
14
-
15
- // 1. Check Active Mirrors (Portfolio)
16
- const userDoc = await db.collection(signedInUsersCollection).doc(String(userCid)).get();
17
- if (userDoc.exists) {
18
- const data = userDoc.data();
19
- if (data.AggregatedMirrors && Array.isArray(data.AggregatedMirrors)) {
20
- const isCopying = data.AggregatedMirrors.some(m => String(m.ParentCID) === String(piCid));
21
- if (isCopying) return true;
76
+ const { signedInUsersCollection } = config;
77
+ const piCidNum = Number(piCid);
78
+ const userCidNum = Number(userCid);
79
+
80
+ try {
81
+ // === PRIMARY: Try to fetch from SignedInUserCopiedPIs computation ===
82
+ const insightsCollection = config.unifiedInsightsCollection || 'unified_insights';
83
+ const category = 'signed_in_user';
84
+ const computationName = 'SignedInUserCopiedPIs';
85
+ const resultsSub = config.resultsSubcollection || 'results';
86
+ const compsSub = config.computationsSubcollection || 'computations';
87
+
88
+ const computationDate = await findLatestComputationDate(
89
+ db,
90
+ insightsCollection,
91
+ resultsSub,
92
+ compsSub,
93
+ category,
94
+ computationName,
95
+ userCidNum,
96
+ 30
97
+ );
98
+
99
+ if (computationDate) {
100
+ const computationRef = db.collection(insightsCollection)
101
+ .doc(computationDate)
102
+ .collection(resultsSub)
103
+ .doc(category)
104
+ .collection(compsSub)
105
+ .doc(computationName);
106
+
107
+ const computationDoc = await computationRef.get();
108
+
109
+ if (computationDoc.exists) {
110
+ const rawData = computationDoc.data();
111
+ const decompressed = tryDecompress(rawData);
112
+
113
+ // Check if user's data exists in computation results
114
+ if (decompressed && decompressed[String(userCidNum)]) {
115
+ const userData = decompressed[String(userCidNum)];
116
+ // Check if PI is in current, past, or all arrays
117
+ const allCopied = userData.all || [];
118
+ if (allCopied.includes(piCidNum)) {
119
+ return true;
120
+ }
121
+ }
122
+ }
22
123
  }
23
- }
24
124
 
25
- // 2. Check Trade History (Past Copies)
26
- // Assuming history is stored as subcollection or separate collection sharded by user
27
- // We check the separate collection defined for signed-in history
28
- const historySnapshot = await db.collection(signedInHistoryCollection) // e.g., 'signed_in_users_history'
29
- .where('CID', '==', Number(userCid)) // Filter by User
30
- .where('ParentCID', '==', Number(piCid)) // Filter by PI
31
- .limit(1)
32
- .get();
125
+ // === FALLBACK: Check Active Mirrors (Portfolio) directly ===
126
+ const userDoc = await db.collection(signedInUsersCollection).doc(String(userCid)).get();
127
+ if (userDoc.exists) {
128
+ const data = userDoc.data();
129
+ if (data.AggregatedMirrors && Array.isArray(data.AggregatedMirrors)) {
130
+ const isCopying = data.AggregatedMirrors.some(m => Number(m.ParentCID) === piCidNum);
131
+ if (isCopying) return true;
132
+ }
133
+ }
33
134
 
34
- return !historySnapshot.empty;
135
+ return false;
136
+ } catch (error) {
137
+ console.error('[hasUserCopied] Error checking copy status:', error);
138
+ // Fallback to false on error
139
+ return false;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Gets username for a user CID
145
+ */
146
+ async function getUsername(db, userCid, config) {
147
+ try {
148
+ const userDoc = await db.collection(config.signedInUsersCollection).doc(String(userCid)).get();
149
+ if (userDoc.exists) {
150
+ const data = userDoc.data();
151
+ return data.username || null;
152
+ }
153
+ } catch (error) {
154
+ console.error('[getUsername] Error fetching username:', error);
155
+ }
156
+ return null;
35
157
  }
36
158
 
37
159
  /**
38
160
  * POST /review
161
+ * Submit or update a review for a Popular Investor
39
162
  */
40
163
  async function submitReview(req, res, dependencies, config) {
41
164
  const { db, logger } = dependencies;
@@ -46,6 +169,12 @@ async function submitReview(req, res, dependencies, config) {
46
169
  return res.status(400).json({ error: "Missing required fields (userCid, piCid, rating)." });
47
170
  }
48
171
 
172
+ // Validate rating
173
+ const ratingNum = Number(rating);
174
+ if (isNaN(ratingNum) || ratingNum < 1 || ratingNum > 5) {
175
+ return res.status(400).json({ error: "Rating must be between 1 and 5." });
176
+ }
177
+
49
178
  try {
50
179
  // 1. Validate "Verified Copier" Status
51
180
  const canReview = await hasUserCopied(db, userCid, piCid, config);
@@ -57,20 +186,40 @@ async function submitReview(req, res, dependencies, config) {
57
186
  });
58
187
  }
59
188
 
60
- // 2. Store Review
189
+ // 2. Get username if not anonymous
190
+ let reviewerUsername = null;
191
+ if (!isAnonymous) {
192
+ reviewerUsername = await getUsername(db, userCid, config);
193
+ }
194
+
195
+ // 3. Check if review already exists (for update)
61
196
  const reviewId = `${userCid}_${piCid}`;
62
- await db.collection(reviewsCollection).doc(reviewId).set({
63
- userCid,
64
- piCid,
65
- rating: Math.max(1, Math.min(5, Number(rating))), // Clamp 1-5
66
- comment: comment || "",
197
+ const existingReview = await db.collection(reviewsCollection).doc(reviewId).get();
198
+ const isUpdate = existingReview.exists;
199
+
200
+ // 4. Store/Update Review
201
+ const reviewData = {
202
+ userCid: Number(userCid),
203
+ piCid: Number(piCid),
204
+ rating: Math.max(1, Math.min(5, ratingNum)),
205
+ comment: (comment || "").trim(),
67
206
  isAnonymous: !!isAnonymous,
68
- createdAt: FieldValue.serverTimestamp(),
207
+ reviewerUsername: reviewerUsername,
69
208
  updatedAt: FieldValue.serverTimestamp()
70
- });
209
+ };
210
+
211
+ if (!isUpdate) {
212
+ reviewData.createdAt = FieldValue.serverTimestamp();
213
+ }
71
214
 
72
- logger.log('INFO', `[Review] User ${userCid} reviewed PI ${piCid}`);
73
- return res.status(200).json({ success: true, message: "Review submitted." });
215
+ await db.collection(reviewsCollection).doc(reviewId).set(reviewData, { merge: true });
216
+
217
+ logger.log('INFO', `[Review] User ${userCid} ${isUpdate ? 'updated' : 'submitted'} review for PI ${piCid}`);
218
+ return res.status(200).json({
219
+ success: true,
220
+ message: isUpdate ? "Review updated." : "Review submitted.",
221
+ reviewId
222
+ });
74
223
 
75
224
  } catch (error) {
76
225
  logger.log('ERROR', '[Review] Submit failed', error);
@@ -80,45 +229,150 @@ async function submitReview(req, res, dependencies, config) {
80
229
 
81
230
  /**
82
231
  * GET /reviews/{piCid}
232
+ * Get all reviews for a Popular Investor with stats
83
233
  */
84
234
  async function getReviews(req, res, dependencies, config) {
85
- const { db } = dependencies;
235
+ const { db, logger } = dependencies;
86
236
  const { piCid } = req.params;
87
237
  const { reviewsCollection } = config;
238
+ const { userCid } = req.query; // Optional: to check if current user has reviewed
88
239
 
89
240
  try {
241
+ const piCidNum = Number(piCid);
242
+
243
+ // Query reviews for this PI
90
244
  const snapshot = await db.collection(reviewsCollection)
91
- .where('piCid', '==', piCid) // Or Number(piCid) depending on storage
245
+ .where('piCid', '==', piCidNum)
92
246
  .orderBy('createdAt', 'desc')
93
- .limit(50)
247
+ .limit(100) // Increased limit
94
248
  .get();
95
249
 
96
250
  const reviews = [];
97
251
  let totalStars = 0;
252
+ const ratingDistribution = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
253
+ let currentUserReview = null;
98
254
 
99
255
  snapshot.forEach(doc => {
100
256
  const data = doc.data();
101
- totalStars += data.rating;
102
- reviews.push({
103
- rating: data.rating,
104
- comment: data.comment,
105
- date: data.createdAt ? data.createdAt.toDate() : null,
106
- user: data.isAnonymous ? "Verified Copier" : `User ${data.userCid}` // We could look up username if needed
107
- });
257
+ const rating = data.rating || 0;
258
+ totalStars += rating;
259
+ ratingDistribution[rating] = (ratingDistribution[rating] || 0) + 1;
260
+
261
+ const review = {
262
+ id: doc.id,
263
+ rating: rating,
264
+ comment: data.comment || "",
265
+ isAnonymous: data.isAnonymous || false,
266
+ reviewerUsername: data.isAnonymous ? null : (data.reviewerUsername || null),
267
+ userCid: data.userCid || null,
268
+ createdAt: data.createdAt ? (data.createdAt.toDate ? data.createdAt.toDate().toISOString() : data.createdAt) : null,
269
+ updatedAt: data.updatedAt ? (data.updatedAt.toDate ? data.updatedAt.toDate().toISOString() : data.updatedAt) : null
270
+ };
271
+
272
+ reviews.push(review);
273
+
274
+ // Check if this is the current user's review
275
+ if (userCid && Number(data.userCid) === Number(userCid)) {
276
+ currentUserReview = review;
277
+ }
108
278
  });
109
279
 
110
- const avgRating = reviews.length > 0 ? (totalStars / reviews.length).toFixed(1) : 0;
280
+ const count = reviews.length;
281
+ const averageRating = count > 0 ? Number((totalStars / count).toFixed(2)) : 0;
282
+
283
+ // Calculate percentage distribution
284
+ const ratingPercentages = {};
285
+ for (let i = 1; i <= 5; i++) {
286
+ ratingPercentages[i] = count > 0 ? Number(((ratingDistribution[i] / count) * 100).toFixed(1)) : 0;
287
+ }
288
+
289
+ return res.status(200).json({
290
+ piCid: piCidNum,
291
+ count,
292
+ averageRating,
293
+ ratingDistribution: {
294
+ counts: ratingDistribution,
295
+ percentages: ratingPercentages
296
+ },
297
+ reviews,
298
+ currentUserReview // Include if userCid was provided
299
+ });
300
+
301
+ } catch (error) {
302
+ logger.log('ERROR', `[getReviews] Error fetching reviews for PI ${piCid}:`, error);
303
+ return res.status(500).json({ error: error.message });
304
+ }
305
+ }
306
+
307
+ /**
308
+ * GET /me/review/{piCid}
309
+ * Get current user's review for a specific PI (if exists)
310
+ */
311
+ async function getUserReview(req, res, dependencies, config) {
312
+ const { db, logger } = dependencies;
313
+ const { piCid } = req.params;
314
+ const { userCid } = req.query;
315
+ const { reviewsCollection } = config;
111
316
 
317
+ if (!userCid) {
318
+ return res.status(400).json({ error: "Missing userCid" });
319
+ }
320
+
321
+ try {
322
+ const reviewId = `${userCid}_${piCid}`;
323
+ const reviewDoc = await db.collection(reviewsCollection).doc(reviewId).get();
324
+
325
+ if (!reviewDoc.exists) {
326
+ return res.status(200).json({ exists: false, review: null });
327
+ }
328
+
329
+ const data = reviewDoc.data();
330
+ const review = {
331
+ id: reviewDoc.id,
332
+ rating: data.rating,
333
+ comment: data.comment || "",
334
+ isAnonymous: data.isAnonymous || false,
335
+ reviewerUsername: data.reviewerUsername || null,
336
+ createdAt: data.createdAt ? (data.createdAt.toDate ? data.createdAt.toDate().toISOString() : data.createdAt) : null,
337
+ updatedAt: data.updatedAt ? (data.updatedAt.toDate ? data.updatedAt.toDate().toISOString() : data.updatedAt) : null
338
+ };
339
+
340
+ return res.status(200).json({ exists: true, review });
341
+
342
+ } catch (error) {
343
+ logger.log('ERROR', `[getUserReview] Error fetching user review:`, error);
344
+ return res.status(500).json({ error: error.message });
345
+ }
346
+ }
347
+
348
+ /**
349
+ * GET /me/review-eligibility/{piCid}
350
+ * Check if current user can review a PI
351
+ */
352
+ async function checkReviewEligibility(req, res, dependencies, config) {
353
+ const { db, logger } = dependencies;
354
+ const { piCid } = req.params;
355
+ const { userCid } = req.query;
356
+
357
+ if (!userCid) {
358
+ return res.status(400).json({ error: "Missing userCid" });
359
+ }
360
+
361
+ try {
362
+ const canReview = await hasUserCopied(db, userCid, piCid, config);
363
+
112
364
  return res.status(200).json({
113
- piCid,
114
- count: reviews.length,
115
- averageRating: avgRating,
116
- reviews
365
+ piCid: Number(piCid),
366
+ eligible: canReview,
367
+ message: canReview
368
+ ? "You are eligible to review this Popular Investor."
369
+ : "You must have copied this Popular Investor to submit a review."
117
370
  });
118
371
 
119
372
  } catch (error) {
373
+ logger.log('ERROR', `[checkReviewEligibility] Error checking eligibility:`, error);
120
374
  return res.status(500).json({ error: error.message });
121
375
  }
122
376
  }
123
377
 
124
- module.exports = { submitReview, getReviews };
378
+ module.exports = { submitReview, getReviews, getUserReview, checkReviewEligibility };
@@ -3,7 +3,7 @@
3
3
  * Handles Reviews, Watchlists, User Analytics, and Verification.
4
4
  */
5
5
  const express = require('express');
6
- const { submitReview, getReviews } = require('./helpers/review_helpers');
6
+ const { submitReview, getReviews, getUserReview, checkReviewEligibility } = require('./helpers/review_helpers');
7
7
  const { getPiAnalytics, getUserRecommendations, getWatchlist, updateWatchlist, autoGenerateWatchlist, getUserDataStatus, getUserPortfolio, getUserSocialPosts, getUserComputations, getUserVerification, getInstrumentMappings, searchPopularInvestors, requestPiAddition, getWatchlistTriggerCounts, checkPisInRankings, getPiProfile } = 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');
@@ -19,6 +19,8 @@ module.exports = (dependencies, config) => {
19
19
  // --- Reviews ---
20
20
  router.post('/review', (req, res) => submitReview(req, res, dependencies, config));
21
21
  router.get('/reviews/:piCid', (req, res) => getReviews(req, res, dependencies, config));
22
+ router.get('/me/review/:piCid', (req, res) => getUserReview(req, res, dependencies, config));
23
+ router.get('/me/review-eligibility/:piCid', (req, res) => checkReviewEligibility(req, res, dependencies, config));
22
24
 
23
25
  // --- Data Serving ---
24
26
  router.get('/pi/:cid/analytics', (req, res) => getPiAnalytics(req, res, dependencies, config));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.418",
3
+ "version": "1.0.419",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [