릴리스: v0.1.47 템플릿 요청과 관리자 승인 흐름 추가
This commit is contained in:
@@ -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(''),
|
||||
|
||||
@@ -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' })
|
||||
|
||||
Reference in New Issue
Block a user