const fs = require('fs/promises') const path = require('path') const express = require('express') const multer = require('multer') const bcrypt = require('bcryptjs') const { z } = require('zod') const { nanoid } = require('nanoid') const { findUserById, findTopicById, findTopicItemById, listTopicItems, findImageAssetById, createTopic, listTopics, updateTopicThumbnail, updateTopicVisibility, createTopicItem, updateTopicItemLabel, updateTopicItemDisplayOrder, countTierListsUsingTopicItem, updateCustomItemLabel, updateImageAssetLabel, deleteTopicItem, deleteTopic, deleteTierList, updateTopicDisplayOrder, listCustomItems, findCustomItemById, findUnusedCustomItems, findCustomItemsByIds, deleteCustomItems, listUsers, findPrimaryAdminUser, listAdminTierLists, summarizeAdminTierLists, findTierListById, updateAdminTierListMeta, listAdminTemplateRequests, findTemplateRequestById, updateTemplateRequestStatus, updateTemplateRequestTargetTopic, adminUpdateUser, adminUpdateUserPassword, adminDeleteUser, listUnusedImageAssets, deleteImageAssets, getImageAssetStats, listRecentImageOptimizationJobs, clearImageOptimizationJobs, cleanupMissingUploadReferences, } = require('../db') const { requireAdmin } = require('../middleware/auth') const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage') const router = express.Router() function getTemplateIdParam(req) { return req.params.templateId || req.params.gameId || '' } function buildUploadFilename(file) { const ext = path.extname(file.originalname || '').toLowerCase() const safeExt = ext && /^[.a-z0-9]+$/.test(ext) ? ext : '' return `${Date.now()}-${nanoid()}${safeExt}` } function buildItemLabelFromFilename(file) { const originalName = file?.originalname || '' const base = path.basename(originalName, path.extname(originalName)) const normalized = base .replace(/[_-]+/g, ' ') .replace(/\s+/g, ' ') .trim() .slice(0, 60) return normalized || 'item' } function buildItemLabelFromSrc(src) { const raw = typeof src === 'string' ? src : '' const base = path.basename(raw.split('?')[0] || '', path.extname(raw.split('?')[0] || '')) const normalized = base .replace(/[_-]+/g, ' ') .replace(/\s+/g, ' ') .trim() .slice(0, 60) return normalized || 'item' } const upload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024, maxCount: 50 }) const avatarUpload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 }) function decorateAdminUser(user, primaryAdmin) { if (!user) return null const isPrimaryAdmin = !!user.isAdmin && primaryAdmin?.id === user.id return { ...user, isPrimaryAdmin, isOperator: !!user.isAdmin && !isPrimaryAdmin, role: isPrimaryAdmin ? 'owner' : user.isAdmin ? 'operator' : 'user', } } async function getAdminUserContext(targetUserId, actingUserId) { const [targetUser, actingUser, primaryAdmin] = await Promise.all([ findUserById(targetUserId), findUserById(actingUserId), findPrimaryAdminUser(), ]) return { targetUser, actingUser, primaryAdmin } } function canManageAdminRole(actingUser, primaryAdmin) { return !!actingUser?.isAdmin && primaryAdmin?.id === actingUser.id } router.post(['/games', '/templates'], requireAdmin, async (req, res) => { const schema = z.object({ id: z.string().min(1), name: z.string().min(1).max(60), isPublic: z.boolean().optional().default(false), thumbnailSrc: z.string().max(255).optional().default(''), }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const exists = await findTopicById(parsed.data.id) if (exists) return res.status(409).json({ error: 'game_id_taken' }) const template = await createTopic({ id: parsed.data.id, name: parsed.data.name, isPublic: parsed.data.isPublic }) if (parsed.data.thumbnailSrc) { const copiedThumb = await copyUploadIntoGameAsset(parsed.data.thumbnailSrc) await updateTopicThumbnail(template.id, copiedThumb) } const savedTemplate = await findTopicById(template.id) res.json({ template: savedTemplate }) }) router.patch(['/games/:gameId', '/templates/:templateId'], requireAdmin, async (req, res) => { const schema = z.object({ isPublic: z.boolean(), }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const templateId = getTemplateIdParam(req) const template = await findTopicById(templateId) if (!template) return res.status(404).json({ error: 'not_found' }) const updated = await updateTopicVisibility(template.id, parsed.data.isPublic) res.json({ template: updated }) }) router.patch(['/games/display-order', '/templates/display-order'], requireAdmin, async (req, res) => { const schema = z.object({ topicIds: z.array(z.string().min(1)).max(50), }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const templates = await listTopics('', { includePrivate: true }) const validTopicIds = new Set(templates.map((template) => template.id)) const filteredIds = parsed.data.topicIds.filter((topicId) => validTopicIds.has(topicId)) const updatedTemplates = await updateTopicDisplayOrder(filteredIds) res.json({ templates: updatedTemplates }) }) router.patch(['/games/:gameId/items/display-order', '/templates/:templateId/items/display-order'], requireAdmin, async (req, res) => { const schema = z.object({ itemIds: z.array(z.string().min(1)).min(1), }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const templateId = getTemplateIdParam(req) const template = await findTopicById(templateId) if (!template) return res.status(404).json({ error: 'not_found' }) const items = await updateTopicItemDisplayOrder(template.id, parsed.data.itemIds) res.json({ items }) }) router.post(['/games/:gameId/thumbnail', '/templates/:templateId/thumbnail'], requireAdmin, upload.single('thumbnail'), async (req, res) => { if (!req.file) return res.status(400).json({ error: 'file_required' }) const templateId = getTemplateIdParam(req) const template = await findTopicById(templateId) if (!template) return res.status(404).json({ error: 'not_found' }) const optimized = await writeOptimizedImage({ file: req.file, directory: 'games', width: 1280, height: 1280, fit: 'inside', quality: 84, }) const updated = await updateTopicThumbnail(templateId, optimized.src) res.json({ template: updated }) }) router.post(['/games/:gameId/images', '/templates/:templateId/images'], requireAdmin, upload.array('images', 50), async (req, res) => { const files = Array.isArray(req.files) ? req.files : [] if (!files.length) return res.status(400).json({ error: 'file_required' }) const templateId = getTemplateIdParam(req) const template = await findTopicById(templateId) if (!template) return res.status(404).json({ error: 'not_found' }) const labelsRaw = req.body?.labels const labels = Array.isArray(labelsRaw) ? labelsRaw : labelsRaw ? [labelsRaw] : [] const normalizedLabels = labels.map((label) => (typeof label === 'string' ? label.trim().slice(0, 60) : '')) if (normalizedLabels.some((label) => label.length > 60)) return res.status(400).json({ error: 'bad_request' }) const items = await Promise.all( files.map(async (file, index) => { const optimized = await writeOptimizedImage({ file, directory: 'games', width: 512, height: 512, fit: 'inside', quality: 84, }) return createTopicItem({ id: nanoid(), topicId: template.id, src: optimized.src, label: normalizedLabels[index] || buildItemLabelFromFilename(file), }) }) ) res.json({ item: items[0], items }) }) router.delete(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:itemId'], requireAdmin, async (req, res) => { const template = await findTopicById(getTemplateIdParam(req)) if (!template) return res.status(404).json({ error: 'not_found' }) await deleteTopicItem(req.params.itemId) res.json({ ok: true }) }) router.get(['/games/:gameId/items/:itemId/usage', '/templates/:templateId/items/:itemId/usage'], requireAdmin, async (req, res) => { const template = await findTopicById(getTemplateIdParam(req)) if (!template) return res.status(404).json({ error: 'not_found' }) const item = await findTopicItemById(req.params.itemId) if (!item || item.topicId !== template.id) return res.status(404).json({ error: 'not_found' }) const usage = await countTierListsUsingTopicItem(req.params.itemId) res.json({ usage }) }) router.patch(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:itemId'], requireAdmin, async (req, res) => { const schema = z.object({ label: z.string().trim().min(1).max(60) }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const template = await findTopicById(getTemplateIdParam(req)) if (!template) return res.status(404).json({ error: 'not_found' }) const updated = await updateTopicItemLabel(req.params.itemId, parsed.data.label) if (!updated || updated.topicId !== template.id) return res.status(404).json({ error: 'not_found' }) res.json({ item: updated }) }) router.delete(['/games/:gameId', '/templates/:templateId'], requireAdmin, async (req, res) => { const templateId = getTemplateIdParam(req) const template = await findTopicById(templateId) if (!template) return res.status(404).json({ error: 'not_found' }) await deleteTopic(templateId) res.json({ ok: true }) }) router.patch('/custom-items/:itemId/label', requireAdmin, async (req, res) => { const schema = z.object({ label: z.string().trim().min(1).max(60), sourceType: z.enum(['template', 'user']).optional().default('user'), }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const itemId = req.params.itemId if (itemId.startsWith('asset:')) { const updated = await updateImageAssetLabel(itemId.slice(6), parsed.data.label) if (!updated) return res.status(404).json({ error: 'not_found' }) return res.json({ item: updated }) } if (parsed.data.sourceType === 'template') { const updated = await updateTopicItemLabel(itemId, parsed.data.label) if (!updated) return res.status(404).json({ error: 'not_found' }) return res.json({ item: updated }) } const updated = await updateCustomItemLabel(itemId, parsed.data.label) if (!updated) return res.status(404).json({ error: 'not_found' }) return res.json({ item: updated }) }) router.get('/custom-items', requireAdmin, async (req, res) => { const schema = z.object({ q: z.string().trim().max(120).optional().default(''), page: z.coerce.number().int().min(1).optional().default(1), limit: z.coerce.number().int().min(1).max(200).optional().default(50), filter: z.enum(['all', 'user', 'template', 'asset', 'unused-user', 'unused-admin']).optional().default('all'), }) const parsed = schema.safeParse(req.query) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const result = await listCustomItems({ queryText: parsed.data.q, page: parsed.data.page, limit: parsed.data.limit, filterMode: parsed.data.filter, }) res.json(result) }) router.get('/tierlists', requireAdmin, async (req, res) => { const schema = z.object({ q: z.string().trim().max(120).optional().default(''), topicId: z.string().trim().max(120).optional().default(''), page: z.coerce.number().int().min(1).optional().default(1), limit: z.coerce.number().int().min(1).max(200).optional().default(50), }) const parsed = schema.safeParse(req.query) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const result = await listAdminTierLists({ queryText: parsed.data.q, topicId: parsed.data.topicId, page: parsed.data.page, limit: parsed.data.limit, currentUserId: req.session?.userId || '', }) res.json(result) }) router.get('/tierlists/stats', requireAdmin, async (req, res) => { const schema = z.object({ q: z.string().trim().max(120).optional().default(''), topicId: z.string().trim().max(120).optional().default(''), }) const parsed = schema.safeParse(req.query) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const result = await summarizeAdminTierLists({ queryText: parsed.data.q, topicId: parsed.data.topicId, }) res.json(result) }) router.get('/template-requests', requireAdmin, async (req, res) => { const requests = await listAdminTemplateRequests({ statuses: ['pending', 'reviewing'] }) res.json({ requests }) }) router.get('/image-assets/orphans', requireAdmin, async (req, res) => { const schema = z.object({ limit: z.coerce.number().int().min(1).max(500).optional().default(100), minAgeHours: z.coerce.number().min(0).max(24 * 365).optional().default(24), }) const parsed = schema.safeParse(req.query) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const assets = await listUnusedImageAssets(parsed.data) res.json({ assets }) }) async function removeImageAssetFiles(assets) { await Promise.all( (assets || []).map(async (asset) => { if (!asset?.src || !asset.src.startsWith('/uploads/')) return const absolutePath = path.join(__dirname, '..', '..', asset.src.replace(/^\//, '')) try { await fs.unlink(absolutePath) } catch (error) { if (error?.code !== 'ENOENT') throw error } }) ) } router.post('/image-assets/cleanup', requireAdmin, async (req, res) => { const schema = z.object({ limit: z.coerce.number().int().min(1).max(500).optional().default(100), minAgeHours: z.coerce.number().min(0).max(24 * 365).optional().default(24), }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const assets = await listUnusedImageAssets(parsed.data) const deleted = await deleteImageAssets(assets.map((asset) => asset.id)) await removeImageAssetFiles(deleted) res.json({ deletedCount: deleted.length, assets: deleted }) }) router.get('/image-assets/stats', requireAdmin, async (req, res) => { const schema = z.object({ month: z.string().regex(/^\d{4}-\d{2}$/).optional(), limit: z.coerce.number().int().min(1).max(24).optional().default(12), }) const parsed = schema.safeParse(req.query) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const filters = { month: parsed.data.month } const [stats, recentJobs] = await Promise.all([ getImageAssetStats(filters), listRecentImageOptimizationJobs(parsed.data.limit, filters), ]) res.json({ stats, filters, queue: getImageOptimizationQueueState(), recentJobs, }) }) router.post('/image-assets/stats/reset', requireAdmin, async (req, res) => { const schema = z.object({ month: z.string().regex(/^\d{4}-\d{2}$/).optional().nullable(), }) const parsed = schema.safeParse(req.body || {}) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const deletedCount = await clearImageOptimizationJobs({ month: parsed.data.month || undefined }) res.json({ deletedCount }) }) router.post('/image-assets/missing/cleanup', requireAdmin, async (req, res) => { const result = await cleanupMissingUploadReferences() res.json({ result }) }) async function removeUploadFiles(srcs) { await Promise.all( (srcs || []).map(async (src) => { if (!src || !src.startsWith('/uploads/')) return const absolutePath = path.join(__dirname, '..', '..', src.replace(/^\//, '')) try { await fs.unlink(absolutePath) } catch (e) { if (e?.code !== 'ENOENT') throw e } }) ) } async function removeCustomItemFiles(items) { await Promise.all( items.map(async (item) => { if (!item?.src || !item.src.startsWith('/uploads/custom/')) return const absolutePath = path.join(__dirname, '..', '..', item.src.replace(/^\//, '')) try { await fs.unlink(absolutePath) } catch (e) { if (e?.code !== 'ENOENT') throw e } }) ) } async function promoteLibraryItemToTemplateItem({ item, templateId }) { return createTopicItem({ id: nanoid(), topicId: templateId, src: item.src || '', label: item.label, }) } async function copyUploadIntoGameAsset(src) { if (typeof src !== 'string') return '' const raw = src.trim() if (!raw) return '' if (raw.startsWith('/uploads/')) { if (raw.startsWith('/uploads/assets/')) return raw return raw } try { const url = new URL(raw) if (url.pathname.startsWith('/uploads/')) { return url.pathname } } catch (error) { return raw } return raw } function uniqueTierListPoolItems(tierList) { const seen = new Set() return (tierList?.pool || []).filter((item) => { if (!item?.id || seen.has(item.id)) return false seen.add(item.id) return true }) } async function promoteTierListItemsToTemplate({ tierList, templateId, itemIds = [] }) { const allowedIds = new Set((itemIds || []).filter(Boolean)) const sourceItems = uniqueTierListPoolItems(tierList).filter((item) => item.origin === 'custom') const itemsToCopy = allowedIds.size ? sourceItems.filter((item) => allowedIds.has(item.id)) : sourceItems const createdItems = [] for (const item of itemsToCopy) { const copiedSrc = await copyUploadIntoGameAsset(item.src) createdItems.push( await createTopicItem({ id: nanoid(), topicId: templateId, src: copiedSrc, label: item.label, }) ) } return createdItems } async function promoteSnapshotItemsToTemplate({ items, templateId }) { const existingItems = await listTopicItems(templateId) const existingSrcs = new Set( existingItems .map((item) => (typeof item?.src === 'string' ? item.src.trim() : '')) .filter(Boolean) ) const createdItems = [] for (const item of items || []) { const copiedSrc = await copyUploadIntoGameAsset(item.src) if (!copiedSrc || existingSrcs.has(copiedSrc)) continue createdItems.push( await createTopicItem({ id: nanoid(), topicId: templateId, src: copiedSrc, label: item.label, }) ) existingSrcs.add(copiedSrc) } return createdItems } function pickTemplateRequestItems(templateRequest, itemIds = [], itemLabels = {}, itemSrcs = []) { const requestedIds = new Set((itemIds || []).filter(Boolean)) const requestedSrcs = new Set((itemSrcs || []).filter((src) => typeof src === 'string' && src.trim()).map((src) => src.trim())) const items = Array.isArray(templateRequest?.items) ? templateRequest.items : [] const filtered = requestedIds.size || requestedSrcs.size ? items.filter((item) => (item?.id && requestedIds.has(item.id)) || (typeof item?.src === 'string' && requestedSrcs.has(item.src.trim()))) : items return filtered .filter((item) => typeof item?.src === 'string' && item.src.trim()) .map((item) => { const draftLabel = typeof itemLabels?.[item.id] === 'string' && itemLabels[item.id].trim() ? itemLabels[item.id].trim().slice(0, 60) : typeof item?.label === 'string' && item.label.trim() ? item.label.trim().slice(0, 60) : buildItemLabelFromSrc(item.src) return { ...item, src: item.src.trim(), label: draftLabel, } }) } async function createTemplateFromTierList({ tierList, templateId, templateName }) { await createTopic({ id: templateId, name: templateName, isPublic: false }) if (tierList.thumbnailSrc) { const copiedThumb = await copyUploadIntoGameAsset(tierList.thumbnailSrc) await updateTopicThumbnail(templateId, copiedThumb) } const createdItems = [] for (const item of uniqueTierListPoolItems(tierList)) { const copiedSrc = await copyUploadIntoGameAsset(item.src) createdItems.push( await createTopicItem({ id: nanoid(), topicId: templateId, src: copiedSrc, label: item.label, }) ) } return { game: await findTopicById(templateId), items: createdItems } } async function createTemplateFromRequest({ templateRequest, templateId, templateName }) { await createTopic({ id: templateId, name: templateName, isPublic: false }) if (templateRequest.thumbnailSrc) { const copiedThumb = await copyUploadIntoGameAsset(templateRequest.thumbnailSrc) await updateTopicThumbnail(templateId, copiedThumb) } const items = await promoteSnapshotItemsToTemplate({ items: templateRequest.items || [], templateId, }) return { game: await findTopicById(templateId), items } } router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => { const result = await listCustomItems({ page: 1, limit: 10000, filterMode: 'all' }) const target = result.items.find((item) => item.id === req.params.itemId) if (!target) return res.status(404).json({ error: 'not_found' }) if (target.sourceType === 'template') { if (String(target.id || '').startsWith('asset:')) { const assetId = String(target.id).slice('asset:'.length) const asset = await findImageAssetById(assetId) if (!asset) return res.status(404).json({ error: 'not_found' }) await deleteImageAssets([assetId]) await removeUploadFiles([asset.src]) return res.json({ ok: true, sourceType: 'template-asset' }) } await deleteTopicItem(target.id) return res.json({ ok: true, sourceType: 'template' }) } if (!target.canDelete) return res.status(409).json({ error: 'item_locked' }) if (target.linkedGames.length > 0) return res.status(409).json({ error: 'item_linked' }) if (target.usageCount > 0) return res.status(409).json({ error: 'item_in_use' }) const items = await findCustomItemsByIds([target.id]) await deleteCustomItems([target.id]) await removeCustomItemFiles(items) res.json({ ok: true, sourceType: 'user' }) }) router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => { const schema = z.object({ topicId: z.string().min(1), }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const template = await findTopicById(parsed.data.topicId) if (!template) return res.status(404).json({ error: 'topic_not_found' }) const customItem = await findCustomItemById(req.params.itemId) const templateItem = customItem ? null : await findTopicItemById(req.params.itemId) const assetItemId = String(req.params.itemId || '') const imageAsset = !customItem && !templateItem && assetItemId.startsWith('asset:') ? await findImageAssetById(assetItemId.slice(6)) : null const sourceItem = customItem || templateItem || (imageAsset ? { src: imageAsset.src || '', label: imageAsset.labelOverride || path.basename(imageAsset.src || '', path.extname(imageAsset.src || '')) || 'item', } : null) if (!sourceItem) return res.status(404).json({ error: 'not_found' }) const item = await promoteLibraryItemToTemplateItem({ item: sourceItem, templateId: template.id }) res.json({ item }) }) router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, res) => { const schema = z.object({ topicId: z.string().min(1), itemIds: z.array(z.string().min(1)).optional().default([]), }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const template = await findTopicById(parsed.data.topicId) if (!template) return res.status(404).json({ error: 'topic_not_found' }) const tierList = await findTierListById(req.params.tierListId) if (!tierList) return res.status(404).json({ error: 'not_found' }) const items = await promoteTierListItemsToTemplate({ tierList, templateId: template.id, itemIds: parsed.data.itemIds, }) res.json({ items }) }) router.post('/tierlists/:tierListId/create-game-template', requireAdmin, async (req, res) => { const schema = z.object({ topicId: z.string().trim().min(1).max(120), name: z.string().trim().min(1).max(120), itemIds: z.array(z.string().min(1)).optional().default([]), }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const exists = await findTopicById(parsed.data.topicId) if (exists) return res.status(409).json({ error: 'topic_id_taken' }) const tierList = await findTierListById(req.params.tierListId) if (!tierList) return res.status(404).json({ error: 'not_found' }) const result = await createTemplateFromTierList({ tierList: { ...tierList, pool: parsed.data.itemIds.length ? (tierList.pool || []).filter((item) => parsed.data.itemIds.includes(item.id)) : tierList.pool, }, templateId: parsed.data.topicId, templateName: parsed.data.name, }) res.json(result) }) router.patch('/tierlists/:tierListId', requireAdmin, async (req, res) => { const schema = z.object({ title: z.string().trim().min(1).max(120), description: z.string().max(500).optional().default(''), isPublic: z.boolean(), }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const tierList = await findTierListById(req.params.tierListId) if (!tierList) return res.status(404).json({ error: 'not_found' }) const updated = await updateAdminTierListMeta({ id: tierList.id, title: parsed.data.title, description: parsed.data.description || '', isPublic: parsed.data.isPublic, }) res.json({ tierList: updated }) }) router.delete('/tierlists/:tierListId', requireAdmin, async (req, res) => { const tierList = await findTierListById(req.params.tierListId) if (!tierList) return res.status(404).json({ error: 'not_found' }) await deleteTierList(tierList.id) res.json({ ok: true }) }) router.post('/template-requests/:requestId/approve', requireAdmin, async (req, res) => { const templateRequest = await findTemplateRequestById(req.params.requestId) if (!templateRequest) return res.status(404).json({ error: 'not_found' }) if (templateRequest.status !== 'pending') return res.status(409).json({ error: 'request_already_handled' }) if (templateRequest.type === 'update') { const targetTopicId = templateRequest.targetTopicId || templateRequest.sourceTopicId const template = await findTopicById(targetTopicId) if (!template) return res.status(404).json({ error: 'topic_not_found' }) const items = await promoteSnapshotItemsToTemplate({ items: templateRequest.items || [], templateId: template.id, }) const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' }) return res.json({ request, items }) } const schema = z.object({ topicId: z.string().trim().min(1).max(120), name: z.string().trim().min(1).max(120), }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const exists = await findTopicById(parsed.data.topicId) if (exists) return res.status(409).json({ error: 'topic_id_taken' }) const result = await createTemplateFromRequest({ templateRequest, templateId: parsed.data.topicId, templateName: parsed.data.name, }) const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' }) res.json({ request, ...result }) }) router.post('/template-requests/:requestId/review', requireAdmin, async (req, res) => { let templateRequest = await findTemplateRequestById(req.params.requestId) if (!templateRequest) return res.status(404).json({ error: 'not_found' }) if (templateRequest.status === 'completed' || templateRequest.status === 'rejected' || templateRequest.status === 'approved') { return res.status(409).json({ error: 'request_already_handled' }) } if (templateRequest.type === 'create' && templateRequest.targetTopicId && !templateRequest.targetTopicName) { templateRequest = await updateTemplateRequestTargetTopic({ id: templateRequest.id, targetTopicId: '', }) } if (templateRequest.status === 'reviewing') { return res.json({ request: templateRequest }) } const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'reviewing' }) res.json({ request }) }) router.post('/template-requests/:requestId/link-game', requireAdmin, async (req, res) => { const schema = z.object({ topicId: z.string().trim().min(1).max(120), }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const templateRequest = await findTemplateRequestById(req.params.requestId) if (!templateRequest) return res.status(404).json({ error: 'not_found' }) if (templateRequest.type !== 'create') return res.status(409).json({ error: 'create_request_required' }) if (!['pending', 'reviewing'].includes(templateRequest.status)) { return res.status(409).json({ error: 'request_already_handled' }) } const template = await findTopicById(parsed.data.topicId) if (!template) return res.status(404).json({ error: 'topic_not_found' }) const request = await updateTemplateRequestTargetTopic({ id: templateRequest.id, targetTopicId: template.id, }) res.json({ request }) }) router.post('/template-requests/:requestId/promote-items', requireAdmin, async (req, res) => { const schema = z.object({ topicId: z.string().trim().min(1).max(120), itemIds: z.array(z.string().min(1)).optional().default([]), itemSrcs: z.array(z.string().min(1)).optional().default([]), itemLabels: z.record(z.string(), z.string().min(1).max(60)).optional().default({}), }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const templateRequest = await findTemplateRequestById(req.params.requestId) if (!templateRequest) return res.status(404).json({ error: 'not_found' }) if (!['pending', 'reviewing'].includes(templateRequest.status)) { return res.status(409).json({ error: 'request_already_handled' }) } const template = await findTopicById(parsed.data.topicId) if (!template) return res.status(404).json({ error: 'topic_not_found' }) const promotableItems = pickTemplateRequestItems(templateRequest, parsed.data.itemIds, parsed.data.itemLabels, parsed.data.itemSrcs) if (!promotableItems.length) { return res.status(400).json({ error: 'no_items_selected' }) } let items = [] try { items = await promoteSnapshotItemsToTemplate({ items: promotableItems, templateId: template.id, }) } catch (error) { console.error('[admin] template request promote-items failed', { requestId: templateRequest.id, topicId: template.id, itemCount: promotableItems.length, message: error?.message || 'unknown_error', code: error?.code || '', stack: error?.stack || '', }) return res.status(500).json({ error: 'promote_items_failed', detail: error?.message || 'unknown_error', code: error?.code || '', }) } const request = templateRequest.status === 'reviewing' ? templateRequest : await updateTemplateRequestStatus({ id: templateRequest.id, status: 'reviewing' }) res.json({ request, items }) }) router.post('/template-requests/:requestId/complete', requireAdmin, async (req, res) => { const templateRequest = await findTemplateRequestById(req.params.requestId) if (!templateRequest) return res.status(404).json({ error: 'not_found' }) if (templateRequest.status === 'completed') return res.json({ request: templateRequest }) if (templateRequest.status === 'rejected' || templateRequest.status === 'approved') { return res.status(409).json({ error: 'request_already_handled' }) } const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'completed' }) res.json({ request }) }) router.post('/template-requests/:requestId/reject', requireAdmin, async (req, res) => { const templateRequest = await findTemplateRequestById(req.params.requestId) if (!templateRequest) return res.status(404).json({ error: 'not_found' }) if (templateRequest.status !== 'pending') return res.status(409).json({ error: 'request_already_handled' }) const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'rejected' }) res.json({ request }) }) router.delete('/custom-items', requireAdmin, async (req, res) => { const schema = z.object({ q: z.string().trim().max(120).optional().default(''), }) const parsed = schema.safeParse(req.query) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const items = await findUnusedCustomItems({ queryText: parsed.data.q }) const ids = items.map((item) => item.id) await deleteCustomItems(ids) await removeCustomItemFiles(items) res.json({ ok: true, deletedCount: ids.length }) }) router.get('/users', requireAdmin, async (req, res) => { const schema = z.object({ q: z.string().trim().max(120).optional().default(''), sort: z.enum(['recent', 'created', 'tierlists']).optional().default('recent'), direction: z.enum(['asc', 'desc']).optional().default('desc'), }) const parsed = schema.safeParse(req.query) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const [users, primaryAdmin] = await Promise.all([ listUsers({ queryText: parsed.data.q, sort: parsed.data.sort, direction: parsed.data.direction }), findPrimaryAdminUser(), ]) res.json({ users: users.map((user) => decorateAdminUser(user, primaryAdmin)) }) }) router.patch('/users/:userId', requireAdmin, async (req, res) => { const schema = z.object({ email: z.string().email(), nickname: z.string().trim().max(40).default(''), isAdmin: z.boolean(), }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId) if (!targetUser) return res.status(404).json({ error: 'not_found' }) const actingIsPrimaryAdmin = canManageAdminRole(actingUser, primaryAdmin) const targetIsPrimaryAdmin = primaryAdmin?.id === targetUser.id const roleChanged = parsed.data.isAdmin !== !!targetUser.isAdmin if (req.params.userId === req.session.userId && !parsed.data.isAdmin) { return res.status(400).json({ error: 'self_admin_required' }) } if (targetIsPrimaryAdmin && !actingIsPrimaryAdmin) { return res.status(403).json({ error: 'primary_admin_protected' }) } if (targetIsPrimaryAdmin && !parsed.data.isAdmin) { return res.status(400).json({ error: 'primary_admin_required' }) } if (roleChanged && !actingIsPrimaryAdmin) { return res.status(403).json({ error: 'primary_admin_only' }) } try { const updated = await adminUpdateUser({ id: targetUser.id, email: parsed.data.email, nickname: parsed.data.nickname, isAdmin: parsed.data.isAdmin, }) res.json({ user: decorateAdminUser(updated, primaryAdmin) }) } catch (e) { if (e && e.code === 'ER_DUP_ENTRY') { return res.status(409).json({ error: 'email_taken' }) } throw e } }) router.post('/users/:userId/avatar', requireAdmin, avatarUpload.single('avatar'), async (req, res) => { const schema = z.object({ removeAvatar: z.union([z.literal('1'), z.undefined()]).optional(), }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId) if (!targetUser) return res.status(404).json({ error: 'not_found' }) if (primaryAdmin?.id === targetUser.id && !canManageAdminRole(actingUser, primaryAdmin)) { return res.status(403).json({ error: 'primary_admin_protected' }) } const optimized = req.file ? await writeOptimizedImage({ file: req.file, directory: 'avatars', width: 512, height: 512, fit: 'cover', quality: 82, }) : null const shouldRemoveAvatar = parsed.data.removeAvatar === '1' const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || targetUser.avatarSrc || '' const updated = await adminUpdateUser({ id: targetUser.id, email: targetUser.email, nickname: targetUser.nickname || '', isAdmin: !!targetUser.isAdmin, avatarSrc: nextAvatarSrc, }) res.json({ user: decorateAdminUser(updated, primaryAdmin) }) }) router.delete('/users/:userId', requireAdmin, async (req, res) => { if (req.params.userId === req.session.userId) { return res.status(400).json({ error: 'cannot_delete_self' }) } const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId) if (!targetUser) return res.status(404).json({ error: 'not_found' }) const actingIsPrimaryAdmin = canManageAdminRole(actingUser, primaryAdmin) const targetIsPrimaryAdmin = primaryAdmin?.id === targetUser.id if (targetIsPrimaryAdmin) { return res.status(400).json({ error: 'cannot_delete_primary_admin' }) } if (targetUser.isAdmin && !actingIsPrimaryAdmin) { return res.status(403).json({ error: 'primary_admin_only' }) } await adminDeleteUser(targetUser.id) res.json({ ok: true }) }) router.patch('/users/:userId/password', requireAdmin, async (req, res) => { const schema = z.object({ password: z.string().min(6).max(120), }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId) if (!targetUser) return res.status(404).json({ error: 'not_found' }) if (primaryAdmin?.id === targetUser.id && !canManageAdminRole(actingUser, primaryAdmin)) { return res.status(403).json({ error: 'primary_admin_protected' }) } const passwordHash = await bcrypt.hash(parsed.data.password, 10) await adminUpdateUserPassword({ id: targetUser.id, passwordHash }) res.json({ ok: true }) }) module.exports = router