Files
Siteforge/server/routes/admin-assets.js
T
nmemmertandClaude Sonnet 4.6 038ef2b039 Add study account engagement features; v1.2.4
- Pause reminders: remindersPausedUntil field + scheduler skip + account UI with 7/14/30-day presets
- Email digest mode: reminderFrequency field (immediate/weekly) + weekly digest email + scheduler + account UI toggle
- Admin user list improvements: streak, reengagement, frequency, pause, per-study lesson progress
- Admin manual re-engagement: POST endpoint + tier selector button in admin panel
- Study bookmarks: bookmarkedSectionIds in progress file + POST/DELETE endpoints + toggle button in lesson UI
- Study completion certificate email: auto-sent on first certificate issuance
- Content fields for certificate and weekly digest email templates in admin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-03 15:16:47 -04:00

248 lines
9.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mkdir, stat, unlink, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { inferImageExtensionFromDataUrl, normalizeAssetBaseName } from '../helpers.js'
import { requireAdminAuth } from '../auth.js'
import { UPLOADS_DIR } from '../config.js'
import { state } from '../state.js'
import {
listUploadedAssets,
readUploadsMetadata,
writeUploadsMetadata,
getUserNotesFilePath,
loadUserProgress,
queueStudyUsersWrite,
} from '../data.js'
import {
hashStudyPassword,
getStudyCatalog,
normalizeStudySlug,
isEnrollableStudySlug,
makeUnsubUrl,
} from '../study-helpers.js'
import { sendStudyReengagementEmail } from '../email.js'
export function register(app) {
app.get('/api/admin-assets', requireAdminAuth, async (_req, res) => {
try {
const assets = await listUploadedAssets()
res.json({ assets })
} catch {
res.status(500).json({ message: 'Could not list uploaded assets.' })
}
})
app.post('/api/admin-assets', requireAdminAuth, async (req, res) => {
try {
const filename = typeof req.body?.filename === 'string' ? req.body.filename : ''
const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : ''
const ext = inferImageExtensionFromDataUrl(dataUrl)
if (!ext) {
res.status(400).json({ message: 'Upload must be a PNG, JPG, WEBP, GIF, PDF, DOC, or DOCX data URL.' })
return
}
const base64 = dataUrl.split(',')[1] ?? ''
const buffer = Buffer.from(base64, 'base64')
if (buffer.length === 0 || buffer.length > (8 * 1024 * 1024)) {
res.status(400).json({ message: 'Upload must be between 1 byte and 8MB.' })
return
}
const baseName = normalizeAssetBaseName(filename.replace(/\.[a-z0-9]+$/i, ''))
const finalName = `${baseName}-${Date.now()}${ext}`
await mkdir(UPLOADS_DIR, { recursive: true })
await writeFile(path.join(UPLOADS_DIR, finalName), buffer)
const metadata = await readUploadsMetadata()
metadata[finalName] = []
await writeUploadsMetadata(metadata)
res.json({ ok: true, asset: { filename: finalName, url: `/uploads/${finalName}` } })
} catch {
res.status(500).json({ message: 'Upload failed.' })
}
})
app.patch('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => {
try {
const { filename } = req.params
if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..')) {
res.status(400).json({ message: 'Invalid filename.' })
return
}
const tags = Array.isArray(req.body?.tags)
? req.body.tags.filter(tag => typeof tag === 'string').map(tag => tag.trim()).filter(Boolean)
: []
const filePath = path.join(UPLOADS_DIR, filename)
await stat(filePath)
const metadata = await readUploadsMetadata()
metadata[filename] = tags
await writeUploadsMetadata(metadata)
res.json({ ok: true, tags })
} catch {
res.status(404).json({ message: 'Asset not found.' })
}
})
app.delete('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => {
try {
const { filename } = req.params
if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..')) {
res.status(400).json({ message: 'Invalid filename.' })
return
}
await unlink(path.join(UPLOADS_DIR, filename))
const metadata = await readUploadsMetadata()
delete metadata[filename]
await writeUploadsMetadata(metadata)
res.json({ ok: true })
} catch {
res.status(404).json({ message: 'Asset not found.' })
}
})
// ── Admin: Study User Management ─────────────────────────────────────────
app.get('/api/admin/study-users', requireAdminAuth, async (req, res) => {
const catalog = getStudyCatalog()
const users = await Promise.all(state.studyUsers.map(async user => {
let noteCount = 0
try {
const { readFile } = await import('node:fs/promises')
const notesRaw = await readFile(getUserNotesFilePath(user.id), 'utf8').catch(() => '{}')
const notes = JSON.parse(notesRaw)
noteCount = Object.values(notes).filter(n => typeof n === 'string' && n.trim()).length
} catch { /* ignore */ }
let progressByStudy = {}
try {
const prog = await loadUserProgress(user.id)
for (const [slug, data] of Object.entries(prog.byStudy)) {
progressByStudy[slug] = { completedLessons: (data.completedSectionIds ?? []).length }
}
} catch { /* ignore */ }
const enrolledStudies = (user.enrolledStudySlugs ?? []).map(slug => {
const study = catalog.find(s => s.slug === slug)
return study
? { slug, title: study.title, completedLessons: progressByStudy[slug]?.completedLessons ?? 0 }
: { slug, title: slug, completedLessons: 0 }
})
return {
id: user.id,
username: user.username,
displayName: user.displayName ?? '',
createdAt: user.createdAt ?? null,
lastLoginAt: user.lastLoginAt ?? null,
enrolledStudies,
noteCount,
subscribeNewsletter: user.subscribeNewsletter !== false,
studyRemindersEnabled: user.studyRemindersEnabled === true,
reminderFrequency: user.reminderFrequency ?? 'immediate',
remindersPausedUntil: user.remindersPausedUntil ?? null,
currentStreak: user.currentStreak ?? 0,
longestStreak: user.longestStreak ?? 0,
reengagementSentAt: user.reengagementSentAt ?? {},
}
}))
res.json({ users })
})
app.post('/api/admin/study-users/:id/send-reengagement', requireAdminAuth, async (req, res) => {
const user = state.studyUsers.find(u => u.id === req.params.id)
if (!user) { res.status(404).json({ message: 'User not found.' }); return }
const tier = req.body?.tier
if (tier !== 't1' && tier !== 't2' && tier !== 't3') {
res.status(400).json({ message: 'tier must be t1, t2, or t3.' }); return
}
const studies = Array.isArray(state.cachedSiteContent?.studies) ? state.cachedSiteContent.studies : []
const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
const enrolledStudies = enrolledStudySlugs
.map(slug => studies.find(s => normalizeStudySlug(s?.slug) === slug))
.filter(Boolean)
if (enrolledStudies.length === 0) { res.status(400).json({ message: 'User has no enrolled studies.' }); return }
const firstStudy = enrolledStudies[0]
const studyTitle = enrolledStudies.length === 1 ? firstStudy.title : null
const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/'
const base = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
const studyUrl = enrolledStudies.length === 1 ? `${base}/study/${firstStudy.slug}` : `${base}/study`
const unsubUrl = makeUnsubUrl(user.id)
try {
await sendStudyReengagementEmail(user.username, user.displayName || user.username, studyTitle, studyUrl, tier, unsubUrl)
res.json({ ok: true })
} catch (err) {
console.error(`[admin] manual reengagement send failed for ${user.username}:`, err)
res.status(500).json({ message: 'Failed to send email.' })
}
})
app.patch('/api/admin/study-users/:id', requireAdminAuth, (req, res) => {
const user = state.studyUsers.find(u => u.id === req.params.id)
if (!user) { res.status(404).json({ message: 'User not found.' }); return }
const { displayName, newPassword, addEnrollment, removeEnrollment } = req.body ?? {}
if (typeof displayName === 'string') {
user.displayName = displayName.trim().slice(0, 80)
}
if (typeof newPassword === 'string') {
if (newPassword.length < 8 || newPassword.length > 200) {
res.status(400).json({ message: 'Password must be 8–200 characters.' }); return
}
user.passwordHash = hashStudyPassword(newPassword)
for (const [token, session] of state.studySessions) {
if (session.userId === user.id) state.studySessions.delete(token)
}
}
if (typeof addEnrollment === 'string' && addEnrollment.trim()) {
const slug = normalizeStudySlug(addEnrollment)
if (!slug || !isEnrollableStudySlug(slug)) {
res.status(400).json({ message: `Unknown study slug: ${addEnrollment.trim()}` }); return
}
if (!Array.isArray(user.enrolledStudySlugs)) user.enrolledStudySlugs = []
if (!user.enrolledStudySlugs.includes(slug)) user.enrolledStudySlugs.push(slug)
}
if (typeof removeEnrollment === 'string' && removeEnrollment.trim()) {
const slug = removeEnrollment.trim()
user.enrolledStudySlugs = (user.enrolledStudySlugs ?? []).filter(s => s !== slug)
}
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
res.json({ ok: true, displayName: user.displayName, enrolledStudySlugs: user.enrolledStudySlugs })
})
app.delete('/api/admin/study-users/:id', requireAdminAuth, async (req, res) => {
const user = state.studyUsers.find(u => u.id === req.params.id)
if (!user) { res.status(404).json({ message: 'User not found.' }); return }
for (const [token, session] of state.studySessions) {
if (session.userId === user.id) state.studySessions.delete(token)
}
state.studyUsers = state.studyUsers.filter(u => u.id !== user.id)
queueStudyUsersWrite()
state.studyNotesCache.delete(user.id)
try { await unlink(getUserNotesFilePath(user.id)) } catch { /* no notes file is fine */ }
res.json({ ok: true })
})
}