Add QR code tracker to admin panel

Each QR code gets a /qr/<slug> redirect that logs scans (IP, user agent,
timestamp) to disk. The admin QR Codes view lets you add, edit, enable/disable,
and delete codes, with per-code scan history inline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-06-16 09:33:35 -04:00
parent 37fc69680e
commit 48c2f81d00
6 changed files with 308 additions and 2 deletions
+6 -1
View File
@@ -1,5 +1,6 @@
import express from 'express' import express from 'express'
import { hasVisitorConsent } from './server/helpers.js' import { hasVisitorConsent } from './server/helpers.js'
import { isValidAdminSession } from './server/auth.js'
import { BACKUP_INTERVAL_MS } from './server/config.js' import { BACKUP_INTERVAL_MS } from './server/config.js'
import { state } from './server/state.js' import { state } from './server/state.js'
import { import {
@@ -18,6 +19,7 @@ import {
loadEpisodeScriptsFromDisk, loadEpisodeScriptsFromDisk,
migrateStudyNotesIfNeeded, migrateStudyNotesIfNeeded,
loadDownloadCountsFromDisk, loadDownloadCountsFromDisk,
loadQrCodesFromDisk,
loadPodcastChecklistFromDisk, loadPodcastChecklistFromDisk,
createBackupSnapshot, createBackupSnapshot,
refreshContentCaches, refreshContentCaches,
@@ -48,6 +50,7 @@ import { register as registerQuestions } from './server/routes/questions.js'
import { register as registerAnalytics } from './server/routes/analytics.js' import { register as registerAnalytics } from './server/routes/analytics.js'
import { register as registerDownloads } from './server/routes/downloads.js' import { register as registerDownloads } from './server/routes/downloads.js'
import { register as registerEpisodes } from './server/routes/episodes.js' import { register as registerEpisodes } from './server/routes/episodes.js'
import { register as registerQrCodes } from './server/routes/qr-codes.js'
import { register as registerPublic } from './server/routes/public.js' import { register as registerPublic } from './server/routes/public.js'
const app = express() const app = express()
@@ -70,6 +73,7 @@ registerQuestions(app)
registerAnalytics(app) registerAnalytics(app)
registerDownloads(app) registerDownloads(app)
registerEpisodes(app) registerEpisodes(app)
registerQrCodes(app)
// Hit-counting middleware (must come before public routes) // Hit-counting middleware (must come before public routes)
app.use((req, res, next) => { app.use((req, res, next) => {
@@ -78,7 +82,7 @@ app.use((req, res, next) => {
const botDetection = detectBot(ua) const botDetection = detectBot(ua)
recordHit(req.path, botDetection.isBot, botDetection.reason) recordHit(req.path, botDetection.isBot, botDetection.reason)
queueHitStatsWrite() queueHitStatsWrite()
if (hasVisitorConsent(req) && !botDetection.isBot) { if (hasVisitorConsent(req) && !botDetection.isBot && !isValidAdminSession(req)) {
recordVisitor(req, res).catch(err => { recordVisitor(req, res).catch(err => {
console.error('[visitor-stats] failed to record visitor:', err) console.error('[visitor-stats] failed to record visitor:', err)
}) })
@@ -107,6 +111,7 @@ Promise.all([
loadEpisodeScriptsFromDisk(), loadEpisodeScriptsFromDisk(),
migrateStudyNotesIfNeeded(), migrateStudyNotesIfNeeded(),
loadDownloadCountsFromDisk(), loadDownloadCountsFromDisk(),
loadQrCodesFromDisk(),
loadPodcastChecklistFromDisk(), loadPodcastChecklistFromDisk(),
refreshContentCaches(), refreshContentCaches(),
]) ])
+1
View File
@@ -36,6 +36,7 @@ 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_COMMENTS_FILE = path.join(DATA_DIR, 'study-section-comments.json')
export const STUDY_CERTIFICATES_FILE = path.join(DATA_DIR, 'study-certificates.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 EPISODE_SCRIPTS_FILE = path.join(DATA_DIR, 'episode-scripts.json')
export const QR_CODES_FILE = path.join(DATA_DIR, 'qr-codes.json')
export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon 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 DIST_DIR = path.join(ROOT_DIR, 'dist')
+27
View File
@@ -20,6 +20,7 @@ import {
STUDY_COMMENTS_FILE, STUDY_COMMENTS_FILE,
STUDY_CERTIFICATES_FILE, STUDY_CERTIFICATES_FILE,
EPISODE_SCRIPTS_FILE, EPISODE_SCRIPTS_FILE,
QR_CODES_FILE,
REPLY_TEMPLATES_FILE, REPLY_TEMPLATES_FILE,
REPLY_HISTORY_FILE, REPLY_HISTORY_FILE,
PODCAST_CHECKLIST_FILE, PODCAST_CHECKLIST_FILE,
@@ -1216,3 +1217,29 @@ export function sanitizeStudyCommunityPosts(value) {
}) })
.filter(Boolean) .filter(Boolean)
} }
// ── QR Codes ───────────────────────────────────────────────────────────────
export function queueQrCodesWrite() {
state.qrCodesWritePromise = state.qrCodesWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(QR_CODES_FILE, JSON.stringify({ codes: state.qrCodes, scans: state.qrScans }, null, 2), 'utf8')
})
.catch(err => {
console.error('[qr-codes] failed to write:', err)
})
}
export function loadQrCodesFromDisk() {
return readFile(QR_CODES_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
state.qrCodes = Array.isArray(parsed?.codes) ? parsed.codes : []
state.qrScans = Array.isArray(parsed?.scans) ? parsed.scans : []
})
.catch(() => {
state.qrCodes = []
state.qrScans = []
})
}
+88
View File
@@ -0,0 +1,88 @@
import { randomUUID } from 'node:crypto'
import { requireAdminAuth } from '../auth.js'
import { state } from '../state.js'
import { queueQrCodesWrite } from '../data.js'
const MAX_SCANS = 5000
export function register(app) {
// Public redirect — logs the scan, then redirects to destination
app.get('/qr/:slug', (req, res) => {
const { slug } = req.params
const code = state.qrCodes.find(c => c.slug === slug && c.active !== false)
if (!code) { res.status(404).send('QR code not found.'); return }
const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket.remoteAddress || 'unknown'
const scan = {
id: randomUUID(),
qrId: code.id,
slug,
scannedAt: new Date().toISOString(),
ip,
userAgent: req.headers['user-agent'] || '',
}
state.qrScans.unshift(scan)
if (state.qrScans.length > MAX_SCANS) state.qrScans.length = MAX_SCANS
queueQrCodesWrite()
res.redirect(302, code.destination)
})
// ── Admin API ──────────────────────────────────────────────────────────────
app.get('/api/admin/qr-codes', requireAdminAuth, (_req, res) => {
const codesWithCounts = state.qrCodes.map(code => ({
...code,
scanCount: state.qrScans.filter(s => s.qrId === code.id).length,
}))
res.json({ codes: codesWithCounts, scans: state.qrScans.slice(0, 200) })
})
app.post('/api/admin/qr-codes', requireAdminAuth, (req, res) => {
const label = typeof req.body?.label === 'string' ? req.body.label.trim().slice(0, 100) : ''
const slug = typeof req.body?.slug === 'string' ? req.body.slug.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 80) : ''
const destination = typeof req.body?.destination === 'string' ? req.body.destination.trim() : ''
if (!label) { res.status(400).json({ message: 'Label is required.' }); return }
if (!slug || !/^[a-z0-9][a-z0-9-]*$/.test(slug)) { res.status(400).json({ message: 'Slug must start with a letter or digit and contain only lowercase letters, digits, and hyphens.' }); return }
if (!destination.startsWith('http')) { res.status(400).json({ message: 'Destination must be a URL.' }); return }
if (state.qrCodes.some(c => c.slug === slug)) { res.status(409).json({ message: 'A QR code with this slug already exists.' }); return }
const code = { id: randomUUID(), slug, label, destination, active: true, createdAt: new Date().toISOString() }
state.qrCodes.push(code)
queueQrCodesWrite()
res.json({ ok: true, code })
})
app.patch('/api/admin/qr-codes/:id', requireAdminAuth, (req, res) => {
const code = state.qrCodes.find(c => c.id === req.params.id)
if (!code) { res.status(404).json({ message: 'QR code not found.' }); return }
if (typeof req.body?.label === 'string') code.label = req.body.label.trim().slice(0, 100)
if (typeof req.body?.destination === 'string') {
if (!req.body.destination.trim().startsWith('http')) { res.status(400).json({ message: 'Destination must be a URL.' }); return }
code.destination = req.body.destination.trim()
}
if (typeof req.body?.active === 'boolean') code.active = req.body.active
queueQrCodesWrite()
res.json({ ok: true, code })
})
app.delete('/api/admin/qr-codes/:id', requireAdminAuth, (req, res) => {
const idx = state.qrCodes.findIndex(c => c.id === req.params.id)
if (idx === -1) { res.status(404).json({ message: 'QR code not found.' }); return }
const { id } = state.qrCodes[idx]
state.qrCodes.splice(idx, 1)
state.qrScans = state.qrScans.filter(s => s.qrId !== id)
queueQrCodesWrite()
res.json({ ok: true })
})
app.delete('/api/admin/qr-codes/:id/scans', requireAdminAuth, (req, res) => {
state.qrScans = state.qrScans.filter(s => s.qrId !== req.params.id)
queueQrCodesWrite()
res.json({ ok: true })
})
}
+6
View File
@@ -59,6 +59,12 @@ export const state = {
downloadCounts: {}, downloadCounts: {},
downloadCountsWritePromise: Promise.resolve(), downloadCountsWritePromise: Promise.resolve(),
// qrCodes: Array<{ id, slug, label, destination, createdAt }>
// qrScans: Array<{ id, qrId, slug, scannedAt, ip, userAgent }>
qrCodes: [],
qrScans: [],
qrCodesWritePromise: Promise.resolve(),
lastBackupStatus: { ok: true, at: null, error: null, file: null }, lastBackupStatus: { ok: true, at: null, error: null, file: null },
lastCachePurgeStatus: { ok: true, at: null, error: null }, lastCachePurgeStatus: { ok: true, at: null, error: null },
lastDeployHookStatus: { ok: true, at: null, error: null }, lastDeployHookStatus: { ok: true, at: null, error: null },
+180 -1
View File
@@ -673,7 +673,7 @@ type AdminView =
| 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact' | 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact'
| 'podcast' | 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series' | 'podcast' | 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series'
| 'downloads' | 'custom-links' | 'content-blocks' | 'downloads' | 'custom-links' | 'content-blocks'
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
| 'emails' | 'subscribers' | 'contacts' | 'study-users' | 'email-templates' | 'emails' | 'subscribers' | 'contacts' | 'study-users' | 'email-templates'
| 'seo' | 'legal' | 'security' | 'brand' | 'global' | 'seo' | 'legal' | 'security' | 'brand' | 'global'
@@ -718,6 +718,7 @@ const ADMIN_VIEW_OPTIONS: Array<{ group: string; options: Array<{ value: AdminVi
{ value: 'email-templates', label: 'Email Templates' }, { value: 'email-templates', label: 'Email Templates' },
{ value: 'analytics', label: 'Analytics' }, { value: 'analytics', label: 'Analytics' },
{ value: 'assets', label: 'Asset Manager' }, { value: 'assets', label: 'Asset Manager' },
{ value: 'qr-codes', label: 'QR Codes' },
], ],
}, },
{ {
@@ -1085,6 +1086,21 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [scriptUploadBusy, setScriptUploadBusy] = useState(false) const [scriptUploadBusy, setScriptUploadBusy] = useState(false)
const [scriptUploadMsg, setScriptUploadMsg] = useState('') const [scriptUploadMsg, setScriptUploadMsg] = useState('')
// QR codes state
interface QrCode { id: string; slug: string; label: string; destination: string; active: boolean; createdAt: string; scanCount?: number }
interface QrScan { id: string; qrId: string; slug: string; scannedAt: string; ip: string; userAgent: string }
const [qrCodes, setQrCodes] = useState<QrCode[]>([])
const [qrScans, setQrScans] = useState<QrScan[]>([])
const [qrLoading, setQrLoading] = useState(false)
const [qrLoaded, setQrLoaded] = useState(false)
const [qrMsg, setQrMsg] = useState('')
const [qrNewLabel, setQrNewLabel] = useState('')
const [qrNewSlug, setQrNewSlug] = useState('')
const [qrNewDest, setQrNewDest] = useState('')
const [qrEditId, setQrEditId] = useState<string | null>(null)
const [qrEditLabel, setQrEditLabel] = useState('')
const [qrEditDest, setQrEditDest] = useState('')
const [podcastChecklist, setPodcastChecklist] = useState<PodcastChecklistData>({ tasks: [], episodes: [] }) const [podcastChecklist, setPodcastChecklist] = useState<PodcastChecklistData>({ tasks: [], episodes: [] })
const [podcastChecklistStatus, setPodcastChecklistStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') const [podcastChecklistStatus, setPodcastChecklistStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [podcastChecklistMsg, setPodcastChecklistMsg] = useState('') const [podcastChecklistMsg, setPodcastChecklistMsg] = useState('')
@@ -2643,6 +2659,78 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
} }
} }
async function loadQrCodes() {
setQrLoading(true)
setQrMsg('')
try {
const res = await fetch('/api/admin/qr-codes')
const data = await res.json()
setQrCodes(Array.isArray(data.codes) ? data.codes : [])
setQrScans(Array.isArray(data.scans) ? data.scans : [])
setQrLoaded(true)
} catch { setQrMsg('Failed to load QR codes.') }
finally { setQrLoading(false) }
}
async function handleCreateQrCode() {
setQrMsg('')
try {
const res = await fetch('/api/admin/qr-codes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label: qrNewLabel, slug: qrNewSlug, destination: qrNewDest }),
})
const data = await res.json()
if (!res.ok) { setQrMsg(data.message || 'Failed to create QR code.'); return }
setQrCodes(prev => [...prev, data.code])
setQrNewLabel(''); setQrNewSlug(''); setQrNewDest('')
} catch { setQrMsg('Failed to create QR code.') }
}
async function handleSaveQrEdit(id: string) {
setQrMsg('')
try {
const res = await fetch(`/api/admin/qr-codes/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label: qrEditLabel, destination: qrEditDest }),
})
const data = await res.json()
if (!res.ok) { setQrMsg(data.message || 'Failed to save.'); return }
setQrCodes(prev => prev.map(c => c.id === id ? { ...c, ...data.code } : c))
setQrEditId(null)
} catch { setQrMsg('Failed to save.') }
}
async function handleToggleQrActive(id: string, active: boolean) {
try {
await fetch(`/api/admin/qr-codes/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ active }),
})
setQrCodes(prev => prev.map(c => c.id === id ? { ...c, active } : c))
} catch { /* ignore */ }
}
async function handleDeleteQrCode(id: string) {
if (!confirm('Delete this QR code and all its scan history?')) return
try {
await fetch(`/api/admin/qr-codes/${id}`, { method: 'DELETE' })
setQrCodes(prev => prev.filter(c => c.id !== id))
setQrScans(prev => prev.filter(s => s.qrId !== id))
} catch { /* ignore */ }
}
async function handleClearQrScans(id: string) {
if (!confirm('Clear all scan history for this QR code?')) return
try {
await fetch(`/api/admin/qr-codes/${id}/scans`, { method: 'DELETE' })
setQrScans(prev => prev.filter(s => s.qrId !== id))
setQrCodes(prev => prev.map(c => c.id === id ? { ...c, scanCount: 0 } : c))
} catch { /* ignore */ }
}
async function loadStudyComments() { async function loadStudyComments() {
setCommentsLoading(true) setCommentsLoading(true)
try { try {
@@ -5435,6 +5523,97 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</section> </section>
)} )}
{/* QR CODES */}
{adminView === 'qr-codes' && (() => {
if (!qrLoaded && !qrLoading) loadQrCodes()
return (
<section className="admin-panel-section" aria-label="QR Codes">
<div className="admin-panel-head">
<h2>QR Codes</h2>
<p>Each QR code is a short redirect at <code>/qr/&lt;slug&gt;</code>. Scans are logged here instead of emailed.</p>
</div>
{/* Add new */}
<div className="admin-form-row" style={{ gap: '8px', flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div style={{ flex: '1 1 140px' }}>
<label style={{ display: 'block', marginBottom: 4, fontSize: '0.85em' }}>Label</label>
<input type="text" placeholder="Spotify Podcast" value={qrNewLabel} onChange={e => setQrNewLabel(e.target.value)} className="admin-input" />
</div>
<div style={{ flex: '1 1 120px' }}>
<label style={{ display: 'block', marginBottom: 4, fontSize: '0.85em' }}>Slug</label>
<input type="text" placeholder="spotify" value={qrNewSlug} onChange={e => setQrNewSlug(e.target.value)} className="admin-input" />
</div>
<div style={{ flex: '2 1 200px' }}>
<label style={{ display: 'block', marginBottom: 4, fontSize: '0.85em' }}>Destination URL</label>
<input type="url" placeholder="https://…" value={qrNewDest} onChange={e => setQrNewDest(e.target.value)} className="admin-input" />
</div>
<button type="button" className="btn-admin-save" onClick={handleCreateQrCode} disabled={!qrNewLabel || !qrNewSlug || !qrNewDest}>
Add QR Code
</button>
</div>
{qrMsg && <p className="admin-stats-note" style={{ color: '#c0392b' }}>{qrMsg}</p>}
{qrLoading && <p className="admin-stats-note">Loading</p>}
{/* Code list */}
{!qrLoading && qrCodes.length === 0 && <p className="admin-stats-note">No QR codes yet.</p>}
{qrCodes.map(code => {
const scans = qrScans.filter(s => s.qrId === code.id)
const isEditing = qrEditId === code.id
return (
<div key={code.id} style={{ border: '1px solid var(--admin-border, #ddd)', borderRadius: 6, padding: '12px 16px', marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ fontWeight: 600, flex: 1 }}>{code.label}</span>
<code style={{ fontSize: '0.82em', background: 'var(--admin-bg2, #f5f5f5)', padding: '2px 6px', borderRadius: 4 }}>/qr/{code.slug}</code>
<span style={{ fontSize: '0.82em', color: '#888' }}>{scans.length} scan{scans.length !== 1 ? 's' : ''}</span>
<span style={{ fontSize: '0.8em', color: code.active ? '#27ae60' : '#c0392b' }}>{code.active ? 'Active' : 'Inactive'}</span>
<button type="button" className="btn-admin-reset" style={{ fontSize: '0.8em' }} onClick={() => { setQrEditId(isEditing ? null : code.id); setQrEditLabel(code.label); setQrEditDest(code.destination) }}>
{isEditing ? 'Cancel' : 'Edit'}
</button>
<button type="button" className="btn-admin-reset" style={{ fontSize: '0.8em' }} onClick={() => handleToggleQrActive(code.id, !code.active)}>
{code.active ? 'Disable' : 'Enable'}
</button>
<button type="button" className="btn-admin-reset" style={{ fontSize: '0.8em' }} onClick={() => handleClearQrScans(code.id)}>
Clear scans
</button>
<button type="button" className="btn-admin-reset" style={{ fontSize: '0.8em', color: '#c0392b' }} onClick={() => handleDeleteQrCode(code.id)}>
Delete
</button>
</div>
<p style={{ margin: '4px 0 0', fontSize: '0.82em', color: '#888', wordBreak: 'break-all' }}>{code.destination}</p>
{isEditing && (
<div style={{ display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap' }}>
<input type="text" value={qrEditLabel} onChange={e => setQrEditLabel(e.target.value)} className="admin-input" style={{ flex: '1 1 140px' }} placeholder="Label" />
<input type="url" value={qrEditDest} onChange={e => setQrEditDest(e.target.value)} className="admin-input" style={{ flex: '2 1 200px' }} placeholder="Destination URL" />
<button type="button" className="btn-admin-save" onClick={() => handleSaveQrEdit(code.id)}>Save</button>
</div>
)}
{scans.length > 0 && (
<details style={{ marginTop: 10 }}>
<summary style={{ cursor: 'pointer', fontSize: '0.85em', color: '#555' }}>Scan history ({scans.length})</summary>
<div className="admin-visits-table-scroll" style={{ marginTop: 6 }}>
<table className="admin-visits-table">
<thead><tr><th>Time</th><th>IP</th><th>Device / UA</th></tr></thead>
<tbody>
{scans.slice(0, 100).map(s => (
<tr key={s.id}>
<td style={{ whiteSpace: 'nowrap' }}>{new Date(s.scannedAt).toLocaleString()}</td>
<td>{s.ip}</td>
<td style={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.userAgent}</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
)}
</div>
)
})}
</section>
)
})()}
{/* SEO & REDIRECTS */} {/* SEO & REDIRECTS */}
{adminView === 'seo' && ( {adminView === 'seo' && (
<section className="admin-panel-section" aria-label="SEO & Redirects"> <section className="admin-panel-section" aria-label="SEO & Redirects">