diff --git a/src/db/schema-admin.sql b/src/db/schema-admin.sql index dabe753..10abd5b 100644 --- a/src/db/schema-admin.sql +++ b/src/db/schema-admin.sql @@ -16,7 +16,7 @@ CREATE TABLE IF NOT EXISTS admin_audit_log ( notes TEXT, ip_address TEXT, user_agent TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); CREATE INDEX IF NOT EXISTS idx_audit_log_admin ON admin_audit_log(admin_id); @@ -35,7 +35,7 @@ CREATE TABLE IF NOT EXISTS mint_merges ( target_mint_id TEXT NOT NULL, reason TEXT, status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'reverted')), - merged_at TEXT NOT NULL DEFAULT (datetime('now')), + merged_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), reverted_at TEXT, admin_id TEXT NOT NULL, -- Store affected data for potential reversal diff --git a/src/db/schema.sql b/src/db/schema.sql index 6d7f103..f11e41c 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -19,8 +19,8 @@ CREATE TABLE IF NOT EXISTS mints ( last_success_at TEXT, last_failure_at TEXT, consecutive_failures INTEGER DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); CREATE INDEX IF NOT EXISTS idx_mints_status ON mints(status); @@ -34,8 +34,8 @@ CREATE TABLE IF NOT EXISTS mint_urls ( url_normalized TEXT NOT NULL, type TEXT NOT NULL DEFAULT 'clearnet' CHECK (type IN ('clearnet', 'tor', 'mirror')), active INTEGER NOT NULL DEFAULT 1, - discovered_at TEXT NOT NULL DEFAULT (datetime('now')), - last_seen_at TEXT NOT NULL DEFAULT (datetime('now')) + discovered_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); CREATE INDEX IF NOT EXISTS idx_mint_urls_mint_id ON mint_urls(mint_id); @@ -51,7 +51,7 @@ CREATE TABLE IF NOT EXISTS probes ( id INTEGER PRIMARY KEY AUTOINCREMENT, mint_id TEXT NOT NULL REFERENCES mints(mint_id) ON DELETE CASCADE, url TEXT NOT NULL, - probed_at TEXT NOT NULL DEFAULT (datetime('now')), + probed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), success INTEGER NOT NULL, status_code INTEGER, rtt_ms INTEGER, @@ -77,7 +77,7 @@ CREATE TABLE IF NOT EXISTS uptime_rollups ( total_checks INTEGER NOT NULL DEFAULT 0, ok_checks INTEGER NOT NULL DEFAULT 0, incident_count INTEGER NOT NULL DEFAULT 0, - computed_at TEXT NOT NULL DEFAULT (datetime('now')), + computed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), UNIQUE(mint_id, window, period_start) ); @@ -117,14 +117,14 @@ CREATE TABLE IF NOT EXISTS metadata_snapshots ( server_time TEXT, raw_json TEXT, -- Full raw response content_hash TEXT, - last_fetched_at TEXT NOT NULL DEFAULT (datetime('now')) + last_fetched_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); -- Metadata History: All changes (append-only) CREATE TABLE IF NOT EXISTS metadata_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, mint_id TEXT NOT NULL REFERENCES mints(mint_id) ON DELETE CASCADE, - fetched_at TEXT NOT NULL DEFAULT (datetime('now')), + fetched_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), change_type TEXT NOT NULL CHECK (change_type IN ('initial', 'update', 'error')), diff TEXT, -- JSON diff of changes content_hash TEXT, @@ -150,7 +150,7 @@ CREATE TABLE IF NOT EXISTS nostr_events ( tags TEXT, -- JSON array sig TEXT NOT NULL, raw_json TEXT NOT NULL, - ingested_at TEXT NOT NULL DEFAULT (datetime('now')) + ingested_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); CREATE INDEX IF NOT EXISTS idx_nostr_events_event_id ON nostr_events(event_id); @@ -168,7 +168,7 @@ CREATE TABLE IF NOT EXISTS reviews ( created_at INTEGER NOT NULL, rating INTEGER CHECK (rating >= 1 AND rating <= 5), content TEXT, - parsed_at TEXT NOT NULL DEFAULT (datetime('now')) + parsed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); CREATE INDEX IF NOT EXISTS idx_reviews_mint_id ON reviews(mint_id); @@ -185,7 +185,7 @@ CREATE TABLE IF NOT EXISTS trust_scores ( score_total INTEGER NOT NULL CHECK (score_total >= 0 AND score_total <= 100), score_level TEXT NOT NULL CHECK (score_level IN ('unknown', 'low', 'medium', 'high', 'excellent')), breakdown TEXT NOT NULL, -- JSON breakdown of score components - computed_at TEXT NOT NULL DEFAULT (datetime('now')), + computed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), UNIQUE(mint_id, computed_at) ); @@ -210,7 +210,7 @@ CREATE TABLE IF NOT EXISTS pageviews ( id INTEGER PRIMARY KEY AUTOINCREMENT, mint_id TEXT NOT NULL REFERENCES mints(mint_id) ON DELETE CASCADE, session_id TEXT NOT NULL, - viewed_at TEXT NOT NULL DEFAULT (datetime('now')), + viewed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), user_agent TEXT, referer TEXT ); @@ -228,7 +228,7 @@ CREATE TABLE IF NOT EXISTS pageview_rollups ( period_end TEXT NOT NULL, view_count INTEGER NOT NULL DEFAULT 0, unique_sessions INTEGER NOT NULL DEFAULT 0, - computed_at TEXT NOT NULL DEFAULT (datetime('now')), + computed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), UNIQUE(mint_id, window, period_start) ); @@ -244,13 +244,13 @@ CREATE TABLE IF NOT EXISTS jobs ( payload TEXT, -- JSON status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'completed', 'failed')), priority INTEGER NOT NULL DEFAULT 0, - run_at TEXT NOT NULL DEFAULT (datetime('now')), + run_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), started_at TEXT, completed_at TEXT, retries INTEGER NOT NULL DEFAULT 0, max_retries INTEGER NOT NULL DEFAULT 3, error_message TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); CREATE INDEX IF NOT EXISTS idx_jobs_status_run_at ON jobs(status, run_at); @@ -264,7 +264,7 @@ CREATE TABLE IF NOT EXISTS system_stats ( id INTEGER PRIMARY KEY AUTOINCREMENT, stat_name TEXT NOT NULL, stat_value TEXT NOT NULL, - recorded_at TEXT NOT NULL DEFAULT (datetime('now')) + recorded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); CREATE INDEX IF NOT EXISTS idx_system_stats_name ON system_stats(stat_name); diff --git a/src/routes/system.js b/src/routes/system.js index 1ffb7d2..b9f0f5c 100644 --- a/src/routes/system.js +++ b/src/routes/system.js @@ -75,7 +75,7 @@ router.get('/stats', async(req, res) => { // Recent activity const probeResult = queryOne(` SELECT COUNT(*) as count FROM probes - WHERE probed_at >= datetime('now', '-24 hours') + WHERE probed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-24 hours') `); const probeCount = probeResult ? probeResult.count : 0; @@ -86,7 +86,7 @@ router.get('/stats', async(req, res) => { const incidentResult = queryOne(` SELECT COUNT(*) as count FROM incidents - WHERE started_at >= datetime('now', '-7 days') + WHERE started_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 days') `); const incidentCount = incidentResult ? incidentResult.count : 0; @@ -147,7 +147,7 @@ router.get('/stats/timeline', (req, res) => { COUNT(*) as total, SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as successful FROM probes - WHERE probed_at >= datetime('now', '-${numDays} days') + WHERE probed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-${numDays} days') GROUP BY date(probed_at) ORDER BY date ASC `); @@ -158,7 +158,7 @@ router.get('/stats/timeline', (req, res) => { date(started_at) as date, COUNT(*) as count FROM incidents - WHERE started_at >= datetime('now', '-${numDays} days') + WHERE started_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-${numDays} days') GROUP BY date(started_at) ORDER BY date ASC `); diff --git a/src/services/AdminService.js b/src/services/AdminService.js index c455292..6996230 100644 --- a/src/services/AdminService.js +++ b/src/services/AdminService.js @@ -36,7 +36,7 @@ export function initAdminSchema() { notes TEXT, ip_address TEXT, user_agent TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ) `); @@ -49,7 +49,7 @@ export function initAdminSchema() { target_mint_id TEXT NOT NULL, reason TEXT, status TEXT NOT NULL DEFAULT 'active', - merged_at TEXT NOT NULL DEFAULT (datetime('now')), + merged_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), reverted_at TEXT, admin_id TEXT NOT NULL, affected_urls TEXT, @@ -701,12 +701,12 @@ export function getSystemMetrics() { const probesLastMinute = getCount(queryOne(` SELECT COUNT(*) as count FROM probes - WHERE probed_at >= datetime('now', '-1 minute') + WHERE probed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 minute') `)); const failedProbesLastMinute = getCount(queryOne(` SELECT COUNT(*) as count FROM probes - WHERE probed_at >= datetime('now', '-1 minute') AND success = 0 + WHERE probed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 minute') AND success = 0 `)); const jobBacklog = getCount(queryOne(` diff --git a/src/services/AnalyticsService.js b/src/services/AnalyticsService.js index a912271..4e720d8 100644 --- a/src/services/AnalyticsService.js +++ b/src/services/AnalyticsService.js @@ -215,12 +215,14 @@ export async function getPopularMints(window = '7d', limit = 20) { /** * Get popular mints from local database (fallback) + * When no pageviews exist, falls back to trust score ranking */ function getLocalPopularMints(window = '7d', limit = 20) { const since = window === '24h' ? hoursAgo(24) : window === '30d' ? daysAgo(30) : daysAgo(7); - return query(` + // First try to get mints with pageviews + const mintsWithViews = query(` SELECT m.mint_id, m.name, @@ -236,11 +238,37 @@ function getLocalPopularMints(window = '7d', limit = 20) { 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 m.status IN ('online', 'degraded') GROUP BY m.mint_id HAVING views > 0 ORDER BY views DESC LIMIT ? `, [since, limit]); + + if (mintsWithViews.length >= limit) { + return mintsWithViews; + } + + // Fallback: return top mints by trust score if not enough pageview data + return 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, + 0 as views, + 0 as unique_sessions + 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 m.status IN ('online', 'degraded') + ORDER BY ts.score_total DESC NULLS LAST, m.name ASC + LIMIT ? + `, [limit]); } // ========================================== diff --git a/src/services/PageviewService.js b/src/services/PageviewService.js index 30602a3..ca3918d 100644 --- a/src/services/PageviewService.js +++ b/src/services/PageviewService.js @@ -219,12 +219,14 @@ export async function getTrendingMints(limit = 10, window = '7d') { /** * Get trending mints from local database (fallback) + * When no pageviews exist, falls back to recently active mints by trust score */ function getLocalTrendingMints(limit = 10, window = '7d') { const since = window === '24h' ? hoursAgo(24) : daysAgo(7); const days = window === '24h' ? 1 : 7; - const mints = query(` + // First try to get mints with pageviews + const mintsWithViews = query(` SELECT m.mint_id, m.canonical_url, @@ -246,10 +248,37 @@ function getLocalTrendingMints(limit = 10, window = '7d') { LIMIT ? `, [since, limit]); - // Calculate view velocity - return mints.map(m => ({ + if (mintsWithViews.length >= limit) { + return mintsWithViews.map(m => ({ + ...m, + view_velocity: Math.round((m.view_count / days) * 10) / 10, + source: 'local', + })); + } + + // Fallback: return top mints by trust score if not enough pageview data + const fallbackMints = 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, + 0 as view_count, + 0 as unique_sessions + FROM mints m + LEFT JOIN current_trust_scores ts ON m.mint_id = ts.mint_id + WHERE m.status IN ('online', 'degraded') + AND (m.visibility IS NULL OR m.visibility = 'public') + ORDER BY ts.score_total DESC NULLS LAST, m.name ASC + LIMIT ? + `, [limit]); + + return fallbackMints.map(m => ({ ...m, - view_velocity: Math.round((m.view_count / days) * 10) / 10, + view_velocity: 0, source: 'local', })); } diff --git a/src/services/ReviewService.js b/src/services/ReviewService.js index 236cf87..6f43054 100644 --- a/src/services/ReviewService.js +++ b/src/services/ReviewService.js @@ -292,7 +292,7 @@ export function getRecentReviews(limit = 20) { m.canonical_url as mint_url FROM reviews r LEFT JOIN mints m ON r.mint_id = m.mint_id - WHERE r.rating IS NOT NULL + WHERE r.mint_id IS NOT NULL ORDER BY r.created_at DESC LIMIT ? `, [limit]); @@ -438,7 +438,7 @@ export function getRecentEcosystemReviews(limit = 20, since = null) { r.created_at FROM reviews r LEFT JOIN mints m ON r.mint_id = m.mint_id - WHERE r.rating IS NOT NULL + WHERE r.mint_id IS NOT NULL `; const params = []; diff --git a/src/utils/time.js b/src/utils/time.js index 2078287..c319274 100644 --- a/src/utils/time.js +++ b/src/utils/time.js @@ -1,124 +1,125 @@ /** * Time Utilities * - * ISO-8601 UTC timestamps for consistency. + * All timestamps are stored as ISO-8601 UTC format: YYYY-MM-DDTHH:MM:SS.sssZ + * This matches JavaScript's Date.toISOString() output. + * Database defaults use SQLite's strftime('%Y-%m-%dT%H:%M:%fZ', 'now') to match. */ /** * Get current UTC timestamp in ISO-8601 format */ export function nowISO() { - return new Date().toISOString(); + return new Date().toISOString(); } /** * Get current Unix timestamp (seconds) */ export function nowUnix() { - return Math.floor(Date.now() / 1000); + return Math.floor(Date.now() / 1000); } /** * Parse ISO timestamp to Date */ export function parseISO(isoString) { - return new Date(isoString); + return new Date(isoString); } /** * Format date to ISO-8601 */ export function toISO(date) { - if (!date) return null; - if (typeof date === 'string') return date; - return date.toISOString(); + if (!date) return null; + if (typeof date === 'string') return date; + return date.toISOString(); } /** * Unix timestamp to ISO */ export function unixToISO(unix) { - return new Date(unix * 1000).toISOString(); + return new Date(unix * 1000).toISOString(); } /** * ISO to Unix timestamp */ export function isoToUnix(iso) { - return Math.floor(new Date(iso).getTime() / 1000); + return Math.floor(new Date(iso).getTime() / 1000); } /** * Get timestamp X milliseconds ago */ export function ago(ms) { - return new Date(Date.now() - ms).toISOString(); + return new Date(Date.now() - ms).toISOString(); } /** * Get timestamp X days ago */ export function daysAgo(days) { - return ago(days * 24 * 60 * 60 * 1000); + return ago(days * 24 * 60 * 60 * 1000); } /** * Get timestamp X hours ago */ export function hoursAgo(hours) { - return ago(hours * 60 * 60 * 1000); + return ago(hours * 60 * 60 * 1000); } /** * Calculate duration in seconds between two ISO timestamps */ export function durationSeconds(start, end) { - const startDate = new Date(start); - const endDate = end ? new Date(end) : new Date(); - return Math.floor((endDate - startDate) / 1000); + const startDate = new Date(start); + const endDate = end ? new Date(end) : new Date(); + return Math.floor((endDate - startDate) / 1000); } /** * Get start of period for rollup calculations */ export function getPeriodStart(window) { - const now = new Date(); + const now = new Date(); - switch (window) { - case '1h': - return new Date(now.getTime() - 60 * 60 * 1000).toISOString(); - case '24h': - return new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(); - case '7d': - return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(); - case '30d': - return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(); - default: - return new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(); - } + switch (window) { + case '1h': + return new Date(now.getTime() - 60 * 60 * 1000).toISOString(); + case '24h': + return new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(); + case '7d': + return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(); + case '30d': + return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(); + default: + return new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(); + } } /** * Get bucket interval in milliseconds */ export function getBucketMs(bucket) { - switch (bucket) { - case '5m': - return 5 * 60 * 1000; - case '15m': - return 15 * 60 * 1000; - case '1h': - return 60 * 60 * 1000; - default: - return 60 * 60 * 1000; - } + switch (bucket) { + case '5m': + return 5 * 60 * 1000; + case '15m': + return 15 * 60 * 1000; + case '1h': + return 60 * 60 * 1000; + default: + return 60 * 60 * 1000; + } } /** * Round timestamp down to bucket start */ export function bucketStart(timestamp, bucketMs) { - const ts = new Date(timestamp).getTime(); - return new Date(Math.floor(ts / bucketMs) * bucketMs).toISOString(); -} - + const ts = new Date(timestamp).getTime(); + return new Date(Math.floor(ts / bucketMs) * bucketMs).toISOString(); +} \ No newline at end of file