Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 136db137ec | |||
| 1fabf66f04 |
@@ -80,6 +80,7 @@ app.use(async (req, res, next) => {
|
||||
|
||||
app.use('/api/auth', authRoutes)
|
||||
app.use('/api/games', gamesRoutes)
|
||||
app.use('/api/topics', gamesRoutes)
|
||||
app.use('/api/tierlists', tierListsRoutes)
|
||||
app.use('/api/admin', adminRoutes)
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -54,6 +54,10 @@ const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
function getTemplateIdParam(req) {
|
||||
return req.params.templateId || req.params.gameId || ''
|
||||
}
|
||||
|
||||
function buildUploadFilename(file) {
|
||||
const ext = path.extname(file.originalname || '').toLowerCase()
|
||||
const safeExt = ext && /^[.a-z0-9]+$/.test(ext) ? ext : ''
|
||||
@@ -110,7 +114,7 @@ function canManageAdminRole(actingUser, primaryAdmin) {
|
||||
return !!actingUser?.isAdmin && primaryAdmin?.id === actingUser.id
|
||||
}
|
||||
|
||||
router.post('/games', requireAdmin, async (req, res) => {
|
||||
router.post(['/games', '/templates'], requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).max(60),
|
||||
@@ -126,24 +130,26 @@ router.post('/games', requireAdmin, async (req, res) => {
|
||||
const copiedThumb = await copyUploadIntoGameAsset(parsed.data.thumbnailSrc)
|
||||
await updateGameThumbnail(game.id, copiedThumb)
|
||||
}
|
||||
res.json({ game: await findGameById(game.id) })
|
||||
const template = await findGameById(game.id)
|
||||
res.json({ game: template, template })
|
||||
})
|
||||
|
||||
router.patch('/games/:gameId', requireAdmin, async (req, res) => {
|
||||
router.patch(['/games/:gameId', '/templates/:templateId'], requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
isPublic: z.boolean(),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const game = await findGameById(req.params.gameId)
|
||||
const templateId = getTemplateIdParam(req)
|
||||
const game = await findGameById(templateId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const updated = await updateGameVisibility(game.id, parsed.data.isPublic)
|
||||
res.json({ game: updated })
|
||||
res.json({ game: updated, template: updated })
|
||||
})
|
||||
|
||||
router.patch('/games/display-order', requireAdmin, async (req, res) => {
|
||||
router.patch(['/games/display-order', '/templates/display-order'], requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
gameIds: z.array(z.string().min(1)).max(50),
|
||||
})
|
||||
@@ -154,26 +160,28 @@ router.patch('/games/display-order', requireAdmin, async (req, res) => {
|
||||
const validGameIds = new Set(games.map((game) => game.id))
|
||||
const filteredIds = parsed.data.gameIds.filter((gameId) => validGameIds.has(gameId))
|
||||
const updatedGames = await updateGameDisplayOrder(filteredIds)
|
||||
res.json({ games: updatedGames })
|
||||
res.json({ games: updatedGames, templates: updatedGames })
|
||||
})
|
||||
|
||||
router.patch('/games/:gameId/items/display-order', requireAdmin, async (req, res) => {
|
||||
router.patch(['/games/:gameId/items/display-order', '/templates/:templateId/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)
|
||||
const templateId = getTemplateIdParam(req)
|
||||
const game = await findGameById(templateId)
|
||||
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) => {
|
||||
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 game = await findGameById(req.params.gameId)
|
||||
const templateId = getTemplateIdParam(req)
|
||||
const game = await findGameById(templateId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const optimized = await writeOptimizedImage({
|
||||
@@ -185,14 +193,15 @@ router.post('/games/:gameId/thumbnail', requireAdmin, upload.single('thumbnail')
|
||||
quality: 84,
|
||||
})
|
||||
|
||||
const updated = await updateGameThumbnail(req.params.gameId, optimized.src)
|
||||
res.json({ game: updated })
|
||||
const updated = await updateGameThumbnail(templateId, optimized.src)
|
||||
res.json({ game: updated, template: updated })
|
||||
})
|
||||
|
||||
router.post('/games/:gameId/images', requireAdmin, upload.array('images', 50), async (req, res) => {
|
||||
router.post(['/games/:gameId/images', '/templates/:templateId/images'], requireAdmin, upload.array('images', 50), async (req, res) => {
|
||||
const files = Array.isArray(req.files) ? req.files : []
|
||||
if (!files.length) return res.status(400).json({ error: 'file_required' })
|
||||
const game = await findGameById(req.params.gameId)
|
||||
const templateId = getTemplateIdParam(req)
|
||||
const game = await findGameById(templateId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const labelsRaw = req.body?.labels
|
||||
@@ -223,19 +232,19 @@ router.post('/games/:gameId/images', requireAdmin, upload.array('images', 50), a
|
||||
res.json({ item: items[0], items })
|
||||
})
|
||||
|
||||
router.delete('/games/:gameId/items/:itemId', requireAdmin, async (req, res) => {
|
||||
const game = await findGameById(req.params.gameId)
|
||||
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)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
router.patch('/games/:gameId/items/:itemId', requireAdmin, async (req, res) => {
|
||||
router.patch(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:itemId'], requireAdmin, async (req, res) => {
|
||||
const schema = z.object({ label: z.string().trim().min(1).max(60) })
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const game = await findGameById(req.params.gameId)
|
||||
const game = await findGameById(getTemplateIdParam(req))
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const updated = await updateGameItemLabel(req.params.itemId, parsed.data.label)
|
||||
@@ -243,10 +252,11 @@ router.patch('/games/:gameId/items/:itemId', requireAdmin, async (req, res) => {
|
||||
res.json({ item: updated })
|
||||
})
|
||||
|
||||
router.delete('/games/:gameId', requireAdmin, async (req, res) => {
|
||||
const game = await findGameById(req.params.gameId)
|
||||
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(req.params.gameId)
|
||||
await deleteGame(templateId)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
@@ -298,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),
|
||||
@@ -307,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,
|
||||
@@ -318,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)
|
||||
@@ -325,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)
|
||||
|
||||
@@ -6,7 +6,7 @@ const router = express.Router()
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const games = await listGames(req.session?.userId || '', { includePrivate: !!req.session?.isAdmin })
|
||||
res.json({ games })
|
||||
res.json({ games, topics: games })
|
||||
})
|
||||
|
||||
router.post('/:gameId/favorite', requireAuth, async (req, res) => {
|
||||
@@ -15,7 +15,7 @@ router.post('/:gameId/favorite', requireAuth, async (req, res) => {
|
||||
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 }
|
||||
res.json({ game: updated })
|
||||
res.json({ game: updated, topic: updated })
|
||||
})
|
||||
|
||||
router.delete('/:gameId/favorite', requireAuth, async (req, res) => {
|
||||
@@ -24,14 +24,14 @@ router.delete('/:gameId/favorite', requireAuth, async (req, res) => {
|
||||
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 }
|
||||
res.json({ game: updated })
|
||||
res.json({ game: updated, topic: updated })
|
||||
})
|
||||
|
||||
router.get('/:gameId', async (req, res) => {
|
||||
const detail = await getGameDetail(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, items: detail.items })
|
||||
res.json({ game: detail.game, topic: detail.game, items: detail.items })
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -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 || '',
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 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`는 호환용으로 남기는 점진 전환이 가장 안전하다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.11
|
||||
- 백엔드 `/api/games` 경로를 바로 바꾸기보다, 프런트 API 객체에서 먼저 `topic/template` 의미 이름을 제공하고 호출부를 옮기는 편이 위험이 훨씬 낮다고 판단했다.
|
||||
|
||||
|
||||
@@ -1,6 +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한다.
|
||||
- 프런트 API 호출부는 `topic/template` 의미 이름으로 옮겼으므로, 다음 단계에서는 `api.js` 안의 레거시 alias를 얼마나 더 유지할지와 백엔드 API 경로를 실제로 바꿀지 범위를 정한다.
|
||||
- 관리자 템플릿/주제 화면과 홈·에디터·즐겨찾기에서 새 API 이름층으로 바뀐 뒤에도 저장, 즐겨찾기, 템플릿 생성, 아이템 정렬 흐름이 자연스러운지 한 번 더 QA한다.
|
||||
- `topicHub / topicId`를 기본 라우트 기준으로 세웠으므로, 기존 `/games/...` 북마크와 새 `/topics/...` 주소 양쪽에서 주제 상세와 에디터 진입이 모두 자연스럽게 이어지는지 한 번 더 QA한다.
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 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`를 타도록 연결해, 경로 이름과 호출 이름이 다시 어긋나지 않게 맞췄다.
|
||||
|
||||
## 2026-04-02 v1.4.11
|
||||
- 프런트 API 이름층을 한 단계 더 정리해 `listTopics / getTopic / favoriteTopic`, `updateAdminTemplate*`, `searchPublicTierListsByTopic` 같은 의미 기반 이름을 추가하고 실제 호출부도 이 기준으로 옮겼다.
|
||||
- 백엔드 경로와 응답 구조는 그대로 유지한 채 프런트에서 읽는 이름만 먼저 바꿔, 다음 단계의 API/모델 리네이밍 부담을 더 줄였다.
|
||||
|
||||
@@ -155,7 +155,7 @@ export function useAdminGameManager({
|
||||
const preserveUploadState = !!options.preserveUploadState
|
||||
resetMessages()
|
||||
try {
|
||||
const res = await fetch(toApiUrl('/api/admin/games'), {
|
||||
const res = await fetch(toApiUrl('/api/admin/templates'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -283,7 +283,7 @@ export function useAdminGameManager({
|
||||
fd.append('images', entry.file)
|
||||
fd.append('labels', entry.label.trim())
|
||||
})
|
||||
const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedTemplateId.value)}/images`), {
|
||||
const res = await fetch(toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/images`), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: fd,
|
||||
|
||||
@@ -30,27 +30,27 @@ export const api = {
|
||||
login: ({ email, password }) => request('/api/auth/login', { method: 'POST', body: { email, password } }),
|
||||
logout: () => request('/api/auth/logout', { method: 'POST' }),
|
||||
|
||||
listTopics: () => request('/api/games'),
|
||||
getTopic: (topicId) => request(`/api/games/${encodeURIComponent(topicId)}`),
|
||||
favoriteTopic: (topicId) => request(`/api/games/${encodeURIComponent(topicId)}/favorite`, { method: 'POST' }),
|
||||
unfavoriteTopic: (topicId) => request(`/api/games/${encodeURIComponent(topicId)}/favorite`, { method: 'DELETE' }),
|
||||
updateAdminTemplateDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }),
|
||||
listTopics: () => request('/api/topics'),
|
||||
getTopic: (topicId) => request(`/api/topics/${encodeURIComponent(topicId)}`),
|
||||
favoriteTopic: (topicId) => request(`/api/topics/${encodeURIComponent(topicId)}/favorite`, { method: 'POST' }),
|
||||
unfavoriteTopic: (topicId) => request(`/api/topics/${encodeURIComponent(topicId)}/favorite`, { method: 'DELETE' }),
|
||||
updateAdminTemplateDisplayOrder: (payload) => request('/api/admin/templates/display-order', { method: 'PATCH', body: payload }),
|
||||
updateAdminTemplateItemDisplayOrder: (templateId, payload) =>
|
||||
request(`/api/admin/games/${encodeURIComponent(templateId)}/items/display-order`, { method: 'PATCH', body: payload }),
|
||||
request(`/api/admin/templates/${encodeURIComponent(templateId)}/items/display-order`, { method: 'PATCH', body: payload }),
|
||||
updateAdminTemplate: (templateId, payload) =>
|
||||
request(`/api/admin/games/${encodeURIComponent(templateId)}`, { method: 'PATCH', body: payload }),
|
||||
request(`/api/admin/templates/${encodeURIComponent(templateId)}`, { method: 'PATCH', body: payload }),
|
||||
updateAdminTemplateItem: (templateId, itemId, payload) =>
|
||||
request(`/api/admin/games/${encodeURIComponent(templateId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
|
||||
request(`/api/admin/templates/${encodeURIComponent(templateId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
|
||||
listAdminCustomItems: ({ q = '', page = 1, limit = 50, filter = 'all' } = {}) =>
|
||||
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 || '')}`),
|
||||
}
|
||||
|
||||
@@ -1148,7 +1148,7 @@ async function uploadThumbnail() {
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('thumbnail', thumbFile.value)
|
||||
const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedTemplateId.value)}/thumbnail`), {
|
||||
const res = await fetch(toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/thumbnail`), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: fd,
|
||||
@@ -1217,7 +1217,7 @@ async function removeTemplateItem(itemId) {
|
||||
resetMessages()
|
||||
try {
|
||||
const res = await fetch(
|
||||
toApiUrl(`/api/admin/games/${encodeURIComponent(selectedTemplateId.value)}/items/${encodeURIComponent(itemId)}`),
|
||||
toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/items/${encodeURIComponent(itemId)}`),
|
||||
{
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
@@ -1263,7 +1263,7 @@ async function removeTemplate() {
|
||||
if (!ok) return
|
||||
|
||||
try {
|
||||
const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedTemplateId.value)}`), {
|
||||
const res = await fetch(toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}`), {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user