From a80b4e272e6ce2a20d43919ae42730daabd6b841 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Wed, 17 Jun 2026 11:45:16 +1000 Subject: [PATCH] Add admin CRUD routes with image upload --- routes/admin.js | 185 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 routes/admin.js diff --git a/routes/admin.js b/routes/admin.js new file mode 100644 index 0000000..0fff467 --- /dev/null +++ b/routes/admin.js @@ -0,0 +1,185 @@ +import { Router } from 'express'; +import multer from 'multer'; +import { randomBytes } from 'node:crypto'; +import { mkdirSync, existsSync, unlinkSync } from 'node:fs'; +import { dirname, extname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import db from '../db/index.js'; +import { requireAuth } from './auth-middleware.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const UPLOAD_DIR = join(__dirname, '..', process.env.UPLOAD_DIR || 'uploads'); +mkdirSync(UPLOAD_DIR, { recursive: true }); + +const router = Router(); +router.use(requireAuth); // everything here requires a valid JWT + +const ALLOWED = new Set(['.gif', '.png', '.jpg', '.jpeg', '.webp']); +const storage = multer.diskStorage({ + destination: (_req, _file, cb) => cb(null, UPLOAD_DIR), + filename: (_req, file, cb) => { + const ext = extname(file.originalname).toLowerCase(); + cb(null, `${Date.now()}-${randomBytes(6).toString('hex')}${ext}`); + }, +}); +const upload = multer({ + storage, + limits: { fileSize: 8 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + const ext = extname(file.originalname).toLowerCase(); + cb(ALLOWED.has(ext) ? null : new Error('unsupported file type'), ALLOWED.has(ext)); + }, +}); + +const toInt = (v, d = 0) => (Number.isFinite(+v) ? parseInt(v, 10) : d); + +/* ---------------- Ghosts ---------------- */ + +router.get('/ghosts', (req, res) => { + res.json({ ghosts: db.prepare('SELECT * FROM ghosts ORDER BY type, rarity, name').all() }); +}); + +router.post('/ghosts', (req, res) => { + const b = req.body || {}; + if (!b.name || !b.type || !b.rarity) { + return res.status(400).json({ error: 'name, type, rarity required' }); + } + const info = db + .prepare( + `INSERT INTO ghosts + (name, display_name, type, rarity, speed, range, charge_shot, + health, damage, ability, is_boss, set_number, set_name, enabled) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)` + ) + .run( + b.name, b.displayName || b.name, b.type, toInt(b.rarity, 1), + toInt(b.speed), toInt(b.range), toInt(b.chargeShot), + toInt(b.health, 300), toInt(b.damage, 150), b.ability || null, + b.isBoss ? 1 : 0, b.setNumber || null, b.setName || null, + b.enabled === false ? 0 : 1 + ); + res.status(201).json({ id: info.lastInsertRowid }); +}); + +router.patch('/ghosts/:id', (req, res) => { + const ghost = db.prepare('SELECT * FROM ghosts WHERE id = ?').get(req.params.id); + if (!ghost) return res.status(404).json({ error: 'not found' }); + const b = req.body || {}; + const map = { + name: 'name', displayName: 'display_name', type: 'type', rarity: 'rarity', + speed: 'speed', range: 'range', chargeShot: 'charge_shot', + health: 'health', damage: 'damage', ability: 'ability', + isBoss: 'is_boss', setNumber: 'set_number', setName: 'set_name', enabled: 'enabled', + }; + const sets = []; + const vals = []; + for (const [k, col] of Object.entries(map)) { + if (k in b) { + sets.push(`${col} = ?`); + let v = b[k]; + if (k === 'isBoss' || k === 'enabled') v = v ? 1 : 0; + vals.push(v); + } + } + if (!sets.length) return res.json({ ok: true, unchanged: true }); + vals.push(req.params.id); + db.prepare(`UPDATE ghosts SET ${sets.join(', ')} WHERE id = ?`).run(...vals); + res.json({ ok: true }); +}); + +router.post('/ghosts/:id/image', upload.single('image'), (req, res) => { + const ghost = db.prepare('SELECT * FROM ghosts WHERE id = ?').get(req.params.id); + if (!ghost) return res.status(404).json({ error: 'not found' }); + if (!req.file) return res.status(400).json({ error: 'no file' }); + // remove old image file if present + if (ghost.image_path) { + const old = join(UPLOAD_DIR, ghost.image_path); + if (existsSync(old)) try { unlinkSync(old); } catch { /* ignore */ } + } + db.prepare('UPDATE ghosts SET image_path = ? WHERE id = ?').run(req.file.filename, ghost.id); + res.json({ ok: true, image: `/uploads/${req.file.filename}` }); +}); + +router.delete('/ghosts/:id', (req, res) => { + const ghost = db.prepare('SELECT * FROM ghosts WHERE id = ?').get(req.params.id); + if (!ghost) return res.status(404).json({ error: 'not found' }); + if (ghost.image_path) { + const p = join(UPLOAD_DIR, ghost.image_path); + if (existsSync(p)) try { unlinkSync(p); } catch { /* ignore */ } + } + db.prepare('DELETE FROM ghosts WHERE id = ?').run(ghost.id); + res.json({ ok: true }); +}); + +/* ---------------- Sets ---------------- */ + +router.get('/sets', (req, res) => { + const sets = db.prepare('SELECT * FROM sets ORDER BY set_number, set_name').all(); + const getRoster = db.prepare( + `SELECT g.id, g.name, g.type, g.rarity, g.is_boss + FROM ghosts g JOIN set_ghosts sg ON sg.ghost_id = g.id + WHERE sg.set_id = ? ORDER BY g.is_boss DESC, g.rarity DESC, g.name` + ); + res.json({ + sets: sets.map((s) => ({ ...s, roster: getRoster.all(s.id) })), + }); +}); + +router.post('/sets', (req, res) => { + const b = req.body || {}; + if (!b.code || !b.setName) return res.status(400).json({ error: 'code and setName required' }); + try { + const info = db + .prepare( + 'INSERT INTO sets (code, set_number, set_name, boss_ghost_id, enabled) VALUES (?,?,?,?,?)' + ) + .run(b.code, b.setNumber || null, b.setName, b.bossGhostId || null, b.enabled === false ? 0 : 1); + res.status(201).json({ id: info.lastInsertRowid }); + } catch (e) { + if (String(e).includes('UNIQUE')) return res.status(409).json({ error: 'code already exists' }); + throw e; + } +}); + +router.patch('/sets/:id', (req, res) => { + const set = db.prepare('SELECT * FROM sets WHERE id = ?').get(req.params.id); + if (!set) return res.status(404).json({ error: 'not found' }); + const b = req.body || {}; + const map = { + code: 'code', setNumber: 'set_number', setName: 'set_name', + bossGhostId: 'boss_ghost_id', enabled: 'enabled', + }; + const sets = []; const vals = []; + for (const [k, col] of Object.entries(map)) { + if (k in b) { + sets.push(`${col} = ?`); + vals.push(k === 'enabled' ? (b[k] ? 1 : 0) : b[k]); + } + } + if (!sets.length) return res.json({ ok: true, unchanged: true }); + vals.push(req.params.id); + db.prepare(`UPDATE sets SET ${sets.join(', ')} WHERE id = ?`).run(...vals); + res.json({ ok: true }); +}); + +router.put('/sets/:id/roster', (req, res) => { + const set = db.prepare('SELECT * FROM sets WHERE id = ?').get(req.params.id); + if (!set) return res.status(404).json({ error: 'not found' }); + const ids = Array.isArray(req.body?.ghostIds) ? req.body.ghostIds : []; + const tx = db.transaction(() => { + db.prepare('DELETE FROM set_ghosts WHERE set_id = ?').run(set.id); + const link = db.prepare('INSERT OR IGNORE INTO set_ghosts (set_id, ghost_id) VALUES (?, ?)'); + for (const gid of ids) link.run(set.id, gid); + }); + tx(); + res.json({ ok: true, count: ids.length }); +}); + +router.delete('/sets/:id', (req, res) => { + const set = db.prepare('SELECT * FROM sets WHERE id = ?').get(req.params.id); + if (!set) return res.status(404).json({ error: 'not found' }); + db.prepare('DELETE FROM sets WHERE id = ?').run(set.id); + res.json({ ok: true }); +}); + +export default router;