관리자 화면 분리 및 요청/아이템 관리 흐름 정리
This commit is contained in:
@@ -85,6 +85,7 @@ function mapGameItemRow(row) {
|
||||
gameId: row.game_id,
|
||||
src: row.src,
|
||||
label: row.label,
|
||||
displayOrder: row.display_order == null ? null : Number(row.display_order),
|
||||
createdAt: Number(row.created_at),
|
||||
}
|
||||
}
|
||||
@@ -271,12 +272,18 @@ async function ensureSchema() {
|
||||
game_id VARCHAR(120) NOT NULL,
|
||||
src VARCHAR(255) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
display_order INT NULL DEFAULT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
INDEX idx_game_items_game_id (game_id),
|
||||
CONSTRAINT fk_game_items_game FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
const gameItemDisplayOrderColumns = await query("SHOW COLUMNS FROM game_items LIKE 'display_order'")
|
||||
if (!gameItemDisplayOrderColumns.length) {
|
||||
await query('ALTER TABLE game_items ADD COLUMN display_order INT NULL DEFAULT NULL AFTER label')
|
||||
}
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS custom_items (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
@@ -668,14 +675,23 @@ async function findGameById(id) {
|
||||
|
||||
async function listGameItems(gameId) {
|
||||
const rows = await query(
|
||||
'SELECT id, game_id, src, label, created_at FROM game_items WHERE game_id = ? ORDER BY created_at ASC',
|
||||
`
|
||||
SELECT id, game_id, src, label, display_order, created_at
|
||||
FROM game_items
|
||||
WHERE game_id = ?
|
||||
ORDER BY
|
||||
CASE WHEN display_order IS NULL THEN 1 ELSE 0 END ASC,
|
||||
display_order ASC,
|
||||
created_at DESC,
|
||||
id DESC
|
||||
`,
|
||||
[gameId]
|
||||
)
|
||||
return rows.map(mapGameItemRow)
|
||||
}
|
||||
|
||||
async function findGameItemById(itemId) {
|
||||
const rows = await query('SELECT id, game_id, src, label, created_at FROM game_items WHERE id = ? LIMIT 1', [itemId])
|
||||
const rows = await query('SELECT id, game_id, src, label, display_order, created_at FROM game_items WHERE id = ? LIMIT 1', [itemId])
|
||||
return mapGameItemRow(rows[0])
|
||||
}
|
||||
|
||||
@@ -1077,23 +1093,42 @@ async function clearImageOptimizationJobs({ month } = {}) {
|
||||
}
|
||||
async function createGameItem({ id, gameId, src, label }) {
|
||||
const createdAt = now()
|
||||
await query('INSERT INTO game_items (id, game_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [
|
||||
const minOrderRows = await query('SELECT MIN(display_order) AS min_display_order FROM game_items WHERE game_id = ?', [gameId])
|
||||
const nextDisplayOrder =
|
||||
minOrderRows[0]?.min_display_order == null ? 0 : Number(minOrderRows[0].min_display_order) - 1
|
||||
await query('INSERT INTO game_items (id, game_id, src, label, display_order, created_at) VALUES (?, ?, ?, ?, ?, ?)', [
|
||||
id,
|
||||
gameId,
|
||||
src,
|
||||
label,
|
||||
nextDisplayOrder,
|
||||
createdAt,
|
||||
])
|
||||
const rows = await query('SELECT id, game_id, src, label, created_at FROM game_items WHERE id = ? LIMIT 1', [id])
|
||||
const rows = await query('SELECT id, game_id, src, label, display_order, created_at FROM game_items WHERE id = ? LIMIT 1', [id])
|
||||
return mapGameItemRow(rows[0])
|
||||
}
|
||||
|
||||
async function updateGameItemLabel(itemId, label) {
|
||||
await query('UPDATE game_items SET label = ? WHERE id = ?', [label, itemId])
|
||||
const rows = await query('SELECT id, game_id, src, label, created_at FROM game_items WHERE id = ? LIMIT 1', [itemId])
|
||||
const rows = await query('SELECT id, game_id, src, label, display_order, created_at FROM game_items WHERE id = ? LIMIT 1', [itemId])
|
||||
return mapGameItemRow(rows[0])
|
||||
}
|
||||
|
||||
async function updateGameItemDisplayOrder(gameId, itemIds) {
|
||||
const normalizedIds = Array.from(new Set((itemIds || []).filter(Boolean)))
|
||||
const existingItems = await listGameItems(gameId)
|
||||
const existingIdSet = new Set(existingItems.map((item) => item.id))
|
||||
const orderedIds = normalizedIds.filter((id) => existingIdSet.has(id))
|
||||
const remainingIds = existingItems.map((item) => item.id).filter((id) => !orderedIds.includes(id))
|
||||
const finalIds = [...orderedIds, ...remainingIds]
|
||||
|
||||
await Promise.all(
|
||||
finalIds.map((itemId, index) => query('UPDATE game_items SET display_order = ? WHERE id = ? AND game_id = ?', [index + 1, itemId, gameId]))
|
||||
)
|
||||
|
||||
return listGameItems(gameId)
|
||||
}
|
||||
|
||||
async function updateCustomItemLabel(itemId, label) {
|
||||
await query('UPDATE custom_items SET label = ? WHERE id = ?', [label, itemId])
|
||||
const rows = await query(`
|
||||
@@ -1916,7 +1951,11 @@ async function findTemplateRequestById(id) {
|
||||
return mapTemplateRequestRow(rows[0])
|
||||
}
|
||||
|
||||
async function listAdminTemplateRequests({ status = 'pending' } = {}) {
|
||||
async function listAdminTemplateRequests({ status = 'pending', statuses = [] } = {}) {
|
||||
const requestedStatuses = Array.isArray(statuses) && statuses.length ? statuses : [status]
|
||||
const validStatuses = requestedStatuses.filter((entry) => typeof entry === 'string' && entry.trim())
|
||||
const normalizedStatuses = validStatuses.length ? validStatuses : ['pending']
|
||||
const placeholders = normalizedStatuses.map(() => '?').join(', ')
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT
|
||||
@@ -1945,10 +1984,16 @@ async function listAdminTemplateRequests({ status = 'pending' } = {}) {
|
||||
INNER JOIN users u ON u.id = tr.requester_id
|
||||
LEFT JOIN games sg ON sg.id = tr.source_game_id
|
||||
LEFT JOIN games tg ON tg.id = tr.target_game_id
|
||||
WHERE tr.status = ?
|
||||
ORDER BY tr.created_at DESC
|
||||
WHERE tr.status IN (${placeholders})
|
||||
ORDER BY
|
||||
CASE tr.status
|
||||
WHEN 'pending' THEN 0
|
||||
WHEN 'reviewing' THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
tr.created_at DESC
|
||||
`,
|
||||
[status]
|
||||
normalizedStatuses
|
||||
)
|
||||
|
||||
return rows.map(mapTemplateRequestRow)
|
||||
@@ -2111,6 +2156,7 @@ module.exports = {
|
||||
getImageAssetStats,
|
||||
createGameItem,
|
||||
updateGameItemLabel,
|
||||
updateGameItemDisplayOrder,
|
||||
updateCustomItemLabel,
|
||||
updateImageAssetLabel,
|
||||
deleteGameItem,
|
||||
|
||||
@@ -15,6 +15,7 @@ const {
|
||||
updateGameThumbnail,
|
||||
createGameItem,
|
||||
updateGameItemLabel,
|
||||
updateGameItemDisplayOrder,
|
||||
updateCustomItemLabel,
|
||||
updateImageAssetLabel,
|
||||
deleteGameItem,
|
||||
@@ -115,6 +116,20 @@ router.patch('/games/display-order', requireAdmin, async (req, res) => {
|
||||
res.json({ games: updatedGames })
|
||||
})
|
||||
|
||||
router.patch('/games/:gameId/items/display-order', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
itemIds: z.array(z.string().min(1)).min(1),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const game = await findGameById(req.params.gameId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const items = await updateGameItemDisplayOrder(game.id, parsed.data.itemIds)
|
||||
res.json({ items })
|
||||
})
|
||||
|
||||
router.post('/games/:gameId/thumbnail', requireAdmin, upload.single('thumbnail'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'file_required' })
|
||||
const game = await findGameById(req.params.gameId)
|
||||
@@ -262,7 +277,7 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
|
||||
})
|
||||
|
||||
router.get('/template-requests', requireAdmin, async (req, res) => {
|
||||
const requests = await listAdminTemplateRequests({ status: 'pending' })
|
||||
const requests = await listAdminTemplateRequests({ statuses: ['pending', 'reviewing'] })
|
||||
res.json({ requests })
|
||||
})
|
||||
|
||||
@@ -429,6 +444,16 @@ async function promoteSnapshotItemsToGame({ items, gameId }) {
|
||||
return createdItems
|
||||
}
|
||||
|
||||
function pickTemplateRequestItems(templateRequest, itemIds = [], itemLabels = {}) {
|
||||
const requestedIds = new Set((itemIds || []).filter(Boolean))
|
||||
const items = Array.isArray(templateRequest?.items) ? templateRequest.items : []
|
||||
const filtered = requestedIds.size ? items.filter((item) => item?.id && requestedIds.has(item.id)) : items
|
||||
return filtered.map((item) => ({
|
||||
...item,
|
||||
label: typeof itemLabels?.[item.id] === 'string' && itemLabels[item.id].trim() ? itemLabels[item.id].trim().slice(0, 60) : item.label,
|
||||
}))
|
||||
}
|
||||
|
||||
async function createGameTemplateFromTierList({ tierList, gameId, gameName }) {
|
||||
await createGame({ id: gameId, name: gameName })
|
||||
if (tierList.thumbnailSrc) {
|
||||
@@ -610,6 +635,64 @@ router.post('/template-requests/:requestId/approve', requireAdmin, async (req, r
|
||||
res.json({ request, ...result })
|
||||
})
|
||||
|
||||
router.post('/template-requests/:requestId/review', requireAdmin, async (req, res) => {
|
||||
const templateRequest = await findTemplateRequestById(req.params.requestId)
|
||||
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
|
||||
if (templateRequest.status === 'completed' || templateRequest.status === 'rejected' || templateRequest.status === 'approved') {
|
||||
return res.status(409).json({ error: 'request_already_handled' })
|
||||
}
|
||||
|
||||
if (templateRequest.status === 'reviewing') {
|
||||
return res.json({ request: templateRequest })
|
||||
}
|
||||
|
||||
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'reviewing' })
|
||||
res.json({ request })
|
||||
})
|
||||
|
||||
router.post('/template-requests/:requestId/promote-items', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
gameId: z.string().trim().min(1).max(120),
|
||||
itemIds: z.array(z.string().min(1)).optional().default([]),
|
||||
itemLabels: z.record(z.string().min(1).max(60)).optional().default({}),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const templateRequest = await findTemplateRequestById(req.params.requestId)
|
||||
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
|
||||
if (!['pending', 'reviewing'].includes(templateRequest.status)) {
|
||||
return res.status(409).json({ error: 'request_already_handled' })
|
||||
}
|
||||
|
||||
const game = await findGameById(parsed.data.gameId)
|
||||
if (!game) return res.status(404).json({ error: 'game_not_found' })
|
||||
|
||||
const items = await promoteSnapshotItemsToGame({
|
||||
items: pickTemplateRequestItems(templateRequest, parsed.data.itemIds, parsed.data.itemLabels),
|
||||
gameId: game.id,
|
||||
})
|
||||
|
||||
const request =
|
||||
templateRequest.status === 'reviewing'
|
||||
? templateRequest
|
||||
: await updateTemplateRequestStatus({ id: templateRequest.id, status: 'reviewing' })
|
||||
|
||||
res.json({ request, items })
|
||||
})
|
||||
|
||||
router.post('/template-requests/:requestId/complete', requireAdmin, async (req, res) => {
|
||||
const templateRequest = await findTemplateRequestById(req.params.requestId)
|
||||
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
|
||||
if (templateRequest.status === 'completed') return res.json({ request: templateRequest })
|
||||
if (templateRequest.status === 'rejected' || templateRequest.status === 'approved') {
|
||||
return res.status(409).json({ error: 'request_already_handled' })
|
||||
}
|
||||
|
||||
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'completed' })
|
||||
res.json({ request })
|
||||
})
|
||||
|
||||
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' })
|
||||
|
||||
Reference in New Issue
Block a user