3303 lines
108 KiB
JavaScript
3303 lines
108 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 || '',
|
|
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 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 '',
|
|
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 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 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, 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, 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, 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, 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, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`,
|
|
[id, email, nickname || '', 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 }) {
|
|
if (typeof avatarSrc === 'string') {
|
|
await query('UPDATE users SET nickname = ?, avatar_src = ? WHERE id = ?', [nickname || '', avatarSrc, id])
|
|
} else {
|
|
await query('UPDATE users SET nickname = ? WHERE id = ?', [nickname || '', id])
|
|
}
|
|
return findUserById(id)
|
|
}
|
|
|
|
async function findPrimaryAdminUser() {
|
|
const rows = await query(
|
|
'SELECT id, email, nickname, 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, pool_json
|
|
FROM tierlists
|
|
`
|
|
)
|
|
|
|
let totalCount = 0
|
|
let publicCount = 0
|
|
let privateCount = 0
|
|
|
|
rows.forEach((row) => {
|
|
const groups = parseJson(row.groups_json, [])
|
|
const pool = parseJson(row.pool_json, [])
|
|
const inGroups = groups.some((group) => (group?.itemIds || []).includes(itemId))
|
|
const inPool = pool.some((item) => item?.id === itemId)
|
|
if (!inGroups && !inPool) 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, t.pool_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 pool = parseJson(row.pool_json, [])
|
|
const seenItemIds = new Set()
|
|
|
|
groups.forEach((group) => {
|
|
;(group?.itemIds || []).forEach((itemId) => {
|
|
usageMap.set(itemId, (usageMap.get(itemId) || 0) + 1)
|
|
if (itemId) seenItemIds.add(itemId)
|
|
})
|
|
})
|
|
|
|
pool.forEach((item) => {
|
|
if (item?.id) {
|
|
usageMap.set(item.id, (usageMap.get(item.id) || 0) + 1)
|
|
seenItemIds.add(item.id)
|
|
}
|
|
})
|
|
|
|
if (!row.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: (templateLinkedBySrc.get(row.src) || new Map()).size,
|
|
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)
|
|
}
|
|
|
|
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,
|
|
followUser,
|
|
unfollowUser,
|
|
favoriteTierList,
|
|
unfavoriteTierList,
|
|
deleteTierList,
|
|
findCustomItemsByIds,
|
|
deleteCustomItems,
|
|
saveTierList,
|
|
duplicateTierListForUser,
|
|
createTemplateRequest,
|
|
findTemplateRequestById,
|
|
listAdminTemplateRequests,
|
|
updateTemplateRequestStatus,
|
|
updateTemplateRequestTargetTopic,
|
|
}
|