diff --git a/backend/index.js b/backend/index.js index e77c046..604834a 100644 --- a/backend/index.js +++ b/backend/index.js @@ -7,7 +7,7 @@ const FileStoreFactory = require('session-file-store') const { ensureData } = require('./src/db') const authRoutes = require('./src/routes/auth') -const gamesRoutes = require('./src/routes/games') +const topicsRoutes = require('./src/routes/games') const tierListsRoutes = require('./src/routes/tierlists') const adminRoutes = require('./src/routes/admin') @@ -80,8 +80,8 @@ app.use(async (req, res, next) => { }) app.use('/api/auth', authRoutes) -app.use('/api/games', gamesRoutes) -app.use('/api/topics', gamesRoutes) +app.use('/api/games', topicsRoutes) +app.use('/api/topics', topicsRoutes) app.use('/api/tierlists', tierListsRoutes) app.use('/api/admin', adminRoutes) diff --git a/backend/src/db.js b/backend/src/db.js index 8fac2c3..8d7ced1 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -737,7 +737,7 @@ async function adminDeleteUser(id) { await query('DELETE FROM users WHERE id = ?', [id]) } -async function listGames(currentUserId = '', options = {}) { +async function listTopics(currentUserId = '', options = {}) { const includePrivate = !!options.includePrivate const rows = await query( ` @@ -753,23 +753,23 @@ async function listGames(currentUserId = '', options = {}) { `, [FREEFORM_TOPIC_ID] ) - const games = rows.map(mapGameRow) - if (!currentUserId) return games.map((game) => ({ ...game, isFavorited: false })) + const topics = rows.map(mapGameRow) + if (!currentUserId) return topics.map((topic) => ({ ...topic, isFavorited: false })) const favoriteRows = await query('SELECT topic_id FROM favorite_topics WHERE user_id = ?', [currentUserId]) const favoriteSet = new Set(favoriteRows.map((row) => row.topic_id)) - return games.map((game) => ({ - ...game, - isFavorited: favoriteSet.has(game.id), + return topics.map((topic) => ({ + ...topic, + isFavorited: favoriteSet.has(topic.id), })) } -async function findGameById(id) { +async function findTopicById(id) { const rows = await query('SELECT id, name, thumbnail_src, is_public, display_rank, created_at FROM topics WHERE id = ? LIMIT 1', [id]) return mapGameRow(rows[0]) } -async function listGameItems(gameId) { +async function listTopicItems(topicId) { const rows = await query( ` SELECT id, topic_id, src, label, display_order, created_at @@ -781,24 +781,24 @@ async function listGameItems(gameId) { created_at DESC, id DESC `, - [gameId] + [topicId] ) return rows.map(mapGameItemRow) } -async function findGameItemById(itemId) { +async function findTopicItemById(itemId) { const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId]) return mapGameItemRow(rows[0]) } -async function getGameDetail(gameId) { - const game = await findGameById(gameId) - if (!game) return null - const items = await listGameItems(gameId) - return { game, items } +async function getTopicDetail(topicId) { + const topic = await findTopicById(topicId) + if (!topic) return null + const items = await listTopicItems(topicId) + return { topic, game: topic, items } } -async function createGame({ id, name, isPublic = true }) { +async function createTopic({ id, name, isPublic = true }) { await query('INSERT INTO topics (id, name, thumbnail_src, is_public, display_rank, created_at) VALUES (?, ?, ?, ?, ?, ?)', [ id, name, @@ -807,17 +807,17 @@ async function createGame({ id, name, isPublic = true }) { null, now(), ]) - return findGameById(id) + return findTopicById(id) } -async function updateGameThumbnail(gameId, thumbnailSrc) { - await query('UPDATE topics SET thumbnail_src = ? WHERE id = ?', [thumbnailSrc, gameId]) - return findGameById(gameId) +async function updateTopicThumbnail(topicId, thumbnailSrc) { + await query('UPDATE topics SET thumbnail_src = ? WHERE id = ?', [thumbnailSrc, topicId]) + return findTopicById(topicId) } -async function updateGameVisibility(gameId, isPublic) { - await query('UPDATE topics SET is_public = ? WHERE id = ?', [isPublic ? 1 : 0, gameId]) - return findGameById(gameId) +async function updateTopicVisibility(topicId, isPublic) { + await query('UPDATE topics SET is_public = ? WHERE id = ?', [isPublic ? 1 : 0, topicId]) + return findTopicById(topicId) } async function findImageAssetByHash(contentHash) { @@ -1347,14 +1347,15 @@ async function clearImageOptimizationJobs({ month } = {}) { const result = await query('DELETE FROM image_optimization_jobs') return Number(result.affectedRows || 0) } -async function createGameItem({ id, gameId, src, label }) { +async function createTopicItem({ id, topicId, gameId = topicId, src, label }) { const createdAt = now() - const minOrderRows = await query('SELECT MIN(display_order) AS min_display_order FROM topic_items WHERE topic_id = ?', [gameId]) + const resolvedTopicId = topicId || gameId + const minOrderRows = await query('SELECT MIN(display_order) AS min_display_order FROM topic_items WHERE topic_id = ?', [resolvedTopicId]) const nextDisplayOrder = minOrderRows[0]?.min_display_order == null ? 0 : Number(minOrderRows[0].min_display_order) - 1 await query('INSERT INTO topic_items (id, topic_id, src, label, display_order, created_at) VALUES (?, ?, ?, ?, ?, ?)', [ id, - gameId, + resolvedTopicId, src, label, nextDisplayOrder, @@ -1364,25 +1365,25 @@ async function createGameItem({ id, gameId, src, label }) { return mapGameItemRow(rows[0]) } -async function updateGameItemLabel(itemId, label) { +async function updateTopicItemLabel(itemId, label) { await query('UPDATE topic_items SET label = ? WHERE id = ?', [label, itemId]) const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId]) return mapGameItemRow(rows[0]) } -async function updateGameItemDisplayOrder(gameId, itemIds) { +async function updateTopicItemDisplayOrder(topicId, itemIds) { const normalizedIds = Array.from(new Set((itemIds || []).filter(Boolean))) - const existingItems = await listGameItems(gameId) + const existingItems = await listTopicItems(topicId) 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 topic_items SET display_order = ? WHERE id = ? AND topic_id = ?', [index + 1, itemId, gameId])) + finalIds.map((itemId, index) => query('UPDATE topic_items SET display_order = ? WHERE id = ? AND topic_id = ?', [index + 1, itemId, topicId])) ) - return listGameItems(gameId) + return listTopicItems(topicId) } async function updateCustomItemLabel(itemId, label) { @@ -1413,7 +1414,7 @@ async function updateImageAssetLabel(assetId, label) { return mapImageAssetRow(rows[0]) } -async function countTierListsUsingGameItem(itemId) { +async function countTierListsUsingTopicItem(itemId) { if (!itemId) return { totalCount: 0, publicCount: 0, privateCount: 0 } const rows = await query( @@ -1441,16 +1442,16 @@ async function countTierListsUsingGameItem(itemId) { return { totalCount, publicCount, privateCount } } -async function deleteGameItem(itemId) { +async function deleteTopicItem(itemId) { await query('DELETE FROM topic_items WHERE id = ?', [itemId]) } -async function deleteGame(gameId) { - await query('DELETE FROM topics WHERE id = ?', [gameId]) +async function deleteTopic(topicId) { + await query('DELETE FROM topics WHERE id = ?', [topicId]) } -async function updateGameDisplayOrder(gameIds) { - const normalizedIds = Array.from(new Set((gameIds || []).filter((id) => id && id !== FREEFORM_TOPIC_ID))).slice(0, 50) +async function updateTopicDisplayOrder(topicIds) { + const normalizedIds = Array.from(new Set((topicIds || []).filter((id) => id && id !== FREEFORM_TOPIC_ID))).slice(0, 50) await query('UPDATE topics SET display_rank = NULL WHERE id <> ?', [FREEFORM_TOPIC_ID]) @@ -1460,7 +1461,7 @@ async function updateGameDisplayOrder(gameIds) { ) ) - return listGames() + return listTopics() } async function createCustomItem({ id, ownerId, src, label }) { @@ -1475,6 +1476,22 @@ async function createCustomItem({ id, ownerId, src, label }) { return { id, ownerId, src, label, origin: 'custom', createdAt } } +const listGames = listTopics +const findGameById = findTopicById +const listGameItems = listTopicItems +const findGameItemById = findTopicItemById +const getGameDetail = getTopicDetail +const createGame = createTopic +const updateGameThumbnail = updateTopicThumbnail +const updateGameVisibility = updateTopicVisibility +const createGameItem = createTopicItem +const updateGameItemLabel = updateTopicItemLabel +const updateGameItemDisplayOrder = updateTopicItemDisplayOrder +const countTierListsUsingGameItem = countTierListsUsingTopicItem +const deleteGameItem = deleteTopicItem +const deleteGame = deleteTopic +const updateGameDisplayOrder = updateTopicDisplayOrder + async function syncOwnedCustomItemLabels({ ownerId, items }) { const customItems = Array.from( new Map( @@ -2511,14 +2528,19 @@ async function unfavoriteTierList({ userId, tierListId }) { await query('DELETE FROM favorite_tierlists WHERE user_id = ? AND tierlist_id = ?', [userId, tierListId]) } -async function favoriteGame({ userId, gameId }) { - await query('INSERT IGNORE INTO favorite_topics (user_id, topic_id, created_at) VALUES (?, ?, ?)', [userId, gameId, now()]) +async function favoriteTopic({ userId, topicId, gameId = topicId }) { + const resolvedTopicId = topicId || gameId + await query('INSERT IGNORE INTO favorite_topics (user_id, topic_id, created_at) VALUES (?, ?, ?)', [userId, resolvedTopicId, now()]) } -async function unfavoriteGame({ userId, gameId }) { - await query('DELETE FROM favorite_topics WHERE user_id = ? AND topic_id = ?', [userId, gameId]) +async function unfavoriteTopic({ userId, topicId, gameId = topicId }) { + const resolvedTopicId = topicId || gameId + await query('DELETE FROM favorite_topics WHERE user_id = ? AND topic_id = ?', [userId, resolvedTopicId]) } +const favoriteGame = favoriteTopic +const unfavoriteGame = unfavoriteTopic + module.exports = { DB_NAME, ensureData, @@ -2533,6 +2555,14 @@ module.exports = { adminUpdateUser, adminUpdateUserPassword, adminDeleteUser, + listTopics, + findTopicById, + listTopicItems, + findTopicItemById, + getTopicDetail, + createTopic, + updateTopicThumbnail, + updateTopicVisibility, listGames, findGameById, listGameItems, @@ -2557,6 +2587,13 @@ module.exports = { clearImageOptimizationJobs, getImageAssetStats, cleanupMissingUploadReferences, + createTopicItem, + updateTopicItemLabel, + updateTopicItemDisplayOrder, + countTierListsUsingTopicItem, + deleteTopicItem, + deleteTopic, + updateTopicDisplayOrder, createGameItem, updateGameItemLabel, updateGameItemDisplayOrder, @@ -2577,6 +2614,8 @@ module.exports = { summarizeAdminTierLists, findTierListById, updateAdminTierListMeta, + favoriteTopic, + unfavoriteTopic, favoriteTierList, unfavoriteTierList, favoriteGame, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 892b0a3..0315f1d 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -7,24 +7,24 @@ const { z } = require('zod') const { nanoid } = require('nanoid') const { findUserById, - findGameById, - findGameItemById, - listGameItems, + findTopicById, + findTopicItemById, + listTopicItems, findImageAssetById, - createGame, - listGames, - updateGameThumbnail, - updateGameVisibility, - createGameItem, - updateGameItemLabel, - updateGameItemDisplayOrder, - countTierListsUsingGameItem, + createTopic, + listTopics, + updateTopicThumbnail, + updateTopicVisibility, + createTopicItem, + updateTopicItemLabel, + updateTopicItemDisplayOrder, + countTierListsUsingTopicItem, updateCustomItemLabel, updateImageAssetLabel, - deleteGameItem, - deleteGame, + deleteTopicItem, + deleteTopic, deleteTierList, - updateGameDisplayOrder, + updateTopicDisplayOrder, listCustomItems, findCustomItemById, findUnusedCustomItems, @@ -124,15 +124,15 @@ router.post(['/games', '/templates'], requireAdmin, async (req, res) => { }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) - const exists = await findGameById(parsed.data.id) + const exists = await findTopicById(parsed.data.id) if (exists) return res.status(409).json({ error: 'game_id_taken' }) - const game = await createGame({ id: parsed.data.id, name: parsed.data.name, isPublic: parsed.data.isPublic }) + const template = await createTopic({ id: parsed.data.id, name: parsed.data.name, isPublic: parsed.data.isPublic }) if (parsed.data.thumbnailSrc) { const copiedThumb = await copyUploadIntoGameAsset(parsed.data.thumbnailSrc) - await updateGameThumbnail(game.id, copiedThumb) + await updateTopicThumbnail(template.id, copiedThumb) } - const template = await findGameById(game.id) - res.json({ game: template, template }) + const savedTemplate = await findTopicById(template.id) + res.json({ game: savedTemplate, template: savedTemplate }) }) router.patch(['/games/:gameId', '/templates/:templateId'], requireAdmin, async (req, res) => { @@ -143,10 +143,10 @@ router.patch(['/games/:gameId', '/templates/:templateId'], requireAdmin, async ( if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const templateId = getTemplateIdParam(req) - const game = await findGameById(templateId) - if (!game) return res.status(404).json({ error: 'not_found' }) + const template = await findTopicById(templateId) + if (!template) return res.status(404).json({ error: 'not_found' }) - const updated = await updateGameVisibility(game.id, parsed.data.isPublic) + const updated = await updateTopicVisibility(template.id, parsed.data.isPublic) res.json({ game: updated, template: updated }) }) @@ -157,10 +157,10 @@ router.patch(['/games/display-order', '/templates/display-order'], requireAdmin, const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) - const games = await listGames('', { includePrivate: true }) - const validGameIds = new Set(games.map((game) => game.id)) + const templates = await listTopics('', { includePrivate: true }) + const validGameIds = new Set(templates.map((template) => template.id)) const filteredIds = parsed.data.gameIds.filter((gameId) => validGameIds.has(gameId)) - const updatedGames = await updateGameDisplayOrder(filteredIds) + const updatedGames = await updateTopicDisplayOrder(filteredIds) res.json({ games: updatedGames, templates: updatedGames }) }) @@ -172,18 +172,18 @@ router.patch(['/games/:gameId/items/display-order', '/templates/:templateId/item if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const templateId = getTemplateIdParam(req) - const game = await findGameById(templateId) - if (!game) return res.status(404).json({ error: 'not_found' }) + const template = await findTopicById(templateId) + if (!template) return res.status(404).json({ error: 'not_found' }) - const items = await updateGameItemDisplayOrder(game.id, parsed.data.itemIds) + const items = await updateTopicItemDisplayOrder(template.id, parsed.data.itemIds) res.json({ items }) }) router.post(['/games/:gameId/thumbnail', '/templates/:templateId/thumbnail'], requireAdmin, upload.single('thumbnail'), async (req, res) => { if (!req.file) return res.status(400).json({ error: 'file_required' }) const templateId = getTemplateIdParam(req) - const game = await findGameById(templateId) - if (!game) return res.status(404).json({ error: 'not_found' }) + const template = await findTopicById(templateId) + if (!template) return res.status(404).json({ error: 'not_found' }) const optimized = await writeOptimizedImage({ file: req.file, @@ -194,7 +194,7 @@ router.post(['/games/:gameId/thumbnail', '/templates/:templateId/thumbnail'], re quality: 84, }) - const updated = await updateGameThumbnail(templateId, optimized.src) + const updated = await updateTopicThumbnail(templateId, optimized.src) res.json({ game: updated, template: updated }) }) @@ -202,8 +202,8 @@ router.post(['/games/:gameId/images', '/templates/:templateId/images'], requireA const files = Array.isArray(req.files) ? req.files : [] if (!files.length) return res.status(400).json({ error: 'file_required' }) const templateId = getTemplateIdParam(req) - const game = await findGameById(templateId) - if (!game) return res.status(404).json({ error: 'not_found' }) + const template = await findTopicById(templateId) + if (!template) return res.status(404).json({ error: 'not_found' }) const labelsRaw = req.body?.labels const labels = Array.isArray(labelsRaw) ? labelsRaw : labelsRaw ? [labelsRaw] : [] @@ -221,9 +221,9 @@ router.post(['/games/:gameId/images', '/templates/:templateId/images'], requireA quality: 84, }) - return createGameItem({ + return createTopicItem({ id: nanoid(), - gameId: game.id, + topicId: template.id, src: optimized.src, label: normalizedLabels[index] || buildItemLabelFromFilename(file), }) @@ -234,18 +234,18 @@ router.post(['/games/:gameId/images', '/templates/:templateId/images'], requireA }) router.delete(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:itemId'], requireAdmin, async (req, res) => { - const game = await findGameById(getTemplateIdParam(req)) - if (!game) return res.status(404).json({ error: 'not_found' }) - await deleteGameItem(req.params.itemId) + const template = await findTopicById(getTemplateIdParam(req)) + if (!template) return res.status(404).json({ error: 'not_found' }) + await deleteTopicItem(req.params.itemId) res.json({ ok: true }) }) router.get(['/games/:gameId/items/:itemId/usage', '/templates/:templateId/items/:itemId/usage'], requireAdmin, async (req, res) => { - const game = await findGameById(getTemplateIdParam(req)) - if (!game) return res.status(404).json({ error: 'not_found' }) - const item = await findGameItemById(req.params.itemId) - if (!item || item.gameId !== game.id) return res.status(404).json({ error: 'not_found' }) - const usage = await countTierListsUsingGameItem(req.params.itemId) + const template = await findTopicById(getTemplateIdParam(req)) + if (!template) return res.status(404).json({ error: 'not_found' }) + const item = await findTopicItemById(req.params.itemId) + if (!item || item.gameId !== template.id) return res.status(404).json({ error: 'not_found' }) + const usage = await countTierListsUsingTopicItem(req.params.itemId) res.json({ usage }) }) @@ -254,19 +254,19 @@ router.patch(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:ite const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) - const game = await findGameById(getTemplateIdParam(req)) - if (!game) return res.status(404).json({ error: 'not_found' }) + const template = await findTopicById(getTemplateIdParam(req)) + if (!template) return res.status(404).json({ error: 'not_found' }) - const updated = await updateGameItemLabel(req.params.itemId, parsed.data.label) - if (!updated || updated.gameId !== game.id) return res.status(404).json({ error: 'not_found' }) + const updated = await updateTopicItemLabel(req.params.itemId, parsed.data.label) + if (!updated || updated.gameId !== template.id) return res.status(404).json({ error: 'not_found' }) res.json({ item: updated }) }) router.delete(['/games/:gameId', '/templates/:templateId'], requireAdmin, async (req, res) => { const templateId = getTemplateIdParam(req) - const game = await findGameById(templateId) - if (!game) return res.status(404).json({ error: 'not_found' }) - await deleteGame(templateId) + const template = await findTopicById(templateId) + if (!template) return res.status(404).json({ error: 'not_found' }) + await deleteTopic(templateId) res.json({ ok: true }) }) @@ -286,7 +286,7 @@ router.patch('/custom-items/:itemId/label', requireAdmin, async (req, res) => { } if (parsed.data.sourceType === 'template') { - const updated = await updateGameItemLabel(itemId, parsed.data.label) + const updated = await updateTopicItemLabel(itemId, parsed.data.label) if (!updated) return res.status(404).json({ error: 'not_found' }) return res.json({ item: updated }) } @@ -464,10 +464,10 @@ async function removeCustomItemFiles(items) { ) } -async function promoteLibraryItemToGameItem({ item, gameId }) { - return createGameItem({ +async function promoteLibraryItemToTemplateItem({ item, templateId }) { + return createTopicItem({ id: nanoid(), - gameId, + topicId: templateId, src: item.src || '', label: item.label, }) @@ -504,7 +504,7 @@ function uniqueTierListPoolItems(tierList) { }) } -async function promoteTierListItemsToGame({ tierList, gameId, itemIds = [] }) { +async function promoteTierListItemsToTemplate({ tierList, templateId, itemIds = [] }) { const allowedIds = new Set((itemIds || []).filter(Boolean)) const sourceItems = uniqueTierListPoolItems(tierList).filter((item) => item.origin === 'custom') const itemsToCopy = allowedIds.size ? sourceItems.filter((item) => allowedIds.has(item.id)) : sourceItems @@ -513,9 +513,9 @@ async function promoteTierListItemsToGame({ tierList, gameId, itemIds = [] }) { for (const item of itemsToCopy) { const copiedSrc = await copyUploadIntoGameAsset(item.src) createdItems.push( - await createGameItem({ + await createTopicItem({ id: nanoid(), - gameId, + topicId: templateId, src: copiedSrc, label: item.label, }) @@ -525,8 +525,8 @@ async function promoteTierListItemsToGame({ tierList, gameId, itemIds = [] }) { return createdItems } -async function promoteSnapshotItemsToGame({ items, gameId }) { - const existingItems = await listGameItems(gameId) +async function promoteSnapshotItemsToTemplate({ items, templateId }) { + const existingItems = await listTopicItems(templateId) const existingSrcs = new Set( existingItems .map((item) => (typeof item?.src === 'string' ? item.src.trim() : '')) @@ -538,9 +538,9 @@ async function promoteSnapshotItemsToGame({ items, gameId }) { const copiedSrc = await copyUploadIntoGameAsset(item.src) if (!copiedSrc || existingSrcs.has(copiedSrc)) continue createdItems.push( - await createGameItem({ + await createTopicItem({ id: nanoid(), - gameId, + topicId: templateId, src: copiedSrc, label: item.label, }) @@ -577,43 +577,43 @@ function pickTemplateRequestItems(templateRequest, itemIds = [], itemLabels = {} }) } -async function createGameTemplateFromTierList({ tierList, gameId, gameName }) { - await createGame({ id: gameId, name: gameName, isPublic: false }) +async function createTemplateFromTierList({ tierList, templateId, templateName }) { + await createTopic({ id: templateId, name: templateName, isPublic: false }) if (tierList.thumbnailSrc) { const copiedThumb = await copyUploadIntoGameAsset(tierList.thumbnailSrc) - await updateGameThumbnail(gameId, copiedThumb) + await updateTopicThumbnail(templateId, copiedThumb) } const createdItems = [] for (const item of uniqueTierListPoolItems(tierList)) { const copiedSrc = await copyUploadIntoGameAsset(item.src) createdItems.push( - await createGameItem({ + await createTopicItem({ id: nanoid(), - gameId, + topicId: templateId, src: copiedSrc, label: item.label, }) ) } - return { game: await findGameById(gameId), items: createdItems } + return { game: await findTopicById(templateId), items: createdItems } } -async function createGameTemplateFromRequest({ templateRequest, gameId, gameName }) { - await createGame({ id: gameId, name: gameName, isPublic: false }) +async function createTemplateFromRequest({ templateRequest, templateId, templateName }) { + await createTopic({ id: templateId, name: templateName, isPublic: false }) if (templateRequest.thumbnailSrc) { const copiedThumb = await copyUploadIntoGameAsset(templateRequest.thumbnailSrc) - await updateGameThumbnail(gameId, copiedThumb) + await updateTopicThumbnail(templateId, copiedThumb) } - const items = await promoteSnapshotItemsToGame({ + const items = await promoteSnapshotItemsToTemplate({ items: templateRequest.items || [], - gameId, + templateId, }) - return { game: await findGameById(gameId), items } + return { game: await findTopicById(templateId), items } } router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => { @@ -630,7 +630,7 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => { return res.json({ ok: true, sourceType: 'template-asset' }) } - await deleteGameItem(target.id) + await deleteTopicItem(target.id) return res.json({ ok: true, sourceType: 'template' }) } @@ -651,16 +651,16 @@ router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => { const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) - const game = await findGameById(parsed.data.gameId) - if (!game) return res.status(404).json({ error: 'game_not_found' }) + const template = await findTopicById(parsed.data.gameId) + if (!template) return res.status(404).json({ error: 'game_not_found' }) const customItem = await findCustomItemById(req.params.itemId) - const gameItem = customItem ? null : await findGameItemById(req.params.itemId) + const templateItem = customItem ? null : await findTopicItemById(req.params.itemId) const assetItemId = String(req.params.itemId || '') - const imageAsset = !customItem && !gameItem && assetItemId.startsWith('asset:') ? await findImageAssetById(assetItemId.slice(6)) : null + const imageAsset = !customItem && !templateItem && assetItemId.startsWith('asset:') ? await findImageAssetById(assetItemId.slice(6)) : null const sourceItem = customItem || - gameItem || + templateItem || (imageAsset ? { src: imageAsset.src || '', @@ -669,7 +669,7 @@ router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => { : null) if (!sourceItem) return res.status(404).json({ error: 'not_found' }) - const item = await promoteLibraryItemToGameItem({ item: sourceItem, gameId: game.id }) + const item = await promoteLibraryItemToTemplateItem({ item: sourceItem, templateId: template.id }) res.json({ item }) }) @@ -681,15 +681,15 @@ router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, re const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) - const game = await findGameById(parsed.data.gameId) - if (!game) return res.status(404).json({ error: 'game_not_found' }) + const template = await findTopicById(parsed.data.gameId) + if (!template) return res.status(404).json({ error: 'game_not_found' }) const tierList = await findTierListById(req.params.tierListId) if (!tierList) return res.status(404).json({ error: 'not_found' }) - const items = await promoteTierListItemsToGame({ + const items = await promoteTierListItemsToTemplate({ tierList, - gameId: game.id, + templateId: template.id, itemIds: parsed.data.itemIds, }) res.json({ items }) @@ -704,19 +704,19 @@ router.post('/tierlists/:tierListId/create-game-template', requireAdmin, async ( const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) - const exists = await findGameById(parsed.data.gameId) + const exists = await findTopicById(parsed.data.gameId) if (exists) return res.status(409).json({ error: 'game_id_taken' }) const tierList = await findTierListById(req.params.tierListId) if (!tierList) return res.status(404).json({ error: 'not_found' }) - const result = await createGameTemplateFromTierList({ + const result = await createTemplateFromTierList({ tierList: { ...tierList, pool: parsed.data.itemIds.length ? (tierList.pool || []).filter((item) => parsed.data.itemIds.includes(item.id)) : tierList.pool, }, - gameId: parsed.data.gameId, - gameName: parsed.data.name, + templateId: parsed.data.gameId, + templateName: parsed.data.name, }) res.json(result) }) @@ -756,12 +756,12 @@ router.post('/template-requests/:requestId/approve', requireAdmin, async (req, r 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 template = await findTopicById(targetGameId) + if (!template) return res.status(404).json({ error: 'game_not_found' }) - const items = await promoteSnapshotItemsToGame({ + const items = await promoteSnapshotItemsToTemplate({ items: templateRequest.items || [], - gameId: game.id, + templateId: template.id, }) const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' }) return res.json({ request, items }) @@ -774,13 +774,13 @@ router.post('/template-requests/:requestId/approve', requireAdmin, async (req, r const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) - const exists = await findGameById(parsed.data.gameId) + const exists = await findTopicById(parsed.data.gameId) if (exists) return res.status(409).json({ error: 'game_id_taken' }) - const result = await createGameTemplateFromRequest({ + const result = await createTemplateFromRequest({ templateRequest, - gameId: parsed.data.gameId, - gameName: parsed.data.name, + templateId: parsed.data.gameId, + templateName: parsed.data.name, }) const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' }) res.json({ request, ...result }) @@ -822,12 +822,12 @@ router.post('/template-requests/:requestId/link-game', requireAdmin, async (req, 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 template = await findTopicById(parsed.data.gameId) + if (!template) return res.status(404).json({ error: 'game_not_found' }) const request = await updateTemplateRequestTargetGame({ id: templateRequest.id, - targetGameId: game.id, + targetGameId: template.id, }) res.json({ request }) }) @@ -848,8 +848,8 @@ router.post('/template-requests/:requestId/promote-items', requireAdmin, async ( 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 template = await findTopicById(parsed.data.gameId) + if (!template) return res.status(404).json({ error: 'game_not_found' }) const promotableItems = pickTemplateRequestItems(templateRequest, parsed.data.itemIds, parsed.data.itemLabels, parsed.data.itemSrcs) if (!promotableItems.length) { @@ -858,14 +858,14 @@ router.post('/template-requests/:requestId/promote-items', requireAdmin, async ( let items = [] try { - items = await promoteSnapshotItemsToGame({ + items = await promoteSnapshotItemsToTemplate({ items: promotableItems, - gameId: game.id, + templateId: template.id, }) } catch (error) { console.error('[admin] template request promote-items failed', { requestId: templateRequest.id, - gameId: game.id, + gameId: template.id, itemCount: promotableItems.length, message: error?.message || 'unknown_error', code: error?.code || '', diff --git a/backend/src/routes/games.js b/backend/src/routes/games.js index 95a58e2..a5ba70d 100644 --- a/backend/src/routes/games.js +++ b/backend/src/routes/games.js @@ -1,37 +1,37 @@ const express = require('express') -const { listGames, getGameDetail, findGameById, favoriteGame, unfavoriteGame } = require('../db') +const { listTopics, getTopicDetail, findTopicById, favoriteTopic, unfavoriteTopic } = require('../db') const { requireAuth } = require('../middleware/auth') const router = express.Router() router.get('/', async (req, res) => { - const games = await listGames(req.session?.userId || '', { includePrivate: !!req.session?.isAdmin }) - res.json({ games, topics: games }) + const topics = await listTopics(req.session?.userId || '', { includePrivate: !!req.session?.isAdmin }) + res.json({ games: topics, topics }) }) router.post('/:gameId/favorite', requireAuth, async (req, res) => { - const game = await findGameById(req.params.gameId) - if (!game || game.id === 'freeform') return res.status(404).json({ error: 'not_found' }) - await favoriteGame({ userId: req.session.userId, gameId: game.id }) - const games = await listGames(req.session.userId) - const updated = games.find((entry) => entry.id === game.id) || { ...game, isFavorited: true } + const topic = await findTopicById(req.params.gameId) + if (!topic || topic.id === 'freeform') return res.status(404).json({ error: 'not_found' }) + await favoriteTopic({ userId: req.session.userId, topicId: topic.id }) + const topics = await listTopics(req.session.userId) + const updated = topics.find((entry) => entry.id === topic.id) || { ...topic, isFavorited: true } res.json({ game: updated, topic: updated }) }) router.delete('/:gameId/favorite', requireAuth, async (req, res) => { - const game = await findGameById(req.params.gameId) - if (!game || game.id === 'freeform') return res.status(404).json({ error: 'not_found' }) - await unfavoriteGame({ userId: req.session.userId, gameId: game.id }) - const games = await listGames(req.session.userId) - const updated = games.find((entry) => entry.id === game.id) || { ...game, isFavorited: false } + const topic = await findTopicById(req.params.gameId) + if (!topic || topic.id === 'freeform') return res.status(404).json({ error: 'not_found' }) + await unfavoriteTopic({ userId: req.session.userId, topicId: topic.id }) + const topics = await listTopics(req.session.userId) + const updated = topics.find((entry) => entry.id === topic.id) || { ...topic, isFavorited: false } res.json({ game: updated, topic: updated }) }) router.get('/:gameId', async (req, res) => { - const detail = await getGameDetail(req.params.gameId) + const detail = await getTopicDetail(req.params.gameId) if (!detail) return res.status(404).json({ error: 'not_found' }) - if (!detail.game.isPublic && !req.session?.isAdmin) return res.status(404).json({ error: 'not_found' }) - res.json({ game: detail.game, topic: detail.game, items: detail.items }) + if (!detail.topic.isPublic && !req.session?.isAdmin) return res.status(404).json({ error: 'not_found' }) + res.json({ game: detail.topic, topic: detail.topic, items: detail.items }) }) module.exports = router diff --git a/docs/history.md b/docs/history.md index 42d0efd..088678c 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-04-02 v1.4.20 +- 스키마만 `topic`으로 옮기고 함수명/라우트 내부가 계속 `game`으로 남아 있으면 이후 유지보수에서 계속 의미 충돌이 생기므로, 이번 단계부터는 백엔드 export와 주요 라우트 내부 이름도 `topic/template`를 기본으로 읽히게 정리하는 편이 맞다고 판단했다. +- 다만 외부 API와 프런트 호환을 한 번에 끊는 건 위험하므로, 실제 구현은 새 `topic` 이름을 기본으로 쓰되 기존 `game` 이름은 alias와 호환 응답으로 잠시 유지하는 점진 전환이 가장 안전하다고 정리했다. + ## 2026-04-02 v1.4.19 - 템플릿 기본 아이템 삭제가 과거에 저장된 티어표까지 바꿔 버리면 운영자 편집 의도보다 사용자 결과물 보존이 더 크게 흔들리므로, 이 삭제는 “앞으로의 템플릿 구성만 바꾸고 기존 저장본은 보존”하는 편이 맞다고 판단했다. - 다만 이미 여러 티어표에서 쓰인 아이템인지 모른 채 지우게 두는 것도 위험하므로, 삭제 자체를 막기보다는 저장된 티어표 사용 개수와 공개/비공개 범위를 먼저 보여주고 운영자가 맥락을 알고 결정하게 하는 쪽이 더 현실적이라고 정리했다. diff --git a/docs/todo.md b/docs/todo.md index e7b784d..438477f 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,6 +1,8 @@ # 할 일 및 이슈 ## 단기 확인 +- `v1.4.20`에서 백엔드 `db` export와 공개/관리자 라우트 내부 이름을 `topic/template` 기준으로 정리했으므로, 실제 브라우저와 관리자 화면에서 주제 목록/즐겨찾기/템플릿 생성/요청 반영 흐름이 모두 정상인지 한 번 더 QA한다. +- 다음 단계에서는 남아 있는 호환 응답 키 `game`, `gameId`, `gameName`과 레거시 route 파일명 `games.js`를 어디까지 실제 `topic` 이름으로 마감할지 범위를 결정한다. - `v1.4.19`에서 템플릿 기본 아이템 삭제는 기존 저장 티어표를 보존하도록 정책이 바뀌었으므로, 실제 운영 데이터에서 삭제 후 예전 티어표의 배치/대기풀이 그대로 유지되는지와 새 티어표 생성 시에만 아이템이 빠지는지 한 번 더 QA한다. - `v1.4.19`에서 삭제 전 영향 개수 경고를 붙였으므로, 공개/비공개 티어표가 섞인 템플릿에서 숫자가 기대대로 보이는지와 삭제 취소/확정 후 스크롤 위치가 안정적으로 유지되는지 한 번 더 QA한다. - `v1.4.19`에서 템플릿 썸네일 등록 아이콘은 썸네일이 있을 때 숨기도록 정리했으므로, 썸네일 있음/없음 상태 전환과 드래그 오버 활성 상태에서 안내 문구가 겹치지 않는지 한 번 더 QA한다. diff --git a/docs/update.md b/docs/update.md index f49ae0b..fbb30a3 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-04-02 v1.4.20 +- 백엔드 `db`와 라우트 내부 이름층을 한 단계 더 `topic` 기준으로 옮겼다. `listTopics / findTopicById / getTopicDetail / createTopic / updateTopicThumbnail / updateTopicVisibility`, `createTopicItem / updateTopicItemLabel / updateTopicItemDisplayOrder / deleteTopicItem / deleteTopic` 같은 이름을 실제 export로 추가하고, 기존 `game` 이름은 호환 alias로만 남겼다. +- 공개 주제 라우트는 이제 `listTopics`, `getTopicDetail`, `favoriteTopic` 기준으로 동작하고, 백엔드 진입점도 `gamesRoutes` 대신 `topicsRoutes`라는 이름으로 읽히도록 정리했다. +- 관리자 라우트 역시 핵심 템플릿 흐름에서 `findTopicById`, `createTopic`, `createTopicItem`, `promoteSnapshotItemsToTemplate`, `createTemplateFromTierList` 같은 의미 이름을 직접 사용하도록 바꿔, 실제 저장 스키마와 코드 언어가 더 가까워지게 맞췄다. + ## 2026-04-02 v1.4.19 - 관리자 템플릿 기본 아이템 삭제는 이제 기존에 저장된 티어표의 그룹/대기풀 데이터를 건드리지 않고, 템플릿의 현재 기본 아이템 목록에서만 제거되도록 바꿨다. 그래서 이미 만들어진 티어표는 그대로 유지되고, 이후 새로 만드는 티어표에서만 해당 아이템이 빠진다. - 삭제 전에는 이 아이템이 이미 저장된 티어표 몇 개에서 사용 중인지(공개/비공개 포함) 확인 문구를 먼저 보여주도록 바꿔, 운영자가 영향 범위를 알고 삭제할 수 있게 했다.