Files
tier-maker/backend/src/db.js

1317 lines
37 KiB
JavaScript

const mysql = require('mysql2/promise')
const DB_HOST = process.env.DB_HOST || '127.0.0.1'
const DB_PORT = process.env.DB_PORT ? Number(process.env.DB_PORT) : 3306
const DB_USER = process.env.DB_USER || 'root'
const DB_PASSWORD = process.env.DB_PASSWORD || ''
const DB_NAME = process.env.DB_NAME || 'tier_cursor'
const DB_CONNECTION_LIMIT = process.env.DB_CONNECTION_LIMIT ? Number(process.env.DB_CONNECTION_LIMIT) : 10
const FREEFORM_GAME_ID = 'freeform'
let poolPromise = null
let initPromise = null
function now() {
return Date.now()
}
function parseJson(value, fallback) {
if (!value) return fallback
try {
return JSON.parse(value)
} catch (e) {
return fallback
}
}
function serializeJson(value) {
return JSON.stringify(value || [])
}
function mapUserRow(row) {
if (!row) return null
return {
id: row.id,
email: row.email,
nickname: row.nickname || '',
isAdmin: !!row.is_admin,
avatarSrc: row.avatar_src || '',
createdAt: Number(row.created_at),
tierListCount: Number(row.tierlist_count || 0),
recentActivityAt: Number(row.recent_activity_at || row.created_at || 0),
}
}
function mapGameRow(row) {
if (!row) return null
return {
id: row.id,
name: row.name,
thumbnailSrc: row.thumbnail_src || '',
displayRank: row.display_rank == null ? null : Number(row.display_rank),
createdAt: Number(row.created_at),
}
}
function mapGameItemRow(row) {
if (!row) return null
return {
id: row.id,
gameId: row.game_id,
src: row.src,
label: row.label,
createdAt: Number(row.created_at),
}
}
function mapTierListRow(row) {
if (!row) return null
return {
id: row.id,
authorId: row.author_id,
authorName: getUserDisplayName(row),
authorAccountName: getUserAccountName(row),
authorAvatarSrc: row.avatar_src || '',
gameId: row.game_id,
gameName: row.game_name || '',
title: row.title,
thumbnailSrc: row.thumbnail_src || '',
description: row.description || '',
isPublic: !!row.is_public,
showCharacterNames: !!row.show_character_names,
groups: parseJson(row.groups_json, []),
pool: parseJson(row.pool_json, []),
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
}
}
function mapTemplateRequestRow(row) {
if (!row) return null
return {
id: row.id,
type: row.request_type,
requesterId: row.requester_id,
requesterName: getUserDisplayName(row),
requesterAccountName: getUserAccountName(row),
requesterAvatarSrc: row.requester_avatar_src || '',
sourceTierListId: row.source_tierlist_id,
sourceGameId: row.source_game_id,
sourceGameName: row.source_game_name || '',
sourceTierListTitle: row.title_snapshot || '',
sourceDescription: row.description_snapshot || '',
thumbnailSrc: row.thumbnail_src_snapshot || '',
targetGameId: row.target_game_id || '',
targetGameName: row.target_game_name || '',
status: row.status,
items: parseJson(row.items_json, []),
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
}
}
function getUserDisplayName(row) {
if (!row) return ''
const nickname = (row.nickname || '').trim()
if (nickname) return nickname
const email = (row.email || '').trim()
if (!email) return ''
return email.split('@')[0] || email
}
function getUserAccountName(row) {
if (!row) return ''
const email = (row.email || '').trim()
if (!email) return ''
return email.split('@')[0] || email
}
async function createPool() {
const rootConnection = await mysql.createConnection({
host: DB_HOST,
port: DB_PORT,
user: DB_USER,
password: DB_PASSWORD,
multipleStatements: true,
})
await rootConnection.query(
`CREATE DATABASE IF NOT EXISTS \`${DB_NAME}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`
)
await rootConnection.end()
return mysql.createPool({
host: DB_HOST,
port: DB_PORT,
user: DB_USER,
password: DB_PASSWORD,
database: DB_NAME,
connectionLimit: DB_CONNECTION_LIMIT,
charset: 'utf8mb4',
})
}
async function getPool() {
if (!poolPromise) {
poolPromise = createPool()
}
return poolPromise
}
async function query(sql, params = []) {
const pool = await getPool()
const [rows] = await pool.execute(sql, params)
return rows
}
async function ensureSchema() {
if (initPromise) return initPromise
initPromise = (async () => {
await query(`
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(64) PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
nickname VARCHAR(80) NOT NULL DEFAULT '',
password_hash VARCHAR(255) NOT NULL,
is_admin TINYINT(1) NOT NULL DEFAULT 0,
avatar_src VARCHAR(255) NOT NULL DEFAULT '',
created_at BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await query(`
CREATE TABLE IF NOT EXISTS games (
id VARCHAR(120) PRIMARY KEY,
name VARCHAR(120) NOT NULL,
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
display_rank INT NULL DEFAULT NULL,
created_at BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const displayRankColumns = await query("SHOW COLUMNS FROM games LIKE 'display_rank'")
if (!displayRankColumns.length) {
await query('ALTER TABLE games ADD COLUMN display_rank INT NULL DEFAULT NULL AFTER thumbnail_src')
}
await query(`
CREATE TABLE IF NOT EXISTS game_items (
id VARCHAR(64) PRIMARY KEY,
game_id VARCHAR(120) NOT NULL,
src VARCHAR(255) NOT NULL,
label VARCHAR(120) NOT NULL,
created_at BIGINT NOT NULL,
INDEX idx_game_items_game_id (game_id),
CONSTRAINT fk_game_items_game FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await query(`
CREATE TABLE IF NOT EXISTS custom_items (
id VARCHAR(64) PRIMARY KEY,
owner_id VARCHAR(64) NOT NULL,
src VARCHAR(255) NOT NULL,
label VARCHAR(120) NOT NULL,
created_at BIGINT NOT NULL,
INDEX idx_custom_items_owner_id (owner_id),
CONSTRAINT fk_custom_items_owner FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await query(`
CREATE TABLE IF NOT EXISTS tierlists (
id VARCHAR(64) PRIMARY KEY,
author_id VARCHAR(64) NOT NULL,
game_id VARCHAR(120) NOT NULL,
title VARCHAR(120) NOT NULL,
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
description TEXT NOT NULL,
is_public TINYINT(1) NOT NULL DEFAULT 0,
show_character_names TINYINT(1) NOT NULL DEFAULT 0,
groups_json LONGTEXT NOT NULL,
pool_json LONGTEXT NOT NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
INDEX idx_tierlists_author_id (author_id),
INDEX idx_tierlists_game_id (game_id),
INDEX idx_tierlists_public_game_updated (is_public, game_id, updated_at),
CONSTRAINT fk_tierlists_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_tierlists_game FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await query(`
CREATE TABLE IF NOT EXISTS favorite_tierlists (
user_id VARCHAR(64) NOT NULL,
tierlist_id VARCHAR(64) NOT NULL,
created_at BIGINT NOT NULL,
PRIMARY KEY (user_id, tierlist_id),
INDEX idx_favorite_tierlists_tierlist_id (tierlist_id),
CONSTRAINT fk_favorite_tierlists_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_favorite_tierlists_tierlist FOREIGN KEY (tierlist_id) REFERENCES tierlists(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await query(`
CREATE TABLE IF NOT EXISTS template_requests (
id VARCHAR(64) PRIMARY KEY,
request_type VARCHAR(20) NOT NULL,
requester_id VARCHAR(64) NOT NULL,
source_tierlist_id VARCHAR(64) NOT NULL,
source_game_id VARCHAR(120) NOT NULL,
target_game_id VARCHAR(120) NOT NULL DEFAULT '',
status VARCHAR(20) NOT NULL DEFAULT 'pending',
title_snapshot VARCHAR(120) NOT NULL,
description_snapshot TEXT NOT NULL,
thumbnail_src_snapshot VARCHAR(255) NOT NULL DEFAULT '',
items_json LONGTEXT NOT NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
INDEX idx_template_requests_status_created (status, created_at),
INDEX idx_template_requests_source_tierlist (source_tierlist_id),
INDEX idx_template_requests_requester (requester_id),
CONSTRAINT fk_template_requests_requester FOREIGN KEY (requester_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_template_requests_source_tierlist FOREIGN KEY (source_tierlist_id) REFERENCES tierlists(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const tierListThumbnailColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'thumbnail_src'")
if (!tierListThumbnailColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN thumbnail_src VARCHAR(255) NOT NULL DEFAULT '' AFTER title")
}
const tierListShowNamesColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'show_character_names'")
if (!tierListShowNamesColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN show_character_names TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public")
}
await query(
`
INSERT INTO games (id, name, thumbnail_src, created_at)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name)
`,
[FREEFORM_GAME_ID, '직접 티어표 만들기', '', now()]
)
const countRows = await query('SELECT COUNT(*) AS count FROM games')
if (Number(countRows[0]?.count || 0) <= 1) {
const createdAt = now()
await query(
`
INSERT INTO games (id, name, thumbnail_src, created_at)
VALUES
(?, ?, ?, ?),
(?, ?, ?, ?)
`,
['example-game', '예시 게임', '', createdAt, 'another-game', '다른 예시 게임', '', createdAt]
)
await query(
`
INSERT INTO game_items (id, game_id, src, label, created_at)
VALUES
(?, ?, ?, ?, ?),
(?, ?, ?, ?, ?)
`,
[
'img-1',
'example-game',
'/uploads/seeds/example1.png',
'샘플 1',
createdAt,
'img-2',
'example-game',
'/uploads/seeds/example2.png',
'샘플 2',
createdAt,
]
)
}
})()
return initPromise
}
async function ensureData() {
await ensureSchema()
}
async function countUsers() {
const rows = await query('SELECT COUNT(*) AS count FROM users')
return Number(rows[0]?.count || 0)
}
async function findUserByEmail(email) {
const rows = await query(
'SELECT id, email, nickname, password_hash, is_admin, avatar_src, created_at FROM users WHERE email = ? LIMIT 1',
[email]
)
const row = rows[0]
if (!row) return null
return { ...mapUserRow(row), passwordHash: row.password_hash }
}
async function findUserById(id) {
const rows = await query(
'SELECT id, email, nickname, is_admin, avatar_src, created_at FROM users WHERE id = ? LIMIT 1',
[id]
)
return mapUserRow(rows[0])
}
async function createUser({ id, email, nickname, passwordHash, isAdmin }) {
const createdAt = now()
await query(
`
INSERT INTO users (id, email, nickname, password_hash, is_admin, avatar_src, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`,
[id, email, nickname || '', passwordHash, isAdmin ? 1 : 0, '', createdAt]
)
return findUserById(id)
}
async function updateUserProfile({ id, nickname, avatarSrc }) {
if (typeof avatarSrc === 'string') {
await query('UPDATE users SET nickname = ?, avatar_src = ? WHERE id = ?', [nickname || '', avatarSrc, id])
} else {
await query('UPDATE users SET nickname = ? WHERE id = ?', [nickname || '', id])
}
return findUserById(id)
}
async function listUsers() {
const rows = await query(`
SELECT
u.id,
u.email,
u.nickname,
u.is_admin,
u.avatar_src,
u.created_at,
COUNT(t.id) AS tierlist_count,
GREATEST(
u.created_at,
COALESCE(MAX(t.updated_at), 0)
) AS recent_activity_at
FROM users u
LEFT JOIN tierlists t ON t.author_id = u.id
GROUP BY u.id, u.email, u.nickname, u.is_admin, u.avatar_src, u.created_at
ORDER BY recent_activity_at DESC, u.created_at ASC, u.email ASC
`)
return rows.map(mapUserRow)
}
async function adminUpdateUser({ id, email, nickname, isAdmin, avatarSrc }) {
if (typeof avatarSrc === 'string') {
await query('UPDATE users SET email = ?, nickname = ?, is_admin = ?, avatar_src = ? WHERE id = ?', [
email,
nickname || '',
isAdmin ? 1 : 0,
avatarSrc,
id,
])
} else {
await query('UPDATE users SET email = ?, nickname = ?, is_admin = ? WHERE id = ?', [
email,
nickname || '',
isAdmin ? 1 : 0,
id,
])
}
return findUserById(id)
}
async function adminUpdateUserPassword({ id, passwordHash }) {
await query('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, id])
return findUserById(id)
}
async function adminDeleteUser(id) {
await query('DELETE FROM users WHERE id = ?', [id])
}
async function listGames() {
const rows = await query(
`
SELECT id, name, thumbnail_src, display_rank, created_at
FROM games
WHERE id <> ?
ORDER BY
CASE WHEN display_rank IS NULL THEN 1 ELSE 0 END ASC,
display_rank ASC,
created_at DESC,
name ASC
`,
[FREEFORM_GAME_ID]
)
return rows.map(mapGameRow)
}
async function findGameById(id) {
const rows = await query('SELECT id, name, thumbnail_src, display_rank, created_at FROM games WHERE id = ? LIMIT 1', [id])
return mapGameRow(rows[0])
}
async function listGameItems(gameId) {
const rows = await query(
'SELECT id, game_id, src, label, created_at FROM game_items WHERE game_id = ? ORDER BY created_at ASC',
[gameId]
)
return rows.map(mapGameItemRow)
}
async function getGameDetail(gameId) {
const game = await findGameById(gameId)
if (!game) return null
const items = await listGameItems(gameId)
return { game, items }
}
async function createGame({ id, name }) {
await query('INSERT INTO games (id, name, thumbnail_src, display_rank, created_at) VALUES (?, ?, ?, ?, ?)', [
id,
name,
'',
null,
now(),
])
return findGameById(id)
}
async function updateGameThumbnail(gameId, thumbnailSrc) {
await query('UPDATE games SET thumbnail_src = ? WHERE id = ?', [thumbnailSrc, gameId])
return findGameById(gameId)
}
async function createGameItem({ id, gameId, src, label }) {
const createdAt = now()
await query('INSERT INTO game_items (id, game_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [
id,
gameId,
src,
label,
createdAt,
])
const rows = await query('SELECT id, game_id, src, label, created_at FROM game_items WHERE id = ? LIMIT 1', [id])
return mapGameItemRow(rows[0])
}
async function updateGameItemLabel(itemId, label) {
await query('UPDATE game_items SET label = ? WHERE id = ?', [label, itemId])
const rows = await query('SELECT id, game_id, src, label, created_at FROM game_items WHERE id = ? LIMIT 1', [itemId])
return mapGameItemRow(rows[0])
}
async function deleteGameItem(itemId) {
const gameItemRows = await query('SELECT game_id FROM game_items WHERE id = ? LIMIT 1', [itemId])
const gameId = gameItemRows[0]?.game_id
if (gameId) {
const tierListRows = await query(
`
SELECT id, author_id, game_id, title, description, is_public, groups_json, pool_json, created_at, updated_at
FROM tierlists
WHERE game_id = ?
`,
[gameId]
)
for (const row of tierListRows) {
const tierList = mapTierListRow(row)
const nextGroups = (tierList.groups || []).map((group) => ({
...group,
itemIds: (group.itemIds || []).filter((id) => id !== itemId),
}))
const nextPool = (tierList.pool || []).filter((item) => item.id !== itemId)
await query(
'UPDATE tierlists SET groups_json = ?, pool_json = ?, updated_at = ? WHERE id = ?',
[serializeJson(nextGroups), serializeJson(nextPool), now(), tierList.id]
)
}
}
await query('DELETE FROM game_items WHERE id = ?', [itemId])
}
async function deleteGame(gameId) {
await query('DELETE FROM games WHERE id = ?', [gameId])
}
async function updateGameDisplayOrder(gameIds) {
const normalizedIds = Array.from(new Set((gameIds || []).filter((id) => id && id !== FREEFORM_GAME_ID))).slice(0, 50)
await query('UPDATE games SET display_rank = NULL WHERE id <> ?', [FREEFORM_GAME_ID])
await Promise.all(
normalizedIds.map((gameId, index) =>
query('UPDATE games SET display_rank = ? WHERE id = ? AND id <> ?', [index + 1, gameId, FREEFORM_GAME_ID])
)
)
return listGames()
}
async function createCustomItem({ id, ownerId, src, label }) {
const createdAt = now()
await query('INSERT INTO custom_items (id, owner_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [
id,
ownerId,
src,
label,
createdAt,
])
return { id, ownerId, src, label, origin: 'custom', createdAt }
}
async function syncOwnedCustomItemLabels({ ownerId, items }) {
const customItems = Array.from(
new Map(
(items || [])
.filter((item) => item?.origin === 'custom' && item?.id && typeof item.label === 'string')
.map((item) => [item.id, item])
).values()
)
if (!customItems.length) return
await Promise.all(
customItems.map((item) =>
query('UPDATE custom_items SET label = ? WHERE id = ? AND owner_id = ?', [item.label.trim().slice(0, 60), item.id, ownerId])
)
)
}
async function findCustomItemById(id) {
const rows = await query(
`
SELECT id, owner_id, src, label, created_at
FROM custom_items
WHERE id = ?
LIMIT 1
`,
[id]
)
const row = rows[0]
if (!row) return null
return {
id: row.id,
ownerId: row.owner_id,
src: row.src,
label: row.label,
createdAt: Number(row.created_at),
}
}
async function getCustomItemUsageMap() {
const rows = await query('SELECT groups_json, pool_json FROM tierlists')
const usageMap = new Map()
rows.forEach((row) => {
const groups = parseJson(row.groups_json, [])
const pool = parseJson(row.pool_json, [])
groups.forEach((group) => {
;(group?.itemIds || []).forEach((itemId) => {
usageMap.set(itemId, (usageMap.get(itemId) || 0) + 1)
})
})
pool.forEach((item) => {
if (item?.id) {
usageMap.set(item.id, (usageMap.get(item.id) || 0) + 1)
}
})
})
return usageMap
}
async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnly = false } = {}) {
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
const normalizedPage = Math.max(Number(page) || 1, 1)
const hasQuery = !!(queryText || '').trim()
const search = `%${(queryText || '').trim()}%`
const whereClause = hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : ''
const params = hasQuery ? [search, search, search, search] : []
const rows = await query(
`
SELECT
c.id,
c.owner_id,
c.src,
c.label,
c.created_at,
u.nickname,
u.email
FROM custom_items c
INNER JOIN users u ON u.id = c.owner_id
${whereClause}
ORDER BY c.created_at DESC
`,
params
)
const usageMap = await getCustomItemUsageMap()
const allItems = rows
.map((row) => ({
id: row.id,
ownerId: row.owner_id,
src: row.src,
label: row.label,
createdAt: Number(row.created_at),
ownerName: row.nickname || row.email,
ownerEmail: row.email,
usageCount: usageMap.get(row.id) || 0,
}))
.filter((item) => (orphanOnly ? item.usageCount === 0 : true))
const total = allItems.length
const offset = (normalizedPage - 1) * normalizedLimit
const pagedItems = allItems.slice(offset, offset + normalizedLimit)
return {
items: pagedItems,
total,
page: normalizedPage,
limit: normalizedLimit,
}
}
async function findUnusedCustomItems({ queryText = '' } = {}) {
const hasQuery = !!(queryText || '').trim()
const search = `%${(queryText || '').trim()}%`
const whereClause = hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : ''
const params = hasQuery ? [search, search, search, search] : []
const rows = await query(
`
SELECT
c.id,
c.owner_id,
c.src,
c.label,
c.created_at,
u.nickname,
u.email
FROM custom_items c
INNER JOIN users u ON u.id = c.owner_id
${whereClause}
ORDER BY c.created_at DESC
`,
params
)
const usageMap = await getCustomItemUsageMap()
return rows
.map((row) => ({
id: row.id,
ownerId: row.owner_id,
src: row.src,
label: row.label,
createdAt: Number(row.created_at),
ownerName: row.nickname || row.email,
ownerEmail: row.email,
usageCount: usageMap.get(row.id) || 0,
}))
.filter((item) => item.usageCount === 0)
}
async function getFavoriteStatsForTierListIds(tierListIds, userId = '') {
const ids = Array.from(new Set((tierListIds || []).filter(Boolean)))
const countMap = new Map()
const favoritedSet = new Set()
if (!ids.length) return { countMap, favoritedSet }
const placeholders = ids.map(() => '?').join(', ')
const countRows = await query(
`
SELECT tierlist_id, COUNT(*) AS favorite_count
FROM favorite_tierlists
WHERE tierlist_id IN (${placeholders})
GROUP BY tierlist_id
`,
ids
)
countRows.forEach((row) => {
countMap.set(row.tierlist_id, Number(row.favorite_count || 0))
})
if (userId) {
const favoriteRows = await query(
`
SELECT tierlist_id
FROM favorite_tierlists
WHERE user_id = ? AND tierlist_id IN (${placeholders})
`,
[userId, ...ids]
)
favoriteRows.forEach((row) => favoritedSet.add(row.tierlist_id))
}
return { countMap, favoritedSet }
}
function applyFavoriteMetaToTierLists(tierLists, favoriteStats) {
return tierLists.map((tierList) => ({
...tierList,
favoriteCount: favoriteStats.countMap.get(tierList.id) || 0,
isFavorited: favoriteStats.favoritedSet.has(tierList.id),
}))
}
async function listPublicTierLists(gameId, currentUserId = '', queryText = '') {
const params = []
let whereClause = 'WHERE t.is_public = 1'
if (gameId) {
whereClause += ' AND t.game_id = ?'
params.push(gameId)
}
if ((queryText || '').trim()) {
const search = `%${queryText.trim()}%`
whereClause += ' AND (t.title LIKE ? OR u.nickname LIKE ? OR u.email LIKE ?)'
params.push(search, search, search)
}
const rows = await query(
`
SELECT
t.id,
t.game_id,
t.title,
t.thumbnail_src,
t.created_at,
t.updated_at,
t.author_id,
u.nickname,
u.email,
u.avatar_src
FROM tierlists t
INNER JOIN users u ON u.id = t.author_id
${whereClause}
ORDER BY t.updated_at DESC
LIMIT 50
`,
params
)
const tierLists = rows.map((row) => ({
id: row.id,
gameId: row.game_id,
title: row.title,
thumbnailSrc: row.thumbnail_src || '',
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
authorId: row.author_id,
authorName: getUserDisplayName(row),
authorAccountName: getUserAccountName(row),
authorAvatarSrc: row.avatar_src || '',
}))
const favoriteStats = await getFavoriteStatsForTierListIds(
tierLists.map((tierList) => tierList.id),
currentUserId
)
return applyFavoriteMetaToTierLists(tierLists, favoriteStats)
}
async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited' } = {}) {
const allowedSort = new Set(['favorited', 'updated', 'favorites'])
const normalizedSort = allowedSort.has(sort) ? sort : 'favorited'
const params = [userId]
let whereClause = 'WHERE f.user_id = ?'
if ((queryText || '').trim()) {
const search = `%${queryText.trim()}%`
whereClause += ' AND (t.title LIKE ? OR g.name LIKE ? OR u.nickname LIKE ? OR u.email LIKE ?)'
params.push(search, search, search, search)
}
const orderClause =
normalizedSort === 'updated'
? 'ORDER BY t.updated_at DESC, f.created_at DESC'
: normalizedSort === 'favorites'
? 'ORDER BY favorite_count DESC, t.updated_at DESC'
: 'ORDER BY f.created_at DESC, t.updated_at DESC'
const rows = await query(
`
SELECT
t.id,
t.author_id,
t.game_id,
g.name AS game_name,
t.title,
t.thumbnail_src,
t.description,
t.is_public,
t.show_character_names,
t.groups_json,
t.pool_json,
t.created_at,
t.updated_at,
f.created_at AS favorited_at,
u.nickname,
u.email,
u.avatar_src,
(
SELECT COUNT(*)
FROM favorite_tierlists ff
WHERE ff.tierlist_id = t.id
) AS favorite_count
FROM favorite_tierlists f
INNER JOIN tierlists t ON t.id = f.tierlist_id
INNER JOIN users u ON u.id = t.author_id
INNER JOIN games g ON g.id = t.game_id
${whereClause}
${orderClause}
`,
params
)
return rows.map((row) => ({
...mapTierListRow(row),
favoritedAt: Number(row.favorited_at || 0),
favoriteCount: Number(row.favorite_count || 0),
isFavorited: true,
}))
}
async function listUserTierLists(userId) {
const rows = await query(
`
SELECT
t.id,
t.game_id,
t.title,
t.thumbnail_src,
t.created_at,
t.updated_at,
t.is_public,
u.nickname,
u.email,
u.avatar_src
FROM tierlists t
INNER JOIN users u ON u.id = t.author_id
WHERE t.author_id = ?
ORDER BY updated_at DESC
`,
[userId]
)
const tierLists = rows.map((row) => ({
id: row.id,
gameId: row.game_id,
title: row.title,
thumbnailSrc: row.thumbnail_src || '',
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
isPublic: !!row.is_public,
authorName: getUserDisplayName(row),
authorAccountName: getUserAccountName(row),
authorAvatarSrc: row.avatar_src || '',
}))
const favoriteStats = await getFavoriteStatsForTierListIds(
tierLists.map((tierList) => tierList.id),
userId
)
return applyFavoriteMetaToTierLists(tierLists, favoriteStats)
}
function uniqueTierListItems(poolItems) {
const map = new Map()
;(poolItems || []).forEach((item) => {
if (!item?.id || map.has(item.id)) return
map.set(item.id, {
id: item.id,
src: item.src || '',
label: item.label || 'item',
origin: item.origin || 'game',
})
})
return Array.from(map.values())
}
function getAutoThumbnailSrc(groups = [], pool = []) {
const itemMap = new Map((pool || []).filter((item) => item?.id && item?.src).map((item) => [item.id, item]))
for (const group of groups || []) {
for (const itemId of group?.itemIds || []) {
const item = itemMap.get(itemId)
if (item?.src) return item.src
}
}
const fallbackItem = (pool || []).find((item) => item?.src)
return fallbackItem?.src || ''
}
async function listAdminTierLists({ queryText = '', page = 1, limit = 50, currentUserId = '' } = {}) {
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
const normalizedPage = Math.max(Number(page) || 1, 1)
const hasQuery = !!(queryText || '').trim()
const search = `%${(queryText || '').trim()}%`
const whereClause = hasQuery
? `
WHERE
t.title LIKE ?
OR g.name LIKE ?
OR g.id LIKE ?
OR u.email LIKE ?
OR u.nickname LIKE ?
`
: ''
const params = hasQuery ? [search, search, search, search, search] : []
const rows = await query(
`
SELECT
t.id,
t.author_id,
t.game_id,
g.name AS game_name,
t.title,
t.thumbnail_src,
t.description,
t.is_public,
t.show_character_names,
t.groups_json,
t.pool_json,
t.created_at,
t.updated_at,
u.nickname,
u.email,
u.avatar_src
FROM tierlists t
INNER JOIN users u ON u.id = t.author_id
INNER JOIN games g ON g.id = t.game_id
${whereClause}
ORDER BY t.updated_at DESC, t.created_at DESC
`,
params
)
const allItems = rows.map((row) => {
const tierList = mapTierListRow(row)
const poolItems = uniqueTierListItems(tierList.pool)
const extraItems = poolItems.filter((item) => item.origin === 'custom')
return {
...tierList,
itemCount: poolItems.length,
extraItemCount: extraItems.length,
extraItems,
}
})
const total = allItems.length
const offset = (normalizedPage - 1) * normalizedLimit
const pagedTierLists = allItems.slice(offset, offset + normalizedLimit)
const favoriteStats = await getFavoriteStatsForTierListIds(
pagedTierLists.map((tierList) => tierList.id),
currentUserId
)
return {
tierLists: applyFavoriteMetaToTierLists(pagedTierLists, favoriteStats),
total,
page: normalizedPage,
limit: normalizedLimit,
}
}
async function findTierListById(id, currentUserId = '') {
const rows = await query(
`
SELECT
t.id,
t.author_id,
t.game_id,
g.name AS game_name,
t.title,
t.thumbnail_src,
t.description,
t.is_public,
t.show_character_names,
t.groups_json,
t.pool_json,
t.created_at,
t.updated_at,
u.nickname,
u.email,
u.avatar_src
FROM tierlists t
INNER JOIN users u ON u.id = t.author_id
INNER JOIN games g ON g.id = t.game_id
WHERE t.id = ?
LIMIT 1
`,
[id]
)
const tierList = mapTierListRow(rows[0])
if (!tierList) return null
const favoriteStats = await getFavoriteStatsForTierListIds([tierList.id], currentUserId)
return applyFavoriteMetaToTierLists([tierList], favoriteStats)[0]
}
async function findPendingTemplateRequestByTierList({ sourceTierListId, type }) {
const rows = await query(
`
SELECT id, request_type, status
FROM template_requests
WHERE source_tierlist_id = ? AND request_type = ? AND status = 'pending'
LIMIT 1
`,
[sourceTierListId, type]
)
return rows[0] || null
}
async function createTemplateRequest({
id,
type,
requesterId,
sourceTierListId,
sourceGameId,
targetGameId = '',
title,
description = '',
thumbnailSrc = '',
items = [],
}) {
const existing = await findPendingTemplateRequestByTierList({ sourceTierListId, type })
if (existing) {
const err = new Error('template_request_exists')
err.code = 'TEMPLATE_REQUEST_EXISTS'
throw err
}
const createdAt = now()
await query(
`
INSERT INTO template_requests (
id,
request_type,
requester_id,
source_tierlist_id,
source_game_id,
target_game_id,
status,
title_snapshot,
description_snapshot,
thumbnail_src_snapshot,
items_json,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?)
`,
[
id,
type,
requesterId,
sourceTierListId,
sourceGameId,
targetGameId,
title,
description,
thumbnailSrc,
serializeJson(items),
createdAt,
createdAt,
]
)
return findTemplateRequestById(id)
}
async function findTemplateRequestById(id) {
const rows = await query(
`
SELECT
tr.id,
tr.request_type,
tr.requester_id,
tr.source_tierlist_id,
tr.source_game_id,
tr.target_game_id,
tr.status,
tr.title_snapshot,
tr.description_snapshot,
tr.thumbnail_src_snapshot,
tr.items_json,
tr.created_at,
tr.updated_at,
u.nickname,
u.email,
u.avatar_src AS requester_avatar_src,
sg.name AS source_game_name,
tg.name AS target_game_name
FROM template_requests tr
INNER JOIN users u ON u.id = tr.requester_id
LEFT JOIN games sg ON sg.id = tr.source_game_id
LEFT JOIN games tg ON tg.id = tr.target_game_id
WHERE tr.id = ?
LIMIT 1
`,
[id]
)
return mapTemplateRequestRow(rows[0])
}
async function listAdminTemplateRequests({ status = 'pending' } = {}) {
const rows = await query(
`
SELECT
tr.id,
tr.request_type,
tr.requester_id,
tr.source_tierlist_id,
tr.source_game_id,
tr.target_game_id,
tr.status,
tr.title_snapshot,
tr.description_snapshot,
tr.thumbnail_src_snapshot,
tr.items_json,
tr.created_at,
tr.updated_at,
u.nickname,
u.email,
u.avatar_src AS requester_avatar_src,
sg.name AS source_game_name,
tg.name AS target_game_name
FROM template_requests tr
INNER JOIN users u ON u.id = tr.requester_id
LEFT JOIN games sg ON sg.id = tr.source_game_id
LEFT JOIN games tg ON tg.id = tr.target_game_id
WHERE tr.status = ?
ORDER BY tr.created_at DESC
`,
[status]
)
return rows.map(mapTemplateRequestRow)
}
async function updateTemplateRequestStatus({ id, status }) {
await query('UPDATE template_requests SET status = ?, updated_at = ? WHERE id = ?', [status, now(), id])
return findTemplateRequestById(id)
}
async function deleteTierList(id) {
await query('DELETE FROM tierlists WHERE id = ?', [id])
}
async function findCustomItemsByIds(ids) {
if (!ids.length) return []
const placeholders = ids.map(() => '?').join(', ')
const rows = await query(
`
SELECT id, owner_id, src, label, created_at
FROM custom_items
WHERE id IN (${placeholders})
`,
ids
)
return rows.map((row) => ({
id: row.id,
ownerId: row.owner_id,
src: row.src,
label: row.label,
createdAt: Number(row.created_at),
}))
}
async function deleteCustomItems(ids) {
if (!ids.length) return
const placeholders = ids.map(() => '?').join(', ')
await query(`DELETE FROM custom_items WHERE id IN (${placeholders})`, ids)
}
async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', description, isPublic, showCharacterNames = false, 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 = ?, groups_json = ?, pool_json = ?, updated_at = ?
WHERE id = ?
`,
[title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, serializeJson(groups), serializeJson(pool), now(), existing.id]
)
return findTierListById(existing.id, authorId)
}
const createdAt = now()
await query(
`
INSERT INTO tierlists (
id, author_id, game_id, title, thumbnail_src, description, is_public, show_character_names, groups_json, pool_json, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[id, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, serializeJson(groups), serializeJson(pool), createdAt, createdAt]
)
return findTierListById(id, authorId)
}
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])
}
module.exports = {
DB_NAME,
ensureData,
countUsers,
findUserByEmail,
findUserById,
createUser,
updateUserProfile,
listUsers,
adminUpdateUser,
adminUpdateUserPassword,
adminDeleteUser,
listGames,
findGameById,
listGameItems,
getGameDetail,
createGame,
updateGameThumbnail,
createGameItem,
updateGameItemLabel,
deleteGameItem,
deleteGame,
updateGameDisplayOrder,
createCustomItem,
findCustomItemById,
listCustomItems,
findUnusedCustomItems,
listPublicTierLists,
listFavoriteTierLists,
listUserTierLists,
listAdminTierLists,
findTierListById,
favoriteTierList,
unfavoriteTierList,
deleteTierList,
findCustomItemsByIds,
deleteCustomItems,
saveTierList,
createTemplateRequest,
findTemplateRequestById,
listAdminTemplateRequests,
updateTemplateRequestStatus,
}