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:
Michaël
2025-12-26 19:15:25 -03:00
parent 48965a6f18
commit 5383af4695
6 changed files with 2142 additions and 35 deletions

View File

@@ -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' }
}
} }
} }
} }

View File

@@ -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;

View File

@@ -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

View File

@@ -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
};
});
} }

View File

@@ -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