From 5383af46952ca28d7aacf41d21abedca6140eb24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl?= Date: Fri, 26 Dec 2025 19:15:25 -0300 Subject: [PATCH] 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 --- src/docs/openapi.js | 694 +++++++++++++++++++ src/routes/mints.js | 198 +++++- src/routes/system.js | 51 +- src/services/AnalyticsService.js | 1094 ++++++++++++++++++++++++++++++ src/services/ReviewService.js | 125 +++- src/services/TrustService.js | 15 +- 6 files changed, 2142 insertions(+), 35 deletions(-) diff --git a/src/docs/openapi.js b/src/docs/openapi.js index 8ba724f..a4a8dc7 100644 --- a/src/docs/openapi.js +++ b/src/docs/openapi.js @@ -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' } + } } } } diff --git a/src/routes/mints.js b/src/routes/mints.js index d9572ab..a7fc267 100644 --- a/src/routes/mints.js +++ b/src/routes/mints.js @@ -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; \ No newline at end of file diff --git a/src/routes/system.js b/src/routes/system.js index b9f0f5c..52c2778 100644 --- a/src/routes/system.js +++ b/src/routes/system.js @@ -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; \ No newline at end of file diff --git a/src/services/AnalyticsService.js b/src/services/AnalyticsService.js index 4e720d8..2fbd427 100644 --- a/src/services/AnalyticsService.js +++ b/src/services/AnalyticsService.js @@ -813,4 +813,1098 @@ export function getMintCard(mintId) { has_tor: !!hasTor, review_count: (reviews && reviews.count) || 0 }; +} + +// ========================================== +// FULL PROFILE ENDPOINT +// ========================================== + +/** + * Get full mint profile (aggregated endpoint for mint page) + */ +export async function getMintProfile(mintId) { + // Basic mint info + const mint = queryOne(` + SELECT + m.mint_id, + m.canonical_url, + m.name, + m.icon_url, + m.status, + m.offline_since, + m.last_success_at, + m.last_failure_at, + m.consecutive_failures, + m.created_at, + m.updated_at, + ts.score_total as trust_score, + ts.score_level as trust_level + FROM mints m + LEFT JOIN current_trust_scores ts ON m.mint_id = ts.mint_id + WHERE m.mint_id = ? + `, [mintId]); + + if (!mint) return null; + + // Get all URLs + const urls = query(` + SELECT url, type, active FROM mint_urls + WHERE mint_id = ? ORDER BY active DESC, type + `, [mintId]); + + // Uptime summary + const uptime24h = queryOne(` + SELECT uptime_pct, avg_rtt_ms, p95_rtt_ms, total_checks FROM uptime_rollups + WHERE mint_id = ? AND window = '24h' + ORDER BY computed_at DESC LIMIT 1 + `, [mintId]); + + const uptime7d = queryOne(` + SELECT uptime_pct, avg_rtt_ms, p95_rtt_ms, total_checks FROM uptime_rollups + WHERE mint_id = ? AND window = '7d' + ORDER BY computed_at DESC LIMIT 1 + `, [mintId]); + + const uptime30d = queryOne(` + SELECT uptime_pct, avg_rtt_ms, p95_rtt_ms, total_checks FROM uptime_rollups + WHERE mint_id = ? AND window = '30d' + ORDER BY computed_at DESC LIMIT 1 + `, [mintId]); + + // Trust score breakdown + const trustData = queryOne(` + SELECT breakdown, computed_at FROM trust_scores + WHERE mint_id = ? + ORDER BY computed_at DESC LIMIT 1 + `, [mintId]); + + let trustBreakdown = null; + if (trustData && trustData.breakdown) { + try { + trustBreakdown = JSON.parse(trustData.breakdown); + } catch (e) { } + } + + // Metadata + const metadata = queryOne(` + SELECT name, pubkey, version, description, description_long, + contact, motd, icon_url, urls, tos_url, nuts, + server_time, last_fetched_at + FROM metadata_snapshots + WHERE mint_id = ? + `, [mintId]); + + // Parse NUTs + let supportedNuts = []; + if (metadata && metadata.nuts) { + try { + const nuts = typeof metadata.nuts === 'string' ? JSON.parse(metadata.nuts) : metadata.nuts; + supportedNuts = Object.keys(nuts).map(n => parseInt(n, 10)).filter(n => !isNaN(n)).sort((a, b) => a - b); + } catch (e) { } + } + + // Review summary + const reviewStats = queryOne(` + SELECT + COUNT(*) as total_reviews, + COUNT(DISTINCT pubkey) as unique_reviewers, + AVG(CASE + WHEN rating IS NOT NULL THEN rating + WHEN content LIKE '%[5/5]%' OR content LIKE '%(5/5)%' THEN 5 + WHEN content LIKE '%[4/5]%' OR content LIKE '%(4/5)%' THEN 4 + WHEN content LIKE '%[3/5]%' OR content LIKE '%(3/5)%' THEN 3 + WHEN content LIKE '%[2/5]%' OR content LIKE '%(2/5)%' THEN 2 + WHEN content LIKE '%[1/5]%' OR content LIKE '%(1/5)%' THEN 1 + ELSE NULL + END) as avg_rating + FROM reviews + WHERE mint_id = ? + `, [mintId]); + + // Popularity (views) + let views = { views_24h: 0, views_7d: 0, views_30d: 0, source: 'local' }; + if (isPlausibleConfigured()) { + try { + const { getMintPageviewStats } = await import('./PlausibleService.js'); + const plausibleStats = await getMintPageviewStats(mintId, '30d'); + if (plausibleStats) { + views = { + views_24h: 0, + views_7d: plausibleStats.pageviews?.value || 0, + views_30d: plausibleStats.pageviews?.value || 0, + source: 'plausible' + }; + } + } catch (e) { } + } + + if (views.source === 'local') { + const v24h = queryOne(`SELECT COUNT(*) as c FROM pageviews WHERE mint_id = ? AND viewed_at >= ?`, [mintId, hoursAgo(24)]); + const v7d = queryOne(`SELECT COUNT(*) as c FROM pageviews WHERE mint_id = ? AND viewed_at >= ?`, [mintId, daysAgo(7)]); + const v30d = queryOne(`SELECT COUNT(*) as c FROM pageviews WHERE mint_id = ? AND viewed_at >= ?`, [mintId, daysAgo(30)]); + views = { + views_24h: v24h?.c || 0, + views_7d: v7d?.c || 0, + views_30d: v30d?.c || 0, + source: 'local' + }; + } + + // Incidents count + const incidents7d = queryOne(`SELECT COUNT(*) as c FROM incidents WHERE mint_id = ? AND started_at >= ?`, [mintId, daysAgo(7)]); + const incidents30d = queryOne(`SELECT COUNT(*) as c FROM incidents WHERE mint_id = ? AND started_at >= ?`, [mintId, daysAgo(30)]); + + return { + // Basic info + mint_id: mint.mint_id, + canonical_url: mint.canonical_url, + urls: urls.map(u => ({ url: u.url, type: u.type, active: !!u.active })), + name: mint.name || (metadata && metadata.name), + icon_url: mint.icon_url || (metadata && metadata.icon_url), + status: mint.status, + offline_since: mint.offline_since, + created_at: mint.created_at, + + // Uptime + uptime: { + '24h': uptime24h ? { uptime_pct: uptime24h.uptime_pct, avg_rtt_ms: Math.round(uptime24h.avg_rtt_ms || 0), checks: uptime24h.total_checks } : null, + '7d': uptime7d ? { uptime_pct: uptime7d.uptime_pct, avg_rtt_ms: Math.round(uptime7d.avg_rtt_ms || 0), checks: uptime7d.total_checks } : null, + '30d': uptime30d ? { uptime_pct: uptime30d.uptime_pct, avg_rtt_ms: Math.round(uptime30d.avg_rtt_ms || 0), checks: uptime30d.total_checks } : null + }, + + // Trust + trust: { + score: mint.trust_score, + level: mint.trust_level || 'unknown', + breakdown: trustBreakdown, + computed_at: trustData?.computed_at + }, + + // Metadata + metadata: metadata ? { + name: metadata.name, + pubkey: metadata.pubkey, + version: metadata.version, + description: metadata.description, + description_long: metadata.description_long, + contact: metadata.contact ? (typeof metadata.contact === 'string' ? JSON.parse(metadata.contact) : metadata.contact) : null, + motd: metadata.motd, + tos_url: metadata.tos_url, + last_fetched_at: metadata.last_fetched_at + } : null, + + // NUTs + supported_nuts: supportedNuts, + + // Reviews + reviews: { + total: reviewStats?.total_reviews || 0, + unique_reviewers: reviewStats?.unique_reviewers || 0, + avg_rating: reviewStats?.avg_rating ? Math.round(reviewStats.avg_rating * 10) / 10 : null + }, + + // Popularity + popularity: { + ...views, + incidents_7d: incidents7d?.c || 0, + incidents_30d: incidents30d?.c || 0 + }, + + computed_at: nowISO() + }; +} + +// ========================================== +// CARDS ENDPOINT (BATCH) +// ========================================== + +/** + * Get mint cards for directory/list views + */ +export function getMintCards(options = {}) { + const { status, limit = 100, offset = 0, sortBy = 'trust_score', sortOrder = 'desc' } = options; + + // Build query - use subquery for uptime to get latest 30d rollup + let sql = ` + SELECT + m.mint_id, + m.name, + m.canonical_url, + m.icon_url, + m.status, + ts.score_total as trust_score, + ts.score_level as trust_level, + (SELECT uptime_pct FROM uptime_rollups WHERE mint_id = m.mint_id AND window = '30d' ORDER BY computed_at DESC LIMIT 1) as uptime_30d, + (SELECT COUNT(*) FROM reviews r WHERE r.mint_id = m.mint_id) as review_count, + (SELECT COUNT(*) FROM mint_urls mu WHERE mu.mint_id = m.mint_id AND mu.active = 1 AND (mu.type = 'tor' OR mu.url LIKE '%.onion%')) as tor_count + FROM mints m + LEFT JOIN current_trust_scores ts ON m.mint_id = ts.mint_id + WHERE (m.visibility IS NULL OR m.visibility = 'public') + AND m.status != 'merged' + `; + const params = []; + + if (status) { + sql += ' AND m.status = ?'; + params.push(status); + } + + // Sort + const validSorts = ['trust_score', 'uptime_30d', 'name', 'created_at']; + const sort = validSorts.includes(sortBy) ? sortBy : 'trust_score'; + const order = sortOrder === 'asc' ? 'ASC' : 'DESC'; + sql += ` ORDER BY ${sort} ${order} NULLS LAST`; + + sql += ' LIMIT ? OFFSET ?'; + params.push(limit, offset); + + const mints = query(sql, params); + + // Get NUT counts + const mintIds = mints.map(m => m.mint_id); + const nutCounts = new Map(); + + if (mintIds.length > 0) { + const metadataRows = query(` + SELECT mint_id, nuts FROM metadata_snapshots + WHERE mint_id IN (${mintIds.map(() => '?').join(',')}) + `, mintIds); + + for (const row of metadataRows) { + try { + const nuts = typeof row.nuts === 'string' ? JSON.parse(row.nuts) : row.nuts; + nutCounts.set(row.mint_id, Object.keys(nuts || {}).length); + } catch (e) { + nutCounts.set(row.mint_id, 0); + } + } + } + + return mints.map(m => ({ + mint_id: m.mint_id, + name: m.name, + url: m.canonical_url, + icon_url: m.icon_url, + status: m.status, + trust_score: m.trust_score, + trust_level: m.trust_level, + uptime_pct: m.uptime_30d, + review_count: m.review_count, + nut_count: nutCounts.get(m.mint_id) || 0, + has_tor: m.tor_count > 0 + })); +} + +// ========================================== +// METRICS TIMESERIES +// ========================================== + +/** + * Get mint metrics timeseries for charts + */ +export function getMintMetrics(mintId, range = '7d') { + const days = range === '30d' ? 30 : range === '90d' ? 90 : 7; + const since = daysAgo(days); + + // Daily uptime/latency + const dailyProbes = query(` + SELECT + date(probed_at) as date, + COUNT(*) as total_checks, + SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as ok_checks, + AVG(CASE WHEN success = 1 THEN rtt_ms ELSE NULL END) as avg_rtt, + MAX(CASE WHEN success = 1 THEN rtt_ms ELSE NULL END) as p95_rtt + FROM probes + WHERE mint_id = ? AND probed_at >= ? + GROUP BY date(probed_at) + ORDER BY date ASC + `, [mintId, since]); + + // Daily reviews + const dailyReviews = query(` + SELECT + date(datetime(created_at, 'unixepoch')) as date, + COUNT(*) as count + FROM reviews + WHERE mint_id = ? AND created_at >= strftime('%s', ?) + GROUP BY date(datetime(created_at, 'unixepoch')) + ORDER BY date ASC + `, [mintId, since]); + + // Daily views + const dailyViews = query(` + SELECT + date(viewed_at) as date, + COUNT(*) as count + FROM pageviews + WHERE mint_id = ? AND viewed_at >= ? + GROUP BY date(viewed_at) + ORDER BY date ASC + `, [mintId, since]); + + // Metadata changes + const metadataChanges = query(` + SELECT + date(fetched_at) as date, + COUNT(*) as count + FROM metadata_history + WHERE mint_id = ? AND fetched_at >= ? AND change_type = 'update' + GROUP BY date(fetched_at) + ORDER BY date ASC + `, [mintId, since]); + + // Build date map + const dates = []; + const now = new Date(); + for (let i = days - 1; i >= 0; i--) { + const d = new Date(now); + d.setDate(d.getDate() - i); + dates.push(d.toISOString().split('T')[0]); + } + + // Map data + const probeMap = new Map(dailyProbes.map(p => [p.date, p])); + const reviewMap = new Map(dailyReviews.map(r => [r.date, r.count])); + const viewMap = new Map(dailyViews.map(v => [v.date, v.count])); + const metaMap = new Map(metadataChanges.map(m => [m.date, m.count])); + + return { + range, + data: dates.map(date => { + const probe = probeMap.get(date); + return { + date, + uptime_pct: probe && probe.total_checks > 0 ? Math.round((probe.ok_checks / probe.total_checks) * 10000) / 100 : null, + avg_rtt_ms: probe ? Math.round(probe.avg_rtt || 0) : null, + failures: probe ? probe.total_checks - probe.ok_checks : 0, + reviews: reviewMap.get(date) || 0, + views: viewMap.get(date) || 0, + metadata_changes: metaMap.get(date) || 0 + }; + }), + computed_at: nowISO() + }; +} + +// ========================================== +// ECOSYSTEM ACTIVITY TIMELINE +// ========================================== + +/** + * Get ecosystem-wide activity timeline + */ +export function getEcosystemActivity(range = '30d') { + const days = range === '7d' ? 7 : range === '90d' ? 90 : 30; + const since = daysAgo(days); + + // New mints per day + const newMints = query(` + SELECT date(created_at) as date, COUNT(*) as count + FROM mints + WHERE created_at >= ? AND (visibility IS NULL OR visibility = 'public') + GROUP BY date(created_at) + ORDER BY date ASC + `, [since]); + + // Status changes (incidents started/resolved) + const incidentsStarted = query(` + SELECT date(started_at) as date, COUNT(*) as count + FROM incidents + WHERE started_at >= ? + GROUP BY date(started_at) + ORDER BY date ASC + `, [since]); + + const incidentsResolved = query(` + SELECT date(resolved_at) as date, COUNT(*) as count + FROM incidents + WHERE resolved_at >= ? + GROUP BY date(resolved_at) + ORDER BY date ASC + `, [since]); + + // Reviews per day + const reviews = query(` + SELECT date(datetime(created_at, 'unixepoch')) as date, COUNT(*) as count + FROM reviews + WHERE created_at >= strftime('%s', ?) + GROUP BY date(datetime(created_at, 'unixepoch')) + ORDER BY date ASC + `, [since]); + + // Metadata updates per day + const metadataUpdates = query(` + SELECT date(fetched_at) as date, COUNT(*) as count + FROM metadata_history + WHERE fetched_at >= ? AND change_type = 'update' + GROUP BY date(fetched_at) + ORDER BY date ASC + `, [since]); + + // Build date range + const dates = []; + const now = new Date(); + for (let i = days - 1; i >= 0; i--) { + const d = new Date(now); + d.setDate(d.getDate() - i); + dates.push(d.toISOString().split('T')[0]); + } + + // Map data + const newMintsMap = new Map(newMints.map(n => [n.date, n.count])); + const startedMap = new Map(incidentsStarted.map(i => [i.date, i.count])); + const resolvedMap = new Map(incidentsResolved.map(i => [i.date, i.count])); + const reviewsMap = new Map(reviews.map(r => [r.date, r.count])); + const metaMap = new Map(metadataUpdates.map(m => [m.date, m.count])); + + return { + range, + data: dates.map(date => ({ + date, + new_mints: newMintsMap.get(date) || 0, + mints_offline: startedMap.get(date) || 0, + mints_online: resolvedMap.get(date) || 0, + reviews: reviewsMap.get(date) || 0, + metadata_updates: metaMap.get(date) || 0 + })), + computed_at: nowISO() + }; +} + +// ========================================== +// COMPARE MINTS +// ========================================== + +/** + * Compare multiple mints side by side + */ +export function compareMints(mintIds) { + if (!mintIds || mintIds.length === 0) return { mints: [] }; + if (mintIds.length > 10) mintIds = mintIds.slice(0, 10); + + const placeholders = mintIds.map(() => '?').join(','); + + const mints = query(` + SELECT + m.mint_id, + m.canonical_url, + m.name, + m.icon_url, + m.status, + ts.score_total as trust_score, + ts.score_level as trust_level, + ts.breakdown as trust_breakdown + FROM mints m + LEFT JOIN current_trust_scores ts ON m.mint_id = ts.mint_id + WHERE m.mint_id IN (${placeholders}) + `, mintIds); + + // Enrich each mint + return { + mints: mints.map(m => { + // Uptime + const uptime30d = queryOne(` + SELECT uptime_pct, avg_rtt_ms FROM uptime_rollups + WHERE mint_id = ? AND window = '30d' + ORDER BY computed_at DESC LIMIT 1 + `, [m.mint_id]); + + // NUTs + const metadata = queryOne(`SELECT nuts, pubkey FROM metadata_snapshots WHERE mint_id = ?`, [m.mint_id]); + let nuts = []; + if (metadata && metadata.nuts) { + try { + const n = typeof metadata.nuts === 'string' ? JSON.parse(metadata.nuts) : metadata.nuts; + nuts = Object.keys(n).map(x => parseInt(x, 10)).filter(x => !isNaN(x)).sort((a, b) => a - b); + } catch (e) { } + } + + // Reviews + const reviews = queryOne(` + SELECT COUNT(*) as c, AVG(CASE + WHEN rating IS NOT NULL THEN rating + WHEN content LIKE '%[5/5]%' THEN 5 + WHEN content LIKE '%[4/5]%' THEN 4 + WHEN content LIKE '%[3/5]%' THEN 3 + WHEN content LIKE '%[2/5]%' THEN 2 + WHEN content LIKE '%[1/5]%' THEN 1 + ELSE NULL END) as avg + FROM reviews WHERE mint_id = ? + `, [m.mint_id]); + + let breakdown = null; + if (m.trust_breakdown) { + try { breakdown = JSON.parse(m.trust_breakdown); } catch (e) { } + } + + return { + mint_id: m.mint_id, + name: m.name, + url: m.canonical_url, + icon_url: m.icon_url, + status: m.status, + trust: { + score: m.trust_score, + level: m.trust_level, + breakdown + }, + uptime: { + pct_30d: uptime30d?.uptime_pct || null, + avg_rtt_ms: uptime30d?.avg_rtt_ms ? Math.round(uptime30d.avg_rtt_ms) : null + }, + nuts: nuts, + reviews: { + count: reviews?.c || 0, + avg_rating: reviews?.avg ? Math.round(reviews.avg * 10) / 10 : null + }, + identity: { + has_pubkey: !!(metadata && metadata.pubkey) + } + }; + }), + computed_at: nowISO() + }; +} + +// ========================================== +// RANKINGS +// ========================================== + +/** + * Get ranked mint leaderboard + */ +export function getMintRankings(by = 'trust', period = '30d', limit = 50) { + const since = period === '7d' ? daysAgo(7) : period === '90d' ? daysAgo(90) : daysAgo(30); + + let sql; + let params = []; + + switch (by) { + case 'uptime': + sql = ` + SELECT + m.mint_id, m.name, m.canonical_url, m.icon_url, m.status, + (SELECT uptime_pct FROM uptime_rollups WHERE mint_id = m.mint_id AND window = '30d' ORDER BY computed_at DESC LIMIT 1) as score, + ts.score_total as trust_score + FROM mints m + LEFT JOIN current_trust_scores ts ON m.mint_id = ts.mint_id + WHERE (m.visibility IS NULL OR m.visibility = 'public') AND m.status != 'merged' + ORDER BY score DESC NULLS LAST + LIMIT ? + `; + params = [limit]; + break; + + case 'latency': + sql = ` + SELECT + m.mint_id, m.name, m.canonical_url, m.icon_url, m.status, + (SELECT avg_rtt_ms FROM uptime_rollups WHERE mint_id = m.mint_id AND window = '30d' ORDER BY computed_at DESC LIMIT 1) as score, + ts.score_total as trust_score + FROM mints m + LEFT JOIN current_trust_scores ts ON m.mint_id = ts.mint_id + WHERE (m.visibility IS NULL OR m.visibility = 'public') AND m.status != 'merged' + AND (SELECT avg_rtt_ms FROM uptime_rollups WHERE mint_id = m.mint_id AND window = '30d' ORDER BY computed_at DESC LIMIT 1) IS NOT NULL + ORDER BY score ASC + LIMIT ? + `; + params = [limit]; + break; + + case 'reviews': + sql = ` + SELECT + m.mint_id, m.name, m.canonical_url, m.icon_url, m.status, + COUNT(r.id) as score, + ts.score_total as trust_score + FROM mints m + LEFT JOIN reviews r ON m.mint_id = r.mint_id + LEFT JOIN current_trust_scores ts ON m.mint_id = ts.mint_id + WHERE (m.visibility IS NULL OR m.visibility = 'public') AND m.status != 'merged' + GROUP BY m.mint_id + ORDER BY score DESC + LIMIT ? + `; + params = [limit]; + break; + + default: // trust + sql = ` + SELECT + m.mint_id, m.name, m.canonical_url, m.icon_url, m.status, + ts.score_total as score, + ts.score_total as trust_score + FROM mints m + LEFT JOIN current_trust_scores ts ON m.mint_id = ts.mint_id + WHERE (m.visibility IS NULL OR m.visibility = 'public') AND m.status != 'merged' + ORDER BY ts.score_total DESC NULLS LAST + LIMIT ? + `; + params = [limit]; + } + + const mints = query(sql, params); + + return { + ranking_by: by, + period, + mints: mints.map((m, idx) => ({ + rank: idx + 1, + mint_id: m.mint_id, + name: m.name, + url: m.canonical_url, + icon_url: m.icon_url, + status: m.status, + score: m.score, + trust_score: m.trust_score + })), + computed_at: nowISO() + }; +} + +// ========================================== +// SIMILAR MINTS +// ========================================== + +/** + * Find mints similar to a given mint + */ +export function getSimilarMints(mintId, limit = 5) { + // Get target mint's characteristics + const target = queryOne(` + SELECT m.mint_id, m.status, ts.score_total as trust_score + FROM mints m + LEFT JOIN current_trust_scores ts ON m.mint_id = ts.mint_id + WHERE m.mint_id = ? + `, [mintId]); + + if (!target) return { mints: [] }; + + // Get target's NUTs + const targetMeta = queryOne(`SELECT nuts FROM metadata_snapshots WHERE mint_id = ?`, [mintId]); + let targetNuts = []; + if (targetMeta && targetMeta.nuts) { + try { + const n = typeof targetMeta.nuts === 'string' ? JSON.parse(targetMeta.nuts) : targetMeta.nuts; + targetNuts = Object.keys(n).map(x => parseInt(x, 10)).filter(x => !isNaN(x)); + } catch (e) { } + } + + // Get all other online mints + const candidates = query(` + SELECT + m.mint_id, m.name, m.canonical_url, m.icon_url, m.status, + ts.score_total as trust_score, + ts.score_level as trust_level, + ms.nuts + FROM mints m + LEFT JOIN current_trust_scores ts ON m.mint_id = ts.mint_id + LEFT JOIN metadata_snapshots ms ON m.mint_id = ms.mint_id + WHERE m.mint_id != ? + AND m.status IN ('online', 'degraded') + AND (m.visibility IS NULL OR m.visibility = 'public') + `, [mintId]); + + // Score similarity + const scored = candidates.map(c => { + let similarity = 0; + + // Trust score similarity (closer = better) + if (target.trust_score && c.trust_score) { + const trustDiff = Math.abs(target.trust_score - c.trust_score); + similarity += Math.max(0, 20 - trustDiff) / 20 * 40; // Max 40 points + } + + // NUT overlap + if (c.nuts && targetNuts.length > 0) { + try { + const candidateNuts = typeof c.nuts === 'string' ? JSON.parse(c.nuts) : c.nuts; + const cNuts = Object.keys(candidateNuts).map(x => parseInt(x, 10)).filter(x => !isNaN(x)); + const overlap = targetNuts.filter(n => cNuts.includes(n)).length; + similarity += (overlap / Math.max(targetNuts.length, cNuts.length)) * 40; // Max 40 points + } catch (e) { } + } + + // Status match + if (c.status === target.status) similarity += 20; // Max 20 points + + return { ...c, similarity }; + }); + + // Sort by similarity and return top + scored.sort((a, b) => b.similarity - a.similarity); + + return { + mints: scored.slice(0, limit).map(m => ({ + mint_id: m.mint_id, + name: m.name, + url: m.canonical_url, + icon_url: m.icon_url, + status: m.status, + trust_score: m.trust_score, + trust_level: m.trust_level, + similarity_score: Math.round(m.similarity) + })), + computed_at: nowISO() + }; +} + +// ========================================== +// RECOMMENDED MINTS +// ========================================== + +/** + * Get recommended mints by use case + */ +export function getRecommendedMints(useCase = 'general', limit = 10) { + let sql; + const params = [limit]; + + switch (useCase) { + case 'mobile': + // Low latency, high uptime + sql = ` + SELECT m.mint_id, m.name, m.canonical_url, m.icon_url, m.status, + ts.score_total as trust_score, ts.score_level as trust_level, + ur.avg_rtt_ms, ur.uptime_pct + FROM mints m + LEFT JOIN current_trust_scores ts ON m.mint_id = ts.mint_id + LEFT JOIN uptime_rollups ur ON m.mint_id = ur.mint_id AND ur.window = '30d' + WHERE m.status = 'online' + AND (m.visibility IS NULL OR m.visibility = 'public') + ORDER BY ur.avg_rtt_ms ASC NULLS LAST, ur.uptime_pct DESC NULLS LAST + LIMIT ? + `; + break; + + case 'high_volume': + // High trust, high uptime + sql = ` + SELECT m.mint_id, m.name, m.canonical_url, m.icon_url, m.status, + ts.score_total as trust_score, ts.score_level as trust_level, + ur.uptime_pct + FROM mints m + LEFT JOIN current_trust_scores ts ON m.mint_id = ts.mint_id + LEFT JOIN uptime_rollups ur ON m.mint_id = ur.mint_id AND ur.window = '30d' + WHERE m.status = 'online' + AND (m.visibility IS NULL OR m.visibility = 'public') + AND ts.score_total >= 70 + AND ur.uptime_pct >= 99 + ORDER BY ts.score_total DESC NULLS LAST + LIMIT ? + `; + break; + + case 'privacy': + // Has Tor, high trust + sql = ` + SELECT DISTINCT m.mint_id, m.name, m.canonical_url, m.icon_url, m.status, + ts.score_total as trust_score, ts.score_level as trust_level + FROM mints m + LEFT JOIN current_trust_scores ts ON m.mint_id = ts.mint_id + JOIN mint_urls mu ON m.mint_id = mu.mint_id + WHERE m.status = 'online' + AND (m.visibility IS NULL OR m.visibility = 'public') + AND (mu.type = 'tor' OR mu.url LIKE '%.onion%') + ORDER BY ts.score_total DESC NULLS LAST + LIMIT ? + `; + break; + + default: // general - best overall + sql = ` + SELECT m.mint_id, m.name, m.canonical_url, m.icon_url, m.status, + ts.score_total as trust_score, ts.score_level as trust_level, + ur.uptime_pct + FROM mints m + LEFT JOIN current_trust_scores ts ON m.mint_id = ts.mint_id + LEFT JOIN uptime_rollups ur ON m.mint_id = ur.mint_id AND ur.window = '30d' + WHERE m.status = 'online' + AND (m.visibility IS NULL OR m.visibility = 'public') + ORDER BY ts.score_total DESC NULLS LAST, ur.uptime_pct DESC NULLS LAST + LIMIT ? + `; + } + + const mints = query(sql, params); + + return { + use_case: useCase, + mints: mints.map(m => ({ + mint_id: m.mint_id, + name: m.name, + url: m.canonical_url, + icon_url: m.icon_url, + status: m.status, + trust_score: m.trust_score, + trust_level: m.trust_level, + uptime_pct: m.uptime_pct || null + })), + computed_at: nowISO() + }; +} + +// ========================================== +// COMPATIBILITY +// ========================================== + +/** + * Get mint compatibility info + */ +export function getMintCompatibility(mintId) { + const metadata = queryOne(` + SELECT nuts, version FROM metadata_snapshots WHERE mint_id = ? + `, [mintId]); + + if (!metadata) { + return { error: 'No metadata available' }; + } + + let nuts = []; + if (metadata.nuts) { + try { + const n = typeof metadata.nuts === 'string' ? JSON.parse(metadata.nuts) : metadata.nuts; + nuts = Object.keys(n).map(x => parseInt(x, 10)).filter(x => !isNaN(x)).sort((a, b) => a - b); + } catch (e) { } + } + + // Derive wallet compatibility + const hasBasicNuts = nuts.includes(4) && nuts.includes(5); + const hasP2PK = nuts.includes(11); + const hasDLEQ = nuts.includes(12); + const hasWebSocket = nuts.includes(17); + + const compatibleWallets = []; + const incompatibleWallets = []; + const knownIssues = []; + + // Wallet compatibility rules + if (hasBasicNuts) { + compatibleWallets.push('Nutstash', 'Minibits', 'eNuts', 'Cashu.me'); + } + + if (!hasP2PK) { + knownIssues.push('No P2PK support (NUT-11) - locked tokens not supported'); + } + + if (!hasDLEQ) { + knownIssues.push('No DLEQ proofs (NUT-12) - offline verification limited'); + } + + // Version warnings + if (metadata.version) { + const versionMatch = metadata.version.match(/(\d+)\.(\d+)\.(\d+)/); + if (versionMatch) { + const [, major, minor] = versionMatch.map(Number); + if (major === 0 && minor < 17) { + knownIssues.push('Older mint version - consider updating for best compatibility'); + } + } + } + + return { + supported_nuts: nuts, + version: metadata.version, + compatible_wallets: compatibleWallets, + incompatible_wallets: incompatibleWallets, + known_issues: knownIssues, + recommendations: compatibleWallets.length > 0 ? compatibleWallets.slice(0, 3) : [], + computed_at: nowISO() + }; +} + +// ========================================== +// WALLET RECOMMENDATIONS +// ========================================== + +/** + * Get recommended mints for a specific wallet + */ +export function getWalletRecommendedMints(walletName, limit = 10) { + // Wallet requirements (simplified - could be expanded) + const walletRequirements = { + 'nutstash': { requiredNuts: [4, 5, 7], preferredNuts: [11, 12] }, + 'minibits': { requiredNuts: [4, 5, 7, 11, 12], preferredNuts: [17] }, + 'enuts': { requiredNuts: [4, 5], preferredNuts: [11, 12] }, + 'cashu.me': { requiredNuts: [4, 5, 7], preferredNuts: [11, 12, 17] } + }; + + const wallet = walletName.toLowerCase(); + const reqs = walletRequirements[wallet] || walletRequirements['nutstash']; + + // Get all online mints with metadata + const mints = query(` + SELECT + m.mint_id, m.name, m.canonical_url, m.icon_url, m.status, + ts.score_total as trust_score, ts.score_level as trust_level, + ms.nuts, ur.uptime_pct + FROM mints m + LEFT JOIN current_trust_scores ts ON m.mint_id = ts.mint_id + LEFT JOIN metadata_snapshots ms ON m.mint_id = ms.mint_id + LEFT JOIN uptime_rollups ur ON m.mint_id = ur.mint_id AND ur.window = '30d' + WHERE m.status = 'online' + AND (m.visibility IS NULL OR m.visibility = 'public') + `); + + // Score compatibility + const scored = mints.map(m => { + let score = 0; + let compatible = false; + let supportedNuts = []; + + if (m.nuts) { + try { + const nuts = typeof m.nuts === 'string' ? JSON.parse(m.nuts) : m.nuts; + supportedNuts = Object.keys(nuts).map(x => parseInt(x, 10)).filter(x => !isNaN(x)); + + // Check required NUTs + const hasRequired = reqs.requiredNuts.every(n => supportedNuts.includes(n)); + if (hasRequired) { + compatible = true; + score += 50; + + // Bonus for preferred NUTs + const preferredCount = reqs.preferredNuts.filter(n => supportedNuts.includes(n)).length; + score += preferredCount * 10; + } + } catch (e) { } + } + + // Trust score bonus + if (m.trust_score) score += Math.min(m.trust_score / 2, 25); + + // Uptime bonus + if (m.uptime_pct) score += Math.min((m.uptime_pct - 90) / 2, 10); + + return { ...m, score, compatible, supportedNuts }; + }); + + // Filter compatible and sort + const compatible = scored.filter(m => m.compatible).sort((a, b) => b.score - a.score).slice(0, limit); + + return { + wallet: walletName, + required_nuts: reqs.requiredNuts, + preferred_nuts: reqs.preferredNuts, + mints: compatible.map(m => ({ + mint_id: m.mint_id, + name: m.name, + url: m.canonical_url, + icon_url: m.icon_url, + status: m.status, + trust_score: m.trust_score, + trust_level: m.trust_level, + uptime_pct: m.uptime_pct, + compatibility_score: Math.round(m.score) + })), + computed_at: nowISO() + }; +} + +// ========================================== +// RISK ASSESSMENT +// ========================================== + +/** + * Get risk flags for a mint + */ +export function getMintRisk(mintId) { + const flags = []; + let riskLevel = 'low'; + let riskScore = 0; + + // Get mint info + const mint = queryOne(` + SELECT m.*, ts.score_total as trust_score + FROM mints m + LEFT JOIN current_trust_scores ts ON m.mint_id = ts.mint_id + WHERE m.mint_id = ? + `, [mintId]); + + if (!mint) return { error: 'Mint not found' }; + + // Check uptime volatility + const uptimeRollups = query(` + SELECT uptime_pct, window FROM uptime_rollups + WHERE mint_id = ? + ORDER BY computed_at DESC + `, [mintId]); + + const uptime24h = uptimeRollups.find(u => u.window === '24h'); + const uptime7d = uptimeRollups.find(u => u.window === '7d'); + const uptime30d = uptimeRollups.find(u => u.window === '30d'); + + if (uptime30d && uptime30d.uptime_pct < 95) { + flags.push({ type: 'uptime_low', severity: 'medium', message: 'Below 95% uptime in 30 days' }); + riskScore += 20; + } + + if (uptime24h && uptime7d && Math.abs(uptime24h.uptime_pct - uptime7d.uptime_pct) > 10) { + flags.push({ type: 'uptime_volatile', severity: 'low', message: 'Uptime fluctuating significantly' }); + riskScore += 10; + } + + // Check metadata churn + const metadataChanges = queryOne(` + SELECT COUNT(*) as c FROM metadata_history + WHERE mint_id = ? AND fetched_at >= ? AND change_type = 'update' + `, [mintId, daysAgo(7)]); + + if (metadataChanges && metadataChanges.c > 5) { + flags.push({ type: 'metadata_churn', severity: 'low', message: 'Frequent metadata changes' }); + riskScore += 5; + } + + // Check incidents + const recentIncidents = queryOne(` + SELECT COUNT(*) as c FROM incidents + WHERE mint_id = ? AND started_at >= ? + `, [mintId, daysAgo(30)]); + + if (recentIncidents && recentIncidents.c > 3) { + flags.push({ type: 'frequent_outages', severity: 'medium', message: 'Multiple outages in 30 days' }); + riskScore += 15; + } + + // Check identity + const metadata = queryOne(`SELECT pubkey FROM metadata_snapshots WHERE mint_id = ?`, [mintId]); + if (!metadata || !metadata.pubkey) { + flags.push({ type: 'no_identity', severity: 'medium', message: 'No operator pubkey published' }); + riskScore += 15; + } + + // Check trust score + if (!mint.trust_score || mint.trust_score < 40) { + flags.push({ type: 'low_trust', severity: 'high', message: 'Low overall trust score' }); + riskScore += 25; + } + + // Check review anomalies (all reviews from same user) + const reviewStats = queryOne(` + SELECT COUNT(*) as total, COUNT(DISTINCT pubkey) as unique_reviewers + FROM reviews WHERE mint_id = ? + `, [mintId]); + + if (reviewStats && reviewStats.total > 3 && reviewStats.unique_reviewers === 1) { + flags.push({ type: 'review_anomaly', severity: 'low', message: 'All reviews from single user' }); + riskScore += 10; + } + + // Determine risk level + if (riskScore >= 50) riskLevel = 'high'; + else if (riskScore >= 25) riskLevel = 'medium'; + + return { + mint_id: mintId, + risk_level: riskLevel, + risk_score: Math.min(100, riskScore), + flags, + summary: { + uptime_volatile: flags.some(f => f.type === 'uptime_volatile'), + metadata_churn: flags.some(f => f.type === 'metadata_churn'), + centralization_risk: flags.some(f => f.type === 'no_identity'), + review_anomalies: flags.some(f => f.type === 'review_anomaly') + }, + computed_at: nowISO() + }; } \ No newline at end of file diff --git a/src/services/ReviewService.js b/src/services/ReviewService.js index 10f7b4c..51c33b5 100644 --- a/src/services/ReviewService.js +++ b/src/services/ReviewService.js @@ -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 + }; + }); } \ No newline at end of file diff --git a/src/services/TrustService.js b/src/services/TrustService.js index 1919ca6..40e3ffb 100644 --- a/src/services/TrustService.js +++ b/src/services/TrustService.js @@ -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