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
+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_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')
+27
View File
@@ -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 = []
})
}
+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: {},
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 },