const mysql = require('mysql2/promise') const DB_HOST = process.env.DB_HOST || '127.0.0.1' const DB_PORT = process.env.DB_PORT ? Number(process.env.DB_PORT) : 3306 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' let poolPromise = null let initPromise = null function now() { return Date.now() } function parseJson(value, fallback) { if (!value) return fallback try { return JSON.parse(value) } catch (e) { return fallback } } function serializeJson(value) { return JSON.stringify(value || []) } function mapUserRow(row) { if (!row) return null return { id: row.id, email: row.email, nickname: row.nickname || '', isAdmin: !!row.is_admin, avatarSrc: row.avatar_src || '', createdAt: Number(row.created_at), tierListCount: Number(row.tierlist_count || 0), recentActivityAt: Number(row.recent_activity_at || row.created_at || 0), } } function mapGameRow(row) { if (!row) return null return { id: row.id, name: row.name, thumbnailSrc: row.thumbnail_src || '', displayRank: row.display_rank == null ? null : Number(row.display_rank), createdAt: Number(row.created_at), } } function mapGameItemRow(row) { if (!row) return null return { id: row.id, gameId: row.game_id, src: row.src, label: row.label, createdAt: Number(row.created_at), } } function mapTierListRow(row) { if (!row) return null return { id: row.id, authorId: row.author_id, authorName: getUserDisplayName(row), authorAccountName: getUserAccountName(row), authorAvatarSrc: row.avatar_src || '', gameId: row.game_id, gameName: row.game_name || '', title: row.title, thumbnailSrc: row.thumbnail_src || '', description: row.description || '', isPublic: !!row.is_public, showCharacterNames: !!row.show_character_names, groups: parseJson(row.groups_json, []), pool: parseJson(row.pool_json, []), createdAt: Number(row.created_at), updatedAt: Number(row.updated_at), } } function mapTemplateRequestRow(row) { if (!row) return null return { id: row.id, type: row.request_type, requesterId: row.requester_id, requesterName: getUserDisplayName(row), requesterAccountName: getUserAccountName(row), requesterAvatarSrc: row.requester_avatar_src || '', sourceTierListId: row.source_tierlist_id, sourceGameId: row.source_game_id, sourceGameName: row.source_game_name || '', sourceTierListTitle: row.title_snapshot || '', sourceDescription: row.description_snapshot || '', thumbnailSrc: row.thumbnail_src_snapshot || '', targetGameId: row.target_game_id || '', targetGameName: row.target_game_name || '', status: row.status, items: parseJson(row.items_json, []), createdAt: Number(row.created_at), updatedAt: Number(row.updated_at), } } function getUserDisplayName(row) { if (!row) return '' const nickname = (row.nickname || '').trim() if (nickname) return nickname const email = (row.email || '').trim() if (!email) return '' return email.split('@')[0] || email } function getUserAccountName(row) { if (!row) return '' const email = (row.email || '').trim() if (!email) return '' return email.split('@')[0] || email } async function createPool() { const rootConnection = await mysql.createConnection({ host: DB_HOST, port: DB_PORT, user: DB_USER, password: DB_PASSWORD, multipleStatements: true, }) await rootConnection.query( `CREATE DATABASE IF NOT EXISTS \`${DB_NAME}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci` ) await rootConnection.end() return mysql.createPool({ host: DB_HOST, port: DB_PORT, user: DB_USER, password: DB_PASSWORD, database: DB_NAME, connectionLimit: DB_CONNECTION_LIMIT, charset: 'utf8mb4', }) } async function getPool() { if (!poolPromise) { poolPromise = createPool() } return poolPromise } async function query(sql, params = []) { const pool = await getPool() const [rows] = await pool.execute(sql, params) return rows } async function ensureSchema() { if (initPromise) return initPromise initPromise = (async () => { await query(` CREATE TABLE IF NOT EXISTS users ( id VARCHAR(64) PRIMARY KEY, email VARCHAR(255) NOT NULL UNIQUE, nickname VARCHAR(80) NOT NULL DEFAULT '', password_hash VARCHAR(255) NOT NULL, is_admin TINYINT(1) NOT NULL DEFAULT 0, avatar_src VARCHAR(255) NOT NULL DEFAULT '', created_at BIGINT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) await query(` CREATE TABLE IF NOT EXISTS games ( id VARCHAR(120) PRIMARY KEY, name VARCHAR(120) NOT NULL, thumbnail_src VARCHAR(255) NOT NULL DEFAULT '', display_rank INT NULL DEFAULT NULL, created_at BIGINT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) const displayRankColumns = await query("SHOW COLUMNS FROM games LIKE 'display_rank'") if (!displayRankColumns.length) { await query('ALTER TABLE games ADD COLUMN display_rank INT NULL DEFAULT NULL AFTER thumbnail_src') } await query(` CREATE TABLE IF NOT EXISTS game_items ( id VARCHAR(64) PRIMARY KEY, game_id VARCHAR(120) NOT NULL, src VARCHAR(255) NOT NULL, label VARCHAR(120) NOT NULL, created_at BIGINT NOT NULL, INDEX idx_game_items_game_id (game_id), CONSTRAINT fk_game_items_game FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) await query(` CREATE TABLE IF NOT EXISTS custom_items ( id VARCHAR(64) PRIMARY KEY, owner_id VARCHAR(64) NOT NULL, src VARCHAR(255) NOT NULL, label VARCHAR(120) NOT NULL, created_at BIGINT NOT NULL, INDEX idx_custom_items_owner_id (owner_id), CONSTRAINT fk_custom_items_owner FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) await query(` CREATE TABLE IF NOT EXISTS tierlists ( id VARCHAR(64) PRIMARY KEY, author_id VARCHAR(64) NOT NULL, game_id VARCHAR(120) NOT NULL, title VARCHAR(120) NOT NULL, thumbnail_src VARCHAR(255) NOT NULL DEFAULT '', description TEXT NOT NULL, is_public TINYINT(1) NOT NULL DEFAULT 0, show_character_names TINYINT(1) NOT NULL DEFAULT 0, groups_json LONGTEXT NOT NULL, pool_json LONGTEXT NOT NULL, 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), 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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) await query(` CREATE TABLE IF NOT EXISTS favorite_tierlists ( user_id VARCHAR(64) NOT NULL, tierlist_id VARCHAR(64) NOT NULL, created_at BIGINT NOT NULL, PRIMARY KEY (user_id, tierlist_id), INDEX idx_favorite_tierlists_tierlist_id (tierlist_id), CONSTRAINT fk_favorite_tierlists_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT fk_favorite_tierlists_tierlist FOREIGN KEY (tierlist_id) REFERENCES tierlists(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) await query(` CREATE TABLE IF NOT EXISTS template_requests ( id VARCHAR(64) PRIMARY KEY, 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 '', status VARCHAR(20) NOT NULL DEFAULT 'pending', title_snapshot VARCHAR(120) NOT NULL, description_snapshot TEXT NOT NULL, thumbnail_src_snapshot VARCHAR(255) NOT NULL DEFAULT '', items_json LONGTEXT NOT NULL, created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL, INDEX idx_template_requests_status_created (status, created_at), INDEX idx_template_requests_source_tierlist (source_tierlist_id), INDEX idx_template_requests_requester (requester_id), CONSTRAINT fk_template_requests_requester FOREIGN KEY (requester_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT fk_template_requests_source_tierlist FOREIGN KEY (source_tierlist_id) REFERENCES tierlists(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) const tierListThumbnailColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'thumbnail_src'") if (!tierListThumbnailColumns.length) { await query("ALTER TABLE tierlists ADD COLUMN thumbnail_src VARCHAR(255) NOT NULL DEFAULT '' AFTER title") } const tierListShowNamesColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'show_character_names'") if (!tierListShowNamesColumns.length) { await query("ALTER TABLE tierlists ADD COLUMN show_character_names TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public") } await query( ` INSERT INTO games (id, name, thumbnail_src, created_at) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE name = VALUES(name) `, [FREEFORM_GAME_ID, '직접 티어표 만들기', '', now()] ) const countRows = await query('SELECT COUNT(*) AS count FROM games') if (Number(countRows[0]?.count || 0) <= 1) { const createdAt = now() await query( ` INSERT INTO games (id, name, thumbnail_src, created_at) VALUES (?, ?, ?, ?), (?, ?, ?, ?) `, ['example-game', '예시 게임', '', createdAt, 'another-game', '다른 예시 게임', '', createdAt] ) await query( ` INSERT INTO game_items (id, game_id, src, label, created_at) VALUES (?, ?, ?, ?, ?), (?, ?, ?, ?, ?) `, [ 'img-1', 'example-game', '/uploads/seeds/example1.png', '샘플 1', createdAt, 'img-2', 'example-game', '/uploads/seeds/example2.png', '샘플 2', createdAt, ] ) } })() return initPromise } async function ensureData() { await ensureSchema() } async function countUsers() { const rows = await query('SELECT COUNT(*) AS count FROM users') return Number(rows[0]?.count || 0) } async function findUserByEmail(email) { const rows = await query( 'SELECT id, email, nickname, password_hash, is_admin, avatar_src, created_at FROM users WHERE email = ? LIMIT 1', [email] ) const row = rows[0] if (!row) return null return { ...mapUserRow(row), passwordHash: row.password_hash } } async function findUserById(id) { const rows = await query( 'SELECT id, email, nickname, is_admin, avatar_src, created_at FROM users WHERE id = ? LIMIT 1', [id] ) return mapUserRow(rows[0]) } async function createUser({ id, email, nickname, passwordHash, isAdmin }) { const createdAt = now() await query( ` INSERT INTO users (id, email, nickname, password_hash, is_admin, avatar_src, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) `, [id, email, nickname || '', passwordHash, isAdmin ? 1 : 0, '', createdAt] ) return findUserById(id) } async function updateUserProfile({ id, nickname, avatarSrc }) { if (typeof avatarSrc === 'string') { await query('UPDATE users SET nickname = ?, avatar_src = ? WHERE id = ?', [nickname || '', avatarSrc, id]) } else { await query('UPDATE users SET nickname = ? WHERE id = ?', [nickname || '', id]) } return findUserById(id) } async function listUsers() { const rows = await query(` SELECT u.id, u.email, u.nickname, u.is_admin, u.avatar_src, u.created_at, COUNT(t.id) AS tierlist_count, GREATEST( u.created_at, COALESCE(MAX(t.updated_at), 0) ) AS recent_activity_at FROM users u LEFT JOIN tierlists t ON t.author_id = u.id GROUP BY u.id, u.email, u.nickname, u.is_admin, u.avatar_src, u.created_at ORDER BY recent_activity_at DESC, u.created_at ASC, u.email ASC `) return rows.map(mapUserRow) } async function adminUpdateUser({ id, email, nickname, isAdmin, avatarSrc }) { if (typeof avatarSrc === 'string') { await query('UPDATE users SET email = ?, nickname = ?, is_admin = ?, avatar_src = ? WHERE id = ?', [ email, nickname || '', isAdmin ? 1 : 0, avatarSrc, id, ]) } else { await query('UPDATE users SET email = ?, nickname = ?, is_admin = ? WHERE id = ?', [ email, nickname || '', isAdmin ? 1 : 0, id, ]) } return findUserById(id) } async function adminUpdateUserPassword({ id, passwordHash }) { await query('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, id]) return findUserById(id) } async function adminDeleteUser(id) { await query('DELETE FROM users WHERE id = ?', [id]) } async function listGames() { const rows = await query( ` SELECT id, name, thumbnail_src, display_rank, created_at FROM games WHERE id <> ? ORDER BY CASE WHEN display_rank IS NULL THEN 1 ELSE 0 END ASC, display_rank ASC, created_at DESC, name ASC `, [FREEFORM_GAME_ID] ) return rows.map(mapGameRow) } async function findGameById(id) { const rows = await query('SELECT id, name, thumbnail_src, display_rank, created_at FROM games WHERE id = ? LIMIT 1', [id]) return mapGameRow(rows[0]) } async function listGameItems(gameId) { const rows = await query( 'SELECT id, game_id, src, label, created_at FROM game_items WHERE game_id = ? ORDER BY created_at ASC', [gameId] ) return rows.map(mapGameItemRow) } async function getGameDetail(gameId) { const game = await findGameById(gameId) if (!game) return null const items = await listGameItems(gameId) return { game, items } } async function createGame({ id, name }) { await query('INSERT INTO games (id, name, thumbnail_src, display_rank, created_at) VALUES (?, ?, ?, ?, ?)', [ id, name, '', null, now(), ]) return findGameById(id) } async function updateGameThumbnail(gameId, thumbnailSrc) { await query('UPDATE games SET thumbnail_src = ? WHERE id = ?', [thumbnailSrc, gameId]) return findGameById(gameId) } async function createGameItem({ id, gameId, src, label }) { const createdAt = now() await query('INSERT INTO game_items (id, game_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [ id, gameId, src, label, createdAt, ]) const rows = await query('SELECT id, game_id, src, label, created_at FROM game_items WHERE id = ? LIMIT 1', [id]) return mapGameItemRow(rows[0]) } async function updateGameItemLabel(itemId, label) { await query('UPDATE game_items SET label = ? WHERE id = ?', [label, itemId]) const rows = await query('SELECT id, game_id, src, label, created_at FROM game_items WHERE id = ? LIMIT 1', [itemId]) return mapGameItemRow(rows[0]) } 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 if (gameId) { const tierListRows = await query( ` SELECT id, author_id, game_id, title, description, is_public, groups_json, pool_json, created_at, updated_at FROM tierlists WHERE game_id = ? `, [gameId] ) for (const row of tierListRows) { const tierList = mapTierListRow(row) const nextGroups = (tierList.groups || []).map((group) => ({ ...group, itemIds: (group.itemIds || []).filter((id) => id !== itemId), })) const nextPool = (tierList.pool || []).filter((item) => item.id !== itemId) await query( 'UPDATE tierlists SET groups_json = ?, pool_json = ?, updated_at = ? WHERE id = ?', [serializeJson(nextGroups), serializeJson(nextPool), now(), tierList.id] ) } } await query('DELETE FROM game_items WHERE id = ?', [itemId]) } async function deleteGame(gameId) { await query('DELETE FROM games WHERE id = ?', [gameId]) } async function updateGameDisplayOrder(gameIds) { const normalizedIds = Array.from(new Set((gameIds || []).filter((id) => id && id !== FREEFORM_GAME_ID))).slice(0, 50) await query('UPDATE games SET display_rank = NULL WHERE id <> ?', [FREEFORM_GAME_ID]) await Promise.all( normalizedIds.map((gameId, index) => query('UPDATE games SET display_rank = ? WHERE id = ? AND id <> ?', [index + 1, gameId, FREEFORM_GAME_ID]) ) ) return listGames() } async function createCustomItem({ id, ownerId, src, label }) { const createdAt = now() await query('INSERT INTO custom_items (id, owner_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [ id, ownerId, src, label, createdAt, ]) return { id, ownerId, src, label, origin: 'custom', createdAt } } async function syncOwnedCustomItemLabels({ ownerId, items }) { const customItems = Array.from( new Map( (items || []) .filter((item) => item?.origin === 'custom' && item?.id && typeof item.label === 'string') .map((item) => [item.id, item]) ).values() ) if (!customItems.length) return await Promise.all( customItems.map((item) => query('UPDATE custom_items SET label = ? WHERE id = ? AND owner_id = ?', [item.label.trim().slice(0, 60), item.id, ownerId]) ) ) } async function findCustomItemById(id) { const rows = await query( ` SELECT id, owner_id, src, label, created_at FROM custom_items WHERE id = ? LIMIT 1 `, [id] ) const row = rows[0] if (!row) return null return { id: row.id, ownerId: row.owner_id, src: row.src, label: row.label, createdAt: Number(row.created_at), } } async function getCustomItemUsageMap() { const rows = await query('SELECT groups_json, pool_json FROM tierlists') const usageMap = new Map() rows.forEach((row) => { const groups = parseJson(row.groups_json, []) const pool = parseJson(row.pool_json, []) groups.forEach((group) => { ;(group?.itemIds || []).forEach((itemId) => { usageMap.set(itemId, (usageMap.get(itemId) || 0) + 1) }) }) pool.forEach((item) => { if (item?.id) { usageMap.set(item.id, (usageMap.get(item.id) || 0) + 1) } }) }) return usageMap } async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnly = false } = {}) { const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200) const normalizedPage = Math.max(Number(page) || 1, 1) const hasQuery = !!(queryText || '').trim() const search = `%${(queryText || '').trim()}%` const whereClause = hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : '' const params = hasQuery ? [search, search, search, search] : [] const rows = await query( ` SELECT c.id, c.owner_id, c.src, c.label, c.created_at, u.nickname, u.email FROM custom_items c INNER JOIN users u ON u.id = c.owner_id ${whereClause} ORDER BY c.created_at DESC `, params ) const usageMap = await getCustomItemUsageMap() const allItems = rows .map((row) => ({ id: row.id, ownerId: row.owner_id, src: row.src, label: row.label, createdAt: Number(row.created_at), ownerName: row.nickname || row.email, ownerEmail: row.email, usageCount: usageMap.get(row.id) || 0, })) .filter((item) => (orphanOnly ? item.usageCount === 0 : true)) const total = allItems.length const offset = (normalizedPage - 1) * normalizedLimit const pagedItems = allItems.slice(offset, offset + normalizedLimit) return { items: pagedItems, total, page: normalizedPage, limit: normalizedLimit, } } async function findUnusedCustomItems({ queryText = '' } = {}) { const hasQuery = !!(queryText || '').trim() const search = `%${(queryText || '').trim()}%` const whereClause = hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : '' const params = hasQuery ? [search, search, search, search] : [] const rows = await query( ` SELECT c.id, c.owner_id, c.src, c.label, c.created_at, u.nickname, u.email FROM custom_items c INNER JOIN users u ON u.id = c.owner_id ${whereClause} ORDER BY c.created_at DESC `, params ) const usageMap = await getCustomItemUsageMap() return rows .map((row) => ({ id: row.id, ownerId: row.owner_id, src: row.src, label: row.label, createdAt: Number(row.created_at), ownerName: row.nickname || row.email, ownerEmail: row.email, usageCount: usageMap.get(row.id) || 0, })) .filter((item) => item.usageCount === 0) } async function getFavoriteStatsForTierListIds(tierListIds, userId = '') { const ids = Array.from(new Set((tierListIds || []).filter(Boolean))) const countMap = new Map() const favoritedSet = new Set() if (!ids.length) return { countMap, favoritedSet } const placeholders = ids.map(() => '?').join(', ') const countRows = await query( ` SELECT tierlist_id, COUNT(*) AS favorite_count FROM favorite_tierlists WHERE tierlist_id IN (${placeholders}) GROUP BY tierlist_id `, ids ) countRows.forEach((row) => { countMap.set(row.tierlist_id, Number(row.favorite_count || 0)) }) if (userId) { const favoriteRows = await query( ` SELECT tierlist_id FROM favorite_tierlists WHERE user_id = ? AND tierlist_id IN (${placeholders}) `, [userId, ...ids] ) favoriteRows.forEach((row) => favoritedSet.add(row.tierlist_id)) } return { countMap, favoritedSet } } function applyFavoriteMetaToTierLists(tierLists, favoriteStats) { return tierLists.map((tierList) => ({ ...tierList, favoriteCount: favoriteStats.countMap.get(tierList.id) || 0, isFavorited: favoriteStats.favoritedSet.has(tierList.id), })) } async function listPublicTierLists(gameId, currentUserId = '', queryText = '') { const params = [] let whereClause = 'WHERE t.is_public = 1' if (gameId) { whereClause += ' AND t.game_id = ?' params.push(gameId) } if ((queryText || '').trim()) { const search = `%${queryText.trim()}%` whereClause += ' AND (t.title LIKE ? OR u.nickname LIKE ? OR u.email LIKE ?)' params.push(search, search, search) } const rows = await query( ` SELECT t.id, t.game_id, t.title, t.thumbnail_src, t.created_at, t.updated_at, t.author_id, u.nickname, u.email, u.avatar_src FROM tierlists t INNER JOIN users u ON u.id = t.author_id ${whereClause} ORDER BY t.updated_at DESC LIMIT 50 `, params ) const tierLists = rows.map((row) => ({ id: row.id, gameId: row.game_id, title: row.title, thumbnailSrc: row.thumbnail_src || '', createdAt: Number(row.created_at), updatedAt: Number(row.updated_at), authorId: row.author_id, authorName: getUserDisplayName(row), authorAccountName: getUserAccountName(row), authorAvatarSrc: row.avatar_src || '', })) const favoriteStats = await getFavoriteStatsForTierListIds( tierLists.map((tierList) => tierList.id), currentUserId ) return applyFavoriteMetaToTierLists(tierLists, favoriteStats) } async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited' } = {}) { const allowedSort = new Set(['favorited', 'updated', 'favorites']) const normalizedSort = allowedSort.has(sort) ? sort : 'favorited' const params = [userId] let whereClause = 'WHERE f.user_id = ?' if ((queryText || '').trim()) { const search = `%${queryText.trim()}%` whereClause += ' AND (t.title LIKE ? OR g.name LIKE ? OR u.nickname LIKE ? OR u.email LIKE ?)' params.push(search, search, search, search) } const orderClause = normalizedSort === 'updated' ? 'ORDER BY t.updated_at DESC, f.created_at DESC' : normalizedSort === 'favorites' ? 'ORDER BY favorite_count DESC, t.updated_at DESC' : 'ORDER BY f.created_at DESC, t.updated_at DESC' const rows = await query( ` SELECT t.id, t.author_id, t.game_id, g.name AS game_name, t.title, t.thumbnail_src, t.description, t.is_public, t.show_character_names, t.groups_json, t.pool_json, t.created_at, t.updated_at, f.created_at AS favorited_at, u.nickname, u.email, u.avatar_src, ( SELECT COUNT(*) FROM favorite_tierlists ff WHERE ff.tierlist_id = t.id ) AS favorite_count 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 ${whereClause} ${orderClause} `, params ) return rows.map((row) => ({ ...mapTierListRow(row), favoritedAt: Number(row.favorited_at || 0), favoriteCount: Number(row.favorite_count || 0), isFavorited: true, })) } async function listUserTierLists(userId) { const rows = await query( ` SELECT t.id, t.game_id, t.title, t.thumbnail_src, t.created_at, t.updated_at, t.is_public, u.nickname, u.email, u.avatar_src FROM tierlists t INNER JOIN users u ON u.id = t.author_id WHERE t.author_id = ? ORDER BY updated_at DESC `, [userId] ) const tierLists = rows.map((row) => ({ id: row.id, gameId: row.game_id, title: row.title, thumbnailSrc: row.thumbnail_src || '', createdAt: Number(row.created_at), updatedAt: Number(row.updated_at), isPublic: !!row.is_public, authorName: getUserDisplayName(row), authorAccountName: getUserAccountName(row), authorAvatarSrc: row.avatar_src || '', })) const favoriteStats = await getFavoriteStatsForTierListIds( tierLists.map((tierList) => tierList.id), userId ) return applyFavoriteMetaToTierLists(tierLists, favoriteStats) } function uniqueTierListItems(poolItems) { const map = new Map() ;(poolItems || []).forEach((item) => { if (!item?.id || map.has(item.id)) return map.set(item.id, { id: item.id, src: item.src || '', label: item.label || 'item', origin: item.origin || 'game', }) }) return Array.from(map.values()) } function getAutoThumbnailSrc(groups = [], pool = []) { const itemMap = new Map((pool || []).filter((item) => item?.id && item?.src).map((item) => [item.id, item])) for (const group of groups || []) { for (const itemId of group?.itemIds || []) { const item = itemMap.get(itemId) if (item?.src) return item.src } } const fallbackItem = (pool || []).find((item) => item?.src) return fallbackItem?.src || '' } async function listAdminTierLists({ queryText = '', 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 search = `%${(queryText || '').trim()}%` const whereClause = hasQuery ? ` WHERE t.title LIKE ? OR g.name LIKE ? OR g.id LIKE ? OR u.email LIKE ? OR u.nickname LIKE ? ` : '' const params = hasQuery ? [search, search, search, search, search] : [] const rows = await query( ` SELECT t.id, t.author_id, t.game_id, g.name AS game_name, t.title, t.thumbnail_src, t.description, t.is_public, t.show_character_names, t.groups_json, t.pool_json, t.created_at, t.updated_at, u.nickname, u.email, 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 ${whereClause} ORDER BY t.updated_at DESC, t.created_at DESC `, params ) const allItems = rows.map((row) => { const tierList = mapTierListRow(row) const poolItems = uniqueTierListItems(tierList.pool) const extraItems = poolItems.filter((item) => item.origin === 'custom') return { ...tierList, itemCount: poolItems.length, extraItemCount: extraItems.length, extraItems, } }) const total = allItems.length const offset = (normalizedPage - 1) * normalizedLimit const pagedTierLists = allItems.slice(offset, offset + normalizedLimit) const favoriteStats = await getFavoriteStatsForTierListIds( pagedTierLists.map((tierList) => tierList.id), currentUserId ) return { tierLists: applyFavoriteMetaToTierLists(pagedTierLists, favoriteStats), total, page: normalizedPage, limit: normalizedLimit, } } async function findTierListById(id, currentUserId = '') { const rows = await query( ` SELECT t.id, t.author_id, t.game_id, g.name AS game_name, t.title, t.thumbnail_src, t.description, t.is_public, t.show_character_names, t.groups_json, t.pool_json, t.created_at, t.updated_at, u.nickname, u.email, 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 WHERE t.id = ? LIMIT 1 `, [id] ) const tierList = mapTierListRow(rows[0]) if (!tierList) return null const favoriteStats = await getFavoriteStatsForTierListIds([tierList.id], currentUserId) return applyFavoriteMetaToTierLists([tierList], favoriteStats)[0] } async function findPendingTemplateRequestByTierList({ sourceTierListId, type }) { const rows = await query( ` SELECT id, request_type, status FROM template_requests WHERE source_tierlist_id = ? AND request_type = ? AND status = 'pending' LIMIT 1 `, [sourceTierListId, type] ) return rows[0] || null } async function createTemplateRequest({ id, type, requesterId, sourceTierListId, sourceGameId, targetGameId = '', title, description = '', thumbnailSrc = '', items = [], }) { const existing = await findPendingTemplateRequestByTierList({ sourceTierListId, type }) if (existing) { const err = new Error('template_request_exists') err.code = 'TEMPLATE_REQUEST_EXISTS' throw err } const createdAt = now() await query( ` INSERT INTO template_requests ( id, request_type, requester_id, source_tierlist_id, source_game_id, target_game_id, status, title_snapshot, description_snapshot, thumbnail_src_snapshot, items_json, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?) `, [ id, type, requesterId, sourceTierListId, sourceGameId, targetGameId, title, description, thumbnailSrc, serializeJson(items), createdAt, createdAt, ] ) return findTemplateRequestById(id) } async function findTemplateRequestById(id) { const rows = await query( ` SELECT tr.id, tr.request_type, tr.requester_id, tr.source_tierlist_id, tr.source_game_id, tr.target_game_id, tr.status, tr.title_snapshot, tr.description_snapshot, tr.thumbnail_src_snapshot, tr.items_json, tr.created_at, tr.updated_at, u.nickname, u.email, u.avatar_src AS requester_avatar_src, sg.name AS source_game_name, tg.name AS target_game_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 WHERE tr.id = ? LIMIT 1 `, [id] ) return mapTemplateRequestRow(rows[0]) } async function listAdminTemplateRequests({ status = 'pending' } = {}) { const rows = await query( ` SELECT tr.id, tr.request_type, tr.requester_id, tr.source_tierlist_id, tr.source_game_id, tr.target_game_id, tr.status, tr.title_snapshot, tr.description_snapshot, tr.thumbnail_src_snapshot, tr.items_json, tr.created_at, tr.updated_at, u.nickname, u.email, u.avatar_src AS requester_avatar_src, sg.name AS source_game_name, tg.name AS target_game_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 WHERE tr.status = ? ORDER BY tr.created_at DESC `, [status] ) return rows.map(mapTemplateRequestRow) } async function updateTemplateRequestStatus({ id, status }) { await query('UPDATE template_requests SET status = ?, updated_at = ? WHERE id = ?', [status, now(), id]) return findTemplateRequestById(id) } async function deleteTierList(id) { await query('DELETE FROM tierlists WHERE id = ?', [id]) } async function findCustomItemsByIds(ids) { if (!ids.length) return [] const placeholders = ids.map(() => '?').join(', ') const rows = await query( ` SELECT id, owner_id, src, label, created_at FROM custom_items WHERE id IN (${placeholders}) `, ids ) return rows.map((row) => ({ id: row.id, ownerId: row.owner_id, src: row.src, label: row.label, createdAt: Number(row.created_at), })) } async function deleteCustomItems(ids) { if (!ids.length) return const placeholders = ids.map(() => '?').join(', ') await query(`DELETE FROM custom_items WHERE id IN (${placeholders})`, ids) } async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', description, isPublic, showCharacterNames = false, groups, pool }) { const existing = id ? await findTierListById(id, authorId) : null await syncOwnedCustomItemLabels({ ownerId: authorId, items: pool }) const nextThumbnailSrc = (thumbnailSrc || '').trim() || getAutoThumbnailSrc(groups, pool) if (existing) { await query( ` UPDATE tierlists SET title = ?, thumbnail_src = ?, description = ?, is_public = ?, show_character_names = ?, groups_json = ?, pool_json = ?, updated_at = ? WHERE id = ? `, [title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, serializeJson(groups), serializeJson(pool), now(), existing.id] ) return findTierListById(existing.id, authorId) } const createdAt = now() await query( ` INSERT INTO tierlists ( id, author_id, game_id, title, thumbnail_src, description, is_public, show_character_names, groups_json, pool_json, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [id, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, serializeJson(groups), serializeJson(pool), createdAt, createdAt] ) return findTierListById(id, authorId) } async function favoriteTierList({ userId, tierListId }) { await query('INSERT IGNORE INTO favorite_tierlists (user_id, tierlist_id, created_at) VALUES (?, ?, ?)', [userId, tierListId, now()]) } async function unfavoriteTierList({ userId, tierListId }) { await query('DELETE FROM favorite_tierlists WHERE user_id = ? AND tierlist_id = ?', [userId, tierListId]) } module.exports = { DB_NAME, ensureData, countUsers, findUserByEmail, findUserById, createUser, updateUserProfile, listUsers, adminUpdateUser, adminUpdateUserPassword, adminDeleteUser, listGames, findGameById, listGameItems, getGameDetail, createGame, updateGameThumbnail, createGameItem, updateGameItemLabel, deleteGameItem, deleteGame, updateGameDisplayOrder, createCustomItem, findCustomItemById, listCustomItems, findUnusedCustomItems, listPublicTierLists, listFavoriteTierLists, listUserTierLists, listAdminTierLists, findTierListById, favoriteTierList, unfavoriteTierList, deleteTierList, findCustomItemsByIds, deleteCustomItems, saveTierList, createTemplateRequest, findTemplateRequestById, listAdminTemplateRequests, updateTemplateRequestStatus, }