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
|
-
//
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
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
|
-
|
|
1521
|
+
// Parse the latest date to calculate earlier dates
|
|
1522
|
+
const latestDateObj = new Date(latestDate + 'T00:00:00Z');
|
|
1496
1523
|
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
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
|
-
//
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
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
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
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 ${
|
|
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
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
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]
|
|
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:
|
|
1592
|
+
cid: cidStr,
|
|
1569
1593
|
data: profileData,
|
|
1570
|
-
isFallback: latestDate !== today,
|
|
1571
|
-
dataDate:
|
|
1572
|
-
|
|
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
|
-
*
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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.
|
|
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).
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
207
|
+
reviewerUsername: reviewerUsername,
|
|
69
208
|
updatedAt: FieldValue.serverTimestamp()
|
|
70
|
-
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
if (!isUpdate) {
|
|
212
|
+
reviewData.createdAt = FieldValue.serverTimestamp();
|
|
213
|
+
}
|
|
71
214
|
|
|
72
|
-
|
|
73
|
-
|
|
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', '==',
|
|
245
|
+
.where('piCid', '==', piCidNum)
|
|
92
246
|
.orderBy('createdAt', 'desc')
|
|
93
|
-
.limit(
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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));
|