320 lines
11 KiB
JavaScript
320 lines
11 KiB
JavaScript
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
|