Add frontend-optimized aggregation endpoints
- Add GET /mints/{mint_id}/profile - full mint profile in one request
- Add GET /mints/cards - lightweight cards for directory/grid views
- Add GET /mints/{mint_id}/metrics - timeseries metrics for charts
- Add GET /analytics/activity - ecosystem activity timeline
- Add POST /mints/compare - side-by-side mint comparison
- Add GET /mints/rankings - ranked leaderboards by trust/uptime/latency/reviews
- Add GET /mints/{mint_id}/similar - find similar mints
- Add GET /mints/recommended - use-case based recommendations
- Add GET /mints/{mint_id}/compatibility - wallet compatibility info
- Add GET /wallets/{wallet_name}/recommended-mints - wallet-specific recommendations
- Add GET /mints/{mint_id}/risk - risk assessment and flags
- Update OpenAPI documentation for all new endpoints
- Fix review rating parsing from content field
- Fix trust score calculation to include parsed ratings
This commit is contained in:
@@ -276,6 +276,62 @@ Admin endpoints require the \`X-Admin-Api-Key\` header. All admin actions are au
|
||||
}
|
||||
}
|
||||
},
|
||||
'/analytics/activity': {
|
||||
get: {
|
||||
tags: ['Analytics'],
|
||||
summary: 'Ecosystem activity timeline',
|
||||
description: 'Returns daily ecosystem activity: new mints, status changes, reviews posted, and metadata updates.',
|
||||
operationId: 'getEcosystemActivity',
|
||||
parameters: [{
|
||||
name: 'range',
|
||||
in: 'query',
|
||||
description: 'Time range for activity',
|
||||
schema: { type: 'string', enum: ['7d', '30d', '90d'], default: '30d' }
|
||||
}],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Ecosystem activity timeline',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/EcosystemActivityResponse' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'/wallets/{wallet_name}/recommended-mints': {
|
||||
get: {
|
||||
tags: ['Mints'],
|
||||
summary: 'Get wallet-recommended mints',
|
||||
description: 'Returns mints that are compatible with and recommended for a specific wallet based on required NUT support.',
|
||||
operationId: 'getWalletRecommendedMints',
|
||||
parameters: [{
|
||||
name: 'wallet_name',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'Wallet name (nutstash, minibits, enuts, cashu.me)',
|
||||
schema: { type: 'string' }
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
in: 'query',
|
||||
description: 'Maximum number of results',
|
||||
schema: { type: 'integer', default: 10, maximum: 50 }
|
||||
}
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Wallet-compatible mints',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/WalletRecommendedMintsResponse' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'/mints/trending': {
|
||||
get: {
|
||||
tags: ['Mints'],
|
||||
@@ -311,6 +367,168 @@ Admin endpoints require the \`X-Admin-Api-Key\` header. All admin actions are au
|
||||
}
|
||||
}
|
||||
},
|
||||
'/mints/cards': {
|
||||
get: {
|
||||
tags: ['Mints'],
|
||||
summary: 'Get mint cards for lists',
|
||||
description: 'Returns lightweight mint cards optimized for directory/grid views. Returns everything needed for a mint card in one request.',
|
||||
operationId: 'getMintCards',
|
||||
parameters: [{
|
||||
name: 'status',
|
||||
in: 'query',
|
||||
description: 'Filter by mint status',
|
||||
schema: { type: 'string', enum: ['online', 'degraded', 'offline'] }
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
in: 'query',
|
||||
description: 'Maximum number of results',
|
||||
schema: { type: 'integer', default: 100, maximum: 500 }
|
||||
},
|
||||
{
|
||||
name: 'offset',
|
||||
in: 'query',
|
||||
description: 'Number of results to skip',
|
||||
schema: { type: 'integer', default: 0 }
|
||||
},
|
||||
{
|
||||
name: 'sort_by',
|
||||
in: 'query',
|
||||
description: 'Sort field',
|
||||
schema: { type: 'string', enum: ['trust_score', 'uptime_30d', 'name', 'created_at'], default: 'trust_score' }
|
||||
},
|
||||
{
|
||||
name: 'sort_order',
|
||||
in: 'query',
|
||||
description: 'Sort order',
|
||||
schema: { type: 'string', enum: ['asc', 'desc'], default: 'desc' }
|
||||
}
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Mint cards',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/MintCardsResponse' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'/mints/compare': {
|
||||
post: {
|
||||
tags: ['Mints'],
|
||||
summary: 'Compare multiple mints',
|
||||
description: 'Compare up to 10 mints side-by-side with uptime, latency, trust score, NUT support, reviews, and identity info.',
|
||||
operationId: 'compareMints',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['mint_ids'],
|
||||
properties: {
|
||||
mint_ids: {
|
||||
type: 'array',
|
||||
items: { type: 'string', format: 'uuid' },
|
||||
maxItems: 10,
|
||||
description: 'Array of mint IDs to compare'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Mint comparison',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/MintComparisonResponse' }
|
||||
}
|
||||
}
|
||||
},
|
||||
400: {
|
||||
description: 'Invalid request',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'/mints/rankings': {
|
||||
get: {
|
||||
tags: ['Mints'],
|
||||
summary: 'Get mint rankings',
|
||||
description: 'Get ranked leaderboard of mints by trust score, uptime, latency, or reviews.',
|
||||
operationId: 'getMintRankings',
|
||||
parameters: [{
|
||||
name: 'by',
|
||||
in: 'query',
|
||||
description: 'Ranking criteria',
|
||||
schema: { type: 'string', enum: ['trust', 'uptime', 'latency', 'reviews'], default: 'trust' }
|
||||
},
|
||||
{
|
||||
name: 'period',
|
||||
in: 'query',
|
||||
description: 'Time period for ranking',
|
||||
schema: { type: 'string', enum: ['7d', '30d', '90d'], default: '30d' }
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
in: 'query',
|
||||
description: 'Maximum number of results',
|
||||
schema: { type: 'integer', default: 50, maximum: 100 }
|
||||
}
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Mint rankings',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/MintRankingsResponse' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'/mints/recommended': {
|
||||
get: {
|
||||
tags: ['Mints'],
|
||||
summary: 'Get recommended mints',
|
||||
description: 'Get mints recommended for specific use cases like mobile, high-volume, or privacy.',
|
||||
operationId: 'getRecommendedMints',
|
||||
parameters: [{
|
||||
name: 'use_case',
|
||||
in: 'query',
|
||||
description: 'Use case for recommendations',
|
||||
schema: { type: 'string', enum: ['general', 'mobile', 'high_volume', 'privacy'], default: 'general' }
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
in: 'query',
|
||||
description: 'Maximum number of results',
|
||||
schema: { type: 'integer', default: 10, maximum: 50 }
|
||||
}
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Recommended mints',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/RecommendedMintsResponse' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'/mints': {
|
||||
get: {
|
||||
tags: ['Mints'],
|
||||
@@ -719,6 +937,128 @@ Admin endpoints require the \`X-Admin-Api-Key\` header. All admin actions are au
|
||||
}
|
||||
}
|
||||
},
|
||||
'/mints/{mint_id}/profile': {
|
||||
get: {
|
||||
tags: ['Mint Details'],
|
||||
summary: 'Get full mint profile',
|
||||
description: 'Returns all mint information in one request: basic info, status, uptime summary (24h/7d/30d), trust score with breakdown, supported NUTs, metadata, review summary, and popularity metrics.',
|
||||
operationId: 'getMintProfile',
|
||||
parameters: [
|
||||
{ $ref: '#/components/parameters/mintId' }
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Full mint profile',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/MintProfileResponse' }
|
||||
}
|
||||
}
|
||||
},
|
||||
404: { $ref: '#/components/responses/NotFound' }
|
||||
}
|
||||
}
|
||||
},
|
||||
'/mints/{mint_id}/metrics': {
|
||||
get: {
|
||||
tags: ['Analytics'],
|
||||
summary: 'Get mint metrics timeseries',
|
||||
description: 'Returns time-bucketed metrics for charts: uptime %, latency avg, failures, reviews, views, and metadata changes over time.',
|
||||
operationId: 'getMintMetrics',
|
||||
parameters: [
|
||||
{ $ref: '#/components/parameters/mintId' },
|
||||
{
|
||||
name: 'range',
|
||||
in: 'query',
|
||||
description: 'Time range for metrics',
|
||||
schema: { type: 'string', enum: ['7d', '30d', '90d'], default: '7d' }
|
||||
}
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Mint metrics timeseries',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/MintMetricsResponse' }
|
||||
}
|
||||
}
|
||||
},
|
||||
404: { $ref: '#/components/responses/NotFound' }
|
||||
}
|
||||
}
|
||||
},
|
||||
'/mints/{mint_id}/similar': {
|
||||
get: {
|
||||
tags: ['Mints'],
|
||||
summary: 'Get similar mints',
|
||||
description: 'Find mints similar to this one based on NUT support, uptime profile, and trust score.',
|
||||
operationId: 'getSimilarMints',
|
||||
parameters: [
|
||||
{ $ref: '#/components/parameters/mintId' },
|
||||
{
|
||||
name: 'limit',
|
||||
in: 'query',
|
||||
description: 'Maximum number of results',
|
||||
schema: { type: 'integer', default: 5, maximum: 20 }
|
||||
}
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Similar mints',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/SimilarMintsResponse' }
|
||||
}
|
||||
}
|
||||
},
|
||||
404: { $ref: '#/components/responses/NotFound' }
|
||||
}
|
||||
}
|
||||
},
|
||||
'/mints/{mint_id}/compatibility': {
|
||||
get: {
|
||||
tags: ['Mint Details'],
|
||||
summary: 'Get wallet compatibility',
|
||||
description: 'Returns supported NUTs, known compatible/incompatible wallets, and known issues.',
|
||||
operationId: 'getMintCompatibility',
|
||||
parameters: [
|
||||
{ $ref: '#/components/parameters/mintId' }
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Compatibility info',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/MintCompatibilityResponse' }
|
||||
}
|
||||
}
|
||||
},
|
||||
404: { $ref: '#/components/responses/NotFound' }
|
||||
}
|
||||
}
|
||||
},
|
||||
'/mints/{mint_id}/risk': {
|
||||
get: {
|
||||
tags: ['Trust'],
|
||||
summary: 'Get risk assessment',
|
||||
description: 'Returns risk flags and indicators: uptime volatility, metadata churn, review anomalies, centralization risk.',
|
||||
operationId: 'getMintRisk',
|
||||
parameters: [
|
||||
{ $ref: '#/components/parameters/mintId' }
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Risk assessment',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/MintRiskResponse' }
|
||||
}
|
||||
}
|
||||
},
|
||||
404: { $ref: '#/components/responses/NotFound' }
|
||||
}
|
||||
}
|
||||
},
|
||||
'/mints/{mint_id}/features': {
|
||||
get: {
|
||||
tags: ['Mint Details'],
|
||||
@@ -2307,6 +2647,360 @@ Admin endpoints require the \`X-Admin-Api-Key\` header. All admin actions are au
|
||||
visibility: { type: 'string', enum: ['public', 'hidden'] },
|
||||
status: { type: 'string' }
|
||||
}
|
||||
},
|
||||
MintCardsResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
cards: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mint_id: { type: 'string', format: 'uuid' },
|
||||
name: { type: 'string', nullable: true },
|
||||
url: { type: 'string' },
|
||||
icon_url: { type: 'string', nullable: true },
|
||||
status: { type: 'string' },
|
||||
trust_score: { type: 'integer', nullable: true },
|
||||
trust_level: { type: 'string', nullable: true },
|
||||
uptime_pct: { type: 'number', nullable: true },
|
||||
review_count: { type: 'integer' },
|
||||
nut_count: { type: 'integer' },
|
||||
has_tor: { type: 'boolean' }
|
||||
}
|
||||
}
|
||||
},
|
||||
total: { type: 'integer' },
|
||||
limit: { type: 'integer' },
|
||||
offset: { type: 'integer' }
|
||||
}
|
||||
},
|
||||
MintComparisonResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mints: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mint_id: { type: 'string', format: 'uuid' },
|
||||
name: { type: 'string', nullable: true },
|
||||
url: { type: 'string' },
|
||||
icon_url: { type: 'string', nullable: true },
|
||||
status: { type: 'string' },
|
||||
trust: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
score: { type: 'integer', nullable: true },
|
||||
level: { type: 'string', nullable: true },
|
||||
breakdown: { type: 'object', nullable: true }
|
||||
}
|
||||
},
|
||||
uptime: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pct_30d: { type: 'number', nullable: true },
|
||||
avg_rtt_ms: { type: 'integer', nullable: true }
|
||||
}
|
||||
},
|
||||
nuts: { type: 'array', items: { type: 'integer' } },
|
||||
reviews: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
count: { type: 'integer' },
|
||||
avg_rating: { type: 'number', nullable: true }
|
||||
}
|
||||
},
|
||||
identity: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
has_pubkey: { type: 'boolean' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed_at: { type: 'string', format: 'date-time' }
|
||||
}
|
||||
},
|
||||
MintRankingsResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ranking_by: { type: 'string', enum: ['trust', 'uptime', 'latency', 'reviews'] },
|
||||
period: { type: 'string' },
|
||||
mints: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rank: { type: 'integer' },
|
||||
mint_id: { type: 'string', format: 'uuid' },
|
||||
name: { type: 'string', nullable: true },
|
||||
url: { type: 'string' },
|
||||
icon_url: { type: 'string', nullable: true },
|
||||
status: { type: 'string' },
|
||||
score: { type: 'number', description: 'Score for the ranking criteria' },
|
||||
trust_score: { type: 'integer', nullable: true }
|
||||
}
|
||||
}
|
||||
},
|
||||
computed_at: { type: 'string', format: 'date-time' }
|
||||
}
|
||||
},
|
||||
RecommendedMintsResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
use_case: { type: 'string', enum: ['general', 'mobile', 'high_volume', 'privacy'] },
|
||||
mints: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mint_id: { type: 'string', format: 'uuid' },
|
||||
name: { type: 'string', nullable: true },
|
||||
url: { type: 'string' },
|
||||
icon_url: { type: 'string', nullable: true },
|
||||
status: { type: 'string' },
|
||||
trust_score: { type: 'integer', nullable: true },
|
||||
trust_level: { type: 'string', nullable: true },
|
||||
uptime_pct: { type: 'number', nullable: true }
|
||||
}
|
||||
}
|
||||
},
|
||||
computed_at: { type: 'string', format: 'date-time' }
|
||||
}
|
||||
},
|
||||
MintProfileResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mint_id: { type: 'string', format: 'uuid' },
|
||||
canonical_url: { type: 'string' },
|
||||
urls: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string' },
|
||||
type: { type: 'string' },
|
||||
active: { type: 'boolean' }
|
||||
}
|
||||
}
|
||||
},
|
||||
name: { type: 'string', nullable: true },
|
||||
icon_url: { type: 'string', nullable: true },
|
||||
status: { type: 'string' },
|
||||
offline_since: { type: 'string', format: 'date-time', nullable: true },
|
||||
created_at: { type: 'string', format: 'date-time' },
|
||||
uptime: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
'24h': {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
properties: {
|
||||
uptime_pct: { type: 'number' },
|
||||
avg_rtt_ms: { type: 'integer' },
|
||||
checks: { type: 'integer' }
|
||||
}
|
||||
},
|
||||
'7d': {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
properties: {
|
||||
uptime_pct: { type: 'number' },
|
||||
avg_rtt_ms: { type: 'integer' },
|
||||
checks: { type: 'integer' }
|
||||
}
|
||||
},
|
||||
'30d': {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
properties: {
|
||||
uptime_pct: { type: 'number' },
|
||||
avg_rtt_ms: { type: 'integer' },
|
||||
checks: { type: 'integer' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
trust: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
score: { type: 'integer', nullable: true },
|
||||
level: { type: 'string' },
|
||||
breakdown: { type: 'object', nullable: true },
|
||||
computed_at: { type: 'string', format: 'date-time', nullable: true }
|
||||
}
|
||||
},
|
||||
metadata: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
properties: {
|
||||
name: { type: 'string', nullable: true },
|
||||
pubkey: { type: 'string', nullable: true },
|
||||
version: { type: 'string', nullable: true },
|
||||
description: { type: 'string', nullable: true },
|
||||
description_long: { type: 'string', nullable: true },
|
||||
contact: { type: 'array', nullable: true },
|
||||
motd: { type: 'string', nullable: true },
|
||||
tos_url: { type: 'string', nullable: true },
|
||||
last_fetched_at: { type: 'string', format: 'date-time' }
|
||||
}
|
||||
},
|
||||
supported_nuts: { type: 'array', items: { type: 'integer' } },
|
||||
reviews: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
total: { type: 'integer' },
|
||||
unique_reviewers: { type: 'integer' },
|
||||
avg_rating: { type: 'number', nullable: true }
|
||||
}
|
||||
},
|
||||
popularity: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
views_24h: { type: 'integer' },
|
||||
views_7d: { type: 'integer' },
|
||||
views_30d: { type: 'integer' },
|
||||
source: { type: 'string' },
|
||||
incidents_7d: { type: 'integer' },
|
||||
incidents_30d: { type: 'integer' }
|
||||
}
|
||||
},
|
||||
computed_at: { type: 'string', format: 'date-time' }
|
||||
}
|
||||
},
|
||||
MintMetricsResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
range: { type: 'string' },
|
||||
data: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
date: { type: 'string', format: 'date' },
|
||||
uptime_pct: { type: 'number', nullable: true },
|
||||
avg_rtt_ms: { type: 'integer', nullable: true },
|
||||
failures: { type: 'integer' },
|
||||
reviews: { type: 'integer' },
|
||||
views: { type: 'integer' },
|
||||
metadata_changes: { type: 'integer' }
|
||||
}
|
||||
}
|
||||
},
|
||||
computed_at: { type: 'string', format: 'date-time' }
|
||||
}
|
||||
},
|
||||
SimilarMintsResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mints: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mint_id: { type: 'string', format: 'uuid' },
|
||||
name: { type: 'string', nullable: true },
|
||||
url: { type: 'string' },
|
||||
icon_url: { type: 'string', nullable: true },
|
||||
status: { type: 'string' },
|
||||
trust_score: { type: 'integer', nullable: true },
|
||||
trust_level: { type: 'string', nullable: true },
|
||||
similarity_score: { type: 'integer', description: 'Similarity score 0-100' }
|
||||
}
|
||||
}
|
||||
},
|
||||
computed_at: { type: 'string', format: 'date-time' }
|
||||
}
|
||||
},
|
||||
MintCompatibilityResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
supported_nuts: { type: 'array', items: { type: 'integer' } },
|
||||
version: { type: 'string', nullable: true },
|
||||
compatible_wallets: { type: 'array', items: { type: 'string' } },
|
||||
incompatible_wallets: { type: 'array', items: { type: 'string' } },
|
||||
known_issues: { type: 'array', items: { type: 'string' } },
|
||||
recommendations: { type: 'array', items: { type: 'string' } },
|
||||
computed_at: { type: 'string', format: 'date-time' }
|
||||
}
|
||||
},
|
||||
MintRiskResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mint_id: { type: 'string', format: 'uuid' },
|
||||
risk_level: { type: 'string', enum: ['low', 'medium', 'high'] },
|
||||
risk_score: { type: 'integer', minimum: 0, maximum: 100 },
|
||||
flags: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string' },
|
||||
severity: { type: 'string', enum: ['low', 'medium', 'high'] },
|
||||
message: { type: 'string' }
|
||||
}
|
||||
}
|
||||
},
|
||||
summary: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
uptime_volatile: { type: 'boolean' },
|
||||
metadata_churn: { type: 'boolean' },
|
||||
centralization_risk: { type: 'boolean' },
|
||||
review_anomalies: { type: 'boolean' }
|
||||
}
|
||||
},
|
||||
computed_at: { type: 'string', format: 'date-time' }
|
||||
}
|
||||
},
|
||||
EcosystemActivityResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
range: { type: 'string' },
|
||||
data: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
date: { type: 'string', format: 'date' },
|
||||
new_mints: { type: 'integer' },
|
||||
mints_offline: { type: 'integer' },
|
||||
mints_online: { type: 'integer' },
|
||||
reviews: { type: 'integer' },
|
||||
metadata_updates: { type: 'integer' }
|
||||
}
|
||||
}
|
||||
},
|
||||
computed_at: { type: 'string', format: 'date-time' }
|
||||
}
|
||||
},
|
||||
WalletRecommendedMintsResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
wallet: { type: 'string' },
|
||||
required_nuts: { type: 'array', items: { type: 'integer' } },
|
||||
preferred_nuts: { type: 'array', items: { type: 'integer' } },
|
||||
mints: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mint_id: { type: 'string', format: 'uuid' },
|
||||
name: { type: 'string', nullable: true },
|
||||
url: { type: 'string' },
|
||||
icon_url: { type: 'string', nullable: true },
|
||||
status: { type: 'string' },
|
||||
trust_score: { type: 'integer', nullable: true },
|
||||
trust_level: { type: 'string', nullable: true },
|
||||
uptime_pct: { type: 'number', nullable: true },
|
||||
compatibility_score: { type: 'integer', description: 'Compatibility score' }
|
||||
}
|
||||
}
|
||||
},
|
||||
computed_at: { type: 'string', format: 'date-time' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,17 @@ import {
|
||||
getPopularMints,
|
||||
getMintStats,
|
||||
getMintAvailability,
|
||||
getMintCard
|
||||
getMintCard,
|
||||
getMintProfile,
|
||||
getMintCards,
|
||||
getMintMetrics,
|
||||
compareMints,
|
||||
getMintRankings,
|
||||
getSimilarMints,
|
||||
getRecommendedMints,
|
||||
getMintCompatibility,
|
||||
getWalletRecommendedMints,
|
||||
getMintRisk
|
||||
} from '../services/AnalyticsService.js';
|
||||
|
||||
const router = Router();
|
||||
@@ -249,6 +259,103 @@ router.get('/trending', async(req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /mints/cards
|
||||
* Get lightweight mint cards for directory/list views
|
||||
*/
|
||||
router.get('/cards', (req, res) => {
|
||||
try {
|
||||
const { status, limit = 100, offset = 0, sort_by = 'trust_score', sort_order = 'desc' } = req.query;
|
||||
|
||||
const cards = getMintCards({
|
||||
status,
|
||||
limit: Math.min(parseInt(limit) || 100, 500),
|
||||
offset: parseInt(offset) || 0,
|
||||
sortBy: sort_by,
|
||||
sortOrder: sort_order
|
||||
});
|
||||
|
||||
res.json({
|
||||
cards,
|
||||
total: cards.length,
|
||||
limit: Math.min(parseInt(limit) || 100, 500),
|
||||
offset: parseInt(offset) || 0
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[API] Error getting mint cards:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /mints/compare
|
||||
* Compare multiple mints side by side
|
||||
*/
|
||||
router.post('/compare', (req, res) => {
|
||||
try {
|
||||
const { mint_ids } = req.body;
|
||||
|
||||
if (!mint_ids || !Array.isArray(mint_ids) || mint_ids.length === 0) {
|
||||
return res.status(400).json({ error: 'mint_ids array required' });
|
||||
}
|
||||
|
||||
if (mint_ids.length > 10) {
|
||||
return res.status(400).json({ error: 'Maximum 10 mints can be compared' });
|
||||
}
|
||||
|
||||
const comparison = compareMints(mint_ids);
|
||||
res.json(comparison);
|
||||
} catch (error) {
|
||||
console.error('[API] Error comparing mints:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /mints/rankings
|
||||
* Get ranked mint leaderboard
|
||||
*/
|
||||
router.get('/rankings', (req, res) => {
|
||||
try {
|
||||
const { by = 'trust', period = '30d', limit = 50 } = req.query;
|
||||
|
||||
const validBy = ['trust', 'uptime', 'latency', 'reviews'];
|
||||
const validPeriods = ['7d', '30d', '90d'];
|
||||
|
||||
const rankings = getMintRankings(
|
||||
validBy.includes(by) ? by : 'trust',
|
||||
validPeriods.includes(period) ? period : '30d',
|
||||
Math.min(parseInt(limit) || 50, 100)
|
||||
);
|
||||
|
||||
res.json(rankings);
|
||||
} catch (error) {
|
||||
console.error('[API] Error getting rankings:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /mints/recommended
|
||||
* Get recommended mints by use case
|
||||
*/
|
||||
router.get('/recommended', (req, res) => {
|
||||
try {
|
||||
const { use_case = 'general', limit = 10 } = req.query;
|
||||
|
||||
const validUseCases = ['general', 'mobile', 'high_volume', 'privacy'];
|
||||
const recommendations = getRecommendedMints(
|
||||
validUseCases.includes(use_case) ? use_case : 'general',
|
||||
Math.min(parseInt(limit) || 10, 50)
|
||||
);
|
||||
|
||||
res.json(recommendations);
|
||||
} catch (error) {
|
||||
console.error('[API] Error getting recommendations:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// BY-URL ROUTES (must come BEFORE :mint_id routes)
|
||||
// ==========================================
|
||||
@@ -906,4 +1013,93 @@ router.get('/:mint_id/card', resolveMintMiddleware, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /mints/:mint_id/profile
|
||||
* Full mint profile - aggregated data in one request
|
||||
*/
|
||||
router.get('/:mint_id/profile', resolveMintMiddleware, async(req, res) => {
|
||||
try {
|
||||
const profile = await getMintProfile(req.mint.mint_id);
|
||||
if (!profile) {
|
||||
return res.status(404).json({ error: 'Mint not found' });
|
||||
}
|
||||
|
||||
// Record pageview
|
||||
recordPageview(req.mint.mint_id, {
|
||||
sessionId: req.headers['x-session-id'],
|
||||
userAgent: req.headers['user-agent'],
|
||||
referer: req.headers['referer']
|
||||
});
|
||||
|
||||
res.json(profile);
|
||||
} catch (error) {
|
||||
console.error('[API] Error getting mint profile:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /mints/:mint_id/metrics
|
||||
* Mint metrics timeseries for charts
|
||||
*/
|
||||
router.get('/:mint_id/metrics', resolveMintMiddleware, (req, res) => {
|
||||
try {
|
||||
const { range = '7d' } = req.query;
|
||||
const validRanges = ['7d', '30d', '90d'];
|
||||
const timeRange = validRanges.includes(range) ? range : '7d';
|
||||
|
||||
const metrics = getMintMetrics(req.mint.mint_id, timeRange);
|
||||
res.json(metrics);
|
||||
} catch (error) {
|
||||
console.error('[API] Error getting mint metrics:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /mints/:mint_id/similar
|
||||
* Find similar mints
|
||||
*/
|
||||
router.get('/:mint_id/similar', resolveMintMiddleware, (req, res) => {
|
||||
try {
|
||||
const { limit = 5 } = req.query;
|
||||
const similar = getSimilarMints(
|
||||
req.mint.mint_id,
|
||||
Math.min(parseInt(limit) || 5, 20)
|
||||
);
|
||||
res.json(similar);
|
||||
} catch (error) {
|
||||
console.error('[API] Error getting similar mints:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /mints/:mint_id/compatibility
|
||||
* Wallet compatibility info
|
||||
*/
|
||||
router.get('/:mint_id/compatibility', resolveMintMiddleware, (req, res) => {
|
||||
try {
|
||||
const compatibility = getMintCompatibility(req.mint.mint_id);
|
||||
res.json(compatibility);
|
||||
} catch (error) {
|
||||
console.error('[API] Error getting compatibility:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /mints/:mint_id/risk
|
||||
* Risk assessment and flags
|
||||
*/
|
||||
router.get('/:mint_id/risk', resolveMintMiddleware, (req, res) => {
|
||||
try {
|
||||
const risk = getMintRisk(req.mint.mint_id);
|
||||
res.json(risk);
|
||||
} catch (error) {
|
||||
console.error('[API] Error getting risk assessment:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
getNutsAnalytics,
|
||||
getStatusDistribution,
|
||||
getNetworkBreakdown,
|
||||
getMetadataQuality
|
||||
getMetadataQuality,
|
||||
getEcosystemActivity,
|
||||
getWalletRecommendedMints
|
||||
} from '../services/AnalyticsService.js';
|
||||
import { getRecentEcosystemReviews } from '../services/ReviewService.js';
|
||||
import { nowISO } from '../utils/time.js';
|
||||
@@ -359,4 +361,51 @@ router.get('/reviews/recent', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /analytics/activity
|
||||
* Ecosystem-wide activity timeline
|
||||
*/
|
||||
router.get('/analytics/activity', (req, res) => {
|
||||
try {
|
||||
const { range = '30d' } = req.query;
|
||||
const validRanges = ['7d', '30d', '90d'];
|
||||
const timeRange = validRanges.includes(range) ? range : '30d';
|
||||
|
||||
const activity = getEcosystemActivity(timeRange);
|
||||
res.json(activity);
|
||||
} catch (error) {
|
||||
console.error('[API] Error getting ecosystem activity:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// WALLET INTEGRATION
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* GET /wallets/:wallet_name/recommended-mints
|
||||
* Get recommended mints for a specific wallet
|
||||
*/
|
||||
router.get('/wallets/:wallet_name/recommended-mints', (req, res) => {
|
||||
try {
|
||||
const { wallet_name } = req.params;
|
||||
const { limit = 10 } = req.query;
|
||||
|
||||
if (!wallet_name) {
|
||||
return res.status(400).json({ error: 'Wallet name required' });
|
||||
}
|
||||
|
||||
const recommendations = getWalletRecommendedMints(
|
||||
wallet_name,
|
||||
Math.min(parseInt(limit) || 10, 50)
|
||||
);
|
||||
|
||||
res.json(recommendations);
|
||||
} catch (error) {
|
||||
console.error('[API] Error getting wallet recommendations:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -94,6 +94,33 @@ export function processReviewEvent(event) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse rating from content text (e.g., "[5/5]", "(4/5)", etc.)
|
||||
* Returns rating 1-5 or null if not found
|
||||
*/
|
||||
function parseRatingFromContent(content) {
|
||||
if (!content) return null;
|
||||
|
||||
// Match patterns like [5/5], (4/5), [3/5], etc.
|
||||
const match = content.match(/[\[\(](\d)\/(\d)[\]\)]/);
|
||||
if (match) {
|
||||
const numerator = parseInt(match[1], 10);
|
||||
const denominator = parseInt(match[2], 10);
|
||||
if (!isNaN(numerator) && !isNaN(denominator) && denominator > 0) {
|
||||
// Convert to 1-5 scale based on numerator/denominator ratio
|
||||
// e.g., 5/5 = 5, 4/5 = 4, 3/5 = 3, etc.
|
||||
if (denominator === 5 && numerator >= 1 && numerator <= 5) {
|
||||
return numerator;
|
||||
}
|
||||
// Handle other formats like 1/1 = 5, 0/1 = 1
|
||||
if (denominator === 1) {
|
||||
return numerator > 0 ? 5 : 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse review data from Nostr event (NIP-87)
|
||||
*
|
||||
@@ -154,6 +181,11 @@ function parseReviewEvent(event) {
|
||||
// Content (review text)
|
||||
const content = event.content ? event.content.trim() : null;
|
||||
|
||||
// Parse rating from content if not found in tags
|
||||
if (rating === null && content) {
|
||||
rating = parseRatingFromContent(content);
|
||||
}
|
||||
|
||||
if (!mintUrl && !content) {
|
||||
return null; // Not a useful review
|
||||
}
|
||||
@@ -205,11 +237,20 @@ export function getReviews(mintId, options = {}) {
|
||||
|
||||
const reviews = query(sql, params);
|
||||
|
||||
// Convert Unix timestamp to ISO
|
||||
return reviews.map(r => ({
|
||||
...r,
|
||||
created_at: unixToISO(r.created_at)
|
||||
}));
|
||||
// Convert Unix timestamp to ISO and parse rating from content if needed
|
||||
return reviews.map(r => {
|
||||
// Parse rating from content if rating column is null
|
||||
let rating = r.rating;
|
||||
if (rating === null && r.content) {
|
||||
rating = parseRatingFromContent(r.content);
|
||||
}
|
||||
|
||||
return {
|
||||
...r,
|
||||
rating,
|
||||
created_at: unixToISO(r.created_at)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -232,11 +273,11 @@ export function getReviewStats(mintId) {
|
||||
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
|
||||
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
|
||||
@@ -311,10 +352,19 @@ export function getRecentReviews(limit = 20) {
|
||||
LIMIT ?
|
||||
`, [limit]);
|
||||
|
||||
return reviews.map(r => ({
|
||||
...r,
|
||||
created_at: unixToISO(r.created_at)
|
||||
}));
|
||||
return reviews.map(r => {
|
||||
// Parse rating from content if rating column is null
|
||||
let rating = r.rating;
|
||||
if (rating === null && r.content) {
|
||||
rating = parseRatingFromContent(r.content);
|
||||
}
|
||||
|
||||
return {
|
||||
...r,
|
||||
rating,
|
||||
created_at: unixToISO(r.created_at)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -359,10 +409,19 @@ export function getAllReviews(options = {}) {
|
||||
|
||||
const reviews = query(sql, params);
|
||||
|
||||
return reviews.map(r => ({
|
||||
...r,
|
||||
created_at: unixToISO(r.created_at)
|
||||
}));
|
||||
return reviews.map(r => {
|
||||
// Parse rating from content if rating column is null
|
||||
let rating = r.rating;
|
||||
if (rating === null && r.content) {
|
||||
rating = parseRatingFromContent(r.content);
|
||||
}
|
||||
|
||||
return {
|
||||
...r,
|
||||
rating,
|
||||
created_at: unixToISO(r.created_at)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -429,11 +488,11 @@ export function getReviewSummary(mintId) {
|
||||
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
|
||||
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
|
||||
@@ -469,6 +528,7 @@ export function getRecentEcosystemReviews(limit = 20, since = null) {
|
||||
m.canonical_url as mint_url,
|
||||
r.pubkey,
|
||||
r.rating,
|
||||
r.content,
|
||||
SUBSTR(r.content, 1, 200) as excerpt,
|
||||
r.created_at
|
||||
FROM reviews r
|
||||
@@ -487,9 +547,18 @@ export function getRecentEcosystemReviews(limit = 20, since = null) {
|
||||
|
||||
const reviews = query(sql, params);
|
||||
|
||||
return reviews.map(r => ({
|
||||
...r,
|
||||
created_at: unixToISO(r.created_at),
|
||||
excerpt: r.excerpt ? (r.excerpt.length >= 200 ? r.excerpt + '...' : r.excerpt) : null
|
||||
}));
|
||||
return reviews.map(r => {
|
||||
// Parse rating from content if rating column is null
|
||||
let rating = r.rating;
|
||||
if (rating === null && r.content) {
|
||||
rating = parseRatingFromContent(r.content);
|
||||
}
|
||||
|
||||
return {
|
||||
...r,
|
||||
rating,
|
||||
created_at: unixToISO(r.created_at),
|
||||
excerpt: r.excerpt ? (r.excerpt.length >= 200 ? r.excerpt + '...' : r.excerpt) : null
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -29,18 +29,23 @@ export function calculateTrustScore(mintId) {
|
||||
|
||||
// Calculate uptime component (max 40 points)
|
||||
const uptimeScore = calculateUptimeScore(mintId, breakdown.uptime);
|
||||
breakdown.uptime.score = Math.round(uptimeScore * 10) / 10;
|
||||
|
||||
// Calculate speed component (max 25 points)
|
||||
const speedScore = calculateSpeedScore(mintId, breakdown.speed);
|
||||
breakdown.speed.score = Math.round(speedScore * 10) / 10;
|
||||
|
||||
// Calculate reviews component (max 20 points)
|
||||
const reviewsScore = calculateReviewsScore(mintId, breakdown.reviews);
|
||||
breakdown.reviews.score = Math.round(reviewsScore * 10) / 10;
|
||||
|
||||
// Calculate identity component (max 10 points)
|
||||
const identityScore = calculateIdentityScore(mintId, breakdown.identity);
|
||||
breakdown.identity.score = Math.round(identityScore * 10) / 10;
|
||||
|
||||
// Calculate penalties (up to -15 points)
|
||||
const penalties = calculatePenalties(mintId, breakdown.penalties);
|
||||
breakdown.penalties.score = Math.round(penalties * 10) / 10;
|
||||
|
||||
// Total score (clamped 0-100)
|
||||
const totalScore = Math.max(0, Math.min(100,
|
||||
@@ -149,11 +154,11 @@ function calculateReviewsScore(mintId, component) {
|
||||
COUNT(*) as count,
|
||||
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
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user