const express = require('express') const multer = require('multer') const { z } = require('zod') const { nanoid } = require('nanoid') const { findTierListById, listPublicTierLists, listFavoriteTierLists, listUserTierLists, deleteTierList, saveTierList, createCustomItem, createTemplateRequest, findUserById, favoriteTierList, unfavoriteTierList, duplicateTierListForUser, } = require('../db') const { requireAuth } = require('../middleware/auth') const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage') const router = express.Router() const FREEFORM_GAME_ID = 'freeform' const FREEFORM_DEFAULT_TITLE = '직접 티어표 만들기' function normalizePoolItem(item) { if (!item || item.origin !== 'game' || typeof item.src !== 'string') return item if (item.src.startsWith('/uploads/')) return item try { const url = new URL(item.src) if (url.pathname.startsWith('/uploads/')) { return { ...item, src: url.pathname } } } catch (e) { return item } return item } function normalizeTierList(tierList) { return { ...tierList, pool: Array.isArray(tierList.pool) ? tierList.pool.map(normalizePoolItem) : [], } } function getCustomTemplateItems(tierList) { const seen = new Set() return (tierList?.pool || []).filter((item) => { if (!item?.id || item.origin !== 'custom' || seen.has(item.id)) return false seen.add(item.id) return true }) } const upload = createMemoryUpload(multer, { fileSize: 6 * 1024 * 1024 }) const thumbnailUpload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024 }) const templateRequestSchema = z.object({ type: z.enum(['create', 'update']), sourceTierListId: z.string().max(64).optional().default(''), gameId: z.string().min(1).max(120), requestTitle: z.string().trim().min(1).max(120), requestDescription: z.string().trim().min(1).max(1000), thumbnailSrc: z.string().max(255).optional().default(''), isPublic: z.boolean().optional().default(false), showCharacterNames: z.boolean().optional().default(false), groups: z.array( z.object({ id: z.string().min(1), name: z.string().min(1).max(16), itemIds: z.array(z.string()).optional().default([]), }).passthrough() ), boardItems: z.array( z.object({ id: z.string().min(1), src: z.string().min(1), label: z.string().min(1).max(60), origin: z.enum(['game', 'custom']).default('game'), }) ), }) const tierListUpsertSchema = z.object({ id: z.string().optional(), gameId: z.string().min(1), title: z.string().min(1).max(120), thumbnailSrc: z.string().max(255).optional().default(''), description: z.string().max(1000).optional().default(''), isPublic: z.boolean().default(false), showCharacterNames: z.boolean().optional().default(false), sourceTierListId: z.string().max(64).optional().default(''), sourceSnapshotTitle: z.string().max(120).optional().default(''), sourceSnapshotAuthor: z.string().max(120).optional().default(''), groups: z.array( z.object({ id: z.string().min(1), name: z.string().min(1).max(16), itemIds: z.array(z.string()).optional().default([]), }).passthrough() ), pool: z.array( z.object({ id: z.string().min(1), src: z.string().min(1), label: z.string().min(1).max(60), origin: z.enum(['game', 'custom']).default('game'), }) ), }) router.get('/public', async (req, res) => { const gameId = req.query.gameId const queryText = typeof req.query.q === 'string' ? req.query.q : '' const lists = await listPublicTierLists(gameId, req.session?.userId || '', queryText) res.json({ tierLists: lists }) }) router.get('/me', requireAuth, async (req, res) => { const lists = await listUserTierLists(req.session.userId) res.json({ tierLists: lists }) }) router.get('/favorites/me', requireAuth, async (req, res) => { const queryText = typeof req.query.q === 'string' ? req.query.q : '' const sort = typeof req.query.sort === 'string' ? req.query.sort : 'favorited' const lists = await listFavoriteTierLists(req.session.userId, { queryText, sort }) res.json({ tierLists: lists }) }) router.get('/:id', async (req, res) => { const t = await findTierListById(req.params.id, req.session?.userId || '') if (!t) return res.status(404).json({ error: 'not_found' }) if (!t.isPublic) { if (!req.session?.userId) return res.status(403).json({ error: 'forbidden' }) const currentUser = req.session.userId === t.authorId ? { isAdmin: false } : await findUserById(req.session.userId) if (req.session.userId !== t.authorId && !currentUser?.isAdmin) return res.status(403).json({ error: 'forbidden' }) } res.json({ tierList: normalizeTierList(t) }) }) router.post('/:id/duplicate', requireAuth, async (req, res) => { const tierList = await findTierListById(req.params.id, req.session.userId) if (!tierList) return res.status(404).json({ error: 'not_found' }) if (!tierList.isPublic && tierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' }) const duplicated = await duplicateTierListForUser({ tierList, targetUserId: req.session.userId }) res.json({ tierList: normalizeTierList(duplicated) }) }) router.delete('/:id', requireAuth, async (req, res) => { const tierList = await findTierListById(req.params.id, req.session.userId) if (!tierList) return res.status(404).json({ error: 'not_found' }) if (tierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' }) await deleteTierList(tierList.id) res.json({ ok: true }) }) router.post('/:id/favorite', requireAuth, async (req, res) => { const tierList = await findTierListById(req.params.id, req.session.userId) if (!tierList) return res.status(404).json({ error: 'not_found' }) if (!tierList.isPublic && tierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' }) await favoriteTierList({ userId: req.session.userId, tierListId: tierList.id }) const updated = await findTierListById(tierList.id, req.session.userId) res.json({ tierList: normalizeTierList(updated) }) }) router.delete('/:id/favorite', requireAuth, async (req, res) => { const tierList = await findTierListById(req.params.id, req.session.userId) if (!tierList) return res.status(404).json({ error: 'not_found' }) await unfavoriteTierList({ userId: req.session.userId, tierListId: tierList.id }) const updated = await findTierListById(tierList.id, req.session.userId) res.json({ tierList: normalizeTierList(updated) }) }) router.post('/custom-items', requireAuth, upload.single('image'), async (req, res) => { if (!req.file) return res.status(400).json({ error: 'file_required' }) 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 optimized = await writeOptimizedImage({ file: req.file, directory: 'custom', width: 512, height: 512, fit: 'inside', quality: 84, }) const item = await createCustomItem({ id: nanoid(), ownerId: req.session.userId, src: optimized.src, label: parsed.data.label, }) res.json({ item }) }) router.post('/thumbnail', requireAuth, thumbnailUpload.single('thumbnail'), async (req, res) => { if (!req.file) return res.status(400).json({ error: 'file_required' }) const optimized = await writeOptimizedImage({ file: req.file, directory: 'tierlists', width: 1280, height: 1280, fit: 'inside', quality: 84, }) res.json({ thumbnailSrc: optimized.src }) }) router.post('/template-request', requireAuth, async (req, res) => { const parsed = templateRequestSchema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const payload = parsed.data const normalizedBoardItems = payload.boardItems.map(normalizePoolItem) const customItems = normalizedBoardItems.filter((item) => item?.origin === 'custom') if (!customItems.length) return res.status(400).json({ error: 'custom_items_required' }) if (payload.type === 'create') { if (payload.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' }) } else if (payload.gameId === FREEFORM_GAME_ID) { return res.status(400).json({ error: 'game_template_required' }) } let sourceTierList = null if (payload.sourceTierListId) { sourceTierList = await findTierListById(payload.sourceTierListId, req.session.userId) if (!sourceTierList) return res.status(404).json({ error: 'not_found' }) if (sourceTierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' }) } if (!payload.sourceTierListId) return res.status(400).json({ error: 'source_tierlist_required' }) try { const request = await createTemplateRequest({ id: nanoid(), type: payload.type, requesterId: req.session.userId, sourceTierListId: sourceTierList?.id || '', sourceGameId: payload.gameId, targetGameId: payload.type === 'update' ? payload.gameId : '', title: payload.requestTitle, description: payload.requestDescription, thumbnailSrc: payload.thumbnailSrc || '', items: customItems, groups: payload.groups, boardItems: normalizedBoardItems, showCharacterNames: !!payload.showCharacterNames, }) return res.json({ request }) } catch (e) { if (e?.code === 'TEMPLATE_REQUEST_EXISTS') { return res.status(409).json({ error: 'template_request_exists' }) } throw e } }) router.post('/', requireAuth, async (req, res) => { const parsed = tierListUpsertSchema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const payload = parsed.data const normalizedPool = payload.pool.map(normalizePoolItem) let existing = null if (payload.id) existing = await findTierListById(payload.id) if (existing) { if (existing.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' }) const updated = await saveTierList({ id: existing.id, authorId: existing.authorId, gameId: existing.gameId, title: payload.title, thumbnailSrc: payload.thumbnailSrc || '', description: payload.description || '', isPublic: !!payload.isPublic, showCharacterNames: !!payload.showCharacterNames, sourceTierListId: payload.sourceTierListId || existing.sourceTierListId || '', sourceSnapshotTitle: payload.sourceSnapshotTitle || existing.sourceSnapshotTitle || '', sourceSnapshotAuthor: payload.sourceSnapshotAuthor || existing.sourceSnapshotAuthor || '', groups: payload.groups, pool: normalizedPool, }) return res.json({ tierList: normalizeTierList(updated) }) } const created = await saveTierList({ id: nanoid(), authorId: req.session.userId, gameId: payload.gameId, title: payload.title, thumbnailSrc: payload.thumbnailSrc || '', description: payload.description || '', isPublic: !!payload.isPublic, showCharacterNames: !!payload.showCharacterNames, sourceTierListId: payload.sourceTierListId || '', sourceSnapshotTitle: payload.sourceSnapshotTitle || '', sourceSnapshotAuthor: payload.sourceSnapshotAuthor || '', groups: payload.groups, pool: normalizedPool, }) res.json({ tierList: normalizeTierList(created) }) }) module.exports = router