Files
tier-maker/backend/src/db.js
2026-04-07 15:03:30 +09:00

3611 lines
120 KiB
JavaScript

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,
}