import { Resend } from 'resend' import { readdir } from 'node:fs/promises' import { requireAdminAuth } from '../auth.js' import { state } from '../state.js' import { getStorageStatus, loadUserProgress } from '../data.js' import { getStudyCatalog } from '../study-helpers.js' import { resendRetry } from '../email.js' import { fetchAllEpisodes } from './episodes.js' import { loadHistory } from './nuggets-host-upload.js' import { BACKUP_DIR } from '../config.js' const DAY_MS = 24 * 60 * 60 * 1000 const STALE_FEED_DAYS = 21 function dayKey(offsetDays) { return new Date(Date.now() - offsetDays * DAY_MS).toISOString().slice(0, 10) } function countSince(items, getDate, fromMs, toMs = Infinity) { let n = 0 for (const item of items) { const t = new Date(getDate(item) ?? 0).getTime() if (t >= fromMs && t < toMs) n++ } return n } function dailySeries(items, getDate, days) { const buckets = new Map() for (let i = days - 1; i >= 0; i--) buckets.set(dayKey(i), 0) for (const item of items) { const key = String(getDate(item) ?? '').slice(0, 10) if (buckets.has(key)) buckets.set(key, buckets.get(key) + 1) } return [...buckets.values()] } function topCounts(values, limit) { const counts = {} for (const v of values) if (v) counts[v] = (counts[v] ?? 0) + 1 return Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, limit) } function buildTrends() { const now = Date.now() const week = 7 * DAY_MS const subscribers = state.contactSubmissions.filter(s => s.subscribe === true) const studyUsers = state.studyUsers const realByDay = state.hitStats.byDayReal ?? {} const visitSeries = Array.from({ length: 14 }, (_, i) => realByDay[dayKey(13 - i)] ?? 0) return { visits: { thisWeek: visitSeries.slice(7).reduce((a, b) => a + b, 0), lastWeek: visitSeries.slice(0, 7).reduce((a, b) => a + b, 0), series: visitSeries, }, subscribers: { thisWeek: countSince(subscribers, s => s.submittedAt, now - week), lastWeek: countSince(subscribers, s => s.submittedAt, now - 2 * week, now - week), series: dailySeries(subscribers, s => s.submittedAt, 14), }, studySignups: { thisWeek: countSince(studyUsers, u => u.createdAt, now - week), lastWeek: countSince(studyUsers, u => u.createdAt, now - 2 * week, now - week), series: dailySeries(studyUsers, u => u.createdAt, 14), }, } } function buildTopTraffic() { const cutoff = dayKey(6) const rows = (state.visitorStats.recentVisits ?? []).filter(r => !r.isBot && r.at && r.at >= cutoff) return { topPages: topCounts(rows.map(r => r.path ?? '/'), 5).map(([path, hits]) => ({ path, hits })), topReferrers: topCounts(rows.map(r => r.referrer), 5).map(([referrer, count]) => ({ referrer, count })), } } function buildReengagement() { const now = Date.now() const tiers = { t1: 0, t2: 0, t3: 0 } let recentlySent = 0 for (const user of state.studyUsers) { const sent = user.reengagementSentAt && typeof user.reengagementSentAt === 'object' ? user.reengagementSentAt : {} for (const at of Object.values(sent)) { if (now - new Date(at).getTime() <= 30 * DAY_MS) recentlySent++ } if (!user.studyRemindersEnabled || !user.lastLoginAt || !(user.enrolledStudySlugs?.length)) continue const days = (now - new Date(user.lastLoginAt).getTime()) / DAY_MS if (days >= 30) tiers.t3++ else if (days >= 14) tiers.t2++ else if (days >= 7) tiers.t1++ } return { ...tiers, recentlySent, remindersEnabled: state.studyUsers.filter(u => u.studyRemindersEnabled).length } } async function buildStudyProgress() { const catalog = getStudyCatalog() const perStudy = new Map(catalog.map(s => [s.slug, { slug: s.slug, title: s.title, enrolled: 0, lessonsCompleted: 0, certificates: 0 }])) let lastActivityAt = null await Promise.all(state.studyUsers.map(async user => { for (const slug of user.enrolledStudySlugs ?? []) { const row = perStudy.get(slug) if (row) row.enrolled++ } try { const progress = await loadUserProgress(user.id) for (const [slug, data] of Object.entries(progress.byStudy ?? {})) { const row = perStudy.get(slug) if (row) row.lessonsCompleted += (data.completedSectionIds ?? []).length } if (progress.updatedAt && (!lastActivityAt || progress.updatedAt > lastActivityAt)) lastActivityAt = progress.updatedAt } catch { /* ignore unreadable progress */ } })) for (const cert of state.studyCertificates ?? []) { const row = perStudy.get(cert.studySlug) if (row) row.certificates++ } return { studies: [...perStudy.values()].filter(s => s.enrolled > 0 || s.lessonsCompleted > 0 || s.certificates > 0), certificatesTotal: (state.studyCertificates ?? []).length, lastActivityAt, } } async function buildLatestEpisode() { try { const episodes = await fetchAllEpisodes() const latest = episodes[0] if (!latest) return { available: false } const published = new Date(latest.pubDate) const valid = !isNaN(published.getTime()) const daysAgo = valid ? Math.floor((Date.now() - published.getTime()) / DAY_MS) : null return { available: true, title: latest.title, episode: latest.episode, season: latest.season, publishedAt: valid ? published.toISOString() : null, daysAgo, stale: daysAgo !== null && daysAgo > STALE_FEED_DAYS, staleAfterDays: STALE_FEED_DAYS, } } catch { return { available: false } } } function buildUpcomingEvents() { const today = dayKey(0) const events = (state.calendarEvents ?? []) .filter(ev => ev.date && ev.date >= today && !ev.completed) .map(ev => ({ id: ev.id, kind: 'event', type: ev.type, title: ev.title, date: ev.date, startTime: ev.startTime ?? null })) const episodes = (state.podcastChecklist?.episodes ?? []) .filter(ep => ep.datePublished && ep.datePublished >= today) .map(ep => ({ id: `ep-${ep.episodeNumber ?? ep.title}`, kind: 'episode', type: 'episode', title: `${ep.episodeNumber ? `Ep. ${ep.episodeNumber}: ` : ''}${ep.title || 'Untitled'}`, date: ep.datePublished, startTime: null, })) return [...events, ...episodes].sort((a, b) => a.date.localeCompare(b.date)).slice(0, 3) } async function buildNuggets() { const { entries } = await loadHistory() const weekAgo = Date.now() - 7 * DAY_MS return { uploadsThisWeek: entries.filter(e => new Date(e.uploadedAt).getTime() >= weekAgo).length, latest: entries.slice(0, 3).map(e => ({ hostName: e.hostName, episodeName: e.episodeName, uploadedAt: e.uploadedAt })), entries: entries.slice(0, 10), } } async function buildHealth() { let backupCount = null try { backupCount = (await readdir(BACKUP_DIR)).filter(f => f.endsWith('.json')).length } catch { /* no backup dir yet */ } const storage = await getStorageStatus().catch(() => null) return { resendConfigured: Boolean(process.env.RESEND_API_KEY), lastBackup: state.lastBackupStatus, backupCount, dataDirWritable: storage ? storage.writable?.ok === true : null, writes: { hitStats: state.lastHitStatsWrite, visitorStats: state.lastVisitorStatsWrite }, serverStartedAt: state.serverStartedAt, } } // The Resend SDK (6.x) exposes no open/click metrics for broadcasts, so this reports // the most recent send and links to the Resend dashboard for engagement numbers. async function buildLastBroadcast() { if (!process.env.RESEND_API_KEY) return { available: false, reason: 'RESEND_API_KEY is not configured.' } try { const resend = new Resend(process.env.RESEND_API_KEY) const { data: list, error } = await resendRetry(() => resend.broadcasts.list()) if (error) throw new Error(error.message) const sent = (list?.data ?? []).filter(b => b.status === 'sent') .sort((a, b) => new Date(b.sent_at ?? 0).getTime() - new Date(a.sent_at ?? 0).getTime())[0] if (!sent) return { available: true, broadcast: null } const { data, error: getError } = await resendRetry(() => resend.broadcasts.get(sent.id)) if (getError || !data) throw new Error(getError?.message ?? 'Broadcast not found.') return { available: true, broadcast: { id: data.id, name: data.name, subject: data.subject, sentAt: data.sent_at ?? sent.sent_at ?? null, dashboardUrl: `https://resend.com/broadcasts/${data.id}`, }, } } catch (err) { return { available: false, reason: err instanceof Error ? err.message : 'Could not reach Resend.' } } } function buildActivity(nuggetEntries) { const items = [] for (const c of state.contactSubmissions) items.push({ at: c.submittedAt, kind: 'contact', text: `${c.name || c.email} sent a contact message` }) for (const q of state.questions) items.push({ at: q.submittedAt, kind: 'question', text: `${q.firstName || 'Someone'} asked a question` }) for (const c of state.studyComments) items.push({ at: c.createdAt, kind: 'comment', text: `${c.displayName || 'A student'} commented on a study` }) for (const u of state.studyUsers) items.push({ at: u.createdAt, kind: 'study-user', text: `${u.displayName || u.username} created a study account` }) for (const c of state.studyCertificates ?? []) items.push({ at: c.issuedAt, kind: 'certificate', text: `${c.displayName} earned a certificate: ${c.studyTitle}` }) for (const e of nuggetEntries) items.push({ at: e.uploadedAt, kind: 'nuggets', text: `${e.hostName || 'A host'} uploaded "${e.episodeName}"` }) return items .filter(i => i.at && !isNaN(new Date(i.at).getTime())) .sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime()) .slice(0, 12) } export function register(app) { app.get('/api/admin-dashboard', requireAdminAuth, async (_req, res) => { try { const [studyProgress, latestEpisode, nuggets, health, lastBroadcast] = await Promise.all([ buildStudyProgress(), buildLatestEpisode(), buildNuggets(), buildHealth(), buildLastBroadcast(), ]) const comments = state.studyComments ?? [] res.json({ trends: buildTrends(), traffic: buildTopTraffic(), reengagement: buildReengagement(), studyProgress, latestEpisode, upcoming: buildUpcomingEvents(), nuggets: { uploadsThisWeek: nuggets.uploadsThisWeek, latest: nuggets.latest }, health, lastBroadcast, comments: { pending: comments.filter(c => c.isApproved !== true).length, thisWeek: countSince(comments, c => c.createdAt, Date.now() - 7 * DAY_MS), }, activity: buildActivity(nuggets.entries), }) } catch (err) { console.error('[admin-dashboard] failed:', err) res.status(500).json({ message: 'Failed to build dashboard data.' }) } }) }