관리자 화면 분리 및 요청/아이템 관리 흐름 정리

This commit is contained in:
2026-04-02 11:23:33 +09:00
parent 66d408dca8
commit 147ff963ab
17 changed files with 1460 additions and 553 deletions

View File

@@ -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,

View File

@@ -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' })