const fs = require('fs/promises') const path = require('path') const mysql = require('mysql2/promise') const { nanoid } = require('nanoid') 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_TOPIC_ID = 'freeform' const TOPIC_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ 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 normalizeTags(tags) { const values = Array.isArray(tags) ? tags : typeof tags === 'string' ? tags.split(',') : [] return Array.from( new Set( values .map((tag) => String(tag || '').trim().replace(/^#/, '').slice(0, 40)) .filter(Boolean) ) ).slice(0, 30) } function normalizeTopicSlug(value) { return String(value || '').trim().toLowerCase() } function assertTopicSlug(slug) { const normalized = normalizeTopicSlug(slug) if (!normalized || normalized.length > 120 || !TOPIC_SLUG_PATTERN.test(normalized)) { const err = new Error('topic_slug_invalid') err.code = 'TOPIC_SLUG_INVALID' throw err } return normalized } 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 || '', nicknameUpdatedAt: Number(row.nickname_updated_at || 0), emailVerified: row.email_verified == null ? true : !!row.email_verified, isAdmin: !!row.is_admin, avatarSrc: row.avatar_src || '', createdAt: Number(row.created_at), tierListCount: Number(row.tierlist_count || 0), followerCount: Number(row.follower_count || 0), receivedFavoriteCount: Number(row.received_favorite_count || 0), lastLoginAt: Number(row.last_login_at || 0), recentActivityAt: Number(row.recent_activity_at || row.created_at || 0), } } function mapTopicRow(row) { if (!row) return null return { id: row.id, slug: row.slug || row.id, name: row.name, topicId: row.id, topicSlug: row.slug || row.id, topicName: row.name, thumbnailSrc: row.thumbnail_src || '', isPublic: row.is_public == null ? true : !!row.is_public, displayRank: row.display_rank == null ? null : Number(row.display_rank), createdAt: Number(row.created_at), tags: normalizeTags(parseJson(row.tags_json, [])), } } function mapTopicItemRow(row) { if (!row) return null return { id: row.id, topicId: row.topic_id, src: row.src, label: row.label, displayOrder: row.display_order == null ? null : Number(row.display_order), createdAt: Number(row.created_at), tags: normalizeTags(parseJson(row.tags_json, [])), } } function mapCustomItemRow(row) { if (!row) return null return { id: row.id, ownerId: row.owner_id, src: row.src, label: row.label, createdAt: Number(row.created_at), replacedByItemId: row.replaced_by_item_id || '', replacedBySrc: row.replaced_by_src || '', replacedByLabel: row.replaced_by_label || '', replacedAt: Number(row.replaced_at || 0), tags: normalizeTags(parseJson(row.tags_json, [])), } } function getSharedItemDisplayPriority(item) { if (!item) return 99 if (item.sourceType === 'user' && !item.replacedAt) return 0 if (item.sourceType === 'user') return 1 if (item.sourceType === 'template') return 2 if (item.sourceType === 'asset' || item.isAssetLibraryItem) return 3 return 4 } function collapseSharedLibraryItems(items) { const grouped = new Map() for (const item of items || []) { const key = String(item?.src || '').trim() if (!key) continue if (!grouped.has(key)) grouped.set(key, []) grouped.get(key).push(item) } return Array.from(grouped.values()) .map((group) => group .slice() .sort((a, b) => { const priorityDiff = getSharedItemDisplayPriority(a) - getSharedItemDisplayPriority(b) if (priorityDiff !== 0) return priorityDiff return Number(b.createdAt || 0) - Number(a.createdAt || 0) })[0] ) .sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0)) } 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 || '', topicId: row.topic_id, topicSlug: row.topic_slug || row.topic_id, topicName: row.topic_name || '', title: row.title, thumbnailSrc: row.thumbnail_src || '', description: row.description || '', isPublic: !!row.is_public, isFeatured: !!row.is_featured, featuredAt: Number(row.featured_at || 0), featuredBy: row.featured_by || '', showCharacterNames: !!row.show_character_names, iconSize: Number(row.icon_size || 80), 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 mapTierListCommentRow(row) { if (!row) return null return { id: row.id, tierListId: row.tierlist_id, parentCommentId: row.parent_comment_id || '', authorId: row.author_id, authorName: getUserDisplayName(row), authorAccountName: getUserAccountName(row), authorAvatarSrc: row.avatar_src || '', content: row.content || '', createdAt: Number(row.created_at || 0), updatedAt: Number(row.updated_at || 0), replies: [], } } function mapCommentNotificationRow(row) { if (!row) return null return { id: row.id, userId: row.user_id, tierListId: row.tierlist_id, topicId: row.topic_id, topicSlug: row.topic_slug || row.topic_id, topicName: row.topic_name || '', tierListTitle: row.tierlist_title || '', tierListThumbnailSrc: row.tierlist_thumbnail_src || '', commentId: row.comment_id, parentCommentId: row.parent_comment_id || '', parentCommentContent: row.parent_comment_content || '', parentCommentCreatedAt: Number(row.parent_comment_created_at || 0), parentAuthorName: getUserDisplayName({ nickname: row.parent_author_nickname, email: row.parent_author_email, }), parentAuthorAccountName: getUserAccountName({ email: row.parent_author_email, }), parentAuthorAvatarSrc: row.parent_author_avatar_src || '', notificationType: row.notification_type || 'tierlist_comment', isRead: !!row.is_read, readAt: Number(row.read_at || 0), createdAt: Number(row.created_at || 0), actorId: row.actor_user_id, actorName: getUserDisplayName({ nickname: row.actor_nickname, email: row.actor_email, }), actorAccountName: getUserAccountName({ email: row.actor_email, }), actorAvatarSrc: row.actor_avatar_src || '', commentContent: row.comment_content || '', } } 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 || '', sourceTopicId: row.source_topic_id, sourceTopicSlug: row.source_topic_slug || row.source_topic_id, sourceTopicName: row.source_topic_name || '', sourceTierListTitle: row.title_snapshot || '', sourceDescription: row.description_snapshot || '', thumbnailSrc: row.thumbnail_src_snapshot || '', targetTopicId: row.target_topic_id || '', targetTopicSlug: row.target_topic_slug || row.target_topic_id || '', targetTopicName: row.target_topic_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 } function getAssetLibrarySourceLabel(src) { const normalizedSrc = String(src || '').trim() if (normalizedSrc.includes('/uploads/assets/avatars/')) return '프로필 아바타' if (normalizedSrc.includes('/uploads/assets/tierlists/')) return '티어표 썸네일' if (normalizedSrc.includes('/uploads/assets/topics/')) return '썸네일 이미지' return '보관 자산' } function getAssetLibraryKind(src) { const normalizedSrc = String(src || '').trim() if (normalizedSrc.includes('/uploads/assets/avatars/')) return 'avatar' if (normalizedSrc.includes('/uploads/assets/tierlists/') || normalizedSrc.includes('/uploads/assets/topics/')) return 'thumbnail' return 'asset' } 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 '', nickname_updated_at BIGINT NOT NULL DEFAULT 0, password_hash VARCHAR(255) NOT NULL, email_verified TINYINT(1) NOT NULL DEFAULT 1, is_admin TINYINT(1) NOT NULL DEFAULT 0, avatar_src VARCHAR(255) NOT NULL DEFAULT '', last_login_at BIGINT NOT NULL DEFAULT 0, created_at BIGINT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) await query(` CREATE TABLE IF NOT EXISTS email_verification_tokens ( id VARCHAR(64) PRIMARY KEY, user_id VARCHAR(64) NOT NULL, token_hash CHAR(64) NOT NULL UNIQUE, expires_at BIGINT NOT NULL, consumed_at BIGINT NOT NULL DEFAULT 0, created_at BIGINT NOT NULL, INDEX idx_email_verification_user (user_id, consumed_at, expires_at), INDEX idx_email_verification_expires (expires_at), CONSTRAINT fk_email_verification_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) await query(` CREATE TABLE IF NOT EXISTS password_reset_tokens ( id VARCHAR(64) PRIMARY KEY, user_id VARCHAR(64) NOT NULL, token_hash CHAR(64) NOT NULL UNIQUE, expires_at BIGINT NOT NULL, consumed_at BIGINT NOT NULL DEFAULT 0, created_at BIGINT NOT NULL, INDEX idx_password_reset_user (user_id, consumed_at, expires_at), INDEX idx_password_reset_expires (expires_at), CONSTRAINT fk_password_reset_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) await query(` CREATE TABLE IF NOT EXISTS topics ( id VARCHAR(120) PRIMARY KEY, slug VARCHAR(120) NOT NULL, name VARCHAR(120) NOT NULL, thumbnail_src VARCHAR(255) NOT NULL DEFAULT '', tags_json LONGTEXT NULL, is_public TINYINT(1) NOT NULL DEFAULT 1, display_rank INT NULL DEFAULT NULL, created_at BIGINT NOT NULL, UNIQUE KEY uq_topics_slug (slug) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) await query(` CREATE TABLE IF NOT EXISTS topic_items ( id VARCHAR(64) PRIMARY KEY, topic_id VARCHAR(120) NOT NULL, src VARCHAR(255) NOT NULL, label VARCHAR(120) NOT NULL, tags_json LONGTEXT NULL, display_order INT NULL DEFAULT NULL, created_at BIGINT NOT NULL, INDEX idx_topic_items_topic_id (topic_id), CONSTRAINT fk_topic_items_topic FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) 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, tags_json LONGTEXT NULL, replaced_by_item_id VARCHAR(64) NOT NULL DEFAULT '', replaced_by_src VARCHAR(255) NOT NULL DEFAULT '', replaced_by_label VARCHAR(120) NOT NULL DEFAULT '', replaced_at BIGINT NOT NULL DEFAULT 0, 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(`ALTER TABLE users ADD COLUMN IF NOT EXISTS nickname_updated_at BIGINT NOT NULL DEFAULT 0 AFTER nickname`) await query(`UPDATE users SET nickname_updated_at = created_at WHERE nickname_updated_at = 0`) await query(`ALTER TABLE topics ADD COLUMN IF NOT EXISTS tags_json LONGTEXT NULL AFTER thumbnail_src`) await query(`ALTER TABLE topic_items ADD COLUMN IF NOT EXISTS tags_json LONGTEXT NULL AFTER label`) await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS tags_json LONGTEXT NULL AFTER label`) await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS replaced_by_item_id VARCHAR(64) NOT NULL DEFAULT '' AFTER label`) await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS replaced_by_src VARCHAR(255) NOT NULL DEFAULT '' AFTER replaced_by_item_id`) await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS replaced_by_label VARCHAR(120) NOT NULL DEFAULT '' AFTER replaced_by_src`) await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS replaced_at BIGINT NOT NULL DEFAULT 0 AFTER replaced_by_label`) await query(` CREATE TABLE IF NOT EXISTS tierlists ( id VARCHAR(64) PRIMARY KEY, author_id VARCHAR(64) NOT NULL, topic_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, is_featured TINYINT(1) NOT NULL DEFAULT 0, featured_at BIGINT NOT NULL DEFAULT 0, featured_by VARCHAR(64) NOT NULL DEFAULT '', show_character_names TINYINT(1) NOT NULL DEFAULT 0, icon_size INT NOT NULL DEFAULT 80, 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_topic_id (topic_id), INDEX idx_tierlists_public_topic_updated (is_public, topic_id, updated_at), INDEX idx_tierlists_featured_topic (is_public, is_featured, topic_id, featured_at), CONSTRAINT fk_tierlists_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT fk_tierlists_topic FOREIGN KEY (topic_id) REFERENCES topics(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_topics ( user_id VARCHAR(64) NOT NULL, topic_id VARCHAR(120) NOT NULL, created_at BIGINT NOT NULL, PRIMARY KEY (user_id, topic_id), INDEX idx_favorite_topics_topic_id (topic_id), CONSTRAINT fk_favorite_topics_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT fk_favorite_topics_topic FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) await query(` CREATE TABLE IF NOT EXISTS tierlist_comments ( id VARCHAR(64) PRIMARY KEY, tierlist_id VARCHAR(64) NOT NULL, author_id VARCHAR(64) NOT NULL, parent_comment_id VARCHAR(64) NULL DEFAULT NULL, content TEXT NOT NULL, created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL, INDEX idx_tierlist_comments_tierlist_created (tierlist_id, created_at), INDEX idx_tierlist_comments_parent (parent_comment_id), CONSTRAINT fk_tierlist_comments_tierlist FOREIGN KEY (tierlist_id) REFERENCES tierlists(id) ON DELETE CASCADE, CONSTRAINT fk_tierlist_comments_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT fk_tierlist_comments_parent FOREIGN KEY (parent_comment_id) REFERENCES tierlist_comments(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) await query(` CREATE TABLE IF NOT EXISTS comment_notifications ( id VARCHAR(64) PRIMARY KEY, user_id VARCHAR(64) NOT NULL, tierlist_id VARCHAR(64) NOT NULL, comment_id VARCHAR(64) NOT NULL, actor_user_id VARCHAR(64) NOT NULL, notification_type VARCHAR(32) NOT NULL, is_read TINYINT(1) NOT NULL DEFAULT 0, read_at BIGINT NOT NULL DEFAULT 0, created_at BIGINT NOT NULL, INDEX idx_comment_notifications_user_read_created (user_id, is_read, created_at), INDEX idx_comment_notifications_comment (comment_id), CONSTRAINT fk_comment_notifications_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT fk_comment_notifications_tierlist FOREIGN KEY (tierlist_id) REFERENCES tierlists(id) ON DELETE CASCADE, CONSTRAINT fk_comment_notifications_comment FOREIGN KEY (comment_id) REFERENCES tierlist_comments(id) ON DELETE CASCADE, CONSTRAINT fk_comment_notifications_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) await query(`ALTER TABLE tierlist_comments ADD COLUMN IF NOT EXISTS parent_comment_id VARCHAR(64) NULL DEFAULT NULL AFTER author_id`) await query(`ALTER TABLE tierlist_comments ADD COLUMN IF NOT EXISTS updated_at BIGINT NOT NULL DEFAULT 0 AFTER created_at`) await query(`ALTER TABLE comment_notifications ADD COLUMN IF NOT EXISTS actor_user_id VARCHAR(64) NOT NULL DEFAULT '' AFTER comment_id`) await query(`ALTER TABLE comment_notifications ADD COLUMN IF NOT EXISTS notification_type VARCHAR(32) NOT NULL DEFAULT 'tierlist_comment' AFTER actor_user_id`) await query(`ALTER TABLE comment_notifications ADD COLUMN IF NOT EXISTS is_read TINYINT(1) NOT NULL DEFAULT 0 AFTER notification_type`) await query(`ALTER TABLE comment_notifications ADD COLUMN IF NOT EXISTS read_at BIGINT NOT NULL DEFAULT 0 AFTER is_read`) await query(`CREATE INDEX IF NOT EXISTS idx_comment_notifications_user_read_created ON comment_notifications (user_id, is_read, created_at)`) await query(`CREATE INDEX IF NOT EXISTS idx_comment_notifications_comment ON comment_notifications (comment_id)`) await query(` CREATE TABLE IF NOT EXISTS user_follows ( follower_id VARCHAR(64) NOT NULL, following_id VARCHAR(64) NOT NULL, created_at BIGINT NOT NULL, PRIMARY KEY (follower_id, following_id), INDEX idx_user_follows_following (following_id, created_at), CONSTRAINT fk_user_follows_follower FOREIGN KEY (follower_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT fk_user_follows_following FOREIGN KEY (following_id) REFERENCES users(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 `) 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) NULL DEFAULT NULL, source_topic_id VARCHAR(120) NOT NULL, target_topic_id VARCHAR(120) NOT NULL DEFAULT '', status VARCHAR(20) NOT NULL DEFAULT 'pending', title_snapshot VARCHAR(120) NOT NULL, description_snapshot TEXT NOT NULL, thumbnail_src_snapshot VARCHAR(255) NOT NULL DEFAULT '', items_json LONGTEXT NOT NULL, groups_json LONGTEXT NOT NULL, board_items_json LONGTEXT NOT NULL, show_character_names_snapshot TINYINT(1) NOT NULL DEFAULT 0, 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 `) await query( ` INSERT INTO topics (id, slug, name, thumbnail_src, tags_json, is_public, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE slug = VALUES(slug), name = VALUES(name), tags_json = VALUES(tags_json), is_public = VALUES(is_public) `, [FREEFORM_TOPIC_ID, FREEFORM_TOPIC_ID, '직접 티어표 만들기', '', '[]', 1, now()] ) })() 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, nickname_updated_at, password_hash, email_verified, is_admin, avatar_src, last_login_at, 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 findUserByNickname(nickname, excludeUserId = '') { const normalized = String(nickname || '').trim() if (!normalized) return null const rows = excludeUserId ? await query( ` SELECT id, email, nickname, nickname_updated_at, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?)) AND id <> ? LIMIT 1 `, [normalized, excludeUserId] ) : await query( ` SELECT id, email, nickname, nickname_updated_at, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?)) LIMIT 1 `, [normalized] ) 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, nickname_updated_at, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE id = ? LIMIT 1', [id] ) return mapUserRow(rows[0]) } async function createUser({ id, email, nickname, passwordHash, emailVerified = true, isAdmin }) { const createdAt = now() await query( ` INSERT INTO users (id, email, nickname, nickname_updated_at, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [id, email, nickname || '', createdAt, passwordHash, emailVerified ? 1 : 0, isAdmin ? 1 : 0, '', isAdmin ? createdAt : 0, createdAt] ) return findUserById(id) } async function touchUserLastLoginAt(userId, timestamp = now()) { const lastLoginAt = Number(timestamp) || now() const staleBefore = lastLoginAt - 10 * 60 * 1000 await query( 'UPDATE users SET last_login_at = ? WHERE id = ? AND last_login_at < ?', [lastLoginAt, userId, staleBefore] ) return findUserById(userId) } async function updateUserPassword({ id, passwordHash }) { await query('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, id]) return findUserById(id) } async function verifyUserEmail(userId) { await query('UPDATE users SET email_verified = 1 WHERE id = ?', [userId]) return findUserById(userId) } async function createEmailVerificationToken({ id, userId, tokenHash, expiresAt }) { const createdAt = now() await query('UPDATE email_verification_tokens SET consumed_at = ? WHERE user_id = ? AND consumed_at = 0', [createdAt, userId]) await query( ` INSERT INTO email_verification_tokens (id, user_id, token_hash, expires_at, consumed_at, created_at) VALUES (?, ?, ?, ?, ?, ?) `, [id, userId, tokenHash, expiresAt, 0, createdAt] ) return { id, userId, tokenHash, expiresAt, consumedAt: 0, createdAt, } } async function findEmailVerificationTokenByHash(tokenHash) { const rows = await query( ` SELECT id, user_id, token_hash, expires_at, consumed_at, created_at FROM email_verification_tokens WHERE token_hash = ? LIMIT 1 `, [tokenHash] ) const row = rows[0] if (!row) return null return { id: row.id, userId: row.user_id, tokenHash: row.token_hash, expiresAt: Number(row.expires_at || 0), consumedAt: Number(row.consumed_at || 0), createdAt: Number(row.created_at || 0), } } async function consumeEmailVerificationToken(tokenId) { await query('UPDATE email_verification_tokens SET consumed_at = ? WHERE id = ? AND consumed_at = 0', [now(), tokenId]) } async function createPasswordResetToken({ id, userId, tokenHash, expiresAt }) { const createdAt = now() await query('UPDATE password_reset_tokens SET consumed_at = ? WHERE user_id = ? AND consumed_at = 0', [createdAt, userId]) await query( ` INSERT INTO password_reset_tokens (id, user_id, token_hash, expires_at, consumed_at, created_at) VALUES (?, ?, ?, ?, ?, ?) `, [id, userId, tokenHash, expiresAt, 0, createdAt] ) return { id, userId, tokenHash, expiresAt, consumedAt: 0, createdAt, } } async function findPasswordResetTokenByHash(tokenHash) { const rows = await query( ` SELECT id, user_id, token_hash, expires_at, consumed_at, created_at FROM password_reset_tokens WHERE token_hash = ? LIMIT 1 `, [tokenHash] ) const row = rows[0] if (!row) return null return { id: row.id, userId: row.user_id, tokenHash: row.token_hash, expiresAt: Number(row.expires_at || 0), consumedAt: Number(row.consumed_at || 0), createdAt: Number(row.created_at || 0), } } async function consumePasswordResetToken(tokenId) { await query('UPDATE password_reset_tokens SET consumed_at = ? WHERE id = ? AND consumed_at = 0', [now(), tokenId]) } async function updateUserProfile({ id, nickname, avatarSrc, touchNicknameUpdatedAt = false }) { if (typeof avatarSrc === 'string') { await query( `UPDATE users SET nickname = ?, avatar_src = ?, nickname_updated_at = CASE WHEN ? = 1 THEN ? ELSE nickname_updated_at END WHERE id = ?`, [nickname || '', avatarSrc, touchNicknameUpdatedAt ? 1 : 0, touchNicknameUpdatedAt ? now() : 0, id] ) } else { await query( `UPDATE users SET nickname = ?, nickname_updated_at = CASE WHEN ? = 1 THEN ? ELSE nickname_updated_at END WHERE id = ?`, [nickname || '', touchNicknameUpdatedAt ? 1 : 0, touchNicknameUpdatedAt ? now() : 0, id] ) } return findUserById(id) } async function findPrimaryAdminUser() { const rows = await query( 'SELECT id, email, nickname, email_verified, is_admin, avatar_src, last_login_at, 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' : sort === 'followers' ? isAsc ? 'follower_count ASC, recent_activity_at ASC, u.email ASC' : 'follower_count DESC, recent_activity_at DESC, u.email ASC' : sort === 'favorites' ? isAsc ? 'received_favorite_count ASC, recent_activity_at ASC, u.email ASC' : 'received_favorite_count DESC, recent_activity_at DESC, u.email ASC' : sort === 'lastLogin' ? isAsc ? 'u.last_login_at ASC, recent_activity_at ASC, u.email ASC' : 'u.last_login_at 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.email_verified, u.is_admin, u.avatar_src, u.last_login_at, u.created_at, COUNT(DISTINCT t.id) AS tierlist_count, COUNT(DISTINCT uf.follower_id) AS follower_count, COUNT(DISTINCT ft.user_id, ft.tierlist_id) AS received_favorite_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 LEFT JOIN user_follows uf ON uf.following_id = u.id LEFT JOIN favorite_tierlists ft ON ft.tierlist_id = t.id ${where.length ? `WHERE ${where.join(' AND ')}` : ''} GROUP BY u.id, u.email, u.nickname, u.email_verified, u.is_admin, u.avatar_src, u.last_login_at, 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 listTopics(currentUserId = '', options = {}) { const includePrivate = !!options.includePrivate const rows = await query( ` SELECT id, slug, name, thumbnail_src, tags_json, is_public, display_rank, created_at FROM topics WHERE id <> ? ${includePrivate ? '' : 'AND is_public = 1'} ORDER BY CASE WHEN display_rank IS NULL THEN 1 ELSE 0 END ASC, display_rank ASC, created_at DESC, name ASC `, [FREEFORM_TOPIC_ID] ) const topics = rows.map(mapTopicRow) if (!currentUserId) return topics.map((topic) => ({ ...topic, isFavorited: false })) const favoriteRows = await query('SELECT topic_id FROM favorite_topics WHERE user_id = ?', [currentUserId]) const favoriteSet = new Set(favoriteRows.map((row) => row.topic_id)) return topics.map((topic) => ({ ...topic, isFavorited: favoriteSet.has(topic.id), })) } async function findTopicById(id) { const rows = await query( 'SELECT id, slug, name, thumbnail_src, tags_json, is_public, display_rank, created_at FROM topics WHERE id = ? LIMIT 1', [id] ) return mapTopicRow(rows[0]) } async function findTopicBySlug(slug) { const normalizedSlug = normalizeTopicSlug(slug) if (!normalizedSlug) return null const rows = await query( 'SELECT id, slug, name, thumbnail_src, tags_json, is_public, display_rank, created_at FROM topics WHERE slug = ? LIMIT 1', [normalizedSlug] ) return mapTopicRow(rows[0]) } async function findTopicByIdentifier(topicRef) { const rawRef = String(topicRef || '').trim() if (!rawRef) return null if (rawRef === FREEFORM_TOPIC_ID) return findTopicById(FREEFORM_TOPIC_ID) const rows = await query( ` SELECT id, slug, name, thumbnail_src, tags_json, is_public, display_rank, created_at FROM topics WHERE id = ? OR slug = ? ORDER BY CASE WHEN id = ? THEN 0 ELSE 1 END ASC LIMIT 1 `, [rawRef, normalizeTopicSlug(rawRef), rawRef] ) return mapTopicRow(rows[0]) } async function listTopicItems(topicId) { const rows = await query( ` SELECT id, topic_id, src, label, tags_json, display_order, created_at FROM topic_items WHERE topic_id = ? ORDER BY CASE WHEN display_order IS NULL THEN 1 ELSE 0 END ASC, display_order ASC, created_at DESC, id DESC `, [topicId] ) return rows.map(mapTopicItemRow) } async function findTopicItemById(itemId) { const rows = await query('SELECT id, topic_id, src, label, tags_json, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId]) return mapTopicItemRow(rows[0]) } async function getTopicDetail(topicRef) { const topic = await findTopicByIdentifier(topicRef) if (!topic) return null const items = await listTopicItems(topic.id) return { topic, template: topic, items } } async function createTopic({ slug, name, tags = [], isPublic = true }) { const topicId = nanoid() const topicSlug = assertTopicSlug(slug) await query('INSERT INTO topics (id, slug, name, thumbnail_src, tags_json, is_public, display_rank, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [ topicId, topicSlug, name, '', serializeJson(normalizeTags(tags)), isPublic ? 1 : 0, null, now(), ]) return findTopicById(topicId) } async function updateTopicMeta(topicId, { slug, name, tags = [], isPublic }) { const topicSlug = assertTopicSlug(slug) await query('UPDATE topics SET slug = ?, name = ?, tags_json = ?, is_public = ? WHERE id = ?', [ topicSlug, name, serializeJson(normalizeTags(tags)), isPublic ? 1 : 0, topicId, ]) return findTopicById(topicId) } async function updateTopicThumbnail(topicId, thumbnailSrc) { await query('UPDATE topics SET thumbnail_src = ? WHERE id = ?', [thumbnailSrc, topicId]) return findTopicById(topicId) } async function updateTopicVisibility(topicId, isPublic) { await query('UPDATE topics SET is_public = ? WHERE id = ?', [isPublic ? 1 : 0, topicId]) return findTopicById(topicId) } 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, topicRows, topicItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([ query("SELECT avatar_src FROM users WHERE avatar_src <> ''"), query("SELECT thumbnail_src FROM topics WHERE thumbnail_src <> ''"), query("SELECT src FROM topic_items WHERE src <> ''"), query("SELECT src FROM custom_items WHERE src <> ''"), query("SELECT thumbnail_src, pool_json FROM tierlists"), query("SELECT thumbnail_src_snapshot, items_json, board_items_json FROM template_requests"), ]) for (const row of userRows) if (row.avatar_src) referencedSrcs.add(row.avatar_src) for (const row of topicRows) if (row.thumbnail_src) referencedSrcs.add(row.thumbnail_src) for (const row of topicItemRows) 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, topicRows, topicItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([ query("SELECT avatar_src FROM users WHERE avatar_src <> ''"), query("SELECT thumbnail_src FROM topics WHERE thumbnail_src <> ''"), query("SELECT src FROM topic_items WHERE src <> ''"), query("SELECT src FROM custom_items WHERE src <> ''"), query("SELECT id, thumbnail_src, pool_json FROM tierlists"), query("SELECT id, thumbnail_src_snapshot, items_json, board_items_json FROM template_requests"), ]) for (const row of userRows) addUsage(row.avatar_src, 'avatar') for (const row of topicRows) addUsage(row.thumbnail_src, 'topic-thumbnail') for (const row of topicItemRows) addUsage(row.src, 'topic-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, toLabel = '') { let changed = false const nextItems = (items || []).map((item) => { if (item?.src !== fromSrc) return item changed = true return { ...item, src: toSrc, ...(typeof toLabel === 'string' && toLabel.trim() ? { label: toLabel.trim().slice(0, 60) } : {}), } }) return { changed, items: nextItems } } function replaceItemById(items, itemId, nextSrc, nextLabel = '') { let changed = false const normalizedLabel = typeof nextLabel === 'string' ? nextLabel.trim().slice(0, 60) : '' const nextItems = (items || []).map((item) => { if (item?.id !== itemId) return item changed = true return { ...item, ...(typeof nextSrc === 'string' && nextSrc ? { src: nextSrc } : {}), ...(normalizedLabel ? { label: normalizedLabel } : {}), } }) return { changed, items: nextItems } } async function replaceUploadSourceReferences({ fromSrc, toSrc, toLabel = '', updateCustomItemsBySrc = true }) { if (!fromSrc || !toSrc || fromSrc === toSrc) return { updatedRows: 0 } const normalizedLabel = typeof toLabel === 'string' ? toLabel.trim().slice(0, 60) : '' const [userResult, topicResult, topicItemResult, customItemResult] = await Promise.all([ query('UPDATE users SET avatar_src = ? WHERE avatar_src = ?', [toSrc, fromSrc]), query('UPDATE topics SET thumbnail_src = ? WHERE thumbnail_src = ?', [toSrc, fromSrc]), normalizedLabel ? query('UPDATE topic_items SET src = ?, label = ? WHERE src = ?', [toSrc, normalizedLabel, fromSrc]) : query('UPDATE topic_items SET src = ? WHERE src = ?', [toSrc, fromSrc]), updateCustomItemsBySrc ? normalizedLabel ? query('UPDATE custom_items SET src = ?, label = ? WHERE src = ?', [toSrc, normalizedLabel, fromSrc]) : query('UPDATE custom_items SET src = ? WHERE src = ?', [toSrc, fromSrc]) : Promise.resolve({ affectedRows: 0 }), ]) let updatedRows = Number(userResult.affectedRows || 0) + Number(topicResult.affectedRows || 0) + Number(topicItemResult.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, normalizedLabel) 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, normalizedLabel) const replacedBoardItems = replaceItemSrc(parseJson(row.board_items_json, []), fromSrc, toSrc, normalizedLabel) 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 updateCustomItemDisplayReferences({ itemId, src = '', label = '' }) { if (!itemId) return { updatedRows: 0 } const normalizedLabel = typeof label === 'string' ? label.trim().slice(0, 60) : '' let updatedRows = 0 const tierListRows = await query('SELECT id, pool_json FROM tierlists') for (const row of tierListRows) { const replacedPool = replaceItemById(parseJson(row.pool_json, []), itemId, src, normalizedLabel) if (!replacedPool.changed) continue await query('UPDATE tierlists SET pool_json = ?, updated_at = ? WHERE id = ?', [ serializeJson(replacedPool.items), now(), row.id, ]) updatedRows += 1 } const requestRows = await query('SELECT id, items_json, board_items_json FROM template_requests') for (const row of requestRows) { const replacedItems = replaceItemById(parseJson(row.items_json, []), itemId, src, normalizedLabel) const replacedBoardItems = replaceItemById(parseJson(row.board_items_json, []), itemId, src, normalizedLabel) if (!replacedItems.changed && !replacedBoardItems.changed) continue await query('UPDATE template_requests SET items_json = ?, board_items_json = ?, updated_at = ? WHERE id = ?', [ 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 updateImageAssetSrc({ fromSrc, toSrc }) { if (!fromSrc || !toSrc || fromSrc === toSrc) return null await query('UPDATE image_assets SET src = ? WHERE src = ?', [toSrc, fromSrc]) return findImageAssetBySrc(toSrc) } 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 fileExistsForUploadSrc(src) { if (typeof src !== 'string' || !src.startsWith('/uploads/')) return true const absolutePath = path.join(__dirname, '..', src.replace(/^\//, '')) try { await fs.stat(absolutePath) return true } catch (error) { if (error?.code === 'ENOENT') return false throw error } } function stripItemIdsFromGroups(groups, missingItemIds) { let changed = false const nextGroups = (groups || []).map((group) => { const nextItemIds = (group?.itemIds || []).filter((itemId) => !missingItemIds.has(itemId)) if (nextItemIds.length !== (group?.itemIds || []).length) changed = true return { ...group, itemIds: nextItemIds, } }) return { changed, groups: nextGroups } } function stripMissingItems(items, missingItemIds, missingSrcs) { let changed = false const nextItems = (items || []).filter((item) => { const shouldRemove = (item?.id && missingItemIds.has(item.id)) || (typeof item?.src === 'string' && missingSrcs.has(item.src)) if (shouldRemove) changed = true return !shouldRemove }) return { changed, items: nextItems } } async function cleanupMissingUploadReferences() { const stats = { clearedAvatars: 0, clearedTopicThumbnails: 0, clearedTierListThumbnails: 0, clearedTemplateRequestThumbnails: 0, deletedTopicItems: 0, updatedTierLists: 0, updatedTemplateRequests: 0, deletedCustomItems: 0, } const [userRows, topicRows, topicItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([ query("SELECT id, avatar_src FROM users WHERE avatar_src <> ''"), query("SELECT id, thumbnail_src FROM topics WHERE thumbnail_src <> ''"), query("SELECT id, src FROM topic_items WHERE src <> ''"), query("SELECT id, src FROM custom_items WHERE src <> ''"), query("SELECT id, thumbnail_src, groups_json, pool_json FROM tierlists"), query("SELECT id, thumbnail_src_snapshot, groups_json, items_json, board_items_json FROM template_requests"), ]) for (const row of userRows) { if (await fileExistsForUploadSrc(row.avatar_src)) continue await query('UPDATE users SET avatar_src = ? WHERE id = ?', ['', row.id]) stats.clearedAvatars += 1 } for (const row of topicRows) { if (await fileExistsForUploadSrc(row.thumbnail_src)) continue await query('UPDATE topics SET thumbnail_src = ? WHERE id = ?', ['', row.id]) stats.clearedTopicThumbnails += 1 } for (const row of topicItemRows) { if (await fileExistsForUploadSrc(row.src)) continue await deleteTopicItem(row.id) stats.deletedTopicItems += 1 } const missingCustomItemIds = new Set() const missingCustomSrcs = new Set() for (const row of customItemRows) { if (await fileExistsForUploadSrc(row.src)) continue missingCustomItemIds.add(row.id) missingCustomSrcs.add(row.src) } for (const row of tierListRows) { const groups = parseJson(row.groups_json, []) const pool = parseJson(row.pool_json, []) let changed = false let nextThumbnail = row.thumbnail_src || '' if (row.thumbnail_src && !(await fileExistsForUploadSrc(row.thumbnail_src))) { nextThumbnail = '' changed = true stats.clearedTierListThumbnails += 1 } const strippedPool = stripMissingItems(pool, missingCustomItemIds, missingCustomSrcs) const strippedGroups = stripItemIdsFromGroups(groups, missingCustomItemIds) if (strippedPool.changed || strippedGroups.changed) changed = true if (changed) { await query('UPDATE tierlists SET thumbnail_src = ?, groups_json = ?, pool_json = ?, updated_at = ? WHERE id = ?', [ nextThumbnail, serializeJson(strippedGroups.groups), serializeJson(strippedPool.items), now(), row.id, ]) stats.updatedTierLists += 1 } } for (const row of templateRequestRows) { const groups = parseJson(row.groups_json, []) const items = parseJson(row.items_json, []) const boardItems = parseJson(row.board_items_json, []) let changed = false let nextThumbnail = row.thumbnail_src_snapshot || '' if (row.thumbnail_src_snapshot && !(await fileExistsForUploadSrc(row.thumbnail_src_snapshot))) { nextThumbnail = '' changed = true stats.clearedTemplateRequestThumbnails += 1 } const strippedItems = stripMissingItems(items, missingCustomItemIds, missingCustomSrcs) const strippedBoardItems = stripMissingItems(boardItems, missingCustomItemIds, missingCustomSrcs) const strippedGroups = stripItemIdsFromGroups(groups, missingCustomItemIds) if (strippedItems.changed || strippedBoardItems.changed || strippedGroups.changed) changed = true if (changed) { await query( 'UPDATE template_requests SET thumbnail_src_snapshot = ?, groups_json = ?, items_json = ?, board_items_json = ?, updated_at = ? WHERE id = ?', [ nextThumbnail, serializeJson(strippedGroups.groups), serializeJson(strippedItems.items), serializeJson(strippedBoardItems.items), now(), row.id, ] ) stats.updatedTemplateRequests += 1 } } if (missingCustomItemIds.size) { await deleteCustomItems(Array.from(missingCustomItemIds)) stats.deletedCustomItems = missingCustomItemIds.size } return stats } 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 createTopicItem({ id, topicId, src, label, tags = [] }) { const createdAt = now() const minOrderRows = await query('SELECT MIN(display_order) AS min_display_order FROM topic_items WHERE topic_id = ?', [topicId]) const nextDisplayOrder = minOrderRows[0]?.min_display_order == null ? 0 : Number(minOrderRows[0].min_display_order) - 1 await query('INSERT INTO topic_items (id, topic_id, src, label, tags_json, display_order, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', [ id, topicId, src, label, serializeJson(normalizeTags(tags)), nextDisplayOrder, createdAt, ]) const rows = await query('SELECT id, topic_id, src, label, tags_json, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [id]) return mapTopicItemRow(rows[0]) } async function updateTopicItemLabel(itemId, label) { await query('UPDATE topic_items SET label = ? WHERE id = ?', [label, itemId]) const rows = await query('SELECT id, topic_id, src, label, tags_json, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId]) return mapTopicItemRow(rows[0]) } async function updateTopicItemMeta(itemId, { label, tags = [] }) { await query('UPDATE topic_items SET label = ?, tags_json = ? WHERE id = ?', [ label, serializeJson(normalizeTags(tags)), itemId, ]) const rows = await query('SELECT id, topic_id, src, label, tags_json, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId]) return mapTopicItemRow(rows[0]) } async function updateTopicItemDisplayOrder(topicId, itemIds) { const normalizedIds = Array.from(new Set((itemIds || []).filter(Boolean))) const existingItems = await listTopicItems(topicId) 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 topic_items SET display_order = ? WHERE id = ? AND topic_id = ?', [index + 1, itemId, topicId])) ) return listTopicItems(topicId) } 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.tags_json, c.replaced_by_item_id, c.replaced_by_src, c.replaced_by_label, c.replaced_at, 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 { ...mapCustomItemRow(row), ownerName: row.nickname || row.email, ownerEmail: row.email, } } async function updateCustomItemMeta(itemId, { label, tags = [] }) { await query('UPDATE custom_items SET label = ?, tags_json = ? WHERE id = ?', [ label, serializeJson(normalizeTags(tags)), itemId, ]) const rows = await query(` SELECT c.id, c.owner_id, c.src, c.label, c.tags_json, c.replaced_by_item_id, c.replaced_by_src, c.replaced_by_label, c.replaced_at, 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 { ...mapCustomItemRow(row), ownerName: row.nickname || row.email, ownerEmail: row.email, } } async function markCustomItemReplaced({ itemId, replacedByItemId = '', replacedBySrc = '', replacedByLabel = '' }) { await query( 'UPDATE custom_items SET replaced_by_item_id = ?, replaced_by_src = ?, replaced_by_label = ?, replaced_at = ? WHERE id = ?', [replacedByItemId || '', replacedBySrc || '', replacedByLabel || '', now(), itemId] ) return findCustomItemById(itemId) } async function clearCustomItemReplacement(itemId) { await query( 'UPDATE custom_items SET replaced_by_item_id = ?, replaced_by_src = ?, replaced_by_label = ?, replaced_at = 0 WHERE id = ?', ['', '', '', itemId] ) return findCustomItemById(itemId) } 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 countTierListsUsingTopicItem(itemId) { if (!itemId) return { totalCount: 0, publicCount: 0, privateCount: 0 } const rows = await query( ` SELECT id, is_public, groups_json FROM tierlists ` ) let totalCount = 0 let publicCount = 0 let privateCount = 0 rows.forEach((row) => { const groups = parseJson(row.groups_json, []) const inGroups = groups.some((group) => (group?.itemIds || []).includes(itemId)) if (!inGroups) return totalCount += 1 if (row.is_public) publicCount += 1 else privateCount += 1 }) return { totalCount, publicCount, privateCount } } async function deleteTopicItem(itemId) { await query('DELETE FROM topic_items WHERE id = ?', [itemId]) } async function deleteTopic(topicId) { await query('DELETE FROM topics WHERE id = ?', [topicId]) } async function updateTopicDisplayOrder(topicIds) { const normalizedIds = Array.from(new Set((topicIds || []).filter((id) => id && id !== FREEFORM_TOPIC_ID))).slice(0, 50) await query('UPDATE topics SET display_rank = NULL WHERE id <> ?', [FREEFORM_TOPIC_ID]) await Promise.all( normalizedIds.map((topicId, index) => query('UPDATE topics SET display_rank = ? WHERE id = ? AND id <> ?', [index + 1, topicId, FREEFORM_TOPIC_ID]) ) ) return listTopics() } async function createCustomItem({ id, ownerId, src, label, tags = [] }) { const createdAt = now() await query('INSERT INTO custom_items (id, owner_id, src, label, tags_json, created_at) VALUES (?, ?, ?, ?, ?, ?)', [ id, ownerId, src, label, serializeJson(normalizeTags(tags)), createdAt, ]) return { id, ownerId, src, label, tags: normalizeTags(tags), 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, tags_json, replaced_by_item_id, replaced_by_src, replaced_by_label, replaced_at, created_at FROM custom_items WHERE id = ? LIMIT 1 `, [id] ) const row = rows[0] return mapCustomItemRow(row) } async function getCustomItemUsageMeta() { const rows = await query( ` SELECT t.topic_id, tp.name AS topic_name, t.groups_json FROM tierlists t LEFT JOIN topics tp ON tp.id = t.topic_id ` ) const usageMap = new Map() const linkedTemplatesMap = new Map() rows.forEach((row) => { const groups = parseJson(row.groups_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) }) }) if (!row.topic_id) return seenItemIds.forEach((itemId) => { if (!linkedTemplatesMap.has(itemId)) linkedTemplatesMap.set(itemId, new Map()) linkedTemplatesMap.get(itemId).set(row.topic_id, { id: row.topic_id, name: row.topic_name || row.topic_id, }) }) }) return { usageMap, linkedTemplatesMap: new Map(Array.from(linkedTemplatesMap.entries()).map(([itemId, templateMap]) => [itemId, Array.from(templateMap.values())])), } } async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMode = 'all', collapseShared = 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, topicItemRows, assetRows, usageMeta] = await Promise.all([ query( ` SELECT c.id, c.owner_id, c.src, c.label, c.tags_json, c.replaced_by_item_id, c.replaced_by_src, c.replaced_by_label, c.replaced_at, 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 c.tags_json LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : ''} ORDER BY c.created_at DESC `, hasQuery ? [search, search, search, search, search] : [] ), query( ` SELECT gi.id, gi.topic_id, gi.src, gi.label, gi.tags_json, gi.created_at, tp.name AS topic_name FROM topic_items gi INNER JOIN topics tp ON tp.id = gi.topic_id ${hasQuery ? 'WHERE gi.label LIKE ? OR gi.src LIKE ? OR gi.tags_json LIKE ? OR gi.topic_id LIKE ? OR tp.name LIKE ?' : ''} ORDER BY gi.created_at DESC `, hasQuery ? [search, 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 [userAvatarRows, topicThumbnailRows, tierListThumbnailRows, templateRequestThumbnailRows] = await Promise.all([ query("SELECT avatar_src AS src FROM users WHERE avatar_src <> ''"), query("SELECT thumbnail_src AS src FROM topics WHERE thumbnail_src <> ''"), query("SELECT thumbnail_src AS src FROM tierlists WHERE thumbnail_src <> ''"), query("SELECT thumbnail_src_snapshot AS src FROM template_requests WHERE thumbnail_src_snapshot <> ''"), ]) const avatarSrcSet = new Set(userAvatarRows.map((row) => row.src).filter(Boolean)) const thumbnailSrcSet = new Set([ ...topicThumbnailRows.map((row) => row.src).filter(Boolean), ...tierListThumbnailRows.map((row) => row.src).filter(Boolean), ...templateRequestThumbnailRows.map((row) => row.src).filter(Boolean), ]) const resolveLibraryAssetKind = (src) => { if (avatarSrcSet.has(src)) return 'avatar' if (thumbnailSrcSet.has(src)) return 'thumbnail' return getAssetLibraryKind(src) } const resolveLibraryAssetLabel = (src) => { if (avatarSrcSet.has(src)) return '프로필 아바타' if (thumbnailSrcSet.has(src)) return '썸네일 이미지' return getAssetLibrarySourceLabel(src) } const templateLinkedBySrc = new Map() topicItemRows.forEach((row) => { if (!row?.src) return if (!templateLinkedBySrc.has(row.src)) templateLinkedBySrc.set(row.src, new Map()) templateLinkedBySrc.get(row.src).set(row.topic_id, { id: row.topic_id, name: row.topic_name || row.topic_id, }) }) const customItems = customRows.map((row) => { const linkedTemplates = Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()) return { id: row.id, ...mapCustomItemRow(row), ownerName: row.nickname || row.email, ownerEmail: row.email, usageCount: usageMeta.usageMap.get(row.id) || 0, linkedTemplates, assetKind: resolveLibraryAssetKind(row.src), sourceType: 'user', sourceLabel: Number(row.replaced_at || 0) > 0 ? '대체된 사용자 아이템' : '사용자 아이템', canDelete: true, } }) const templateSrcSet = new Set(topicItemRows.map((row) => row.src).filter(Boolean)) const customSrcSet = new Set(customRows.map((row) => row.src).filter(Boolean)) const assetLibraryItems = assetRows .filter((row) => { if (!row?.src) return false if (avatarSrcSet.has(row.src) || thumbnailSrcSet.has(row.src)) return true return !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(/\.[^.]+$/, '') || '이름 없음', tags: [], createdAt: Number(row.created_at || 0), ownerName: '관리자 미사용 이미지', ownerEmail: '', usageCount: 0, linkedTemplates: [], sourceType: 'asset', sourceLabel: resolveLibraryAssetLabel(row.src), assetKind: resolveLibraryAssetKind(row.src), canDelete: true, sourceTopicId: '', sourceTopicName: '', isAssetLibraryItem: true, })) const templateItems = topicItemRows.map((row) => ({ id: row.id, ownerId: '', src: row.src, label: row.label, tags: normalizeTags(parseJson(row.tags_json, [])), createdAt: Number(row.created_at), ownerName: row.topic_name || row.topic_id, ownerEmail: '', usageCount: usageMeta.usageMap.get(row.id) || 0, linkedTemplates: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()), assetKind: resolveLibraryAssetKind(row.src), sourceType: 'template', sourceLabel: '템플릿 아이템', canDelete: true, sourceTopicId: row.topic_id, sourceTopicName: row.topic_name || row.topic_id, })) const baseItems = [...customItems, ...templateItems, ...assetLibraryItems] const groupedBySrc = new Map() for (const item of baseItems) { if (!item?.src) continue if (!groupedBySrc.has(item.src)) groupedBySrc.set(item.src, []) groupedBySrc.get(item.src).push(item) } const allItems = baseItems .map((item) => { const siblings = groupedBySrc.get(item.src) || [item] const linkedTemplates = new Map() let userReferenceCount = 0 let templateReferenceCount = 0 let assetReferenceCount = 0 siblings.forEach((entry) => { if (entry.sourceType === 'user') userReferenceCount += 1 else if (entry.sourceType === 'asset' || entry.isAssetLibraryItem) assetReferenceCount += 1 else templateReferenceCount += 1 ;(entry.linkedTemplates || []).forEach((template) => { if (template?.id) linkedTemplates.set(template.id, template) }) }) return { ...item, sharedReferenceCount: siblings.length, sharedUserReferenceCount: userReferenceCount, sharedTemplateReferenceCount: templateReferenceCount, sharedAssetReferenceCount: assetReferenceCount, sharedLinkedTemplateCount: linkedTemplates.size, sharedEntries: siblings .slice() .sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0)) .map((entry) => ({ id: entry.id, label: entry.label, sourceLabel: entry.sourceLabel, sourceType: entry.sourceType, assetKind: entry.assetKind || '', ownerName: entry.ownerName, createdAt: entry.createdAt, sourceTopicId: entry.sourceTopicId || '', sourceTopicName: entry.sourceTopicName || '', usageCount: entry.usageCount || 0, linkedTemplates: entry.linkedTemplates || [], isAssetLibraryItem: !!entry.isAssetLibraryItem, tags: entry.tags || [], replacedByItemId: entry.replacedByItemId || '', replacedBySrc: entry.replacedBySrc || '', replacedByLabel: entry.replacedByLabel || '', replacedAt: entry.replacedAt || 0, })), } }) .filter((item) => { switch (filterMode) { case 'user': return item.sourceType === 'user' case 'template': return item.sourceType === 'template' && !item.isAssetLibraryItem case 'asset': return item.sourceType === 'asset' || !!item.isAssetLibraryItem case 'thumbnail': return item.assetKind === 'thumbnail' case 'avatar': return item.assetKind === 'avatar' case 'library': return item.sourceType === 'user' || (item.sourceType === 'template' && !item.isAssetLibraryItem) case 'unused': return (item.sourceType === 'user' && ((item.usageCount === 0 && item.linkedTemplates.length === 0) || !!item.replacedAt)) || item.sourceType === 'asset' || !!item.isAssetLibraryItem case 'unused-user': return item.sourceType === 'user' && ((item.usageCount === 0 && item.linkedTemplates.length === 0) || !!item.replacedAt) case 'replaced-user': return item.sourceType === 'user' && !!item.replacedAt case 'unused-admin': return item.sourceType === 'asset' || !!item.isAssetLibraryItem default: return true } }) .sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0)) const visibleItems = collapseShared ? collapseSharedLibraryItems(allItems) : allItems const total = visibleItems.length const offset = (normalizedPage - 1) * normalizedLimit const pagedItems = visibleItems.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 [rows, topicItemRows, usageMeta] = await Promise.all([ query( ` SELECT c.id, c.owner_id, c.src, c.label, c.tags_json, c.replaced_by_item_id, c.replaced_by_src, c.replaced_by_label, c.replaced_at, 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 c.tags_json LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : ''} ORDER BY c.created_at DESC `, hasQuery ? [search, search, search, search, search] : [] ), query( ` SELECT ti.topic_id, tp.name AS topic_name, ti.src FROM topic_items ti LEFT JOIN topics tp ON tp.id = ti.topic_id ` ), getCustomItemUsageMeta(), ]) const templateLinkedBySrc = new Map() topicItemRows.forEach((row) => { if (!row?.src) return if (!templateLinkedBySrc.has(row.src)) templateLinkedBySrc.set(row.src, new Map()) templateLinkedBySrc.get(row.src).set(row.topic_id, { id: row.topic_id, name: row.topic_name || row.topic_id, }) }) return rows .map((row) => ({ ...mapCustomItemRow(row), ownerName: row.nickname || row.email, ownerEmail: row.email, usageCount: usageMeta.usageMap.get(row.id) || 0, linkedTemplates: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()), })) .filter((item) => ((item.usageCount === 0 && item.linkedTemplates.length === 0) || !!item.replacedAt)) } 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(topicId, currentUserId = '', queryText = '') { const params = [] let whereClause = 'WHERE t.is_public = 1' if (topicId) { const topic = await findTopicByIdentifier(topicId) if (!topic) { return { featuredTierLists: [], tierLists: [], } } whereClause += ' AND t.topic_id = ?' params.push(topic.id) } 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.topic_id, tp.slug AS topic_slug, tp.name AS topic_name, t.title, t.thumbnail_src, t.is_featured, t.featured_at, t.featured_by, 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 INNER JOIN topics tp ON tp.id = t.topic_id ${whereClause} ORDER BY t.is_featured DESC, t.featured_at DESC, t.updated_at DESC LIMIT 200 `, params ) const tierLists = rows.map((row) => ({ id: row.id, topicId: row.topic_id, topicSlug: row.topic_slug || row.topic_id, topicName: row.topic_name || '', title: row.title, thumbnailSrc: row.thumbnail_src || '', isFeatured: !!row.is_featured, featuredAt: Number(row.featured_at || 0), 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 ) const mergedTierLists = applyFavoriteMetaToTierLists(tierLists, favoriteStats) const featuredTierLists = mergedTierLists .filter((tierList) => tierList.isFeatured) .slice() .sort( (a, b) => Number(b.featuredAt || 0) - Number(a.featuredAt || 0) || Number(b.favoriteCount || 0) - Number(a.favoriteCount || 0) || Number(b.updatedAt || 0) - Number(a.updatedAt || 0) ) .slice(0, 16) return { featuredTierLists, tierLists: mergedTierLists.filter((tierList) => !tierList.isFeatured), } } 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 tp.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.topic_id, tp.slug AS topic_slug, tp.name AS topic_name, t.title, t.thumbnail_src, t.description, t.is_public, t.is_featured, t.featured_at, t.featured_by, t.show_character_names, t.icon_size, 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 topics tp ON tp.id = t.topic_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.topic_id, tp.slug AS topic_slug, tp.name AS topic_name, 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 INNER JOIN topics tp ON tp.id = t.topic_id WHERE t.author_id = ? ORDER BY updated_at DESC `, [userId] ) const tierLists = rows.map((row) => ({ id: row.id, topicId: row.topic_id, topicSlug: row.topic_slug || row.topic_id, topicName: row.topic_name || '', 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) } async function findUserProfileById(userId, currentUserId = '') { const rows = await query( ` SELECT u.id, u.email, u.nickname, u.avatar_src, u.created_at, ( SELECT COUNT(*) FROM tierlists t WHERE t.author_id = u.id AND t.is_public = 1 ) AS public_tierlist_count, ( SELECT COUNT(*) FROM user_follows uf WHERE uf.following_id = u.id ) AS follower_count, ( SELECT COUNT(*) FROM user_follows uf WHERE uf.follower_id = u.id ) AS following_count, ${ currentUserId ? `EXISTS( SELECT 1 FROM user_follows uf WHERE uf.follower_id = ? AND uf.following_id = u.id )` : '0' } AS is_following FROM users u WHERE u.id = ? LIMIT 1 `, currentUserId ? [currentUserId, userId] : [userId] ) const row = rows[0] if (!row) return null return { id: row.id, nickname: row.nickname || '', accountName: getUserAccountName(row), avatarSrc: row.avatar_src || '', createdAt: Number(row.created_at || 0), publicTierListCount: Number(row.public_tierlist_count || 0), followerCount: Number(row.follower_count || 0), followingCount: Number(row.following_count || 0), isFollowing: !!row.is_following, isSelf: !!currentUserId && currentUserId === row.id, } } async function followUser({ followerId, followingId }) { await query('INSERT IGNORE INTO user_follows (follower_id, following_id, created_at) VALUES (?, ?, ?)', [ followerId, followingId, now(), ]) } async function unfollowUser({ followerId, followingId }) { await query('DELETE FROM user_follows WHERE follower_id = ? AND following_id = ?', [followerId, followingId]) } async function listPublicTierListsByAuthor(authorId, currentUserId = '', queryText = '') { const params = [authorId] let whereClause = 'WHERE t.author_id = ? AND t.is_public = 1' if ((queryText || '').trim()) { const search = `%${queryText.trim()}%` whereClause += ' AND (t.title LIKE ? OR tp.name LIKE ?)' params.push(search, search) } const rows = await query( ` SELECT t.id, t.topic_id, tp.slug AS topic_slug, tp.name AS topic_name, t.title, t.thumbnail_src, t.is_public, t.is_featured, t.featured_at, t.featured_by, 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 INNER JOIN topics tp ON tp.id = t.topic_id ${whereClause} ORDER BY t.is_featured DESC, t.featured_at DESC, t.updated_at DESC LIMIT 200 `, params ) const tierLists = rows.map((row) => ({ id: row.id, topicId: row.topic_id, topicSlug: row.topic_slug || row.topic_id, topicName: row.topic_name || '', title: row.title, thumbnailSrc: row.thumbnail_src || '', isPublic: !!row.is_public, isFeatured: !!row.is_featured, featuredAt: Number(row.featured_at || 0), 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 listFollowingTierLists(userId, queryText = '') { const params = [userId] let whereClause = 'WHERE uf.follower_id = ? AND t.is_public = 1' if ((queryText || '').trim()) { const search = `%${queryText.trim()}%` whereClause += ' AND (t.title LIKE ? OR tp.name LIKE ? OR u.nickname LIKE ? OR u.email LIKE ?)' params.push(search, search, search, search) } const rows = await query( ` SELECT t.id, t.topic_id, tp.slug AS topic_slug, tp.name AS topic_name, t.title, t.thumbnail_src, t.is_public, t.is_featured, t.featured_at, t.featured_by, t.created_at, t.updated_at, t.author_id, uf.created_at AS followed_at, u.nickname, u.email, u.avatar_src FROM user_follows uf INNER JOIN tierlists t ON t.author_id = uf.following_id INNER JOIN users u ON u.id = t.author_id INNER JOIN topics tp ON tp.id = t.topic_id ${whereClause} ORDER BY t.updated_at DESC, uf.created_at DESC LIMIT 200 `, params ) const tierLists = rows.map((row) => ({ id: row.id, topicId: row.topic_id, topicSlug: row.topic_slug || row.topic_id, topicName: row.topic_name || '', title: row.title, thumbnailSrc: row.thumbnail_src || '', isPublic: !!row.is_public, isFeatured: !!row.is_featured, featuredAt: Number(row.featured_at || 0), createdAt: Number(row.created_at), updatedAt: Number(row.updated_at), authorId: row.author_id, authorName: getUserDisplayName(row), authorAccountName: getUserAccountName(row), authorAvatarSrc: row.avatar_src || '', isFavorited: false, })) const favoriteStats = await getFavoriteStatsForTierListIds( tierLists.map((tierList) => tierList.id), userId ) return applyFavoriteMetaToTierLists(tierLists, favoriteStats) } async function listTierListComments(tierListId) { const rows = await query( ` SELECT c.id, c.tierlist_id, c.author_id, c.parent_comment_id, c.content, c.created_at, c.updated_at, u.nickname, u.email, u.avatar_src FROM tierlist_comments c INNER JOIN users u ON u.id = c.author_id WHERE c.tierlist_id = ? ORDER BY c.created_at ASC, c.id ASC `, [tierListId] ) const comments = rows.map(mapTierListCommentRow) const byId = new Map(comments.map((comment) => [comment.id, comment])) const roots = [] comments.forEach((comment) => { if (comment.parentCommentId) { const parent = byId.get(comment.parentCommentId) if (parent) { parent.replies.push(comment) return } } roots.push(comment) }) roots.sort((a, b) => { const timeDiff = Number(b.createdAt || 0) - Number(a.createdAt || 0) if (timeDiff !== 0) return timeDiff return String(b.id || '').localeCompare(String(a.id || '')) }) return roots } async function findTierListCommentById(commentId) { const rows = await query( ` SELECT id, tierlist_id, author_id, parent_comment_id, content, created_at, updated_at FROM tierlist_comments WHERE id = ? LIMIT 1 `, [commentId] ) const row = rows[0] if (!row) return null return { id: row.id, tierListId: row.tierlist_id, authorId: row.author_id, parentCommentId: row.parent_comment_id || '', content: row.content || '', createdAt: Number(row.created_at || 0), updatedAt: Number(row.updated_at || 0), } } async function createTierListComment({ tierListId, authorId, parentCommentId = '', content }) { let parentComment = null if (parentCommentId) { parentComment = await findTierListCommentById(parentCommentId) if (!parentComment || parentComment.tierListId !== tierListId) { const error = new Error('comment_parent_invalid') error.code = 'COMMENT_PARENT_INVALID' throw error } if (parentComment.parentCommentId) { const error = new Error('comment_reply_depth_invalid') error.code = 'COMMENT_REPLY_DEPTH_INVALID' throw error } } const commentId = nanoid() const createdAt = now() await query( ` INSERT INTO tierlist_comments (id, tierlist_id, author_id, parent_comment_id, content, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) `, [commentId, tierListId, authorId, parentComment ? parentComment.id : null, String(content || '').trim(), createdAt, createdAt] ) return commentId } async function deleteTierListComment(commentId) { await query('DELETE FROM tierlist_comments WHERE id = ?', [commentId]) } async function createCommentNotificationsForComment(commentId) { const comment = await findTierListCommentById(commentId) if (!comment) return const tierList = await findTierListById(comment.tierListId) if (!tierList) return const recipientMap = new Map() if (tierList.authorId && tierList.authorId !== comment.authorId) { recipientMap.set(tierList.authorId, 'tierlist_comment') } if (comment.parentCommentId) { const parentComment = await findTierListCommentById(comment.parentCommentId) if (parentComment && parentComment.authorId && parentComment.authorId !== comment.authorId) { recipientMap.set(parentComment.authorId, 'comment_reply') } } await Promise.all( Array.from(recipientMap.entries()).map(([userId, notificationType]) => query( ` INSERT INTO comment_notifications (id, user_id, tierlist_id, comment_id, actor_user_id, notification_type, is_read, read_at, created_at) VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?) `, [nanoid(), userId, comment.tierListId, comment.id, comment.authorId, notificationType, now()] ) ) ) } async function listCommentNotifications(userId, { unreadOnly = false } = {}) { const rows = await query( ` SELECT n.id, n.user_id, n.tierlist_id, n.comment_id, n.notification_type, n.is_read, n.read_at, n.created_at, n.actor_user_id, c.parent_comment_id, c.content AS comment_content, parent.content AS parent_comment_content, parent.created_at AS parent_comment_created_at, t.topic_id, tp.slug AS topic_slug, tp.name AS topic_name, t.title AS tierlist_title, t.thumbnail_src AS tierlist_thumbnail_src, actor.nickname AS actor_nickname, actor.email AS actor_email, actor.avatar_src AS actor_avatar_src, parent_author.nickname AS parent_author_nickname, parent_author.email AS parent_author_email, parent_author.avatar_src AS parent_author_avatar_src FROM comment_notifications n INNER JOIN tierlist_comments c ON c.id = n.comment_id LEFT JOIN tierlist_comments parent ON parent.id = c.parent_comment_id LEFT JOIN users parent_author ON parent_author.id = parent.author_id INNER JOIN tierlists t ON t.id = n.tierlist_id INNER JOIN topics tp ON tp.id = t.topic_id INNER JOIN users actor ON actor.id = n.actor_user_id WHERE n.user_id = ? ${unreadOnly ? 'AND n.is_read = 0' : ''} ORDER BY n.is_read ASC, n.created_at DESC LIMIT 300 `, [userId] ) return rows.map(mapCommentNotificationRow) } async function countUnreadCommentNotifications(userId) { const rows = await query('SELECT COUNT(*) AS count FROM comment_notifications WHERE user_id = ? AND is_read = 0', [userId]) return Number(rows[0]?.count || 0) } async function markCommentNotificationsRead(userId, { notificationIds = [], all = false } = {}) { if (all) { await query('UPDATE comment_notifications SET is_read = 1, read_at = ? WHERE user_id = ? AND is_read = 0', [now(), userId]) return } const ids = Array.from(new Set((notificationIds || []).filter(Boolean))).slice(0, 100) if (!ids.length) return await query( `UPDATE comment_notifications SET is_read = 1, read_at = ? WHERE user_id = ? AND id IN (${ids.map(() => '?').join(', ')}) AND is_read = 0`, [now(), userId, ...ids] ) } 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 || 'template', }) }) 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 = '', topicId = '', page = 1, limit = 50, sort = 'recent', minFavorites = 0, currentUserId = '', } = {}) { const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200) const normalizedPage = Math.max(Number(page) || 1, 1) const normalizedMinFavorites = Math.max(Number(minFavorites) || 0, 0) const hasQuery = !!(queryText || '').trim() const resolvedTopicId = (topicId || '').trim() const hasTopicId = !!resolvedTopicId const search = `%${(queryText || '').trim()}%` const whereParts = [] const params = [] if (hasTopicId) { whereParts.push('t.topic_id = ?') params.push(resolvedTopicId) } if (hasQuery) { whereParts.push(`( t.title LIKE ? OR tp.name LIKE ? OR tp.slug LIKE ? OR tp.id LIKE ? OR u.email LIKE ? OR u.nickname LIKE ? )`) params.push(search, search, search, search, search, search) } const whereClause = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '' const rows = await query( ` SELECT t.id, t.author_id, t.topic_id, tp.slug AS topic_slug, tp.name AS topic_name, t.title, t.thumbnail_src, t.description, t.is_public, t.is_featured, t.featured_at, t.featured_by, t.show_character_names, t.icon_size, 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 topics tp ON tp.id = t.topic_id ${whereClause} ORDER BY t.updated_at DESC, t.created_at DESC `, params ) const baseItems = 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 favoriteStats = await getFavoriteStatsForTierListIds( baseItems.map((tierList) => tierList.id), currentUserId ) const filteredItems = applyFavoriteMetaToTierLists(baseItems, favoriteStats) .filter((tierList) => Number(tierList.favoriteCount || 0) >= normalizedMinFavorites) .sort((a, b) => { if (sort === 'favorites') { return ( Number(b.favoriteCount || 0) - Number(a.favoriteCount || 0) || Number(b.updatedAt || 0) - Number(a.updatedAt || 0) || Number(b.createdAt || 0) - Number(a.createdAt || 0) ) } if (sort === 'created') { return ( Number(b.createdAt || 0) - Number(a.createdAt || 0) || Number(b.updatedAt || 0) - Number(a.updatedAt || 0) || String(a.title || '').localeCompare(String(b.title || '')) ) } return ( Number(b.updatedAt || 0) - Number(a.updatedAt || 0) || Number(b.createdAt || 0) - Number(a.createdAt || 0) || String(a.title || '').localeCompare(String(b.title || '')) ) }) const total = filteredItems.length const offset = (normalizedPage - 1) * normalizedLimit const pagedTierLists = filteredItems.slice(offset, offset + normalizedLimit) return { tierLists: pagedTierLists, total, page: normalizedPage, limit: normalizedLimit, } } async function summarizeAdminTierLists({ queryText = '', topicId = '', minFavorites = 0 } = {}) { const normalizedMinFavorites = Math.max(Number(minFavorites) || 0, 0) const hasQuery = !!(queryText || '').trim() const resolvedTopicId = (topicId || '').trim() const hasTopicId = !!resolvedTopicId const search = `%${(queryText || '').trim()}%` const whereParts = [] const params = [] if (hasTopicId) { whereParts.push('t.topic_id = ?') params.push(resolvedTopicId) } if (hasQuery) { whereParts.push(`( t.title LIKE ? OR tp.name LIKE ? OR tp.slug LIKE ? OR tp.id LIKE ? OR u.email LIKE ? OR u.nickname LIKE ? )`) params.push(search, search, search, search, search, search) } const whereClause = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '' const rows = await query( ` SELECT t.is_public, t.is_featured , t.id FROM tierlists t INNER JOIN users u ON u.id = t.author_id INNER JOIN topics tp ON tp.id = t.topic_id ${whereClause} `, params ) const favoriteStats = normalizedMinFavorites > 0 ? await getFavoriteStatsForTierListIds(rows.map((row) => row.id), '') : { countMap: new Map(), favoritedSet: new Set() } const scopedRows = normalizedMinFavorites > 0 ? rows.filter((row) => Number(favoriteStats.countMap.get(row.id) || 0) >= normalizedMinFavorites) : rows const total = scopedRows.length const publicCount = scopedRows.filter((row) => Number(row.is_public) === 1).length const featuredCount = scopedRows.filter((row) => Number(row.is_featured) === 1).length return { total, publicCount, privateCount: Math.max(0, total - publicCount), featuredCount, } } async function findTierListById(id, currentUserId = '') { const rows = await query( ` SELECT t.id, t.author_id, t.topic_id, tp.slug AS topic_slug, tp.name AS topic_name, t.title, t.thumbnail_src, t.description, t.is_public, t.is_featured, t.featured_at, t.featured_by, t.show_character_names, t.icon_size, 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 topics tp ON tp.id = t.topic_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 = '', sourceTopicId, targetTopicId = '', 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_topic_id, target_topic_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, sourceTopicId, targetTopicId, 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_topic_id, tr.target_topic_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.slug AS source_topic_slug, sg.name AS source_topic_name, tg.slug AS target_topic_slug, tg.name AS target_topic_name FROM template_requests tr INNER JOIN users u ON u.id = tr.requester_id LEFT JOIN topics sg ON sg.id = tr.source_topic_id LEFT JOIN topics tg ON tg.id = tr.target_topic_id WHERE tr.id = ? LIMIT 1 `, [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_topic_id, tr.target_topic_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.slug AS source_topic_slug, sg.name AS source_topic_name, tg.slug AS target_topic_slug, tg.name AS target_topic_name FROM template_requests tr INNER JOIN users u ON u.id = tr.requester_id LEFT JOIN topics sg ON sg.id = tr.source_topic_id LEFT JOIN topics tg ON tg.id = tr.target_topic_id WHERE tr.status IN (${placeholders}) ORDER BY CASE tr.status 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 updateTemplateRequestTargetTopic({ id, targetTopicId }) { await query('UPDATE template_requests SET target_topic_id = ?, updated_at = ? WHERE id = ?', [targetTopicId || '', now(), id]) return findTemplateRequestById(id) } async function deleteTierList(id) { await query('DELETE FROM tierlists WHERE id = ?', [id]) } async function updateAdminTierListMeta({ id, title, description = '', isPublic }) { const nextUpdatedAt = now() if (!isPublic) { await query( ` UPDATE tierlists SET title = ?, description = ?, is_public = 0, is_featured = 0, featured_at = 0, featured_by = '', updated_at = ? WHERE id = ? `, [title, description || '', nextUpdatedAt, id] ) return findTierListById(id) } await query( ` UPDATE tierlists SET title = ?, description = ?, is_public = ?, updated_at = ? WHERE id = ? `, [title, description || '', 1, nextUpdatedAt, id] ) return findTierListById(id) } async function updateTierListFeaturedStatus({ id, isFeatured, adminUserId }) { await query( ` UPDATE tierlists SET is_featured = ?, featured_at = ?, featured_by = ? WHERE id = ? `, [isFeatured ? 1 : 0, isFeatured ? now() : 0, isFeatured ? adminUserId || '' : '', id] ) return findTierListById(id) } async function findCustomItemsByIds(ids) { if (!ids.length) return [] const placeholders = ids.map(() => '?').join(', ') const rows = await query( ` SELECT id, owner_id, src, label, tags_json, replaced_by_item_id, replaced_by_src, replaced_by_label, replaced_at, created_at FROM custom_items WHERE id IN (${placeholders}) `, ids ) return rows.map(mapCustomItemRow) } 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, topicId, title, thumbnailSrc = '', description, isPublic, showCharacterNames = false, iconSize = 80, 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 = ?, icon_size = ?, 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, Number(iconSize) || 80, 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, topic_id, title, thumbnail_src, description, is_public, show_character_names, icon_size, source_tierlist_id, source_snapshot_title, source_snapshot_author, groups_json, pool_json, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [nextId, authorId, topicId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, Number(iconSize) || 80, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), createdAt, createdAt] ) return findTierListById(nextId, authorId) } 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, topicId: tierList.topicId, title: copyTitle, thumbnailSrc: tierList.thumbnailSrc || '', description: tierList.description || '', isPublic: false, showCharacterNames: !!tierList.showCharacterNames, iconSize: Number(tierList.iconSize || 80), 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 favoriteTopic({ userId, topicId }) { await query('INSERT IGNORE INTO favorite_topics (user_id, topic_id, created_at) VALUES (?, ?, ?)', [userId, topicId, now()]) } async function unfavoriteTopic({ userId, topicId }) { await query('DELETE FROM favorite_topics WHERE user_id = ? AND topic_id = ?', [userId, topicId]) } module.exports = { DB_NAME, ensureData, closePool, countUsers, findUserByEmail, findUserByNickname, findUserById, findUserProfileById, createUser, touchUserLastLoginAt, updateUserPassword, verifyUserEmail, createEmailVerificationToken, findEmailVerificationTokenByHash, consumeEmailVerificationToken, createPasswordResetToken, findPasswordResetTokenByHash, consumePasswordResetToken, updateUserProfile, findPrimaryAdminUser, listUsers, adminUpdateUser, adminUpdateUserPassword, adminDeleteUser, listTopics, findTopicById, findTopicBySlug, findTopicByIdentifier, listTopicItems, findTopicItemById, getTopicDetail, createTopic, updateTopicMeta, updateTopicThumbnail, updateTopicVisibility, findImageAssetByHash, findImageAssetBySrc, findImageAssetById, updateImageAssetSrc, createImageAsset, createImageOptimizationJob, findImageOptimizationJobById, updateImageOptimizationJobStatus, listRecentImageOptimizationJobs, listUnusedImageAssets, deleteImageAssets, listImageAssets, listReferencedUploadSources, listReferencedUploadUsage, replaceUploadSourceReferences, updateCustomItemDisplayReferences, clearImageOptimizationJobs, getImageAssetStats, cleanupMissingUploadReferences, createTopicItem, updateTopicItemLabel, updateTopicItemMeta, updateTopicItemDisplayOrder, countTierListsUsingTopicItem, deleteTopicItem, deleteTopic, updateTopicDisplayOrder, updateCustomItemLabel, updateCustomItemMeta, clearCustomItemReplacement, markCustomItemReplaced, updateImageAssetLabel, createCustomItem, findCustomItemById, listCustomItems, findUnusedCustomItems, listPublicTierLists, listPublicTierListsByAuthor, listFollowingTierLists, listFavoriteTierLists, listUserTierLists, listAdminTierLists, summarizeAdminTierLists, findTierListById, updateAdminTierListMeta, updateTierListFeaturedStatus, favoriteTopic, unfavoriteTopic, listTierListComments, findTierListCommentById, createTierListComment, deleteTierListComment, createCommentNotificationsForComment, listCommentNotifications, countUnreadCommentNotifications, markCommentNotificationsRead, followUser, unfollowUser, favoriteTierList, unfavoriteTierList, deleteTierList, findCustomItemsByIds, deleteCustomItems, saveTierList, duplicateTierListForUser, createTemplateRequest, findTemplateRequestById, listAdminTemplateRequests, updateTemplateRequestStatus, updateTemplateRequestTargetTopic, }