Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
254 lines
10 KiB
JavaScript
254 lines
10 KiB
JavaScript
import { Resend } from 'resend'
|
|
import { readdir } from 'node:fs/promises'
|
|
import { requireAdminAuth } from '../auth.js'
|
|
import { buildSeasonCheck } from '../season-check.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 ?? []
|
|
const seasonCheck = await buildSeasonCheck()
|
|
.then(c => ({
|
|
total: c.total,
|
|
errors: c.errors,
|
|
warnings: c.warnings,
|
|
general: c.general.length,
|
|
lanes: c.lanes.map(l => ({ lane: l.lane, title: l.title, season: l.season, errors: l.errors, warnings: l.warnings })),
|
|
}))
|
|
.catch(() => null)
|
|
res.json({
|
|
seasonCheck,
|
|
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.' })
|
|
}
|
|
})
|
|
}
|