From 48c2f81d0057376eacab00730510d9ff090c06e3 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Tue, 16 Jun 2026 09:33:35 -0400 Subject: [PATCH] Add QR code tracker to admin panel Each QR code gets a /qr/ 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 --- server.js | 7 +- server/config.js | 1 + server/data.js | 27 ++++++ server/routes/qr-codes.js | 88 ++++++++++++++++++ server/state.js | 6 ++ src/AdminPage.tsx | 181 +++++++++++++++++++++++++++++++++++++- 6 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 server/routes/qr-codes.js diff --git a/server.js b/server.js index 0a3a846..6bc7f1c 100644 --- a/server.js +++ b/server.js @@ -1,5 +1,6 @@ import express from 'express' import { hasVisitorConsent } from './server/helpers.js' +import { isValidAdminSession } from './server/auth.js' import { BACKUP_INTERVAL_MS } from './server/config.js' import { state } from './server/state.js' import { @@ -18,6 +19,7 @@ import { loadEpisodeScriptsFromDisk, migrateStudyNotesIfNeeded, loadDownloadCountsFromDisk, + loadQrCodesFromDisk, loadPodcastChecklistFromDisk, createBackupSnapshot, 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 registerDownloads } from './server/routes/downloads.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' const app = express() @@ -70,6 +73,7 @@ registerQuestions(app) registerAnalytics(app) registerDownloads(app) registerEpisodes(app) +registerQrCodes(app) // Hit-counting middleware (must come before public routes) app.use((req, res, next) => { @@ -78,7 +82,7 @@ app.use((req, res, next) => { const botDetection = detectBot(ua) recordHit(req.path, botDetection.isBot, botDetection.reason) queueHitStatsWrite() - if (hasVisitorConsent(req) && !botDetection.isBot) { + if (hasVisitorConsent(req) && !botDetection.isBot && !isValidAdminSession(req)) { recordVisitor(req, res).catch(err => { console.error('[visitor-stats] failed to record visitor:', err) }) @@ -107,6 +111,7 @@ Promise.all([ loadEpisodeScriptsFromDisk(), migrateStudyNotesIfNeeded(), loadDownloadCountsFromDisk(), + loadQrCodesFromDisk(), loadPodcastChecklistFromDisk(), refreshContentCaches(), ]) diff --git a/server/config.js b/server/config.js index c41f437..2469889 100644 --- a/server/config.js +++ b/server/config.js @@ -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_CERTIFICATES_FILE = path.join(DATA_DIR, 'study-certificates.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 DIST_DIR = path.join(ROOT_DIR, 'dist') diff --git a/server/data.js b/server/data.js index d276883..1b6594f 100644 --- a/server/data.js +++ b/server/data.js @@ -20,6 +20,7 @@ import { STUDY_COMMENTS_FILE, STUDY_CERTIFICATES_FILE, EPISODE_SCRIPTS_FILE, + QR_CODES_FILE, REPLY_TEMPLATES_FILE, REPLY_HISTORY_FILE, PODCAST_CHECKLIST_FILE, @@ -1216,3 +1217,29 @@ export function sanitizeStudyCommunityPosts(value) { }) .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 = [] + }) +} diff --git a/server/routes/qr-codes.js b/server/routes/qr-codes.js new file mode 100644 index 0000000..0fcfba6 --- /dev/null +++ b/server/routes/qr-codes.js @@ -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 }) + }) +} diff --git a/server/state.js b/server/state.js index 9176024..c89879a 100644 --- a/server/state.js +++ b/server/state.js @@ -59,6 +59,12 @@ export const state = { downloadCounts: {}, 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 }, lastCachePurgeStatus: { ok: true, at: null, error: null }, lastDeployHookStatus: { ok: true, at: null, error: null }, diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 9ba2923..af64f26 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -673,7 +673,7 @@ type AdminView = | 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact' | 'podcast' | 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series' | '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' | '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: 'analytics', label: 'Analytics' }, { 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 [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([]) + const [qrScans, setQrScans] = useState([]) + 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(null) + const [qrEditLabel, setQrEditLabel] = useState('') + const [qrEditDest, setQrEditDest] = useState('') + const [podcastChecklist, setPodcastChecklist] = useState({ tasks: [], episodes: [] }) const [podcastChecklistStatus, setPodcastChecklistStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') 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() { setCommentsLoading(true) try { @@ -5435,6 +5523,97 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { )} + {/* QR CODES */} + {adminView === 'qr-codes' && (() => { + if (!qrLoaded && !qrLoading) loadQrCodes() + return ( +
+
+

QR Codes

+

Each QR code is a short redirect at /qr/<slug>. Scans are logged here instead of emailed.

+
+ + {/* Add new */} +
+
+ + setQrNewLabel(e.target.value)} className="admin-input" /> +
+
+ + setQrNewSlug(e.target.value)} className="admin-input" /> +
+
+ + setQrNewDest(e.target.value)} className="admin-input" /> +
+ +
+ {qrMsg &&

{qrMsg}

} + + {qrLoading &&

Loading…

} + + {/* Code list */} + {!qrLoading && qrCodes.length === 0 &&

No QR codes yet.

} + {qrCodes.map(code => { + const scans = qrScans.filter(s => s.qrId === code.id) + const isEditing = qrEditId === code.id + return ( +
+
+ {code.label} + /qr/{code.slug} + {scans.length} scan{scans.length !== 1 ? 's' : ''} + {code.active ? 'Active' : 'Inactive'} + + + + +
+

{code.destination}

+ {isEditing && ( +
+ setQrEditLabel(e.target.value)} className="admin-input" style={{ flex: '1 1 140px' }} placeholder="Label" /> + setQrEditDest(e.target.value)} className="admin-input" style={{ flex: '2 1 200px' }} placeholder="Destination URL" /> + +
+ )} + {scans.length > 0 && ( +
+ Scan history ({scans.length}) +
+ + + + {scans.slice(0, 100).map(s => ( + + + + + + ))} + +
TimeIPDevice / UA
{new Date(s.scannedAt).toLocaleString()}{s.ip}{s.userAgent}
+
+
+ )} +
+ ) + })} +
+ ) + })()} + {/* SEO & REDIRECTS */} {adminView === 'seo' && (