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': {
|
'/mints/trending': {
|
||||||
get: {
|
get: {
|
||||||
tags: ['Mints'],
|
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': {
|
'/mints': {
|
||||||
get: {
|
get: {
|
||||||
tags: ['Mints'],
|
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': {
|
'/mints/{mint_id}/features': {
|
||||||
get: {
|
get: {
|
||||||
tags: ['Mint Details'],
|
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'] },
|
visibility: { type: 'string', enum: ['public', 'hidden'] },
|
||||||
status: { type: 'string' }
|
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,
|
getPopularMints,
|
||||||
getMintStats,
|
getMintStats,
|
||||||
getMintAvailability,
|
getMintAvailability,
|
||||||
getMintCard
|
getMintCard,
|
||||||
|
getMintProfile,
|
||||||
|
getMintCards,
|
||||||
|
getMintMetrics,
|
||||||
|
compareMints,
|
||||||
|
getMintRankings,
|
||||||
|
getSimilarMints,
|
||||||
|
getRecommendedMints,
|
||||||
|
getMintCompatibility,
|
||||||
|
getWalletRecommendedMints,
|
||||||
|
getMintRisk
|
||||||
} from '../services/AnalyticsService.js';
|
} from '../services/AnalyticsService.js';
|
||||||
|
|
||||||
const router = Router();
|
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)
|
// 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;
|
export default router;
|
||||||
@@ -16,7 +16,9 @@ import {
|
|||||||
getNutsAnalytics,
|
getNutsAnalytics,
|
||||||
getStatusDistribution,
|
getStatusDistribution,
|
||||||
getNetworkBreakdown,
|
getNetworkBreakdown,
|
||||||
getMetadataQuality
|
getMetadataQuality,
|
||||||
|
getEcosystemActivity,
|
||||||
|
getWalletRecommendedMints
|
||||||
} from '../services/AnalyticsService.js';
|
} from '../services/AnalyticsService.js';
|
||||||
import { getRecentEcosystemReviews } from '../services/ReviewService.js';
|
import { getRecentEcosystemReviews } from '../services/ReviewService.js';
|
||||||
import { nowISO } from '../utils/time.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;
|
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)
|
* Parse review data from Nostr event (NIP-87)
|
||||||
*
|
*
|
||||||
@@ -154,6 +181,11 @@ function parseReviewEvent(event) {
|
|||||||
// Content (review text)
|
// Content (review text)
|
||||||
const content = event.content ? event.content.trim() : null;
|
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) {
|
if (!mintUrl && !content) {
|
||||||
return null; // Not a useful review
|
return null; // Not a useful review
|
||||||
}
|
}
|
||||||
@@ -205,11 +237,20 @@ export function getReviews(mintId, options = {}) {
|
|||||||
|
|
||||||
const reviews = query(sql, params);
|
const reviews = query(sql, params);
|
||||||
|
|
||||||
// Convert Unix timestamp to ISO
|
// Convert Unix timestamp to ISO and parse rating from content if needed
|
||||||
return reviews.map(r => ({
|
return reviews.map(r => {
|
||||||
...r,
|
// Parse rating from content if rating column is null
|
||||||
created_at: unixToISO(r.created_at)
|
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,
|
pubkey,
|
||||||
CASE
|
CASE
|
||||||
WHEN rating IS NOT NULL THEN rating
|
WHEN rating IS NOT NULL THEN rating
|
||||||
WHEN content LIKE '[5/5]%' OR content LIKE '%(5/5)%' THEN 5
|
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 '%[4/5]%' OR content LIKE '%(4/5)%' THEN 4
|
||||||
WHEN content LIKE '[3/5]%' OR content LIKE '%(3/5)%' THEN 3
|
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 '%[2/5]%' OR content LIKE '%(2/5)%' THEN 2
|
||||||
WHEN content LIKE '[1/5]%' OR content LIKE '%(1/5)%' THEN 1
|
WHEN content LIKE '%[1/5]%' OR content LIKE '%(1/5)%' THEN 1
|
||||||
ELSE NULL
|
ELSE NULL
|
||||||
END as parsed_rating
|
END as parsed_rating
|
||||||
FROM reviews
|
FROM reviews
|
||||||
@@ -311,10 +352,19 @@ export function getRecentReviews(limit = 20) {
|
|||||||
LIMIT ?
|
LIMIT ?
|
||||||
`, [limit]);
|
`, [limit]);
|
||||||
|
|
||||||
return reviews.map(r => ({
|
return reviews.map(r => {
|
||||||
...r,
|
// Parse rating from content if rating column is null
|
||||||
created_at: unixToISO(r.created_at)
|
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);
|
const reviews = query(sql, params);
|
||||||
|
|
||||||
return reviews.map(r => ({
|
return reviews.map(r => {
|
||||||
...r,
|
// Parse rating from content if rating column is null
|
||||||
created_at: unixToISO(r.created_at)
|
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 (
|
FROM (
|
||||||
SELECT CASE
|
SELECT CASE
|
||||||
WHEN rating IS NOT NULL THEN rating
|
WHEN rating IS NOT NULL THEN rating
|
||||||
WHEN content LIKE '[5/5]%' OR content LIKE '%(5/5)%' THEN 5
|
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 '%[4/5]%' OR content LIKE '%(4/5)%' THEN 4
|
||||||
WHEN content LIKE '[3/5]%' OR content LIKE '%(3/5)%' THEN 3
|
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 '%[2/5]%' OR content LIKE '%(2/5)%' THEN 2
|
||||||
WHEN content LIKE '[1/5]%' OR content LIKE '%(1/5)%' THEN 1
|
WHEN content LIKE '%[1/5]%' OR content LIKE '%(1/5)%' THEN 1
|
||||||
ELSE NULL
|
ELSE NULL
|
||||||
END as parsed_rating
|
END as parsed_rating
|
||||||
FROM reviews
|
FROM reviews
|
||||||
@@ -469,6 +528,7 @@ export function getRecentEcosystemReviews(limit = 20, since = null) {
|
|||||||
m.canonical_url as mint_url,
|
m.canonical_url as mint_url,
|
||||||
r.pubkey,
|
r.pubkey,
|
||||||
r.rating,
|
r.rating,
|
||||||
|
r.content,
|
||||||
SUBSTR(r.content, 1, 200) as excerpt,
|
SUBSTR(r.content, 1, 200) as excerpt,
|
||||||
r.created_at
|
r.created_at
|
||||||
FROM reviews r
|
FROM reviews r
|
||||||
@@ -487,9 +547,18 @@ export function getRecentEcosystemReviews(limit = 20, since = null) {
|
|||||||
|
|
||||||
const reviews = query(sql, params);
|
const reviews = query(sql, params);
|
||||||
|
|
||||||
return reviews.map(r => ({
|
return reviews.map(r => {
|
||||||
...r,
|
// Parse rating from content if rating column is null
|
||||||
created_at: unixToISO(r.created_at),
|
let rating = r.rating;
|
||||||
excerpt: r.excerpt ? (r.excerpt.length >= 200 ? r.excerpt + '...' : r.excerpt) : null
|
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)
|
// Calculate uptime component (max 40 points)
|
||||||
const uptimeScore = calculateUptimeScore(mintId, breakdown.uptime);
|
const uptimeScore = calculateUptimeScore(mintId, breakdown.uptime);
|
||||||
|
breakdown.uptime.score = Math.round(uptimeScore * 10) / 10;
|
||||||
|
|
||||||
// Calculate speed component (max 25 points)
|
// Calculate speed component (max 25 points)
|
||||||
const speedScore = calculateSpeedScore(mintId, breakdown.speed);
|
const speedScore = calculateSpeedScore(mintId, breakdown.speed);
|
||||||
|
breakdown.speed.score = Math.round(speedScore * 10) / 10;
|
||||||
|
|
||||||
// Calculate reviews component (max 20 points)
|
// Calculate reviews component (max 20 points)
|
||||||
const reviewsScore = calculateReviewsScore(mintId, breakdown.reviews);
|
const reviewsScore = calculateReviewsScore(mintId, breakdown.reviews);
|
||||||
|
breakdown.reviews.score = Math.round(reviewsScore * 10) / 10;
|
||||||
|
|
||||||
// Calculate identity component (max 10 points)
|
// Calculate identity component (max 10 points)
|
||||||
const identityScore = calculateIdentityScore(mintId, breakdown.identity);
|
const identityScore = calculateIdentityScore(mintId, breakdown.identity);
|
||||||
|
breakdown.identity.score = Math.round(identityScore * 10) / 10;
|
||||||
|
|
||||||
// Calculate penalties (up to -15 points)
|
// Calculate penalties (up to -15 points)
|
||||||
const penalties = calculatePenalties(mintId, breakdown.penalties);
|
const penalties = calculatePenalties(mintId, breakdown.penalties);
|
||||||
|
breakdown.penalties.score = Math.round(penalties * 10) / 10;
|
||||||
|
|
||||||
// Total score (clamped 0-100)
|
// Total score (clamped 0-100)
|
||||||
const totalScore = Math.max(0, Math.min(100,
|
const totalScore = Math.max(0, Math.min(100,
|
||||||
@@ -149,11 +154,11 @@ function calculateReviewsScore(mintId, component) {
|
|||||||
COUNT(*) as count,
|
COUNT(*) as count,
|
||||||
AVG(CASE
|
AVG(CASE
|
||||||
WHEN rating IS NOT NULL THEN rating
|
WHEN rating IS NOT NULL THEN rating
|
||||||
WHEN content LIKE '[5/5]%' OR content LIKE '%(5/5)%' THEN 5
|
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 '%[4/5]%' OR content LIKE '%(4/5)%' THEN 4
|
||||||
WHEN content LIKE '[3/5]%' OR content LIKE '%(3/5)%' THEN 3
|
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 '%[2/5]%' OR content LIKE '%(2/5)%' THEN 2
|
||||||
WHEN content LIKE '[1/5]%' OR content LIKE '%(1/5)%' THEN 1
|
WHEN content LIKE '%[1/5]%' OR content LIKE '%(1/5)%' THEN 1
|
||||||
ELSE NULL
|
ELSE NULL
|
||||||
END) as avg_rating,
|
END) as avg_rating,
|
||||||
COUNT(DISTINCT pubkey) as unique_reviewers
|
COUNT(DISTINCT pubkey) as unique_reviewers
|
||||||
|
|||||||
Reference in New Issue
Block a user