Fix reviews and trust score calculation, improve metadata API docs

- Fix reviews/recent and getRecentReviews to return all reviews (not just those with parsed ratings)
- Fix trust score calculation to parse ratings from content text (e.g., [5/5])
- Fix getReviewSummary and getReviewStats to parse ratings from content
- Fix OpenAPI schema for contact field (should be array, not object)
- Fix popular/trending mints endpoints to show results even without pageviews
This commit is contained in:
Michaël
2025-12-21 19:31:58 -03:00
parent c2a7267459
commit 48965a6f18
3 changed files with 108 additions and 39 deletions

View File

@@ -1794,9 +1794,22 @@ Admin endpoints require the \`X-Admin-Api-Key\` header. All admin actions are au
description: { type: 'string', nullable: true }, description: { type: 'string', nullable: true },
description_long: { type: 'string', nullable: true }, description_long: { type: 'string', nullable: true },
contact: { contact: {
type: 'object', type: 'array',
nullable: true, nullable: true,
additionalProperties: { type: 'string' } description: 'Contact methods for the mint operator',
items: {
type: 'object',
properties: {
method: {
type: 'string',
description: 'Contact method (email, twitter, nostr, etc.)'
},
info: {
type: 'string',
description: 'Contact identifier (email address, handle, npub, etc.)'
}
}
}
}, },
motd: { motd: {
type: 'string', type: 'string',

View File

@@ -214,20 +214,34 @@ export function getReviews(mintId, options = {}) {
/** /**
* Get review stats for a mint * Get review stats for a mint
* Parses ratings from content text if rating column is null
*/ */
export function getReviewStats(mintId) { export function getReviewStats(mintId) {
return queryOne(` return queryOne(`
SELECT SELECT
COUNT(*) as total_reviews, COUNT(*) as total_reviews,
AVG(rating) as average_rating, AVG(parsed_rating) as average_rating,
COUNT(DISTINCT pubkey) as unique_reviewers, COUNT(DISTINCT pubkey) as unique_reviewers,
SUM(CASE WHEN rating = 5 THEN 1 ELSE 0 END) as five_star, SUM(CASE WHEN parsed_rating = 5 THEN 1 ELSE 0 END) as five_star,
SUM(CASE WHEN rating = 4 THEN 1 ELSE 0 END) as four_star, SUM(CASE WHEN parsed_rating = 4 THEN 1 ELSE 0 END) as four_star,
SUM(CASE WHEN rating = 3 THEN 1 ELSE 0 END) as three_star, SUM(CASE WHEN parsed_rating = 3 THEN 1 ELSE 0 END) as three_star,
SUM(CASE WHEN rating = 2 THEN 1 ELSE 0 END) as two_star, SUM(CASE WHEN parsed_rating = 2 THEN 1 ELSE 0 END) as two_star,
SUM(CASE WHEN rating = 1 THEN 1 ELSE 0 END) as one_star SUM(CASE WHEN parsed_rating = 1 THEN 1 ELSE 0 END) as one_star
FROM (
SELECT
pubkey,
CASE
WHEN rating IS NOT NULL THEN rating
WHEN content LIKE '[5/5]%' OR content LIKE '%(5/5)%' THEN 5
WHEN content LIKE '[4/5]%' OR content LIKE '%(4/5)%' THEN 4
WHEN content LIKE '[3/5]%' OR content LIKE '%(3/5)%' THEN 3
WHEN content LIKE '[2/5]%' OR content LIKE '%(2/5)%' THEN 2
WHEN content LIKE '[1/5]%' OR content LIKE '%(1/5)%' THEN 1
ELSE NULL
END as parsed_rating
FROM reviews FROM reviews
WHERE mint_id = ? AND rating IS NOT NULL WHERE mint_id = ?
) parsed
`, [mintId]); `, [mintId]);
} }
@@ -381,24 +395,20 @@ export function countReviews(options = {}) {
/** /**
* Get review summary for a mint (quick overview) * Get review summary for a mint (quick overview)
* Parses ratings from content text if rating column is null
*/ */
export function getReviewSummary(mintId) { export function getReviewSummary(mintId) {
const stats = queryOne(` // First get total count of all reviews
const totalStats = queryOne(`
SELECT SELECT
COUNT(*) as total_reviews, COUNT(*) as total_reviews,
AVG(rating) as avg_rating,
COUNT(DISTINCT pubkey) as unique_reviewers, COUNT(DISTINCT pubkey) as unique_reviewers,
SUM(CASE WHEN rating = 5 THEN 1 ELSE 0 END) as five_star,
SUM(CASE WHEN rating = 4 THEN 1 ELSE 0 END) as four_star,
SUM(CASE WHEN rating = 3 THEN 1 ELSE 0 END) as three_star,
SUM(CASE WHEN rating = 2 THEN 1 ELSE 0 END) as two_star,
SUM(CASE WHEN rating = 1 THEN 1 ELSE 0 END) as one_star,
MAX(created_at) as last_review_at MAX(created_at) as last_review_at
FROM reviews FROM reviews
WHERE mint_id = ? AND rating IS NOT NULL WHERE mint_id = ?
`, [mintId]); `, [mintId]);
if (!stats || stats.total_reviews === 0) { if (!totalStats || totalStats.total_reviews === 0) {
return { return {
total_reviews: 0, total_reviews: 0,
avg_rating: null, avg_rating: null,
@@ -407,18 +417,43 @@ export function getReviewSummary(mintId) {
}; };
} }
// Parse ratings from content if rating column is null
const ratingStats = queryOne(`
SELECT
AVG(parsed_rating) as avg_rating,
SUM(CASE WHEN parsed_rating = 5 THEN 1 ELSE 0 END) as five_star,
SUM(CASE WHEN parsed_rating = 4 THEN 1 ELSE 0 END) as four_star,
SUM(CASE WHEN parsed_rating = 3 THEN 1 ELSE 0 END) as three_star,
SUM(CASE WHEN parsed_rating = 2 THEN 1 ELSE 0 END) as two_star,
SUM(CASE WHEN parsed_rating = 1 THEN 1 ELSE 0 END) as one_star
FROM (
SELECT CASE
WHEN rating IS NOT NULL THEN rating
WHEN content LIKE '[5/5]%' OR content LIKE '%(5/5)%' THEN 5
WHEN content LIKE '[4/5]%' OR content LIKE '%(4/5)%' THEN 4
WHEN content LIKE '[3/5]%' OR content LIKE '%(3/5)%' THEN 3
WHEN content LIKE '[2/5]%' OR content LIKE '%(2/5)%' THEN 2
WHEN content LIKE '[1/5]%' OR content LIKE '%(1/5)%' THEN 1
ELSE NULL
END as parsed_rating
FROM reviews
WHERE mint_id = ?
) parsed
WHERE parsed_rating IS NOT NULL
`, [mintId]);
return { return {
total_reviews: stats.total_reviews, total_reviews: totalStats.total_reviews,
avg_rating: stats.avg_rating ? Math.round(stats.avg_rating * 10) / 10 : null, avg_rating: ratingStats && ratingStats.avg_rating ? Math.round(ratingStats.avg_rating * 10) / 10 : null,
unique_reviewers: stats.unique_reviewers, unique_reviewers: totalStats.unique_reviewers,
rating_distribution: { rating_distribution: {
5: stats.five_star || 0, 5: (ratingStats && ratingStats.five_star) || 0,
4: stats.four_star || 0, 4: (ratingStats && ratingStats.four_star) || 0,
3: stats.three_star || 0, 3: (ratingStats && ratingStats.three_star) || 0,
2: stats.two_star || 0, 2: (ratingStats && ratingStats.two_star) || 0,
1: stats.one_star || 0 1: (ratingStats && ratingStats.one_star) || 0
}, },
last_review_at: stats.last_review_at ? unixToISO(stats.last_review_at) : null last_review_at: totalStats.last_review_at ? unixToISO(totalStats.last_review_at) : null
}; };
} }

View File

@@ -142,14 +142,23 @@ function calculateSpeedScore(mintId, component) {
* Calculate reviews score (max 20 points) * Calculate reviews score (max 20 points)
*/ */
function calculateReviewsScore(mintId, component) { function calculateReviewsScore(mintId, component) {
// Get review stats // Get review stats - include all reviews, not just those with parsed ratings
// Parse rating from content if rating column is null (ratings often in "[5/5]" format in content)
const stats = queryOne(` const stats = queryOne(`
SELECT SELECT
COUNT(*) as count, COUNT(*) as count,
AVG(rating) as avg_rating, AVG(CASE
WHEN rating IS NOT NULL THEN rating
WHEN content LIKE '[5/5]%' OR content LIKE '%(5/5)%' THEN 5
WHEN content LIKE '[4/5]%' OR content LIKE '%(4/5)%' THEN 4
WHEN content LIKE '[3/5]%' OR content LIKE '%(3/5)%' THEN 3
WHEN content LIKE '[2/5]%' OR content LIKE '%(2/5)%' THEN 2
WHEN content LIKE '[1/5]%' OR content LIKE '%(1/5)%' THEN 1
ELSE NULL
END) as avg_rating,
COUNT(DISTINCT pubkey) as unique_reviewers COUNT(DISTINCT pubkey) as unique_reviewers
FROM reviews FROM reviews
WHERE mint_id = ? AND rating IS NOT NULL WHERE mint_id = ?
`, [mintId]); `, [mintId]);
if (!stats || stats.count === 0) { if (!stats || stats.count === 0) {
@@ -163,10 +172,12 @@ function calculateReviewsScore(mintId, component) {
component.details = { component.details = {
review_count: reviewCount, review_count: reviewCount,
average_rating: Math.round(avgRating * 10) / 10, average_rating: avgRating ? Math.round(avgRating * 10) / 10 : null,
unique_reviewers: uniqueReviewers unique_reviewers: uniqueReviewers
}; };
// If we have a calculated average rating, use it
if (avgRating) {
// Base score from average rating (1-5 scale) // Base score from average rating (1-5 scale)
const ratingScore = ((avgRating - 1) / 4) * (trustWeights.reviews * 0.7); const ratingScore = ((avgRating - 1) / 4) * (trustWeights.reviews * 0.7);
@@ -177,6 +188,16 @@ function calculateReviewsScore(mintId, component) {
); );
return ratingScore + quantityBonus; return ratingScore + quantityBonus;
}
// Fallback: if we have reviews but no parseable ratings, give partial credit based on count
// This rewards having reviews even without explicit ratings
const quantityScore = Math.min(
trustWeights.reviews * 0.5, // Max 50% of review score without ratings
(Math.log10(reviewCount + 1) / 2) * trustWeights.reviews * 0.5
);
return quantityScore;
} }
/** /**