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), } } function mapGameRow(row) { if (!row) return null return { id: row.id, name: row.name, thumbnailSrc: row.thumbnail_src || '', 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, gameId: row.game_id, title: row.title, description: row.description || '', isPublic: !!row.is_public, groups: parseJson(row.groups_json, []), pool: parseJson(row.pool_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 } 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 '', created_at BIGINT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) 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, description TEXT NOT NULL, is_public 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( ` 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 id, email, nickname, is_admin, avatar_src, created_at FROM users ORDER BY created_at ASC, email ASC' ) return rows.map(mapUserRow) } async function adminUpdateUser({ id, email, nickname, isAdmin }) { 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, created_at FROM games WHERE id <> ? ORDER BY created_at ASC, name ASC', [FREEFORM_GAME_ID] ) return rows.map(mapGameRow) } async function findGameById(id) { const rows = await query('SELECT id, name, thumbnail_src, 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, created_at) VALUES (?, ?, ?, ?)', [id, name, '', 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 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 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 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 listPublicTierLists(gameId) { const params = [] let whereClause = 'WHERE t.is_public = 1' if (gameId) { whereClause += ' AND t.game_id = ?' params.push(gameId) } const rows = await query( ` SELECT t.id, t.game_id, t.title, 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 ) return rows.map((row) => ({ id: row.id, gameId: row.game_id, title: row.title, createdAt: Number(row.created_at), updatedAt: Number(row.updated_at), authorId: row.author_id, authorName: getUserDisplayName(row), authorAvatarSrc: row.avatar_src || '', })) } async function listUserTierLists(userId) { const rows = await query( ` SELECT t.id, t.game_id, t.title, 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] ) return rows.map((row) => ({ id: row.id, gameId: row.game_id, title: row.title, createdAt: Number(row.created_at), updatedAt: Number(row.updated_at), isPublic: !!row.is_public, authorName: getUserDisplayName(row), authorAvatarSrc: row.avatar_src || '', })) } async function findTierListById(id) { const rows = await query( ` SELECT id, author_id, game_id, title, description, is_public, groups_json, pool_json, created_at, updated_at FROM tierlists WHERE id = ? LIMIT 1 `, [id] ) return mapTierListRow(rows[0]) } 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, description, isPublic, groups, pool }) { const existing = id ? await findTierListById(id) : null if (existing) { await query( ` UPDATE tierlists SET title = ?, description = ?, is_public = ?, groups_json = ?, pool_json = ?, updated_at = ? WHERE id = ? `, [title, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), now(), existing.id] ) return findTierListById(existing.id) } const createdAt = now() await query( ` INSERT INTO tierlists ( id, author_id, game_id, title, description, is_public, groups_json, pool_json, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [id, authorId, gameId, title, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), createdAt, createdAt] ) return findTierListById(id) } module.exports = { DB_NAME, ensureData, countUsers, findUserByEmail, findUserById, createUser, updateUserProfile, listUsers, adminUpdateUser, adminUpdateUserPassword, adminDeleteUser, listGames, findGameById, listGameItems, getGameDetail, createGame, updateGameThumbnail, createGameItem, deleteGameItem, deleteGame, createCustomItem, listCustomItems, findUnusedCustomItems, listPublicTierLists, listUserTierLists, findTierListById, deleteTierList, findCustomItemsByIds, deleteCustomItems, saveTierList, }