diff --git a/backend/src/db.js b/backend/src/db.js index a308fbf..b2f9782 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -8,7 +8,8 @@ const DB_USER = process.env.DB_USER || 'root' const DB_PASSWORD = process.env.DB_PASSWORD || '' const DB_NAME = process.env.DB_NAME || 'tier_cursor' const DB_CONNECTION_LIMIT = process.env.DB_CONNECTION_LIMIT ? Number(process.env.DB_CONNECTION_LIMIT) : 10 -const FREEFORM_GAME_ID = 'freeform' +const FREEFORM_TOPIC_ID = 'freeform' +const FREEFORM_GAME_ID = FREEFORM_TOPIC_ID let poolPromise = null let initPromise = null @@ -72,6 +73,8 @@ function mapGameRow(row) { return { id: row.id, name: row.name, + topicId: row.id, + topicName: row.name, thumbnailSrc: row.thumbnail_src || '', isPublic: row.is_public == null ? true : !!row.is_public, displayRank: row.display_rank == null ? null : Number(row.display_rank), @@ -83,7 +86,8 @@ function mapGameItemRow(row) { if (!row) return null return { id: row.id, - gameId: row.game_id, + topicId: row.topic_id, + gameId: row.topic_id, src: row.src, label: row.label, displayOrder: row.display_order == null ? null : Number(row.display_order), @@ -131,8 +135,10 @@ function mapTierListRow(row) { authorName: getUserDisplayName(row), authorAccountName: getUserAccountName(row), authorAvatarSrc: row.avatar_src || '', - gameId: row.game_id, - gameName: row.game_name || '', + topicId: row.topic_id, + topicName: row.topic_name || '', + gameId: row.topic_id, + gameName: row.topic_name || '', title: row.title, thumbnailSrc: row.thumbnail_src || '', description: row.description || '', @@ -159,13 +165,17 @@ function mapTemplateRequestRow(row) { requesterAccountName: getUserAccountName(row), requesterAvatarSrc: row.requester_avatar_src || '', sourceTierListId: row.source_tierlist_id || '', - sourceGameId: row.source_game_id, - sourceGameName: row.source_game_name || '', + sourceTopicId: row.source_topic_id, + sourceTopicName: row.source_topic_name || '', + sourceGameId: row.source_topic_id, + sourceGameName: row.source_topic_name || '', sourceTierListTitle: row.title_snapshot || '', sourceDescription: row.description_snapshot || '', thumbnailSrc: row.thumbnail_src_snapshot || '', - targetGameId: row.target_game_id || '', - targetGameName: row.target_game_name || '', + targetTopicId: row.target_topic_id || '', + targetTopicName: row.target_topic_name || '', + targetGameId: row.target_topic_id || '', + targetGameName: row.target_topic_name || '', status: row.status, items: parseJson(row.items_json, []), snapshotGroups: parseJson(row.groups_json, []), @@ -230,6 +240,16 @@ async function query(sql, params = []) { return rows } +async function tableExists(name) { + const rows = await query('SHOW TABLES LIKE ?', [name]) + return rows.length > 0 +} + +async function columnExists(tableName, columnName) { + const rows = await query(`SHOW COLUMNS FROM \`${tableName}\` LIKE ?`, [columnName]) + return rows.length > 0 +} + async function closePool() { if (!poolPromise) return const pool = await poolPromise @@ -241,6 +261,32 @@ async function closePool() { async function ensureSchema() { if (initPromise) return initPromise initPromise = (async () => { + if ((await tableExists('games')) && !(await tableExists('topics'))) { + await query('RENAME TABLE games TO topics') + } + if ((await tableExists('game_items')) && !(await tableExists('topic_items'))) { + await query('RENAME TABLE game_items TO topic_items') + } + if ((await tableExists('favorite_games')) && !(await tableExists('favorite_topics'))) { + await query('RENAME TABLE favorite_games TO favorite_topics') + } + + if ((await tableExists('tierlists')) && (await columnExists('tierlists', 'game_id')) && !(await columnExists('tierlists', 'topic_id'))) { + await query('ALTER TABLE tierlists CHANGE COLUMN game_id topic_id VARCHAR(120) NOT NULL') + } + if ((await tableExists('topic_items')) && (await columnExists('topic_items', 'game_id')) && !(await columnExists('topic_items', 'topic_id'))) { + await query('ALTER TABLE topic_items CHANGE COLUMN game_id topic_id VARCHAR(120) NOT NULL') + } + if ((await tableExists('favorite_topics')) && (await columnExists('favorite_topics', 'game_id')) && !(await columnExists('favorite_topics', 'topic_id'))) { + await query('ALTER TABLE favorite_topics CHANGE COLUMN game_id topic_id VARCHAR(120) NOT NULL') + } + if ((await tableExists('template_requests')) && (await columnExists('template_requests', 'source_game_id')) && !(await columnExists('template_requests', 'source_topic_id'))) { + await query('ALTER TABLE template_requests CHANGE COLUMN source_game_id source_topic_id VARCHAR(120) NOT NULL') + } + if ((await tableExists('template_requests')) && (await columnExists('template_requests', 'target_game_id')) && !(await columnExists('template_requests', 'target_topic_id'))) { + await query("ALTER TABLE template_requests CHANGE COLUMN target_game_id target_topic_id VARCHAR(120) NOT NULL DEFAULT ''") + } + await query(` CREATE TABLE IF NOT EXISTS users ( id VARCHAR(64) PRIMARY KEY, @@ -254,7 +300,7 @@ async function ensureSchema() { `) await query(` - CREATE TABLE IF NOT EXISTS games ( + CREATE TABLE IF NOT EXISTS topics ( id VARCHAR(120) PRIMARY KEY, name VARCHAR(120) NOT NULL, thumbnail_src VARCHAR(255) NOT NULL DEFAULT '', @@ -264,33 +310,33 @@ async function ensureSchema() { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) - const gameIsPublicColumns = await query("SHOW COLUMNS FROM games LIKE 'is_public'") + const gameIsPublicColumns = await query("SHOW COLUMNS FROM topics LIKE 'is_public'") if (!gameIsPublicColumns.length) { - await query('ALTER TABLE games ADD COLUMN is_public TINYINT(1) NOT NULL DEFAULT 1 AFTER thumbnail_src') - await query('UPDATE games SET is_public = 1 WHERE is_public IS NULL') + await query('ALTER TABLE topics ADD COLUMN is_public TINYINT(1) NOT NULL DEFAULT 1 AFTER thumbnail_src') + await query('UPDATE topics SET is_public = 1 WHERE is_public IS NULL') } - const displayRankColumns = await query("SHOW COLUMNS FROM games LIKE 'display_rank'") + const displayRankColumns = await query("SHOW COLUMNS FROM topics LIKE 'display_rank'") if (!displayRankColumns.length) { - await query('ALTER TABLE games ADD COLUMN display_rank INT NULL DEFAULT NULL AFTER thumbnail_src') + await query('ALTER TABLE topics ADD COLUMN display_rank INT NULL DEFAULT NULL AFTER thumbnail_src') } await query(` - CREATE TABLE IF NOT EXISTS game_items ( + CREATE TABLE IF NOT EXISTS topic_items ( id VARCHAR(64) PRIMARY KEY, - game_id VARCHAR(120) NOT NULL, + topic_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 + INDEX idx_topic_items_topic_id (topic_id), + CONSTRAINT fk_topic_items_topic FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) - const gameItemDisplayOrderColumns = await query("SHOW COLUMNS FROM game_items LIKE 'display_order'") + const gameItemDisplayOrderColumns = await query("SHOW COLUMNS FROM topic_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('ALTER TABLE topic_items ADD COLUMN display_order INT NULL DEFAULT NULL AFTER label') } await query(` @@ -309,7 +355,7 @@ async function ensureSchema() { CREATE TABLE IF NOT EXISTS tierlists ( id VARCHAR(64) PRIMARY KEY, author_id VARCHAR(64) NOT NULL, - game_id VARCHAR(120) NOT NULL, + topic_id VARCHAR(120) NOT NULL, title VARCHAR(120) NOT NULL, thumbnail_src VARCHAR(255) NOT NULL DEFAULT '', description TEXT NOT NULL, @@ -324,10 +370,10 @@ async function ensureSchema() { created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL, INDEX idx_tierlists_author_id (author_id), - INDEX idx_tierlists_game_id (game_id), - INDEX idx_tierlists_public_game_updated (is_public, game_id, updated_at), + INDEX idx_tierlists_topic_id (topic_id), + INDEX idx_tierlists_public_topic_updated (is_public, topic_id, updated_at), CONSTRAINT fk_tierlists_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE, - CONSTRAINT fk_tierlists_game FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE + CONSTRAINT fk_tierlists_topic FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) @@ -344,14 +390,14 @@ async function ensureSchema() { `) await query(` - CREATE TABLE IF NOT EXISTS favorite_games ( + CREATE TABLE IF NOT EXISTS favorite_topics ( user_id VARCHAR(64) NOT NULL, - game_id VARCHAR(120) NOT NULL, + topic_id VARCHAR(120) NOT NULL, created_at BIGINT NOT NULL, - PRIMARY KEY (user_id, game_id), - INDEX idx_favorite_games_game_id (game_id), - CONSTRAINT fk_favorite_games_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - CONSTRAINT fk_favorite_games_game FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE + PRIMARY KEY (user_id, topic_id), + INDEX idx_favorite_topics_topic_id (topic_id), + CONSTRAINT fk_favorite_topics_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_favorite_topics_topic FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) @@ -399,8 +445,8 @@ async function ensureSchema() { request_type VARCHAR(20) NOT NULL, requester_id VARCHAR(64) NOT NULL, source_tierlist_id VARCHAR(64) NOT NULL, - source_game_id VARCHAR(120) NOT NULL, - target_game_id VARCHAR(120) NOT NULL DEFAULT '', + source_topic_id VARCHAR(120) NOT NULL, + target_topic_id VARCHAR(120) NOT NULL DEFAULT '', status VARCHAR(20) NOT NULL DEFAULT 'pending', title_snapshot VARCHAR(120) NOT NULL, description_snapshot TEXT NOT NULL, @@ -424,17 +470,17 @@ async function ensureSchema() { if (!templateRequestTypeColumns.length) { await query("ALTER TABLE template_requests ADD COLUMN request_type VARCHAR(20) NOT NULL DEFAULT 'create' AFTER id") } - const templateRequestSourceGameColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'source_game_id'") + const templateRequestSourceGameColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'source_topic_id'") if (!templateRequestSourceGameColumns.length) { - await query("ALTER TABLE template_requests ADD COLUMN source_game_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER source_tierlist_id") + await query("ALTER TABLE template_requests ADD COLUMN source_topic_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER source_tierlist_id") } - const templateRequestTargetGameColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'target_game_id'") + const templateRequestTargetGameColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'target_topic_id'") if (!templateRequestTargetGameColumns.length) { - await query("ALTER TABLE template_requests ADD COLUMN target_game_id VARCHAR(120) NOT NULL DEFAULT '' AFTER source_game_id") + await query("ALTER TABLE template_requests ADD COLUMN target_topic_id VARCHAR(120) NOT NULL DEFAULT '' AFTER source_topic_id") } const templateRequestStatusColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'status'") if (!templateRequestStatusColumns.length) { - await query("ALTER TABLE template_requests ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'pending' AFTER target_game_id") + await query("ALTER TABLE template_requests ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'pending' AFTER target_topic_id") } const templateRequestGroupsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'groups_json'") if (!templateRequestGroupsColumns.length) { @@ -478,19 +524,19 @@ async function ensureSchema() { await query( ` - INSERT INTO games (id, name, thumbnail_src, created_at) + INSERT INTO topics (id, name, thumbnail_src, created_at) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE name = VALUES(name) `, - [FREEFORM_GAME_ID, '직접 티어표 만들기', '', now()] + [FREEFORM_TOPIC_ID, '직접 티어표 만들기', '', now()] ) - const countRows = await query('SELECT COUNT(*) AS count FROM games') + const countRows = await query('SELECT COUNT(*) AS count FROM topics') if (Number(countRows[0]?.count || 0) <= 1) { const createdAt = now() await query( ` - INSERT INTO games (id, name, thumbnail_src, created_at) + INSERT INTO topics (id, name, thumbnail_src, created_at) VALUES (?, ?, ?, ?), (?, ?, ?, ?) @@ -500,7 +546,7 @@ async function ensureSchema() { await query( ` - INSERT INTO game_items (id, game_id, src, label, created_at) + INSERT INTO topic_items (id, topic_id, src, label, created_at) VALUES (?, ?, ?, ?, ?), (?, ?, ?, ?, ?) @@ -662,7 +708,7 @@ async function listGames(currentUserId = '', options = {}) { const rows = await query( ` SELECT id, name, thumbnail_src, is_public, display_rank, created_at - FROM games + FROM topics WHERE id <> ? ${includePrivate ? '' : 'AND is_public = 1'} ORDER BY @@ -671,13 +717,13 @@ async function listGames(currentUserId = '', options = {}) { created_at DESC, name ASC `, - [FREEFORM_GAME_ID] + [FREEFORM_TOPIC_ID] ) const games = rows.map(mapGameRow) if (!currentUserId) return games.map((game) => ({ ...game, isFavorited: false })) - const favoriteRows = await query('SELECT game_id FROM favorite_games WHERE user_id = ?', [currentUserId]) - const favoriteSet = new Set(favoriteRows.map((row) => row.game_id)) + 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), @@ -685,16 +731,16 @@ async function listGames(currentUserId = '', options = {}) { } async function findGameById(id) { - const rows = await query('SELECT id, name, thumbnail_src, is_public, display_rank, created_at FROM games WHERE id = ? LIMIT 1', [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) { const rows = await query( ` - SELECT id, game_id, src, label, display_order, created_at - FROM game_items - WHERE game_id = ? + SELECT id, topic_id, src, label, display_order, created_at + FROM topic_items + WHERE topic_id = ? ORDER BY CASE WHEN display_order IS NULL THEN 1 ELSE 0 END ASC, display_order ASC, @@ -707,7 +753,7 @@ async function listGameItems(gameId) { } async function findGameItemById(itemId) { - const rows = await query('SELECT id, game_id, src, label, display_order, created_at FROM game_items WHERE id = ? LIMIT 1', [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]) } @@ -719,7 +765,7 @@ async function getGameDetail(gameId) { } async function createGame({ id, name, isPublic = true }) { - await query('INSERT INTO games (id, name, thumbnail_src, is_public, display_rank, created_at) VALUES (?, ?, ?, ?, ?, ?)', [ + await query('INSERT INTO topics (id, name, thumbnail_src, is_public, display_rank, created_at) VALUES (?, ?, ?, ?, ?, ?)', [ id, name, '', @@ -731,12 +777,12 @@ async function createGame({ id, name, isPublic = true }) { } async function updateGameThumbnail(gameId, thumbnailSrc) { - await query('UPDATE games SET thumbnail_src = ? WHERE id = ?', [thumbnailSrc, gameId]) + await query('UPDATE topics SET thumbnail_src = ? WHERE id = ?', [thumbnailSrc, gameId]) return findGameById(gameId) } async function updateGameVisibility(gameId, isPublic) { - await query('UPDATE games SET is_public = ? WHERE id = ?', [isPublic ? 1 : 0, gameId]) + await query('UPDATE topics SET is_public = ? WHERE id = ?', [isPublic ? 1 : 0, gameId]) return findGameById(gameId) } @@ -838,8 +884,8 @@ async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) { const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([ query("SELECT avatar_src FROM users WHERE avatar_src <> ''"), - query("SELECT thumbnail_src FROM games WHERE thumbnail_src <> ''"), - query("SELECT src FROM game_items WHERE src <> ''"), + query("SELECT thumbnail_src FROM topics WHERE thumbnail_src <> ''"), + query("SELECT src FROM topic_items WHERE src <> ''"), query("SELECT src FROM custom_items WHERE src <> ''"), query("SELECT thumbnail_src, pool_json FROM tierlists"), query("SELECT thumbnail_src_snapshot, items_json, board_items_json FROM template_requests"), @@ -891,8 +937,8 @@ async function listReferencedUploadUsage() { const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([ query("SELECT avatar_src FROM users WHERE avatar_src <> ''"), - query("SELECT thumbnail_src FROM games WHERE thumbnail_src <> ''"), - query("SELECT src FROM game_items WHERE src <> ''"), + query("SELECT thumbnail_src FROM topics WHERE thumbnail_src <> ''"), + query("SELECT src FROM topic_items WHERE src <> ''"), query("SELECT src FROM custom_items WHERE src <> ''"), query("SELECT id, thumbnail_src, pool_json FROM tierlists"), query("SELECT id, thumbnail_src_snapshot, items_json, board_items_json FROM template_requests"), @@ -934,8 +980,8 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc }) { const [userResult, gameResult, gameItemResult, customItemResult] = await Promise.all([ query('UPDATE users SET avatar_src = ? WHERE avatar_src = ?', [toSrc, fromSrc]), - query('UPDATE games SET thumbnail_src = ? WHERE thumbnail_src = ?', [toSrc, fromSrc]), - query('UPDATE game_items SET src = ? WHERE src = ?', [toSrc, fromSrc]), + query('UPDATE topics SET thumbnail_src = ? WHERE thumbnail_src = ?', [toSrc, fromSrc]), + query('UPDATE topic_items SET src = ? WHERE src = ?', [toSrc, fromSrc]), query('UPDATE custom_items SET src = ? WHERE src = ?', [toSrc, fromSrc]), ]) @@ -1099,8 +1145,8 @@ async function cleanupMissingUploadReferences() { const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([ query("SELECT id, avatar_src FROM users WHERE avatar_src <> ''"), - query("SELECT id, thumbnail_src FROM games WHERE thumbnail_src <> ''"), - query("SELECT id, src FROM game_items WHERE src <> ''"), + query("SELECT id, thumbnail_src FROM topics WHERE thumbnail_src <> ''"), + query("SELECT id, src FROM topic_items WHERE src <> ''"), query("SELECT id, src FROM custom_items WHERE src <> ''"), query("SELECT id, thumbnail_src, groups_json, pool_json FROM tierlists"), query("SELECT id, thumbnail_src_snapshot, groups_json, items_json, board_items_json FROM template_requests"), @@ -1114,7 +1160,7 @@ async function cleanupMissingUploadReferences() { for (const row of gameRows) { if (await fileExistsForUploadSrc(row.thumbnail_src)) continue - await query('UPDATE games SET thumbnail_src = ? WHERE id = ?', ['', row.id]) + await query('UPDATE topics SET thumbnail_src = ? WHERE id = ?', ['', row.id]) stats.clearedGameThumbnails += 1 } @@ -1269,10 +1315,10 @@ async function clearImageOptimizationJobs({ month } = {}) { } async function createGameItem({ id, gameId, src, label }) { const createdAt = now() - const minOrderRows = await query('SELECT MIN(display_order) AS min_display_order FROM game_items WHERE game_id = ?', [gameId]) + const minOrderRows = await query('SELECT MIN(display_order) AS min_display_order FROM topic_items WHERE topic_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 (?, ?, ?, ?, ?, ?)', [ + await query('INSERT INTO topic_items (id, topic_id, src, label, display_order, created_at) VALUES (?, ?, ?, ?, ?, ?)', [ id, gameId, src, @@ -1280,13 +1326,13 @@ async function createGameItem({ id, gameId, src, label }) { nextDisplayOrder, createdAt, ]) - const rows = await query('SELECT id, game_id, src, label, display_order, created_at FROM game_items WHERE id = ? LIMIT 1', [id]) + const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_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, display_order, created_at FROM game_items WHERE id = ? LIMIT 1', [itemId]) + 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]) } @@ -1299,7 +1345,7 @@ async function updateGameItemDisplayOrder(gameId, itemIds) { 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])) + finalIds.map((itemId, index) => query('UPDATE topic_items SET display_order = ? WHERE id = ? AND topic_id = ?', [index + 1, itemId, gameId])) ) return listGameItems(gameId) @@ -1334,15 +1380,15 @@ async function updateImageAssetLabel(assetId, label) { } async function deleteGameItem(itemId) { - const gameItemRows = await query('SELECT game_id FROM game_items WHERE id = ? LIMIT 1', [itemId]) - const gameId = gameItemRows[0]?.game_id + const gameItemRows = await query('SELECT topic_id FROM topic_items WHERE id = ? LIMIT 1', [itemId]) + const gameId = gameItemRows[0]?.topic_id if (gameId) { const tierListRows = await query( ` - SELECT id, author_id, game_id, title, description, is_public, groups_json, pool_json, created_at, updated_at + SELECT id, author_id, topic_id, title, description, is_public, groups_json, pool_json, created_at, updated_at FROM tierlists - WHERE game_id = ? + WHERE topic_id = ? `, [gameId] ) @@ -1362,21 +1408,21 @@ async function deleteGameItem(itemId) { } } - await query('DELETE FROM game_items WHERE id = ?', [itemId]) + await query('DELETE FROM topic_items WHERE id = ?', [itemId]) } async function deleteGame(gameId) { - await query('DELETE FROM games WHERE id = ?', [gameId]) + await query('DELETE FROM topics WHERE id = ?', [gameId]) } async function updateGameDisplayOrder(gameIds) { - const normalizedIds = Array.from(new Set((gameIds || []).filter((id) => id && id !== FREEFORM_GAME_ID))).slice(0, 50) + const normalizedIds = Array.from(new Set((gameIds || []).filter((id) => id && id !== FREEFORM_TOPIC_ID))).slice(0, 50) - await query('UPDATE games SET display_rank = NULL WHERE id <> ?', [FREEFORM_GAME_ID]) + await query('UPDATE topics SET display_rank = NULL WHERE id <> ?', [FREEFORM_TOPIC_ID]) await Promise.all( normalizedIds.map((gameId, index) => - query('UPDATE games SET display_rank = ? WHERE id = ? AND id <> ?', [index + 1, gameId, FREEFORM_GAME_ID]) + query('UPDATE topics SET display_rank = ? WHERE id = ? AND id <> ?', [index + 1, gameId, FREEFORM_TOPIC_ID]) ) ) @@ -1438,9 +1484,9 @@ async function findCustomItemById(id) { async function getCustomItemUsageMeta() { const rows = await query( ` - SELECT t.game_id, g.name AS game_name, t.groups_json, t.pool_json + SELECT t.topic_id, tp.name AS topic_name, t.groups_json, t.pool_json FROM tierlists t - LEFT JOIN games g ON g.id = t.game_id + LEFT JOIN topics tp ON tp.id = t.topic_id ` ) const usageMap = new Map() @@ -1465,13 +1511,13 @@ async function getCustomItemUsageMeta() { } }) - if (!row.game_id) return + if (!row.topic_id) return seenItemIds.forEach((itemId) => { if (!linkedGamesMap.has(itemId)) linkedGamesMap.set(itemId, new Map()) - linkedGamesMap.get(itemId).set(row.game_id, { - id: row.game_id, - name: row.game_name || row.game_id, + linkedGamesMap.get(itemId).set(row.topic_id, { + id: row.topic_id, + name: row.topic_name || row.topic_id, }) }) }) @@ -1511,14 +1557,14 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod ` SELECT gi.id, - gi.game_id, + gi.topic_id, gi.src, gi.label, gi.created_at, - g.name AS game_name - FROM game_items gi - INNER JOIN games g ON g.id = gi.game_id - ${hasQuery ? 'WHERE gi.label LIKE ? OR gi.src LIKE ? OR gi.game_id LIKE ? OR g.name LIKE ?' : ''} + tp.name AS topic_name + FROM topic_items gi + INNER JOIN topics tp ON tp.id = gi.topic_id + ${hasQuery ? 'WHERE gi.label LIKE ? OR gi.src LIKE ? OR gi.topic_id LIKE ? OR tp.name LIKE ?' : ''} ORDER BY gi.created_at DESC `, hasQuery ? [search, search, search, search] : [] @@ -1540,9 +1586,9 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod gameItemRows.forEach((row) => { if (!row?.src) return if (!templateLinkedBySrc.has(row.src)) templateLinkedBySrc.set(row.src, new Map()) - templateLinkedBySrc.get(row.src).set(row.game_id, { - id: row.game_id, - name: row.game_name || row.game_id, + templateLinkedBySrc.get(row.src).set(row.topic_id, { + id: row.topic_id, + name: row.topic_name || row.topic_id, }) }) @@ -1593,15 +1639,15 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod src: row.src, label: row.label, createdAt: Number(row.created_at), - ownerName: row.game_name || row.game_id, + ownerName: row.topic_name || row.topic_id, ownerEmail: '', usageCount: (templateLinkedBySrc.get(row.src) || new Map()).size, linkedGames: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()), sourceType: 'template', sourceLabel: '관리자 템플릿', canDelete: true, - sourceGameId: row.game_id, - sourceGameName: row.game_name || row.game_id, + sourceGameId: row.topic_id, + sourceGameName: row.topic_name || row.topic_id, })) const baseItems = [...customItems, ...templateItems, ...assetLibraryItems] @@ -1767,12 +1813,12 @@ function applyFavoriteMetaToTierLists(tierLists, favoriteStats) { })) } -async function listPublicTierLists(gameId, currentUserId = '', queryText = '') { +async function listPublicTierLists(topicId, currentUserId = '', queryText = '') { const params = [] let whereClause = 'WHERE t.is_public = 1' - if (gameId) { - whereClause += ' AND t.game_id = ?' - params.push(gameId) + if (topicId) { + whereClause += ' AND t.topic_id = ?' + params.push(topicId) } if ((queryText || '').trim()) { const search = `%${queryText.trim()}%` @@ -1784,7 +1830,7 @@ async function listPublicTierLists(gameId, currentUserId = '', queryText = '') { ` SELECT t.id, - t.game_id, + t.topic_id, t.title, t.thumbnail_src, t.created_at, @@ -1804,7 +1850,8 @@ async function listPublicTierLists(gameId, currentUserId = '', queryText = '') { const tierLists = rows.map((row) => ({ id: row.id, - gameId: row.game_id, + topicId: row.topic_id, + gameId: row.topic_id, title: row.title, thumbnailSrc: row.thumbnail_src || '', createdAt: Number(row.created_at), @@ -1830,7 +1877,7 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited if ((queryText || '').trim()) { const search = `%${queryText.trim()}%` - whereClause += ' AND (t.title LIKE ? OR g.name LIKE ? OR u.nickname LIKE ? OR u.email LIKE ?)' + whereClause += ' AND (t.title LIKE ? OR tp.name LIKE ? OR u.nickname LIKE ? OR u.email LIKE ?)' params.push(search, search, search, search) } @@ -1846,8 +1893,8 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited SELECT t.id, t.author_id, - t.game_id, - g.name AS game_name, + t.topic_id, + tp.name AS topic_name, t.title, t.thumbnail_src, t.description, @@ -1873,7 +1920,7 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited FROM favorite_tierlists f INNER JOIN tierlists t ON t.id = f.tierlist_id INNER JOIN users u ON u.id = t.author_id - INNER JOIN games g ON g.id = t.game_id + INNER JOIN topics tp ON tp.id = t.topic_id ${whereClause} ${orderClause} `, @@ -1893,7 +1940,7 @@ async function listUserTierLists(userId) { ` SELECT t.id, - t.game_id, + t.topic_id, t.title, t.thumbnail_src, t.created_at, @@ -1912,7 +1959,8 @@ async function listUserTierLists(userId) { const tierLists = rows.map((row) => ({ id: row.id, - gameId: row.game_id, + topicId: row.topic_id, + gameId: row.topic_id, title: row.title, thumbnailSrc: row.thumbnail_src || '', createdAt: Number(row.created_at), @@ -1958,25 +2006,26 @@ function getAutoThumbnailSrc(groups = [], pool = []) { return fallbackItem?.src || '' } -async function listAdminTierLists({ queryText = '', gameId = '', page = 1, limit = 50, currentUserId = '' } = {}) { +async function listAdminTierLists({ queryText = '', gameId = '', topicId = '', page = 1, limit = 50, currentUserId = '' } = {}) { const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200) const normalizedPage = Math.max(Number(page) || 1, 1) const hasQuery = !!(queryText || '').trim() - const hasGameId = !!(gameId || '').trim() + const resolvedTopicId = (topicId || gameId || '').trim() + const hasGameId = !!resolvedTopicId const search = `%${(queryText || '').trim()}%` const whereParts = [] const params = [] if (hasGameId) { - whereParts.push('t.game_id = ?') - params.push((gameId || '').trim()) + whereParts.push('t.topic_id = ?') + params.push(resolvedTopicId) } if (hasQuery) { whereParts.push(`( t.title LIKE ? - OR g.name LIKE ? - OR g.id LIKE ? + OR tp.name LIKE ? + OR tp.id LIKE ? OR u.email LIKE ? OR u.nickname LIKE ? )`) @@ -1990,8 +2039,8 @@ async function listAdminTierLists({ queryText = '', gameId = '', page = 1, limit SELECT t.id, t.author_id, - t.game_id, - g.name AS game_name, + t.topic_id, + tp.name AS topic_name, t.title, t.thumbnail_src, t.description, @@ -2010,7 +2059,7 @@ async function listAdminTierLists({ queryText = '', gameId = '', page = 1, limit u.avatar_src FROM tierlists t INNER JOIN users u ON u.id = t.author_id - INNER JOIN games g ON g.id = t.game_id + INNER JOIN topics tp ON tp.id = t.topic_id ${whereClause} ORDER BY t.updated_at DESC, t.created_at DESC `, @@ -2044,23 +2093,24 @@ async function listAdminTierLists({ queryText = '', gameId = '', page = 1, limit } } -async function summarizeAdminTierLists({ queryText = '', gameId = '' } = {}) { +async function summarizeAdminTierLists({ queryText = '', gameId = '', topicId = '' } = {}) { const hasQuery = !!(queryText || '').trim() - const hasGameId = !!(gameId || '').trim() + const resolvedTopicId = (topicId || gameId || '').trim() + const hasGameId = !!resolvedTopicId const search = `%${(queryText || '').trim()}%` const whereParts = [] const params = [] if (hasGameId) { - whereParts.push('t.game_id = ?') - params.push((gameId || '').trim()) + whereParts.push('t.topic_id = ?') + params.push(resolvedTopicId) } if (hasQuery) { whereParts.push(`( t.title LIKE ? - OR g.name LIKE ? - OR g.id LIKE ? + OR tp.name LIKE ? + OR tp.id LIKE ? OR u.email LIKE ? OR u.nickname LIKE ? )`) @@ -2073,7 +2123,7 @@ async function summarizeAdminTierLists({ queryText = '', gameId = '' } = {}) { SELECT t.is_public FROM tierlists t INNER JOIN users u ON u.id = t.author_id - INNER JOIN games g ON g.id = t.game_id + INNER JOIN topics tp ON tp.id = t.topic_id ${whereClause} `, params @@ -2094,8 +2144,8 @@ async function findTierListById(id, currentUserId = '') { SELECT t.id, t.author_id, - t.game_id, - g.name AS game_name, + t.topic_id, + tp.name AS topic_name, t.title, t.thumbnail_src, t.description, @@ -2114,7 +2164,7 @@ async function findTierListById(id, currentUserId = '') { u.avatar_src FROM tierlists t INNER JOIN users u ON u.id = t.author_id - INNER JOIN games g ON g.id = t.game_id + INNER JOIN topics tp ON tp.id = t.topic_id WHERE t.id = ? LIMIT 1 `, @@ -2146,6 +2196,8 @@ async function createTemplateRequest({ sourceTierListId = '', sourceGameId, targetGameId = '', + sourceTopicId = sourceGameId, + targetTopicId = targetGameId, title, description = '', thumbnailSrc = '', @@ -2171,8 +2223,8 @@ async function createTemplateRequest({ request_type, requester_id, source_tierlist_id, - source_game_id, - target_game_id, + source_topic_id, + target_topic_id, status, title_snapshot, description_snapshot, @@ -2191,8 +2243,8 @@ async function createTemplateRequest({ type, requesterId, sourceTierListId || null, - sourceGameId, - targetGameId, + sourceTopicId, + targetTopicId, title, description, thumbnailSrc, @@ -2215,8 +2267,8 @@ async function findTemplateRequestById(id) { tr.request_type, tr.requester_id, tr.source_tierlist_id, - tr.source_game_id, - tr.target_game_id, + tr.source_topic_id, + tr.target_topic_id, tr.status, tr.title_snapshot, tr.description_snapshot, @@ -2230,12 +2282,12 @@ async function findTemplateRequestById(id) { u.nickname, u.email, u.avatar_src AS requester_avatar_src, - sg.name AS source_game_name, - tg.name AS target_game_name + sg.name AS source_topic_name, + tg.name AS target_topic_name FROM template_requests tr 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 + LEFT JOIN topics sg ON sg.id = tr.source_topic_id + LEFT JOIN topics tg ON tg.id = tr.target_topic_id WHERE tr.id = ? LIMIT 1 `, @@ -2257,8 +2309,8 @@ async function listAdminTemplateRequests({ status = 'pending', statuses = [] } = tr.request_type, tr.requester_id, tr.source_tierlist_id, - tr.source_game_id, - tr.target_game_id, + tr.source_topic_id, + tr.target_topic_id, tr.status, tr.title_snapshot, tr.description_snapshot, @@ -2272,12 +2324,12 @@ async function listAdminTemplateRequests({ status = 'pending', statuses = [] } = u.nickname, u.email, u.avatar_src AS requester_avatar_src, - sg.name AS source_game_name, - tg.name AS target_game_name + sg.name AS source_topic_name, + tg.name AS target_topic_name FROM template_requests tr 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 + LEFT JOIN topics sg ON sg.id = tr.source_topic_id + LEFT JOIN topics tg ON tg.id = tr.target_topic_id WHERE tr.status IN (${placeholders}) ORDER BY CASE tr.status @@ -2299,7 +2351,7 @@ async function updateTemplateRequestStatus({ id, status }) { } async function updateTemplateRequestTargetGame({ id, targetGameId }) { - await query('UPDATE template_requests SET target_game_id = ?, updated_at = ? WHERE id = ?', [targetGameId || '', now(), id]) + await query('UPDATE template_requests SET target_topic_id = ?, updated_at = ? WHERE id = ?', [targetGameId || '', now(), id]) return findTemplateRequestById(id) } @@ -2350,6 +2402,7 @@ async function saveTierList({ id, authorId, gameId, + topicId = gameId, title, thumbnailSrc = '', description, @@ -2383,11 +2436,11 @@ async function saveTierList({ await query( ` INSERT INTO tierlists ( - id, author_id, game_id, title, thumbnail_src, description, is_public, show_character_names, icon_size, source_tierlist_id, source_snapshot_title, source_snapshot_author, groups_json, pool_json, created_at, updated_at + id, author_id, topic_id, title, thumbnail_src, description, is_public, show_character_names, icon_size, source_tierlist_id, source_snapshot_title, source_snapshot_author, groups_json, pool_json, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, - [nextId, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, Number(iconSize) || 80, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), createdAt, createdAt] + [nextId, authorId, topicId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, Number(iconSize) || 80, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), createdAt, createdAt] ) return findTierListById(nextId, authorId) } @@ -2400,7 +2453,8 @@ async function duplicateTierListForUser({ tierList, targetUserId }) { return saveTierList({ id: duplicateId, authorId: targetUserId, - gameId: tierList.gameId, + gameId: tierList.topicId || tierList.gameId, + topicId: tierList.topicId || tierList.gameId, title: copyTitle, thumbnailSrc: tierList.thumbnailSrc || '', description: tierList.description || '', @@ -2424,11 +2478,11 @@ async function unfavoriteTierList({ userId, tierListId }) { } async function favoriteGame({ userId, gameId }) { - await query('INSERT IGNORE INTO favorite_games (user_id, game_id, created_at) VALUES (?, ?, ?)', [userId, gameId, now()]) + await query('INSERT IGNORE INTO favorite_topics (user_id, topic_id, created_at) VALUES (?, ?, ?)', [userId, gameId, now()]) } async function unfavoriteGame({ userId, gameId }) { - await query('DELETE FROM favorite_games WHERE user_id = ? AND game_id = ?', [userId, gameId]) + await query('DELETE FROM favorite_topics WHERE user_id = ? AND topic_id = ?', [userId, gameId]) } module.exports = { diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index d6286f5..49d52a5 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -308,6 +308,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => { router.get('/tierlists', requireAdmin, async (req, res) => { const schema = z.object({ q: z.string().trim().max(120).optional().default(''), + topicId: z.string().trim().max(120).optional().default(''), gameId: z.string().trim().max(120).optional().default(''), page: z.coerce.number().int().min(1).optional().default(1), limit: z.coerce.number().int().min(1).max(200).optional().default(50), @@ -317,6 +318,7 @@ router.get('/tierlists', requireAdmin, async (req, res) => { const result = await listAdminTierLists({ queryText: parsed.data.q, + topicId: parsed.data.topicId || parsed.data.gameId, gameId: parsed.data.gameId, page: parsed.data.page, limit: parsed.data.limit, @@ -328,6 +330,7 @@ router.get('/tierlists', requireAdmin, async (req, res) => { router.get('/tierlists/stats', requireAdmin, async (req, res) => { const schema = z.object({ q: z.string().trim().max(120).optional().default(''), + topicId: z.string().trim().max(120).optional().default(''), gameId: z.string().trim().max(120).optional().default(''), }) const parsed = schema.safeParse(req.query) @@ -335,6 +338,7 @@ router.get('/tierlists/stats', requireAdmin, async (req, res) => { const result = await summarizeAdminTierLists({ queryText: parsed.data.q, + topicId: parsed.data.topicId || parsed.data.gameId, gameId: parsed.data.gameId, }) res.json(result) diff --git a/backend/src/routes/tierlists.js b/backend/src/routes/tierlists.js index 16387be..d2b8071 100644 --- a/backend/src/routes/tierlists.js +++ b/backend/src/routes/tierlists.js @@ -20,7 +20,7 @@ const { requireAuth } = require('../middleware/auth') const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage') const router = express.Router() -const FREEFORM_GAME_ID = 'freeform' +const FREEFORM_TOPIC_ID = 'freeform' const FREEFORM_DEFAULT_TITLE = '직접 티어표 만들기' function normalizePoolItem(item) { @@ -61,7 +61,8 @@ const thumbnailUpload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024 } const templateRequestSchema = z.object({ type: z.enum(['create', 'update']), sourceTierListId: z.string().max(64).optional().default(''), - gameId: z.string().min(1).max(120), + gameId: z.string().min(1).max(120).optional(), + topicId: z.string().min(1).max(120).optional(), requestTitle: z.string().trim().min(1).max(120), requestDescription: z.string().trim().min(1).max(1000), thumbnailSrc: z.string().max(255).optional().default(''), @@ -72,7 +73,11 @@ const templateRequestSchema = z.object({ id: z.string().min(1), name: z.string().min(1).max(16), itemIds: z.array(z.string()).optional().default([]), - }).passthrough() +}).passthrough().superRefine((value, ctx) => { + if (!(value.topicId || value.gameId)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'topicId_required', path: ['topicId'] }) + } +}) ), boardItems: z.array( z.object({ @@ -86,7 +91,8 @@ const templateRequestSchema = z.object({ const tierListUpsertSchema = z.object({ id: z.string().optional(), - gameId: z.string().min(1), + gameId: z.string().min(1).optional(), + topicId: z.string().min(1).optional(), title: z.string().min(1).max(120), thumbnailSrc: z.string().max(255).optional().default(''), description: z.string().max(1000).optional().default(''), @@ -111,12 +117,16 @@ const tierListUpsertSchema = z.object({ origin: z.enum(['game', 'custom']).default('game'), }) ), +}).superRefine((value, ctx) => { + if (!(value.topicId || value.gameId)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'topicId_required', path: ['topicId'] }) + } }) router.get('/public', async (req, res) => { - const gameId = req.query.gameId + const topicId = typeof req.query.topicId === 'string' ? req.query.topicId : req.query.gameId const queryText = typeof req.query.q === 'string' ? req.query.q : '' - const lists = await listPublicTierLists(gameId, req.session?.userId || '', queryText) + const lists = await listPublicTierLists(topicId, req.session?.userId || '', queryText) res.json({ tierLists: lists }) }) @@ -226,14 +236,15 @@ router.post('/template-request', requireAuth, async (req, res) => { if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const payload = parsed.data + const topicId = payload.topicId || payload.gameId const normalizedBoardItems = payload.boardItems.map(normalizePoolItem) const customItems = normalizedBoardItems.filter((item) => item?.origin === 'custom') if (!customItems.length) return res.status(400).json({ error: 'custom_items_required' }) if (payload.type === 'create') { - if (payload.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' }) - } else if (payload.gameId === FREEFORM_GAME_ID) { - return res.status(400).json({ error: 'game_template_required' }) + if (topicId !== FREEFORM_TOPIC_ID) return res.status(400).json({ error: 'freeform_required' }) + } else if (topicId === FREEFORM_TOPIC_ID) { + return res.status(400).json({ error: 'topic_template_required' }) } let sourceTierList = null @@ -251,8 +262,10 @@ router.post('/template-request', requireAuth, async (req, res) => { type: payload.type, requesterId: req.session.userId, sourceTierListId: sourceTierList?.id || '', - sourceGameId: payload.gameId, - targetGameId: payload.type === 'update' ? payload.gameId : '', + sourceGameId: topicId, + sourceTopicId: topicId, + targetGameId: payload.type === 'update' ? topicId : '', + targetTopicId: payload.type === 'update' ? topicId : '', title: payload.requestTitle, description: payload.requestDescription, thumbnailSrc: payload.thumbnailSrc || '', @@ -274,6 +287,7 @@ router.post('/', requireAuth, async (req, res) => { const parsed = tierListUpsertSchema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const payload = parsed.data + const topicId = payload.topicId || payload.gameId const normalizedPool = payload.pool.map(normalizePoolItem) let existing = null @@ -284,7 +298,8 @@ router.post('/', requireAuth, async (req, res) => { const updated = await saveTierList({ id: existing.id, authorId: existing.authorId, - gameId: existing.gameId, + gameId: existing.topicId || existing.gameId, + topicId: existing.topicId || existing.gameId, title: payload.title, thumbnailSrc: payload.thumbnailSrc || '', description: payload.description || '', @@ -303,7 +318,8 @@ router.post('/', requireAuth, async (req, res) => { const created = await saveTierList({ id: nanoid(), authorId: req.session.userId, - gameId: payload.gameId, + gameId: topicId, + topicId, title: payload.title, thumbnailSrc: payload.thumbnailSrc || '', description: payload.description || '', diff --git a/docs/history.md b/docs/history.md index 3fd585d..a5e7d2e 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-04-02 v1.4.13 +- 사용자 표면과 API 이름층까지 `topic/template`로 옮긴 뒤에는, DB 스키마도 실제로 따라오게 해야 이후 유지보수 비용이 덜 쌓이므로 `games` 계열 실명을 `topics` 계열로 마이그레이션하는 편이 맞다고 판단했다. +- 다만 한 번에 응답 키까지 완전히 끊으면 프런트와 관리자 흐름이 너무 크게 흔들릴 수 있으므로, 이번 단계에서는 실제 저장 스키마는 `topic`으로 옮기고 응답의 `gameId / gameName`은 호환 키로 잠시 함께 유지하는 점진 마감이 가장 안전하다고 정리했다. + ## 2026-04-02 v1.4.12 - 프런트 이름만 바꾸는 단계가 끝난 뒤에는, 백엔드도 새 `/api/topics`, `/api/admin/templates` 경로를 열고 기존 `/games`는 호환용으로 남기는 점진 전환이 가장 안전하다고 판단했다. diff --git a/docs/todo.md b/docs/todo.md index 58a9a20..e1601e8 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,8 +1,10 @@ # 할 일 및 이슈 ## 단기 확인 +- `v1.4.13`부터 DB 실명도 `topics / topic_items / favorite_topics / topic_id` 기준으로 옮겼으므로, 기존 운영 DB에서 서버 재시작 후 자동 마이그레이션이 한 번만 자연스럽게 수행되는지 먼저 확인한다. +- 백엔드 응답은 현재 `topicId / topicName`과 `gameId / gameName`을 함께 내려주고 있으므로, 다음 단계에서는 실제 프런트/관리자에서 더 이상 `gameId` fallback이 필요 없는 지점을 확인해 호환 키 제거 순서를 정한다. +- 티어표 공개 목록, 관리자 전체 티어표 관리, 저장/요청 API는 `topicId`를 우선 받도록 바꿨으므로, 실제 브라우저에서 검색/저장/공유/관리자 필터가 모두 같은 파라미터 체계로 자연스럽게 이어지는지 한 번 더 QA한다. - `/api/topics`, `/api/admin/templates` alias를 연 뒤 프런트 호출도 새 경로로 옮겼으므로, 실제 브라우저에서 주제 목록/즐겨찾기/주제 상세/관리자 템플릿 관리가 모두 같은 세션으로 자연스럽게 동작하는지 한 번 더 QA한다. -- 다음 마지막 단계에서는 DB 스키마와 백엔드 함수/변수명까지 실제로 옮길지, 아니면 현재 alias 구조를 안정판으로 남길지 최종 결정한다. - 프런트 API 호출부는 `topic/template` 의미 이름으로 옮겼으므로, 다음 단계에서는 `api.js` 안의 레거시 alias를 얼마나 더 유지할지와 백엔드 API 경로를 실제로 바꿀지 범위를 정한다. - 관리자 템플릿/주제 화면과 홈·에디터·즐겨찾기에서 새 API 이름층으로 바뀐 뒤에도 저장, 즐겨찾기, 템플릿 생성, 아이템 정렬 흐름이 자연스러운지 한 번 더 QA한다. - `topicHub / topicId`를 기본 라우트 기준으로 세웠으므로, 기존 `/games/...` 북마크와 새 `/topics/...` 주소 양쪽에서 주제 상세와 에디터 진입이 모두 자연스럽게 이어지는지 한 번 더 QA한다. diff --git a/docs/update.md b/docs/update.md index d8a8cfa..5708a65 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-04-02 v1.4.13 +- DB 실명 변경 마지막 단계로 `games / game_items / favorite_games`를 `topics / topic_items / favorite_topics` 기준으로 자동 마이그레이션하도록 정리하고, `tierlists.game_id`, `template_requests.source_game_id/target_game_id`도 각각 `topic_id`, `source_topic_id/target_topic_id`로 옮기게 했다. +- 백엔드 저장/조회 쿼리는 이제 새 topic 스키마를 기준으로 동작하고, 응답에는 `topicId / topicName`을 기본으로 내려주되 기존 프런트가 바로 깨지지 않도록 `gameId / gameName`도 잠시 함께 유지했다. +- 티어표 공개 목록, 관리자 전체 티어표 관리, 티어표 저장/요청 API는 `topicId`를 우선 받도록 정리하고 기존 `gameId`는 호환 입력으로만 남겨, 외부 표면과 실제 저장 스키마가 한 단계 더 가까워지게 맞췄다. + ## 2026-04-02 v1.4.12 - 백엔드에 `/api/topics`와 `/api/admin/templates` alias 경로를 추가하고, 주제/템플릿 응답도 `topic/topics`, `template/templates` 키를 함께 내려주도록 정리했다. - 프런트의 새 의미 이름은 이제 실제로도 `/api/topics`, `/api/admin/templates`를 타도록 연결해, 경로 이름과 호출 이름이 다시 어긋나지 않게 맞췄다. diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 5e328f2..272ba34 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -45,12 +45,12 @@ export const api = { request( `/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}` ), - listAdminTierLists: ({ q = '', gameId = '', page = 1, limit = 50 } = {}) => + listAdminTierLists: ({ q = '', topicId = '', gameId = '', page = 1, limit = 50 } = {}) => request( - `/api/admin/tierlists?q=${encodeURIComponent(q)}&gameId=${encodeURIComponent(gameId)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}` + `/api/admin/tierlists?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId || gameId)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}` ), - getAdminTierListStats: ({ q = '', gameId = '' } = {}) => - request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&gameId=${encodeURIComponent(gameId)}`), + getAdminTierListStats: ({ q = '', topicId = '', gameId = '' } = {}) => + request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId || gameId)}`), updateAdminTierList: (tierListId, payload) => request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'PATCH', body: payload }), deleteAdminTierList: (tierListId) => @@ -112,9 +112,9 @@ export const api = { deleteAdminUser: (userId) => request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }), listPublicTierListsByTopic: (topicId) => - request(`/api/tierlists/public?gameId=${encodeURIComponent(topicId || '')}`), + request(`/api/tierlists/public?topicId=${encodeURIComponent(topicId || '')}`), searchPublicTierListsByTopic: (topicId, q = '') => - request(`/api/tierlists/public?gameId=${encodeURIComponent(topicId || '')}&q=${encodeURIComponent(q || '')}`), + request(`/api/tierlists/public?topicId=${encodeURIComponent(topicId || '')}&q=${encodeURIComponent(q || '')}`), searchAllPublicTierLists: (q = '') => request(`/api/tierlists/public?q=${encodeURIComponent(q || '')}`), listMyTierLists: () => request('/api/tierlists/me'), listMyFavoriteTierLists: ({ q = '', sort = 'favorited' } = {}) => @@ -163,7 +163,7 @@ export const api = { createAdminGameTemplateFromTierList: (tierListId, payload) => request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-game-template`, { method: 'POST', body: payload }), listPublicTierLists: (gameId) => - request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}`), + request(`/api/tierlists/public?topicId=${encodeURIComponent(gameId || '')}`), searchPublicTierLists: (gameId, q = '') => - request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}&q=${encodeURIComponent(q || '')}`), + request(`/api/tierlists/public?topicId=${encodeURIComponent(gameId || '')}&q=${encodeURIComponent(q || '')}`), }