const fs = require('fs/promises') const path = require('path') 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 collectUploadSrcsFromItems(items, bucket) { for (const item of items || []) { if (typeof item?.src === 'string' && item.src.startsWith('/uploads/')) { bucket.add(item.src) } } } function resolveMonthRange(month) { if (typeof month !== 'string') return null const match = month.trim().match(/^(\d{4})-(\d{2})$/) if (!match) return null const year = Number(match[1]) const monthIndex = Number(match[2]) - 1 if (!Number.isInteger(year) || monthIndex < 0 || monthIndex > 11) return null return { start: new Date(year, monthIndex, 1).getTime(), end: new Date(year, monthIndex + 1, 1).getTime(), } } 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, displayOrder: row.display_order == null ? null : Number(row.display_order), createdAt: Number(row.created_at), } } function mapImageAssetRow(row) { if (!row) return null return { id: row.id, contentHash: row.content_hash, src: row.src || '', labelOverride: row.label_override || '', mimeType: row.mime_type || 'image/webp', byteSize: Number(row.byte_size || 0), originalByteSize: Number(row.original_byte_size || 0), width: Number(row.width || 0), height: Number(row.height || 0), createdAt: Number(row.created_at || 0), } } function mapImageOptimizationJobRow(row) { if (!row) return null return { id: row.id, status: row.status, sourceCategory: row.source_category || '', targetDirectory: row.target_directory || '', originalByteSize: Number(row.original_byte_size || 0), optimizedByteSize: Number(row.optimized_byte_size || 0), reusedAsset: !!row.reused_asset, errorMessage: row.error_message || '', queuedAt: Number(row.queued_at || 0), startedAt: Number(row.started_at || 0), finishedAt: Number(row.finished_at || 0), } } 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, sourceTierListId: row.source_tierlist_id || '', sourceSnapshotTitle: row.source_snapshot_title || '', sourceSnapshotAuthor: row.source_snapshot_author || '', 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, []), snapshotGroups: parseJson(row.groups_json, []), snapshotItems: parseJson(row.board_items_json, []), snapshotShowCharacterNames: !!row.show_character_names_snapshot, 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 closePool() { if (!poolPromise) return const pool = await poolPromise await pool.end() poolPromise = null initPromise = null } 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, 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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) const gameItemDisplayOrderColumns = await query("SHOW COLUMNS FROM game_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(` 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, source_tierlist_id VARCHAR(64) NULL DEFAULT NULL, source_snapshot_title VARCHAR(120) NOT NULL DEFAULT '', source_snapshot_author VARCHAR(120) NOT NULL DEFAULT '', 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 favorite_games ( user_id VARCHAR(64) NOT NULL, game_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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) await query(` CREATE TABLE IF NOT EXISTS image_assets ( id VARCHAR(64) PRIMARY KEY, content_hash CHAR(64) NOT NULL UNIQUE, src VARCHAR(255) NOT NULL UNIQUE, label_override VARCHAR(120) NOT NULL DEFAULT '', mime_type VARCHAR(32) NOT NULL DEFAULT 'image/webp', byte_size INT UNSIGNED NOT NULL, original_byte_size INT UNSIGNED NOT NULL, width INT UNSIGNED NOT NULL DEFAULT 0, height INT UNSIGNED NOT NULL DEFAULT 0, created_at BIGINT NOT NULL, INDEX idx_image_assets_created_at (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) const imageAssetLabelColumns = await query("SHOW COLUMNS FROM image_assets LIKE 'label_override'") if (!imageAssetLabelColumns.length) { await query("ALTER TABLE image_assets ADD COLUMN label_override VARCHAR(120) NOT NULL DEFAULT '' AFTER src") } await query(` CREATE TABLE IF NOT EXISTS image_optimization_jobs ( id VARCHAR(64) PRIMARY KEY, status VARCHAR(20) NOT NULL DEFAULT 'queued', source_category VARCHAR(40) NOT NULL DEFAULT '', target_directory VARCHAR(40) NOT NULL DEFAULT '', original_byte_size INT UNSIGNED NOT NULL DEFAULT 0, optimized_byte_size INT UNSIGNED NOT NULL DEFAULT 0, reused_asset TINYINT(1) NOT NULL DEFAULT 0, error_message VARCHAR(255) NOT NULL DEFAULT '', queued_at BIGINT NOT NULL, started_at BIGINT NOT NULL DEFAULT 0, finished_at BIGINT NOT NULL DEFAULT 0, INDEX idx_image_optimization_jobs_status_queued (status, queued_at), INDEX idx_image_optimization_jobs_finished_at (finished_at) ) 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 templateRequestSourceTierListColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'source_tierlist_id'") if (templateRequestSourceTierListColumns[0]?.Null !== 'YES') { await query('ALTER TABLE template_requests MODIFY source_tierlist_id VARCHAR(64) NULL') } const templateRequestTypeColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'request_type'") 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'") if (!templateRequestSourceGameColumns.length) { await query("ALTER TABLE template_requests ADD COLUMN source_game_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER source_tierlist_id") } const templateRequestTargetGameColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'target_game_id'") if (!templateRequestTargetGameColumns.length) { await query("ALTER TABLE template_requests ADD COLUMN target_game_id VARCHAR(120) NOT NULL DEFAULT '' AFTER source_game_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") } const templateRequestGroupsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'groups_json'") if (!templateRequestGroupsColumns.length) { await query("ALTER TABLE template_requests ADD COLUMN groups_json LONGTEXT NOT NULL AFTER items_json") } const templateRequestBoardItemsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'board_items_json'") if (!templateRequestBoardItemsColumns.length) { await query("ALTER TABLE template_requests ADD COLUMN board_items_json LONGTEXT NOT NULL AFTER groups_json") } const templateRequestShowNamesColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'show_character_names_snapshot'") if (!templateRequestShowNamesColumns.length) { await query("ALTER TABLE template_requests ADD COLUMN show_character_names_snapshot TINYINT(1) NOT NULL DEFAULT 0 AFTER board_items_json") } 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") } const tierListSourceIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_tierlist_id'") if (!tierListSourceIdColumns.length) { await query("ALTER TABLE tierlists ADD COLUMN source_tierlist_id VARCHAR(64) NULL DEFAULT NULL AFTER show_character_names") } else if (tierListSourceIdColumns[0]?.Null !== 'YES') { await query('ALTER TABLE tierlists MODIFY source_tierlist_id VARCHAR(64) NULL DEFAULT NULL') } const tierListSourceTitleColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_snapshot_title'") if (!tierListSourceTitleColumns.length) { await query("ALTER TABLE tierlists ADD COLUMN source_snapshot_title VARCHAR(120) NOT NULL DEFAULT '' AFTER source_tierlist_id") } const tierListSourceAuthorColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_snapshot_author'") if (!tierListSourceAuthorColumns.length) { await query("ALTER TABLE tierlists ADD COLUMN source_snapshot_author VARCHAR(120) NOT NULL DEFAULT '' AFTER source_snapshot_title") } 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 findPrimaryAdminUser() { const rows = await query( 'SELECT id, email, nickname, is_admin, avatar_src, created_at FROM users WHERE is_admin = 1 ORDER BY created_at ASC, email ASC LIMIT 1' ) return mapUserRow(rows[0]) } async function listUsers({ queryText = '', sort = 'recent', direction = 'desc' } = {}) { const where = [] const params = [] const trimmedQuery = typeof queryText === 'string' ? queryText.trim() : '' if (trimmedQuery) { where.push('(u.email LIKE ? OR u.nickname LIKE ?)') params.push(`%${trimmedQuery}%`, `%${trimmedQuery}%`) } const isAsc = direction === 'asc' const orderBy = sort === 'created' ? isAsc ? 'u.created_at ASC, recent_activity_at ASC, u.email ASC' : 'u.created_at DESC, recent_activity_at DESC, u.email ASC' : sort === 'tierlists' ? isAsc ? 'tierlist_count ASC, recent_activity_at ASC, u.email ASC' : 'tierlist_count DESC, recent_activity_at DESC, u.email ASC' : isAsc ? 'recent_activity_at ASC, u.created_at ASC, u.email ASC' : 'recent_activity_at DESC, u.created_at ASC, u.email ASC' 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 ${where.length ? `WHERE ${where.join(' AND ')}` : ''} GROUP BY u.id, u.email, u.nickname, u.is_admin, u.avatar_src, u.created_at ORDER BY ${orderBy} `, params ) 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(currentUserId = '') { 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] ) 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)) return games.map((game) => ({ ...game, isFavorited: favoriteSet.has(game.id), })) } 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, display_order, created_at FROM game_items WHERE game_id = ? ORDER BY CASE WHEN display_order IS NULL THEN 1 ELSE 0 END ASC, display_order ASC, created_at DESC, id DESC `, [gameId] ) return rows.map(mapGameItemRow) } 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]) return mapGameItemRow(rows[0]) } async function getGameDetail(gameId) { const game = await findGameById(gameId) if (!game) return null const items = await listGameItems(gameId) return { game, items } } async function 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 findImageAssetByHash(contentHash) { const rows = await query( 'SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE content_hash = ? LIMIT 1', [contentHash] ) return mapImageAssetRow(rows[0]) } async function findImageAssetBySrc(src) { const rows = await query( 'SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE src = ? LIMIT 1', [src] ) return mapImageAssetRow(rows[0]) } async function createImageAsset({ id, contentHash, src, mimeType = "image/webp", byteSize, originalByteSize, width, height }) { const createdAt = now() await query( 'INSERT INTO image_assets (id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', [id, contentHash, src, mimeType, byteSize, originalByteSize, width, height, createdAt] ) return findImageAssetByHash(contentHash) } async function createImageOptimizationJob({ id, sourceCategory, targetDirectory, originalByteSize }) { const queuedAt = now() await query( 'INSERT INTO image_optimization_jobs (id, status, source_category, target_directory, original_byte_size, optimized_byte_size, reused_asset, error_message, queued_at, started_at, finished_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [id, 'queued', sourceCategory || '', targetDirectory || '', originalByteSize || 0, 0, 0, '', queuedAt, 0, 0] ) return findImageOptimizationJobById(id) } async function findImageOptimizationJobById(id) { const rows = await query( 'SELECT id, status, source_category, target_directory, original_byte_size, optimized_byte_size, reused_asset, error_message, queued_at, started_at, finished_at FROM image_optimization_jobs WHERE id = ? LIMIT 1', [id] ) return mapImageOptimizationJobRow(rows[0]) } async function updateImageOptimizationJobStatus({ id, status, optimizedByteSize = 0, reusedAsset = false, errorMessage = '', startedAt, finishedAt }) { const fields = ['status = ?', 'optimized_byte_size = ?', 'reused_asset = ?', 'error_message = ?'] const params = [status, optimizedByteSize, reusedAsset ? 1 : 0, errorMessage.slice(0, 255)] if (typeof startedAt === 'number') { fields.push('started_at = ?') params.push(startedAt) } if (typeof finishedAt === 'number') { fields.push('finished_at = ?') params.push(finishedAt) } params.push(id) await query(`UPDATE image_optimization_jobs SET ${fields.join(', ')} WHERE id = ?`, params) return findImageOptimizationJobById(id) } async function listRecentImageOptimizationJobs(limit = 20, { month } = {}) { const safeLimit = Math.max(1, Math.min(100, Number(limit) || 20)) const range = resolveMonthRange(month) const where = [] const params = [] if (range) { where.push('queued_at >= ? AND queued_at < ?') params.push(range.start, range.end) } const rows = await query( `SELECT id, status, source_category, target_directory, original_byte_size, optimized_byte_size, reused_asset, error_message, queued_at, started_at, finished_at FROM image_optimization_jobs ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY queued_at DESC LIMIT ${safeLimit}`, params ) return rows.map(mapImageOptimizationJobRow) } async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) { const safeLimit = Math.max(1, Math.min(500, Number(limit) || 100)) const safeMinAgeHours = Math.max(0, Number(minAgeHours) || 24) const cutoff = now() - safeMinAgeHours * 60 * 60 * 1000 const assets = (await query( `SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE created_at <= ? ORDER BY created_at ASC LIMIT ${safeLimit}`, [cutoff] )).map(mapImageAssetRow) if (!assets.length) return [] const referencedSrcs = new Set() 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 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"), ]) for (const row of userRows) if (row.avatar_src) referencedSrcs.add(row.avatar_src) for (const row of gameRows) if (row.thumbnail_src) referencedSrcs.add(row.thumbnail_src) for (const row of gameItemRows) if (row.src) referencedSrcs.add(row.src) for (const row of customItemRows) if (row.src) referencedSrcs.add(row.src) for (const row of tierListRows) { if (row.thumbnail_src) referencedSrcs.add(row.thumbnail_src) collectUploadSrcsFromItems(parseJson(row.pool_json, []), referencedSrcs) } for (const row of templateRequestRows) { if (row.thumbnail_src_snapshot) referencedSrcs.add(row.thumbnail_src_snapshot) collectUploadSrcsFromItems(parseJson(row.items_json, []), referencedSrcs) collectUploadSrcsFromItems(parseJson(row.board_items_json, []), referencedSrcs) } return assets.filter((asset) => !referencedSrcs.has(asset.src)) } async function deleteImageAssets(ids) { const uniqueIds = Array.from(new Set((ids || []).filter(Boolean))) if (!uniqueIds.length) return [] const placeholders = uniqueIds.map(() => '?').join(', ') const rows = await query( `SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id IN (${placeholders})`, uniqueIds ) await query(`DELETE FROM image_assets WHERE id IN (${placeholders})`, uniqueIds) return rows.map(mapImageAssetRow) } async function listReferencedUploadSources() { const usage = await listReferencedUploadUsage() return usage.map((entry) => entry.src) } async function listReferencedUploadUsage() { const usageMap = new Map() const addUsage = (src, role) => { if (typeof src !== 'string' || !src.startsWith('/uploads/')) return if (!usageMap.has(src)) usageMap.set(src, new Set()) usageMap.get(src).add(role) } 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 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"), ]) for (const row of userRows) addUsage(row.avatar_src, 'avatar') for (const row of gameRows) addUsage(row.thumbnail_src, 'game-thumbnail') for (const row of gameItemRows) addUsage(row.src, 'game-item') for (const row of customItemRows) addUsage(row.src, 'custom-item') for (const row of tierListRows) { addUsage(row.thumbnail_src, 'tierlist-thumbnail') for (const item of parseJson(row.pool_json, [])) addUsage(item?.src, 'tierlist-pool') } for (const row of templateRequestRows) { addUsage(row.thumbnail_src_snapshot, 'template-thumbnail') for (const item of parseJson(row.items_json, [])) addUsage(item?.src, 'template-item') for (const item of parseJson(row.board_items_json, [])) addUsage(item?.src, 'template-board-item') } return Array.from(usageMap.entries()) .map(([src, roles]) => ({ src, roles: Array.from(roles).sort() })) .sort((a, b) => a.src.localeCompare(b.src)) } function replaceItemSrc(items, fromSrc, toSrc) { let changed = false const nextItems = (items || []).map((item) => { if (item?.src !== fromSrc) return item changed = true return { ...item, src: toSrc } }) return { changed, items: nextItems } } async function replaceUploadSourceReferences({ fromSrc, toSrc }) { if (!fromSrc || !toSrc || fromSrc === toSrc) return { updatedRows: 0 } 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 custom_items SET src = ? WHERE src = ?', [toSrc, fromSrc]), ]) let updatedRows = Number(userResult.affectedRows || 0) + Number(gameResult.affectedRows || 0) + Number(gameItemResult.affectedRows || 0) + Number(customItemResult.affectedRows || 0) const tierListRows = await query('SELECT id, thumbnail_src, pool_json FROM tierlists') for (const row of tierListRows) { let nextThumbnail = row.thumbnail_src let changed = false if (row.thumbnail_src === fromSrc) { nextThumbnail = toSrc changed = true } const replacedPool = replaceItemSrc(parseJson(row.pool_json, []), fromSrc, toSrc) if (replacedPool.changed) changed = true if (changed) { await query('UPDATE tierlists SET thumbnail_src = ?, pool_json = ?, updated_at = ? WHERE id = ?', [ nextThumbnail || '', serializeJson(replacedPool.items), now(), row.id, ]) updatedRows += 1 } } const requestRows = await query('SELECT id, thumbnail_src_snapshot, items_json, board_items_json FROM template_requests') for (const row of requestRows) { let nextThumbnail = row.thumbnail_src_snapshot let changed = false if (row.thumbnail_src_snapshot === fromSrc) { nextThumbnail = toSrc changed = true } const replacedItems = replaceItemSrc(parseJson(row.items_json, []), fromSrc, toSrc) const replacedBoardItems = replaceItemSrc(parseJson(row.board_items_json, []), fromSrc, toSrc) if (replacedItems.changed || replacedBoardItems.changed) changed = true if (changed) { await query('UPDATE template_requests SET thumbnail_src_snapshot = ?, items_json = ?, board_items_json = ?, updated_at = ? WHERE id = ?', [ nextThumbnail || '', serializeJson(replacedItems.items), serializeJson(replacedBoardItems.items), now(), row.id, ]) updatedRows += 1 } } return { updatedRows } } async function listImageAssets() { const rows = await query( 'SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets ORDER BY created_at DESC' ) return rows.map(mapImageAssetRow) } async function findImageAssetById(id) { const rows = await query( 'SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id = ? LIMIT 1', [id] ) return mapImageAssetRow(rows[0]) } async function getReferencedUploadFootprint() { const [referencedSrcs, assets] = await Promise.all([listReferencedUploadSources(), listImageAssets()]) const assetMap = new Map(assets.map((asset) => [asset.src, asset])) let totalReferencedByteSize = 0 let trackedReferencedByteSize = 0 let legacyReferencedByteSize = 0 let trackedReferencedCount = 0 let legacyReferencedCount = 0 let missingCount = 0 for (const src of referencedSrcs) { if (typeof src !== 'string' || !src.startsWith('/uploads/')) continue const absolutePath = path.join(__dirname, '..', src.replace(/^\//, '')) try { const stat = await fs.stat(absolutePath) const size = Number(stat.size || 0) totalReferencedByteSize += size if (assetMap.has(src)) { trackedReferencedCount += 1 trackedReferencedByteSize += size } else { legacyReferencedCount += 1 legacyReferencedByteSize += size } } catch (error) { if (error?.code === 'ENOENT') missingCount += 1 } } return { referencedCount: referencedSrcs.length, totalReferencedByteSize, trackedReferencedCount, trackedReferencedByteSize, legacyReferencedCount, legacyReferencedByteSize, missingCount, } } async function getImageAssetStats({ month } = {}) { const range = resolveMonthRange(month) const jobWhere = [] const jobParams = [] if (range) { jobWhere.push('queued_at >= ? AND queued_at < ?') jobParams.push(range.start, range.end) } const [assetRows, jobRows, footprint] = await Promise.all([ query( `SELECT COUNT(*) AS asset_count, COALESCE(SUM(byte_size), 0) AS total_byte_size, COALESCE(SUM(original_byte_size), 0) AS total_original_byte_size FROM image_assets` ), query( `SELECT COALESCE(SUM(CASE WHEN status = 'queued' THEN 1 ELSE 0 END), 0) AS queued_count, COALESCE(SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END), 0) AS processing_count, COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0) AS completed_count, COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0) AS failed_count, COALESCE(SUM(CASE WHEN status = 'completed' AND reused_asset = 1 THEN 1 ELSE 0 END), 0) AS reused_count FROM image_optimization_jobs ${jobWhere.length ? `WHERE ${jobWhere.join(' AND ')}` : ''}`, jobParams ), getReferencedUploadFootprint(), ]) const asset = assetRows[0] || {} const jobs = jobRows[0] || {} const totalByteSize = Number(asset.total_byte_size || 0) const totalOriginalByteSize = Number(asset.total_original_byte_size || 0) const savedByteSize = Math.max(0, totalOriginalByteSize - totalByteSize) return { assetCount: Number(asset.asset_count || 0), totalByteSize, totalOriginalByteSize, savedByteSize, savingsRatio: totalOriginalByteSize > 0 ? savedByteSize / totalOriginalByteSize : 0, referencedCount: Number(footprint.referencedCount || 0), referencedByteSize: Number(footprint.totalReferencedByteSize || 0), trackedReferencedCount: Number(footprint.trackedReferencedCount || 0), trackedReferencedByteSize: Number(footprint.trackedReferencedByteSize || 0), legacyReferencedCount: Number(footprint.legacyReferencedCount || 0), legacyReferencedByteSize: Number(footprint.legacyReferencedByteSize || 0), missingReferencedCount: Number(footprint.missingCount || 0), queuedCount: Number(jobs.queued_count || 0), processingCount: Number(jobs.processing_count || 0), completedCount: Number(jobs.completed_count || 0), failedCount: Number(jobs.failed_count || 0), reusedCount: Number(jobs.reused_count || 0), } } async function clearImageOptimizationJobs({ month } = {}) { const range = resolveMonthRange(month) if (range) { const result = await query('DELETE FROM image_optimization_jobs WHERE queued_at >= ? AND queued_at < ?', [range.start, range.end]) return Number(result.affectedRows || 0) } const result = await query('DELETE FROM image_optimization_jobs') return Number(result.affectedRows || 0) } 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 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 (?, ?, ?, ?, ?, ?)', [ 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]) 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]) return mapGameItemRow(rows[0]) } async function updateGameItemDisplayOrder(gameId, itemIds) { const normalizedIds = Array.from(new Set((itemIds || []).filter(Boolean))) const existingItems = await listGameItems(gameId) const existingIdSet = new Set(existingItems.map((item) => item.id)) const orderedIds = normalizedIds.filter((id) => existingIdSet.has(id)) const remainingIds = existingItems.map((item) => item.id).filter((id) => !orderedIds.includes(id)) const finalIds = [...orderedIds, ...remainingIds] await Promise.all( finalIds.map((itemId, index) => query('UPDATE game_items SET display_order = ? WHERE id = ? AND game_id = ?', [index + 1, itemId, gameId])) ) return listGameItems(gameId) } async function updateCustomItemLabel(itemId, label) { await query('UPDATE custom_items SET label = ? WHERE id = ?', [label, itemId]) 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 WHERE c.id = ? LIMIT 1 `, [itemId]) 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), ownerName: row.nickname || row.email, ownerEmail: row.email, } } async function updateImageAssetLabel(assetId, label) { await query('UPDATE image_assets SET label_override = ? WHERE id = ?', [label, assetId]) const rows = await query('SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id = ? LIMIT 1', [assetId]) return mapImageAssetRow(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 getCustomItemUsageMeta() { const rows = await query( ` SELECT t.game_id, g.name AS game_name, t.groups_json, t.pool_json FROM tierlists t LEFT JOIN games g ON g.id = t.game_id ` ) const usageMap = new Map() const linkedGamesMap = new Map() rows.forEach((row) => { const groups = parseJson(row.groups_json, []) const pool = parseJson(row.pool_json, []) const seenItemIds = new Set() groups.forEach((group) => { ;(group?.itemIds || []).forEach((itemId) => { usageMap.set(itemId, (usageMap.get(itemId) || 0) + 1) if (itemId) seenItemIds.add(itemId) }) }) pool.forEach((item) => { if (item?.id) { usageMap.set(item.id, (usageMap.get(item.id) || 0) + 1) seenItemIds.add(item.id) } }) if (!row.game_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, }) }) }) return { usageMap, linkedGamesMap: new Map(Array.from(linkedGamesMap.entries()).map(([itemId, gameMap]) => [itemId, Array.from(gameMap.values())])), } } 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 searchText = (queryText || '').trim() const hasQuery = !!searchText const search = `%${searchText}%` const [customRows, gameItemRows, assetRows, usageMeta] = await Promise.all([ 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 ${hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : ''} ORDER BY c.created_at DESC `, hasQuery ? [search, search, search, search] : [] ), query( ` SELECT gi.id, gi.game_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 ?' : ''} ORDER BY gi.created_at DESC `, hasQuery ? [search, search, search, search] : [] ), query( ` SELECT ia.id, ia.src, ia.label_override, ia.created_at FROM image_assets ia WHERE ia.src LIKE '/uploads/assets/%' ${hasQuery ? 'AND ia.src LIKE ?' : ''} ORDER BY ia.created_at DESC `, hasQuery ? [search] : [] ), getCustomItemUsageMeta(), ]) const templateLinkedBySrc = new Map() 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, }) }) const customItems = customRows.map((row) => { const linkedGames = Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()) return { 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: usageMeta.usageMap.get(row.id) || 0, linkedGames, sourceType: 'user', sourceLabel: '사용자 업로드', canDelete: true, } }) const templateSrcSet = new Set(gameItemRows.map((row) => row.src).filter(Boolean)) const customSrcSet = new Set(customRows.map((row) => row.src).filter(Boolean)) const assetLibraryItems = assetRows .filter((row) => row?.src && !templateSrcSet.has(row.src) && !customSrcSet.has(row.src)) .map((row) => ({ id: `asset:${row.id}`, assetId: row.id, ownerId: '', src: row.src, label: row.label_override || (row.src.split('/').pop() || '').replace(/\.[^.]+$/, '') || '이름 없음', createdAt: Number(row.created_at || 0), ownerName: '관리자 보관 자산', ownerEmail: '', usageCount: 0, linkedGames: [], sourceType: 'template', sourceLabel: '관리자 템플릿', canDelete: true, sourceGameId: '', sourceGameName: '', isAssetLibraryItem: true, })) const templateItems = gameItemRows.map((row) => ({ id: row.id, ownerId: '', src: row.src, label: row.label, createdAt: Number(row.created_at), ownerName: row.game_name || row.game_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, })) const allItems = [...customItems, ...templateItems, ...assetLibraryItems] .filter((item) => { if (!orphanOnly) return true return item.sourceType === 'user' && item.usageCount === 0 && item.linkedGames.length === 0 }) .sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0)) 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 getCustomItemUsageMeta() 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.source_tierlist_id, t.source_snapshot_title, t.source_snapshot_author, 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.source_tierlist_id, t.source_snapshot_title, t.source_snapshot_author, 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.source_tierlist_id, t.source_snapshot_title, t.source_snapshot_author, 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 = [], groups = [], boardItems = [], showCharacterNames = false, }) { if (sourceTierListId) { 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, groups_json, board_items_json, show_character_names_snapshot, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ id, type, requesterId, sourceTierListId || null, sourceGameId, targetGameId, title, description, thumbnailSrc, serializeJson(items), serializeJson(groups), serializeJson(boardItems), showCharacterNames ? 1 : 0, 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.groups_json, tr.board_items_json, tr.show_character_names_snapshot, 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', statuses = [] } = {}) { const requestedStatuses = Array.isArray(statuses) && statuses.length ? statuses : [status] const validStatuses = requestedStatuses.filter((entry) => typeof entry === 'string' && entry.trim()) const normalizedStatuses = validStatuses.length ? validStatuses : ['pending'] const placeholders = normalizedStatuses.map(() => '?').join(', ') 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.groups_json, tr.board_items_json, tr.show_character_names_snapshot, 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 IN (${placeholders}) ORDER BY CASE tr.status WHEN 'pending' THEN 0 WHEN 'reviewing' THEN 1 ELSE 2 END, tr.created_at DESC `, normalizedStatuses ) 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, sourceTierListId = '', sourceSnapshotTitle = '', sourceSnapshotAuthor = '', 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 = ?, source_tierlist_id = ?, source_snapshot_title = ?, source_snapshot_author = ?, groups_json = ?, pool_json = ?, updated_at = ? WHERE id = ? `, [title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), now(), existing.id] ) return findTierListById(existing.id, authorId) } const nextId = id || nanoid() const createdAt = now() await query( ` INSERT INTO tierlists ( id, author_id, game_id, title, thumbnail_src, description, is_public, show_character_names, 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, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), createdAt, createdAt] ) return findTierListById(nextId, authorId) } async function duplicateTierListForUser({ tierList, targetUserId }) { const { nanoid } = require('nanoid') const duplicateId = nanoid() const baseTitle = (tierList.title || '티어표').trim() || '티어표' const copyTitle = baseTitle.endsWith(' 복사본') ? baseTitle : `${baseTitle} 복사본` return saveTierList({ id: duplicateId, authorId: targetUserId, gameId: tierList.gameId, title: copyTitle, thumbnailSrc: tierList.thumbnailSrc || '', description: tierList.description || '', isPublic: false, showCharacterNames: !!tierList.showCharacterNames, sourceTierListId: tierList.id, sourceSnapshotTitle: tierList.title || '', sourceSnapshotAuthor: tierList.authorName || tierList.authorAccountName || '', groups: JSON.parse(JSON.stringify(tierList.groups || [])), pool: JSON.parse(JSON.stringify(tierList.pool || [])), }) } 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]) } async function favoriteGame({ userId, gameId }) { await query('INSERT IGNORE INTO favorite_games (user_id, game_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]) } module.exports = { DB_NAME, ensureData, closePool, countUsers, findUserByEmail, findUserById, createUser, updateUserProfile, findPrimaryAdminUser, listUsers, adminUpdateUser, adminUpdateUserPassword, adminDeleteUser, listGames, findGameById, listGameItems, findGameItemById, getGameDetail, createGame, updateGameThumbnail, findImageAssetByHash, findImageAssetBySrc, findImageAssetById, createImageAsset, createImageOptimizationJob, findImageOptimizationJobById, updateImageOptimizationJobStatus, listRecentImageOptimizationJobs, listUnusedImageAssets, deleteImageAssets, listReferencedUploadSources, listReferencedUploadUsage, replaceUploadSourceReferences, clearImageOptimizationJobs, getImageAssetStats, createGameItem, updateGameItemLabel, updateGameItemDisplayOrder, updateCustomItemLabel, updateImageAssetLabel, deleteGameItem, deleteGame, updateGameDisplayOrder, createCustomItem, findCustomItemById, listCustomItems, findUnusedCustomItems, listPublicTierLists, listFavoriteTierLists, listUserTierLists, listAdminTierLists, findTierListById, favoriteTierList, unfavoriteTierList, favoriteGame, unfavoriteGame, deleteTierList, findCustomItemsByIds, deleteCustomItems, saveTierList, duplicateTierListForUser, createTemplateRequest, findTemplateRequestById, listAdminTemplateRequests, updateTemplateRequestStatus, }