import { Router } from 'express'; import bcrypt from 'bcryptjs'; import { createRequireAdmin } from '../middleware/auth.js'; import { contentHash, validateCards, nowIso } from '../utils.js'; import { resolveDeckCards } from '../services/cardResolver.js'; import { getPreviewUrl, deletePreviewImage, listOrphanPreviewImages, deleteOrphanPreviewImages, } from '../services/previewImage.js'; import { getAvatarUrl, saveAvatarImage, deleteAvatarImage } from '../services/avatarImage.js'; /** * 회원 목록에 저장 덱 수(공개/전체) 부여 * @param {import('better-sqlite3').Database} db * @param {Array<{ id: number }>} users * @returns {Array} */ function attachUserDeckCounts(db, users) { if (!users.length) return users; const placeholders = users.map(() => '?').join(','); const ids = users.map((u) => u.id); const counts = db .prepare( ` SELECT user_id AS userId, COUNT(*) AS deckTotal, SUM(CASE WHEN is_public = 1 THEN 1 ELSE 0 END) AS deckPublic FROM decks WHERE share_type = 'user' AND user_id IN (${placeholders}) GROUP BY user_id `, ) .all(...ids); const countByUser = new Map(counts.map((row) => [row.userId, row])); return users.map((user) => { const stats = countByUser.get(user.id); return { ...user, deckPublic: stats?.deckPublic ?? 0, deckTotal: stats?.deckTotal ?? 0, }; }); } /** * 관리자 라우트 * @param {import('better-sqlite3').Database} db * @returns {import('express').Router} */ export function createAdminRouter(db) { const router = Router(); router.use(createRequireAdmin(db)); router.get('/users', (req, res) => { const { q, page = 1, limit = 20 } = req.query; const offset = (Number(page) - 1) * Number(limit); let rows; let total; if (q) { const like = `%${q}%`; total = db.prepare(` SELECT COUNT(*) AS c FROM users WHERE email LIKE ? OR username LIKE ? OR display_name LIKE ? `).get(like, like, like).c; rows = db.prepare(` SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl, role, is_active AS isActive, email_verified_at AS emailVerifiedAt, last_login_at AS lastLoginAt, created_at AS createdAt, updated_at AS updatedAt FROM users WHERE email LIKE ? OR username LIKE ? OR display_name LIKE ? ORDER BY created_at DESC LIMIT ? OFFSET ? `).all(like, like, like, Number(limit), offset); } else { total = db.prepare('SELECT COUNT(*) AS c FROM users').get().c; rows = db.prepare(` SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl, role, is_active AS isActive, email_verified_at AS emailVerifiedAt, last_login_at AS lastLoginAt, created_at AS createdAt, updated_at AS updatedAt FROM users ORDER BY created_at DESC LIMIT ? OFFSET ? `).all(Number(limit), offset); } res.json({ users: attachUserDeckCounts( db, rows.map((u) => ({ ...u, avatarUrl: getAvatarUrl(u.id) || u.avatarUrl, })), ), total, page: Number(page), limit: Number(limit), }); }); router.get('/users/:id', (req, res) => { const user = db.prepare(` SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl, role, is_active AS isActive, email_verified_at AS emailVerifiedAt, last_login_at AS lastLoginAt, created_at AS createdAt, updated_at AS updatedAt FROM users WHERE id = ? `).get(req.params.id); if (!user) { return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' }); } const deckTotal = db.prepare( "SELECT COUNT(*) AS c FROM decks WHERE user_id = ? AND share_type = 'user'", ).get(user.id).c; const deckPublic = db.prepare( "SELECT COUNT(*) AS c FROM decks WHERE user_id = ? AND share_type = 'user' AND is_public = 1", ).get(user.id).c; user.avatarUrl = getAvatarUrl(user.id) || user.avatarUrl; res.json({ user, deckCount: deckTotal, deckPublic, deckTotal }); }); router.put('/users/:id', (req, res) => { const targetId = Number(req.params.id); const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(targetId); if (!user) { return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' }); } const isSelf = req.user.id === targetId; const { email, username, role, isActive, avatarImage, removeAvatar } = req.body || {}; if (isSelf) { if (role === 'user') { return res.status(400).json({ error: '자신의 관리자 권한은 해제할 수 없습니다.' }); } if (isActive === false) { return res.status(400).json({ error: '자신의 계정은 비활성화할 수 없습니다.' }); } } let nextAvatarUrl; if (avatarImage) { const saved = saveAvatarImage(targetId, avatarImage); if (!saved) { return res.status(400).json({ error: '프로필 이미지 저장에 실패했습니다.' }); } nextAvatarUrl = getAvatarUrl(targetId); } else if (removeAvatar === true) { deleteAvatarImage(targetId); nextAvatarUrl = null; } const trimmedUsername = username?.trim() ?? null; const trimmedEmail = email?.trim().toLowerCase() ?? null; try { const fields = ['updated_at = ?']; const values = [nowIso()]; if (trimmedEmail) { if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail) || trimmedEmail.length > 254) { return res.status(400).json({ error: '올바른 이메일 주소를 입력하세요.' }); } fields.push('email = ?'); values.push(trimmedEmail); } if (trimmedUsername) { if ( trimmedUsername.length < 2 || trimmedUsername.length > 30 || !/^[\p{L}\p{N}_-]+$/u.test(trimmedUsername) ) { return res.status(400).json({ error: '사용자명은 2~30자의 한글·영문·숫자·밑줄·하이픈만 사용할 수 있습니다.', }); } const duplicate = db.prepare(` SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ? `).get(trimmedUsername, targetId); if (duplicate) { return res.status(409).json({ error: '이미 사용 중인 사용자명입니다.' }); } fields.push('username = ?', 'display_name = ?'); values.push(trimmedUsername, trimmedUsername); } if (!isSelf && role) { fields.push('role = ?'); values.push(role); } if (!isSelf && isActive !== undefined) { fields.push('is_active = ?'); values.push(isActive ? 1 : 0); } if (nextAvatarUrl !== undefined) { fields.push('avatar_url = ?'); values.push(nextAvatarUrl); } values.push(targetId); db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values); } catch (err) { if (String(err.message).includes('UNIQUE')) { return res.status(409).json({ error: '이메일 또는 사용자명이 이미 사용 중입니다.' }); } throw err; } const updated = db.prepare(` SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl, role, is_active AS isActive, email_verified_at AS emailVerifiedAt, last_login_at AS lastLoginAt, created_at AS createdAt, updated_at AS updatedAt FROM users WHERE id = ? `).get(targetId); updated.avatarUrl = getAvatarUrl(updated.id) || updated.avatarUrl; res.json({ user: updated }); }); router.post('/users/:id/verify-email', (req, res) => { const targetId = Number(req.params.id); const user = db.prepare(` SELECT id, email_verified_at AS emailVerifiedAt FROM users WHERE id = ? `).get(targetId); if (!user) { return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' }); } const verifiedAt = user.emailVerifiedAt || nowIso(); const transaction = db.transaction(() => { db.prepare(` UPDATE users SET email_verified_at = ?, updated_at = ? WHERE id = ? `).run(verifiedAt, nowIso(), targetId); db.prepare(` UPDATE auth_tokens SET used_at = ? WHERE user_id = ? AND type = 'verify_email' AND used_at IS NULL `).run(nowIso(), targetId); }); transaction(); res.json({ ok: true, emailVerifiedAt: verifiedAt, message: '이메일 인증을 승인했습니다.', }); }); router.post('/users/:id/reset-password', (req, res) => { const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id); if (!user) { return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' }); } const { newPassword } = req.body || {}; if (!newPassword || newPassword.length < 8) { return res.status(400).json({ error: '새 비밀번호는 8자 이상이어야 합니다.' }); } const hash = bcrypt.hashSync(newPassword, 12); db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?').run(hash, nowIso(), req.params.id); res.json({ ok: true, message: '비밀번호가 초기화되었습니다.' }); }); router.post('/decks/preset', async (req, res, next) => { try { const { series, seriesName, name, cards, isPublic } = req.body || {}; if (!series || !cards || !name) { return res.status(400).json({ error: 'series, name, cards는 필수입니다.' }); } const normalized = validateCards(cards); const hash = contentHash(normalized); const existing = db.prepare('SELECT share_code FROM decks WHERE content_hash = ? AND share_type = ?').get(hash, 'preset'); if (existing) { return res.status(409).json({ error: '동일한 구성의 프리셋이 이미 존재합니다.', shareCode: existing.share_code, }); } const cardsJson = JSON.stringify(normalized); db.prepare(` INSERT INTO decks (share_code, share_type, content_hash, user_id, series, series_name, name, cards_json, is_public) VALUES (?, 'preset', ?, ?, ?, ?, ?, ?, ?) `).run( hash, hash, req.user.id, series, seriesName || series, name, cardsJson, isPublic === false ? 0 : 1, ); const row = db.prepare('SELECT * FROM decks WHERE share_code = ?').get(hash); const { cards: resolved, totalCards } = await resolveDeckCards(series, normalized); res.status(201).json({ v: 1, shareCode: row.share_code, shareType: 'preset', series: row.series, seriesName: row.series_name, name: row.name, totalCards, cards: resolved, }); } catch (err) { if (err.message?.includes('카드') || err.message?.includes('덱')) { return res.status(400).json({ error: err.message }); } next(err); } }); router.get('/decks', (req, res) => { const rows = db.prepare(` SELECT d.id, d.share_code AS shareCode, d.share_type AS shareType, d.series, d.series_name AS seriesName, d.name, d.is_public AS isPublic, d.view_count AS viewCount, d.created_at AS createdAt, d.preview_image AS previewImage, d.updated_at AS updatedAt, u.username, u.id AS userId FROM decks d LEFT JOIN users u ON d.user_id = u.id ORDER BY d.created_at DESC LIMIT 100 `).all(); res.json({ decks: rows.map((row) => ({ ...row, preview_url: getPreviewUrl(row.shareCode, row.previewImage), })), }); }); router.get('/previews/orphans', (_req, res) => { const referencedCodes = db.prepare('SELECT share_code FROM decks').all().map((row) => row.share_code); const files = listOrphanPreviewImages(referencedCodes); res.json({ files, totalCount: files.length, totalBytes: files.reduce((sum, file) => sum + file.size, 0), }); }); router.delete('/previews/orphans', (_req, res) => { const referencedCodes = db.prepare('SELECT share_code FROM decks').all().map((row) => row.share_code); res.json({ ok: true, ...deleteOrphanPreviewImages(referencedCodes) }); }); router.delete('/decks/:shareCode', (req, res) => { const row = db.prepare('SELECT id, share_code FROM decks WHERE share_code = ?').get(req.params.shareCode); if (!row) { return res.status(404).json({ error: '덱을 찾을 수 없습니다.' }); } db.prepare('DELETE FROM decks WHERE id = ?').run(row.id); const previewDeleted = deletePreviewImage(row.share_code); res.json({ ok: true, previewDeleted }); }); return router; }