릴리스: v0.1.47 템플릿 요청과 관리자 승인 흐름 추가

This commit is contained in:
2026-03-27 11:10:45 +09:00
parent e0eeaa01cd
commit 3b314381a0
11 changed files with 725 additions and 15 deletions

View File

@@ -24,6 +24,9 @@ const {
listUsers,
listAdminTierLists,
findTierListById,
listAdminTemplateRequests,
findTemplateRequestById,
updateTemplateRequestStatus,
adminUpdateUser,
adminUpdateUserPassword,
adminDeleteUser,
@@ -181,6 +184,11 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
res.json(result)
})
router.get('/template-requests', requireAdmin, async (req, res) => {
const requests = await listAdminTemplateRequests({ status: 'pending' })
res.json({ requests })
})
async function removeCustomItemFiles(items) {
await Promise.all(
items.map(async (item) => {
@@ -255,6 +263,24 @@ async function promoteTierListItemsToGame({ tierList, gameId, itemIds = [] }) {
return createdItems
}
async function promoteSnapshotItemsToGame({ items, gameId }) {
const createdItems = []
for (const item of items || []) {
const copiedSrc = await copyUploadIntoGameAsset(item.src)
createdItems.push(
await createGameItem({
id: nanoid(),
gameId,
src: copiedSrc,
label: item.label,
})
)
}
return createdItems
}
async function createGameTemplateFromTierList({ tierList, gameId, gameName }) {
await createGame({ id: gameId, name: gameName })
if (tierList.thumbnailSrc) {
@@ -278,6 +304,22 @@ async function createGameTemplateFromTierList({ tierList, gameId, gameName }) {
return { game: await findGameById(gameId), items: createdItems }
}
async function createGameTemplateFromRequest({ templateRequest, gameId, gameName }) {
await createGame({ id: gameId, name: gameName })
if (templateRequest.thumbnailSrc) {
const copiedThumb = await copyUploadIntoGameAsset(templateRequest.thumbnailSrc)
await updateGameThumbnail(gameId, copiedThumb)
}
const items = await promoteSnapshotItemsToGame({
items: templateRequest.items || [],
gameId,
})
return { game: await findGameById(gameId), items }
}
router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
const result = await listCustomItems({ page: 1, limit: 200, orphanOnly: false })
const target = result.items.find((item) => item.id === req.params.itemId)
@@ -355,6 +397,52 @@ router.post('/tierlists/:tierListId/create-game-template', requireAdmin, async (
res.json(result)
})
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 targetGameId = templateRequest.targetGameId || templateRequest.sourceGameId
const game = await findGameById(targetGameId)
if (!game) return res.status(404).json({ error: 'game_not_found' })
const items = await promoteSnapshotItemsToGame({
items: templateRequest.items || [],
gameId: game.id,
})
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' })
return res.json({ request, items })
}
const schema = z.object({
gameId: 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 findGameById(parsed.data.gameId)
if (exists) return res.status(409).json({ error: 'game_id_taken' })
const result = await createGameTemplateFromRequest({
templateRequest,
gameId: parsed.data.gameId,
gameName: parsed.data.name,
})
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' })
res.json({ request, ...result })
})
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(''),

View File

@@ -11,6 +11,7 @@ const {
deleteTierList,
saveTierList,
createCustomItem,
createTemplateRequest,
findUserById,
favoriteTierList,
unfavoriteTierList,
@@ -18,6 +19,7 @@ const {
const { requireAuth } = require('../middleware/auth')
const router = express.Router()
const FREEFORM_GAME_ID = 'freeform'
function normalizePoolItem(item) {
if (!item || item.origin !== 'game' || typeof item.src !== 'string') return item
@@ -42,6 +44,19 @@ function normalizeTierList(tierList) {
}
}
function isTierListBoardEmpty(tierList) {
return !(tierList?.groups || []).some((group) => Array.isArray(group?.itemIds) && group.itemIds.length > 0)
}
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 : ''
@@ -168,6 +183,49 @@ router.post('/thumbnail', requireAuth, thumbnailUpload.single('thumbnail'), asyn
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 (!isTierListBoardEmpty(tierList)) return res.status(400).json({ error: 'board_must_be_empty' })
} 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' })