- 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>
248 lines
9.5 KiB
JavaScript
248 lines
9.5 KiB
JavaScript
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 })
|
||
})
|
||
}
|