Files
tier-maker/backend/src/routes/tierlists.js

268 lines
9.1 KiB
JavaScript

const express = require('express')
const path = require('path')
const multer = require('multer')
const { z } = require('zod')
const { nanoid } = require('nanoid')
const {
findTierListById,
listPublicTierLists,
listFavoriteTierLists,
listUserTierLists,
deleteTierList,
saveTierList,
createCustomItem,
createTemplateRequest,
findUserById,
favoriteTierList,
unfavoriteTierList,
} = require('../db')
const { requireAuth } = require('../middleware/auth')
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
})
}
function buildUploadFilename(file) {
const ext = path.extname(file.originalname || '').toLowerCase()
const safeExt = ext && /^[.a-z0-9]+$/.test(ext) ? ext : ''
return `${Date.now()}-${nanoid()}${safeExt}`
}
const upload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, path.join(__dirname, '..', '..', 'uploads', 'custom')),
filename: (req, file, cb) => cb(null, buildUploadFilename(file)),
}),
limits: { fileSize: 6 * 1024 * 1024 },
})
const thumbnailUpload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, path.join(__dirname, '..', '..', 'uploads', 'tierlists')),
filename: (req, file, cb) => cb(null, buildUploadFilename(file)),
}),
limits: { fileSize: 6 * 1024 * 1024 },
})
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),
groups: z.array(
z.object({
id: z.string().min(1),
name: z.string().min(1).max(16),
itemIds: z.array(z.string()),
})
),
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.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 item = await createCustomItem({
id: nanoid(),
ownerId: req.session.userId,
src: `/uploads/custom/${req.file.filename}`,
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' })
res.json({ thumbnailSrc: `/uploads/tierlists/${req.file.filename}` })
})
router.post('/:id/template-request', requireAuth, async (req, res) => {
const schema = z.object({
type: z.enum(['create', 'update']),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
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' })
const customItems = getCustomTemplateItems(tierList)
if (!customItems.length) return res.status(400).json({ error: 'custom_items_required' })
if (parsed.data.type === 'create') {
if (tierList.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
if (!(tierList.title || '').trim() || (tierList.title || '').trim() === FREEFORM_DEFAULT_TITLE) {
return res.status(400).json({ error: 'title_required' })
}
} else {
if (tierList.gameId === FREEFORM_GAME_ID) return res.status(400).json({ error: 'game_template_required' })
}
try {
const request = await createTemplateRequest({
id: nanoid(),
type: parsed.data.type,
requesterId: req.session.userId,
sourceTierListId: tierList.id,
sourceGameId: tierList.gameId,
targetGameId: parsed.data.type === 'update' ? tierList.gameId : '',
title: tierList.title,
description: tierList.description || '',
thumbnailSrc: tierList.thumbnailSrc || '',
items: customItems,
})
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,
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,
groups: payload.groups,
pool: normalizedPool,
})
res.json({ tierList: normalizeTierList(created) })
})
module.exports = router