Add cross-site improvements across three phases
Phase 1 — Quick wins: - Image lazy-loading on series/resource cards - Newsletter signup added to Episodes page (before highlights) - Per-route meta tags via usePageMeta hook (title, og:title, og:description) - Breadcrumbs on study index, section, and notes pages - SVG completion checkmark badges on study section list - Analytics time-range filter (7d / 30d / 90d) in admin panel Phase 2 — Medium features: - Related episodes on archived series detail pages - Resource library two-tier filter (type + tag chips) - Global search (Fuse.js) moved below sticky header as full-width bar - Q&A anonymous upvoting with localStorage dedup + admin pin/unpin - Study enrollment funnel tracking (firstVisitAt, firstCompletionAt) with funnel chart in analytics Phase 3 — Larger features: - Study section comments (auto-approve for enrolled users, admin moderation panel) - Study completion certificate (canvas render, PNG download, shareable public URL) - Episode script full-text search (mammoth docx extraction, server-side search, admin upload UI) - Reflection questions renamed from Discussion Questions; quiz answers can be shared to section discussion - Public certificate route at /certificate/:token with og meta tags - Comment moderation panel added to admin under Manage > Study Comments Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,10 @@ export const UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
|
||||
export const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json')
|
||||
export const DOWNLOAD_COUNTS_FILE = path.join(DATA_DIR, 'download-counts.json')
|
||||
export const STUDY_REMINDERS_FILE = path.join(DATA_DIR, 'study-reminders.json')
|
||||
export const STUDY_COMMENTS_FILE = path.join(DATA_DIR, 'study-section-comments.json')
|
||||
export const STUDY_CERTIFICATES_FILE = path.join(DATA_DIR, 'study-certificates.json')
|
||||
export const EPISODE_SCRIPTS_FILE = path.join(DATA_DIR, 'episode-scripts.json')
|
||||
export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
|
||||
|
||||
export const DIST_DIR = path.join(ROOT_DIR, 'dist')
|
||||
export const INDEX_FILE = path.join(DIST_DIR, 'index.html')
|
||||
@@ -60,6 +64,8 @@ export const CONTACT_EMAIL_COOLDOWN_MS = Math.max(10 * 1000, Number(process.env.
|
||||
export const DOWNLOAD_TOKEN_TTL_MS = 10 * 60 * 1000
|
||||
|
||||
export const MAX_QUESTIONS = 1000
|
||||
export const MAX_STUDY_COMMENTS = 10000
|
||||
export const MAX_STUDY_COMMENT_LENGTH = 2000
|
||||
export const MAX_STUDY_USERS = 5000
|
||||
export const MAX_STUDY_ENROLLMENTS_PER_USER = 100
|
||||
export const MAX_STUDY_NOTES_PER_USER = 500
|
||||
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
STUDY_NOTES_FILE,
|
||||
STUDY_PROGRESS_DIR,
|
||||
STUDY_REMINDERS_FILE,
|
||||
STUDY_COMMENTS_FILE,
|
||||
STUDY_CERTIFICATES_FILE,
|
||||
EPISODE_SCRIPTS_FILE,
|
||||
REPLY_TEMPLATES_FILE,
|
||||
REPLY_HISTORY_FILE,
|
||||
PODCAST_CHECKLIST_FILE,
|
||||
@@ -35,6 +38,7 @@ import {
|
||||
MAX_STUDY_ENROLLMENTS_PER_USER,
|
||||
MAX_STUDY_NOTES_PER_USER,
|
||||
MAX_STUDY_NOTE_LENGTH,
|
||||
MAX_STUDY_COMMENTS,
|
||||
DEFAULT_PODCAST_CHECKLIST_TASKS,
|
||||
buildDefaultPodcastChecklist,
|
||||
} from './config.js'
|
||||
@@ -572,6 +576,75 @@ export async function loadStudyRemindersFromDisk() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Study section comments ─────────────────────────────────────────────────
|
||||
|
||||
export function queueStudyCommentsWrite() {
|
||||
state.studyCommentsWritePromise = state.studyCommentsWritePromise
|
||||
.then(async () => {
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(STUDY_COMMENTS_FILE, JSON.stringify(state.studyComments, null, 2), 'utf8')
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[study-comments] failed to write:', err)
|
||||
})
|
||||
}
|
||||
|
||||
export async function loadStudyCommentsFromDisk() {
|
||||
try {
|
||||
const raw = await readFile(STUDY_COMMENTS_FILE, 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
state.studyComments = Array.isArray(parsed) ? parsed.slice(0, MAX_STUDY_COMMENTS) : []
|
||||
} catch {
|
||||
state.studyComments = []
|
||||
}
|
||||
}
|
||||
|
||||
// ── Study certificates ─────────────────────────────────────────────────────
|
||||
|
||||
export function queueStudyCertificatesWrite() {
|
||||
state.studyCertificatesWritePromise = state.studyCertificatesWritePromise
|
||||
.then(async () => {
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(STUDY_CERTIFICATES_FILE, JSON.stringify(state.studyCertificates, null, 2), 'utf8')
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[study-certificates] failed to write:', err)
|
||||
})
|
||||
}
|
||||
|
||||
export async function loadStudyCertificatesFromDisk() {
|
||||
try {
|
||||
const raw = await readFile(STUDY_CERTIFICATES_FILE, 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
state.studyCertificates = Array.isArray(parsed) ? parsed : []
|
||||
} catch {
|
||||
state.studyCertificates = []
|
||||
}
|
||||
}
|
||||
|
||||
// ── Episode scripts ───────────────────────────────────────────────────────
|
||||
|
||||
export function queueEpisodeScriptsWrite() {
|
||||
state.episodeScriptsWritePromise = state.episodeScriptsWritePromise
|
||||
.then(async () => {
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(EPISODE_SCRIPTS_FILE, JSON.stringify(state.episodeScripts, null, 2), 'utf8')
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[episode-scripts] failed to write:', err)
|
||||
})
|
||||
}
|
||||
|
||||
export async function loadEpisodeScriptsFromDisk() {
|
||||
try {
|
||||
const raw = await readFile(EPISODE_SCRIPTS_FILE, 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
state.episodeScripts = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {}
|
||||
} catch {
|
||||
state.episodeScripts = {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Download counts ────────────────────────────────────────────────────────
|
||||
|
||||
export function queueDownloadCountsWrite() {
|
||||
|
||||
+57
-10
@@ -163,13 +163,47 @@ export function register(app) {
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.get('/api/admin-stats', requireAdminAuth, (_req, res) => {
|
||||
const topPaths = Object.entries(state.hitStats.byPath)
|
||||
.sort((a, b) => b[1] - a[1]).slice(0, 10).map(([pathKey, hits]) => ({ path: pathKey, hits }))
|
||||
const topPathsReal = Object.entries(state.hitStats.byPathReal)
|
||||
.sort((a, b) => b[1] - a[1]).slice(0, 10).map(([pathKey, hits]) => ({ path: pathKey, hits }))
|
||||
const topPathsBot = Object.entries(state.hitStats.byPathBot)
|
||||
.sort((a, b) => b[1] - a[1]).slice(0, 10).map(([pathKey, hits]) => ({ path: pathKey, hits }))
|
||||
app.get('/api/admin-stats', requireAdminAuth, (req, res) => {
|
||||
// Time-range filter: 7d | 30d | 90d (default: all-time for aggregate, 30d for charts)
|
||||
const rangeParam = req.query.range
|
||||
const rangeDays = rangeParam === '7d' ? 7 : rangeParam === '90d' ? 90 : 30
|
||||
const rangeLabel = rangeParam === '7d' ? '7d' : rangeParam === '90d' ? '90d' : '30d'
|
||||
|
||||
// Compute a cutoff date string (YYYY-MM-DD) for filtering daily buckets
|
||||
const cutoffDate = (() => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - (rangeDays - 1))
|
||||
return d.toISOString().slice(0, 10)
|
||||
})()
|
||||
|
||||
// Filter byDayReal/byDayBot keys to only those within the range
|
||||
const filteredDayKeys = Object.keys(state.hitStats.byDayReal ?? {}).filter(day => day >= cutoffDate)
|
||||
|
||||
// Aggregate hits for the range
|
||||
const rangeRealHits = filteredDayKeys.reduce((sum, day) => sum + (state.hitStats.byDayReal?.[day] ?? 0), 0)
|
||||
const rangeBotHits = filteredDayKeys.reduce((sum, day) => sum + (state.hitStats.byDayBot?.[day] ?? 0), 0)
|
||||
const rangeTotalHits = rangeRealHits + rangeBotHits
|
||||
|
||||
// Filter per-path stats by range — approximate using recent visitor rows scoped to range
|
||||
const rangeVisitorRows = state.visitorStats.recentVisits.filter(row => {
|
||||
if (!row.visitedAt) return true // include if no timestamp
|
||||
return row.visitedAt >= cutoffDate
|
||||
})
|
||||
|
||||
const rangePathCountsReal = {}
|
||||
const rangePathCountsBot = {}
|
||||
for (const row of rangeVisitorRows) {
|
||||
const p = row.path ?? '/'
|
||||
if (row.isBot) rangePathCountsBot[p] = (rangePathCountsBot[p] ?? 0) + 1
|
||||
else rangePathCountsReal[p] = (rangePathCountsReal[p] ?? 0) + 1
|
||||
}
|
||||
const topPathsReal = Object.entries(rangePathCountsReal).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([path, hits]) => ({ path, hits }))
|
||||
const topPathsBot = Object.entries(rangePathCountsBot).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([path, hits]) => ({ path, hits }))
|
||||
const topPaths = [...topPathsReal, ...topPathsBot].reduce((acc, { path, hits }) => {
|
||||
const existing = acc.find(x => x.path === path)
|
||||
if (existing) existing.hits += hits; else acc.push({ path, hits })
|
||||
return acc
|
||||
}, []).sort((a, b) => b.hits - a.hits).slice(0, 10)
|
||||
|
||||
const last7Days = buildLastNDaysStats(7)
|
||||
const last7DaysReal = last7Days.map(item => ({ day: item.day, hits: state.hitStats.byDayReal?.[item.day] ?? 0 }))
|
||||
@@ -183,7 +217,7 @@ export function register(app) {
|
||||
const botReasons = Object.entries(state.hitStats.botReasons ?? {})
|
||||
.sort((a, b) => b[1] - a[1]).slice(0, 10).map(([reason, count]) => ({ reason, count }))
|
||||
|
||||
const recentVisitorRows = state.visitorStats.recentVisits.slice(0, 100).map(row => {
|
||||
const recentVisitorRows = rangeVisitorRows.slice(0, 100).map(row => {
|
||||
const fullVisitor = state.visitorStats.visitors[row.visitorId]
|
||||
return { ...row, pageHistory: fullVisitor?.pageHistory ?? [] }
|
||||
})
|
||||
@@ -217,10 +251,22 @@ export function register(app) {
|
||||
const enrolledUsers = state.studyUsers.filter(user => (user.enrolledStudySlugs?.length ?? 0) > 0).length
|
||||
const totalEnrollments = Object.values(enrollmentCountsBySlug).reduce((sum, count) => sum + count, 0)
|
||||
|
||||
// Enrollment funnel: signups → first study visit → first section completed
|
||||
const funnelSignups = state.studyUsers.length
|
||||
const funnelFirstVisit = state.studyUsers.filter(u => u.firstVisitAt).length
|
||||
const funnelFirstCompletion = state.studyUsers.filter(u => u.firstCompletionAt).length
|
||||
|
||||
res.json({
|
||||
totalHits: state.hitStats.totalHits,
|
||||
realHits: state.hitStats.realHits ?? 0,
|
||||
botHits: state.hitStats.botHits ?? 0,
|
||||
realHits: rangeRealHits,
|
||||
botHits: rangeBotHits,
|
||||
rangeTotalHits,
|
||||
rangeLabel,
|
||||
rangeDays,
|
||||
// All-time totals for reference
|
||||
allTimeRealHits: state.hitStats.realHits ?? 0,
|
||||
allTimeBotHits: state.hitStats.botHits ?? 0,
|
||||
allTimeTotalHits: state.hitStats.totalHits,
|
||||
firstHitAt: state.hitStats.firstHitAt,
|
||||
lastHitAt: state.hitStats.lastHitAt,
|
||||
topPaths, topPathsReal, topPathsBot,
|
||||
@@ -273,6 +319,7 @@ export function register(app) {
|
||||
totalEnrollments,
|
||||
enrollmentsByStudy,
|
||||
users,
|
||||
funnel: { signups: funnelSignups, firstVisit: funnelFirstVisit, firstCompletion: funnelFirstCompletion },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import mammoth from 'mammoth'
|
||||
import { requireAdminAuth } from '../auth.js'
|
||||
import { state } from '../state.js'
|
||||
import { queueEpisodeScriptsWrite } from '../data.js'
|
||||
import { MAX_EPISODE_SCRIPT_LENGTH } from '../config.js'
|
||||
|
||||
/**
|
||||
* Simple whitespace-normalised search over episode script text.
|
||||
* Returns { episodeNumber, title, filename, snippet, score } for matches.
|
||||
*/
|
||||
function searchScripts(query) {
|
||||
if (!query || query.trim().length < 2) return []
|
||||
const q = query.trim().toLowerCase()
|
||||
const words = q.split(/\s+/).filter(w => w.length >= 2)
|
||||
const results = []
|
||||
|
||||
for (const [episodeNumber, entry] of Object.entries(state.episodeScripts)) {
|
||||
if (!entry?.text) continue
|
||||
const text = entry.text.toLowerCase()
|
||||
|
||||
// Count how many words appear in the text
|
||||
const matchCount = words.filter(w => text.includes(w)).length
|
||||
if (matchCount === 0) continue
|
||||
|
||||
// Find a snippet around the first matching word
|
||||
const firstWord = words.find(w => text.includes(w))
|
||||
const idx = firstWord ? text.indexOf(firstWord) : 0
|
||||
const start = Math.max(0, idx - 80)
|
||||
const end = Math.min(entry.text.length, idx + 180)
|
||||
let snippet = entry.text.slice(start, end).replace(/\s+/g, ' ').trim()
|
||||
if (start > 0) snippet = '…' + snippet
|
||||
if (end < entry.text.length) snippet = snippet + '…'
|
||||
|
||||
results.push({
|
||||
episodeNumber,
|
||||
title: entry.title ?? `Episode ${episodeNumber}`,
|
||||
filename: entry.filename ?? '',
|
||||
snippet,
|
||||
score: matchCount / words.length, // 0–1: fraction of query words found
|
||||
})
|
||||
}
|
||||
|
||||
return results.sort((a, b) => b.score - a.score).slice(0, 8)
|
||||
}
|
||||
|
||||
export function register(app) {
|
||||
// Public script search — called by the frontend global search
|
||||
app.get('/api/episode-scripts/search', (req, res) => {
|
||||
const q = typeof req.query.q === 'string' ? req.query.q.trim() : ''
|
||||
if (!q || q.length < 2) { res.json({ results: [] }); return }
|
||||
res.json({ results: searchScripts(q) })
|
||||
})
|
||||
|
||||
// Admin: list all uploaded scripts
|
||||
app.get('/api/admin-episode-scripts', requireAdminAuth, (_req, res) => {
|
||||
const list = Object.entries(state.episodeScripts).map(([episodeNumber, entry]) => ({
|
||||
episodeNumber,
|
||||
title: entry.title ?? `Episode ${episodeNumber}`,
|
||||
filename: entry.filename ?? '',
|
||||
uploadedAt: entry.uploadedAt ?? null,
|
||||
wordCount: entry.text ? entry.text.split(/\s+/).filter(Boolean).length : 0,
|
||||
}))
|
||||
list.sort((a, b) => Number(a.episodeNumber) - Number(b.episodeNumber))
|
||||
res.json({ scripts: list })
|
||||
})
|
||||
|
||||
// Admin: upload a docx script for an episode
|
||||
app.post('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, async (req, res) => {
|
||||
const episodeNumber = req.params.episodeNumber?.trim()
|
||||
if (!episodeNumber || !/^\d+$/.test(episodeNumber)) {
|
||||
res.status(400).json({ message: 'Episode number must be a positive integer.' }); return
|
||||
}
|
||||
|
||||
const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : ''
|
||||
const filename = typeof req.body?.filename === 'string' ? req.body.filename.trim() : `episode-${episodeNumber}.docx`
|
||||
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : `Episode ${episodeNumber}`
|
||||
|
||||
if (!dataUrl.startsWith('data:')) {
|
||||
res.status(400).json({ message: 'dataUrl must be a valid data URL.' }); return
|
||||
}
|
||||
|
||||
const base64 = dataUrl.split(',')[1] ?? ''
|
||||
if (!base64) { res.status(400).json({ message: 'Empty file.' }); return }
|
||||
|
||||
const buffer = Buffer.from(base64, 'base64')
|
||||
if (buffer.length === 0 || buffer.length > 20 * 1024 * 1024) {
|
||||
res.status(400).json({ message: 'File must be between 1 byte and 20MB.' }); return
|
||||
}
|
||||
|
||||
let text
|
||||
try {
|
||||
const result = await mammoth.extractRawText({ buffer })
|
||||
text = result.value.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim()
|
||||
} catch (err) {
|
||||
console.error('[episode-scripts] mammoth extraction failed:', err.message)
|
||||
res.status(422).json({ message: 'Could not extract text from file. Make sure it is a valid .docx file.' }); return
|
||||
}
|
||||
|
||||
if (!text || text.length < 10) {
|
||||
res.status(422).json({ message: 'No readable text found in document.' }); return
|
||||
}
|
||||
|
||||
// Trim to max length
|
||||
if (text.length > MAX_EPISODE_SCRIPT_LENGTH) {
|
||||
text = text.slice(0, MAX_EPISODE_SCRIPT_LENGTH)
|
||||
}
|
||||
|
||||
state.episodeScripts[episodeNumber] = {
|
||||
title,
|
||||
filename,
|
||||
text,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
wordCount: text.split(/\s+/).filter(Boolean).length,
|
||||
}
|
||||
queueEpisodeScriptsWrite()
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
episodeNumber,
|
||||
wordCount: state.episodeScripts[episodeNumber].wordCount,
|
||||
})
|
||||
})
|
||||
|
||||
// Admin: delete a script
|
||||
app.delete('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, (req, res) => {
|
||||
const episodeNumber = req.params.episodeNumber?.trim()
|
||||
if (!state.episodeScripts[episodeNumber]) {
|
||||
res.status(404).json({ message: 'Script not found.' }); return
|
||||
}
|
||||
delete state.episodeScripts[episodeNumber]
|
||||
queueEpisodeScriptsWrite()
|
||||
res.json({ ok: true })
|
||||
})
|
||||
}
|
||||
@@ -12,7 +12,14 @@ function ensureDraftQuestions() {
|
||||
export function register(app) {
|
||||
app.get('/api/questions', (_req, res) => {
|
||||
const sourceQuestions = state.draftQuestions ?? state.questions
|
||||
const publicQuestions = sourceQuestions.filter(q => q.isApproved === true && q.answer && q.answer.trim().length > 0)
|
||||
const publicQuestions = sourceQuestions
|
||||
.filter(q => q.isApproved === true && q.answer && q.answer.trim().length > 0)
|
||||
.sort((a, b) => {
|
||||
// Pinned questions always float to top
|
||||
if (a.pinned && !b.pinned) return -1
|
||||
if (!a.pinned && b.pinned) return 1
|
||||
return 0
|
||||
})
|
||||
res.json({ questions: publicQuestions })
|
||||
})
|
||||
|
||||
@@ -113,4 +120,41 @@ export function register(app) {
|
||||
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// Anonymous upvote — no auth required, lightweight increment
|
||||
app.post('/api/questions/:id/upvote', (req, res) => {
|
||||
const { id } = req.params
|
||||
// Look in live questions first, then draft
|
||||
const liveQ = state.questions.find(q => q.id === id && q.isApproved === true)
|
||||
const draftQ = state.draftQuestions ? state.draftQuestions.find(q => q.id === id) : null
|
||||
const question = liveQ ?? draftQ
|
||||
if (!question) {
|
||||
res.status(404).json({ message: 'Question not found.' }); return
|
||||
}
|
||||
question.upvotes = ((question.upvotes ?? 0) + 1)
|
||||
if (liveQ) queueQuestionsWrite()
|
||||
if (draftQ) queueDraftQuestionsWrite()
|
||||
res.json({ ok: true, upvotes: question.upvotes })
|
||||
})
|
||||
|
||||
// Admin: pin / unpin a question
|
||||
app.post('/api/admin-questions/:id/pin', requireAdminAuth, (req, res) => {
|
||||
const { id } = req.params
|
||||
ensureDraftQuestions()
|
||||
const question = state.draftQuestions.find(q => q.id === id)
|
||||
if (!question) { res.status(404).json({ message: 'Question not found.' }); return }
|
||||
question.pinned = true
|
||||
queueDraftQuestionsWrite()
|
||||
res.json({ ok: true, question })
|
||||
})
|
||||
|
||||
app.delete('/api/admin-questions/:id/pin', requireAdminAuth, (req, res) => {
|
||||
const { id } = req.params
|
||||
ensureDraftQuestions()
|
||||
const question = state.draftQuestions.find(q => q.id === id)
|
||||
if (!question) { res.status(404).json({ message: 'Question not found.' }); return }
|
||||
question.pinned = false
|
||||
queueDraftQuestionsWrite()
|
||||
res.json({ ok: true, question })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { requireAdminAuth } from '../auth.js'
|
||||
import { requireStudyAuth, normalizeStudySlug, isStudyUserEnrolled, getStudyTitleBySlug } from '../study-helpers.js'
|
||||
import { state } from '../state.js'
|
||||
import { loadUserProgress, queueStudyCertificatesWrite } from '../data.js'
|
||||
|
||||
/**
|
||||
* Returns the list of required section IDs for a study (only released sections).
|
||||
*/
|
||||
function getRequiredSectionIds(studySlug) {
|
||||
const content = state.cachedSiteContent
|
||||
if (!content || !Array.isArray(content.studies)) return null
|
||||
const study = content.studies.find(s => s?.slug === studySlug)
|
||||
if (!study || !Array.isArray(study.sections)) return null
|
||||
return study.sections
|
||||
.filter(s => s?.status !== 'unreleased' && s?.id)
|
||||
.map(s => s.id)
|
||||
}
|
||||
|
||||
export function register(app) {
|
||||
// GET — check eligibility and return existing certificate token if one exists
|
||||
app.get('/api/study-certificate/:studySlug', requireStudyAuth, async (req, res) => {
|
||||
const user = req.studyUser
|
||||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||||
if (!studySlug) { res.status(400).json({ message: 'Invalid study slug.' }); return }
|
||||
|
||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
||||
res.status(403).json({ message: 'Not enrolled in this study.' }); return
|
||||
}
|
||||
|
||||
const requiredIds = getRequiredSectionIds(studySlug)
|
||||
if (!requiredIds || requiredIds.length === 0) {
|
||||
res.status(404).json({ message: 'Study sections not found.' }); return
|
||||
}
|
||||
|
||||
const progress = await loadUserProgress(user.id)
|
||||
const completedIds = progress.byStudy[studySlug]?.completedSectionIds ?? []
|
||||
const eligible = requiredIds.every(id => completedIds.includes(id))
|
||||
|
||||
const existing = state.studyCertificates.find(c => c.userId === user.id && c.studySlug === studySlug)
|
||||
|
||||
res.json({
|
||||
eligible,
|
||||
studyTitle: getStudyTitleBySlug(studySlug) || studySlug,
|
||||
completedCount: completedIds.filter(id => requiredIds.includes(id)).length,
|
||||
totalCount: requiredIds.length,
|
||||
token: existing?.token ?? null,
|
||||
issuedAt: existing?.issuedAt ?? null,
|
||||
})
|
||||
})
|
||||
|
||||
// POST — issue/re-issue a certificate (must be eligible)
|
||||
app.post('/api/study-certificate/:studySlug', requireStudyAuth, async (req, res) => {
|
||||
const user = req.studyUser
|
||||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||||
if (!studySlug) { res.status(400).json({ message: 'Invalid study slug.' }); return }
|
||||
|
||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
||||
res.status(403).json({ message: 'Not enrolled in this study.' }); return
|
||||
}
|
||||
|
||||
const requiredIds = getRequiredSectionIds(studySlug)
|
||||
if (!requiredIds || requiredIds.length === 0) {
|
||||
res.status(404).json({ message: 'Study sections not found.' }); return
|
||||
}
|
||||
|
||||
const progress = await loadUserProgress(user.id)
|
||||
const completedIds = progress.byStudy[studySlug]?.completedSectionIds ?? []
|
||||
const eligible = requiredIds.every(id => completedIds.includes(id))
|
||||
|
||||
if (!eligible) {
|
||||
res.status(403).json({ message: 'Complete all sections to earn your certificate.' }); return
|
||||
}
|
||||
|
||||
// Find or create
|
||||
let cert = state.studyCertificates.find(c => c.userId === user.id && c.studySlug === studySlug)
|
||||
if (!cert) {
|
||||
cert = {
|
||||
id: randomUUID(),
|
||||
token: randomUUID(),
|
||||
userId: user.id,
|
||||
studySlug,
|
||||
studyTitle: getStudyTitleBySlug(studySlug) || studySlug,
|
||||
displayName: user.displayName || user.username,
|
||||
issuedAt: new Date().toISOString(),
|
||||
}
|
||||
state.studyCertificates.push(cert)
|
||||
queueStudyCertificatesWrite()
|
||||
}
|
||||
|
||||
res.json({ ok: true, token: cert.token, issuedAt: cert.issuedAt })
|
||||
})
|
||||
|
||||
// GET — public certificate page data (no auth, by token)
|
||||
app.get('/api/public/certificate/:token', (req, res) => {
|
||||
const { token } = req.params
|
||||
if (!token || typeof token !== 'string' || token.length > 100) {
|
||||
res.status(400).json({ message: 'Invalid token.' }); return
|
||||
}
|
||||
|
||||
const cert = state.studyCertificates.find(c => c.token === token)
|
||||
if (!cert) { res.status(404).json({ message: 'Certificate not found.' }); return }
|
||||
|
||||
res.json({
|
||||
studyTitle: cert.studyTitle,
|
||||
displayName: cert.displayName,
|
||||
issuedAt: cert.issuedAt,
|
||||
})
|
||||
})
|
||||
|
||||
// Admin: list all certificates
|
||||
app.get('/api/admin-study-certificates', requireAdminAuth, (_req, res) => {
|
||||
res.json({ certificates: state.studyCertificates })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { requireAdminAuth } from '../auth.js'
|
||||
import { requireStudyAuth, normalizeStudySlug, normalizeLessonSectionId, isStudyUserEnrolled } from '../study-helpers.js'
|
||||
import { state } from '../state.js'
|
||||
import { queueStudyCommentsWrite } from '../data.js'
|
||||
import { MAX_STUDY_COMMENTS, MAX_STUDY_COMMENT_LENGTH } from '../config.js'
|
||||
|
||||
export function register(app) {
|
||||
// GET public-approved comments for a section (no auth)
|
||||
app.get('/api/study-comments/:studySlug/:sectionId', (req, res) => {
|
||||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||||
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
||||
if (!studySlug || !sectionId) {
|
||||
res.status(400).json({ message: 'Invalid study slug or section id.' }); return
|
||||
}
|
||||
|
||||
const comments = state.studyComments
|
||||
.filter(c => c.studySlug === studySlug && c.sectionId === sectionId && c.isApproved === true)
|
||||
.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
|
||||
.map(c => ({
|
||||
id: c.id,
|
||||
displayName: c.displayName,
|
||||
text: c.text,
|
||||
createdAt: c.createdAt,
|
||||
}))
|
||||
|
||||
res.json({ comments })
|
||||
})
|
||||
|
||||
// POST — enrolled study users post a comment (auto-approved for enrolled users)
|
||||
app.post('/api/study-comments/:studySlug/:sectionId', requireStudyAuth, (req, res) => {
|
||||
const user = req.studyUser
|
||||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||||
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
||||
|
||||
if (!studySlug || !sectionId) {
|
||||
res.status(400).json({ message: 'Invalid study slug or section id.' }); return
|
||||
}
|
||||
|
||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to leave a comment.' }); return
|
||||
}
|
||||
|
||||
const text = typeof req.body?.text === 'string' ? req.body.text.trim() : ''
|
||||
if (!text || text.length < 2 || text.length > MAX_STUDY_COMMENT_LENGTH) {
|
||||
res.status(400).json({ message: `Comment must be between 2 and ${MAX_STUDY_COMMENT_LENGTH} characters.` }); return
|
||||
}
|
||||
|
||||
// Auto-approve for enrolled users
|
||||
const now = new Date().toISOString()
|
||||
const comment = {
|
||||
id: randomUUID(),
|
||||
studySlug,
|
||||
sectionId,
|
||||
userId: user.id,
|
||||
displayName: user.displayName || user.username,
|
||||
text,
|
||||
createdAt: now,
|
||||
isApproved: true,
|
||||
approvedAt: now,
|
||||
}
|
||||
|
||||
state.studyComments.unshift(comment)
|
||||
if (state.studyComments.length > MAX_STUDY_COMMENTS) {
|
||||
state.studyComments = state.studyComments.slice(0, MAX_STUDY_COMMENTS)
|
||||
}
|
||||
queueStudyCommentsWrite()
|
||||
|
||||
res.status(201).json({
|
||||
ok: true,
|
||||
comment: {
|
||||
id: comment.id,
|
||||
displayName: comment.displayName,
|
||||
text: comment.text,
|
||||
createdAt: comment.createdAt,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// DELETE — enrolled users can delete their own comment
|
||||
app.delete('/api/study-comments/:commentId', requireStudyAuth, (req, res) => {
|
||||
const user = req.studyUser
|
||||
const { commentId } = req.params
|
||||
const idx = state.studyComments.findIndex(c => c.id === commentId && c.userId === user.id)
|
||||
if (idx === -1) {
|
||||
res.status(404).json({ message: 'Comment not found.' }); return
|
||||
}
|
||||
state.studyComments.splice(idx, 1)
|
||||
queueStudyCommentsWrite()
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// Admin: list all comments (optionally filter by studySlug)
|
||||
app.get('/api/admin-study-comments', requireAdminAuth, (req, res) => {
|
||||
const { studySlug, sectionId, approved } = req.query
|
||||
let comments = state.studyComments
|
||||
if (studySlug) comments = comments.filter(c => c.studySlug === studySlug)
|
||||
if (sectionId) comments = comments.filter(c => c.sectionId === sectionId)
|
||||
if (approved === 'true') comments = comments.filter(c => c.isApproved === true)
|
||||
if (approved === 'false') comments = comments.filter(c => c.isApproved !== true)
|
||||
res.json({ comments: comments.slice(0, 500) })
|
||||
})
|
||||
|
||||
// Admin: approve a comment
|
||||
app.post('/api/admin-study-comments/:commentId/approve', requireAdminAuth, (req, res) => {
|
||||
const comment = state.studyComments.find(c => c.id === req.params.commentId)
|
||||
if (!comment) { res.status(404).json({ message: 'Comment not found.' }); return }
|
||||
comment.isApproved = true
|
||||
comment.approvedAt = new Date().toISOString()
|
||||
queueStudyCommentsWrite()
|
||||
res.json({ ok: true, comment })
|
||||
})
|
||||
|
||||
// Admin: delete any comment
|
||||
app.delete('/api/admin-study-comments/:commentId', requireAdminAuth, (req, res) => {
|
||||
const idx = state.studyComments.findIndex(c => c.id === req.params.commentId)
|
||||
if (idx === -1) { res.status(404).json({ message: 'Comment not found.' }); return }
|
||||
state.studyComments.splice(idx, 1)
|
||||
queueStudyCommentsWrite()
|
||||
res.json({ ok: true })
|
||||
})
|
||||
}
|
||||
@@ -154,6 +154,13 @@ export function register(app) {
|
||||
|
||||
const progress = await loadUserProgress(user.id)
|
||||
const completedSectionIds = progress.byStudy[studySlug]?.completedSectionIds ?? []
|
||||
|
||||
// Record first visit timestamp on the user record (additive, never overwrite)
|
||||
if (!user.firstVisitAt) {
|
||||
user.firstVisitAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
}
|
||||
|
||||
res.json({ studySlug, completedSectionIds })
|
||||
})
|
||||
|
||||
@@ -181,6 +188,12 @@ export function register(app) {
|
||||
state.studyProgressCache.set(user.id, progress)
|
||||
queueUserProgressWrite(user.id)
|
||||
|
||||
// Record first completion timestamp on the user record (additive, never overwrite)
|
||||
if (!user.firstCompletionAt) {
|
||||
user.firstCompletionAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
}
|
||||
|
||||
res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds })
|
||||
})
|
||||
|
||||
|
||||
@@ -46,6 +46,16 @@ export const state = {
|
||||
studyReminders: { users: {}, updatedAt: new Date().toISOString() },
|
||||
studyRemindersWritePromise: Promise.resolve(),
|
||||
|
||||
studyComments: [],
|
||||
studyCommentsWritePromise: Promise.resolve(),
|
||||
|
||||
studyCertificates: [],
|
||||
studyCertificatesWritePromise: Promise.resolve(),
|
||||
|
||||
// episodeScripts: { [episodeNumber]: { text, title, filename, uploadedAt } }
|
||||
episodeScripts: {},
|
||||
episodeScriptsWritePromise: Promise.resolve(),
|
||||
|
||||
downloadCounts: {},
|
||||
downloadCountsWritePromise: Promise.resolve(),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user