Files
Siteforge/server/routes/admin-dashboard.js
T

243 lines
9.8 KiB
JavaScript

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 }
}
}
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,
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.' })
}
})
}