템플릿 slug 구조와 빈 DB 초기화를 정리

This commit is contained in:
2026-04-03 14:36:52 +09:00
parent 30ec2e55b0
commit f506e31549
20 changed files with 422 additions and 290 deletions

View File

@@ -1,6 +1,7 @@
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
@@ -9,6 +10,7 @@ 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
@@ -30,6 +32,20 @@ function serializeJson(value) {
return JSON.stringify(value || [])
}
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/')) {
@@ -75,8 +91,10 @@ 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,
@@ -138,6 +156,7 @@ function mapTierListRow(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 || '',
@@ -169,11 +188,13 @@ function mapTemplateRequestRow(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, []),
@@ -254,32 +275,6 @@ async function query(sql, params = []) {
return rows
}
async function tableExists(name) {
const rows = await query(
`
SELECT TABLE_NAME
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
LIMIT 1
`,
[DB_NAME, name]
)
return rows.length > 0
}
async function columnExists(tableName, columnName) {
const rows = await query(
`
SELECT COLUMN_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?
LIMIT 1
`,
[DB_NAME, tableName, columnName]
)
return rows.length > 0
}
async function closePool() {
if (!poolPromise) return
const pool = await poolPromise
@@ -305,18 +300,6 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const userEmailVerifiedColumns = await query("SHOW COLUMNS FROM users LIKE 'email_verified'")
if (!userEmailVerifiedColumns.length) {
await query('ALTER TABLE users ADD COLUMN email_verified TINYINT(1) NOT NULL DEFAULT 1 AFTER password_hash')
await query('UPDATE users SET email_verified = 1 WHERE email_verified IS NULL')
}
const userLastLoginColumns = await query("SHOW COLUMNS FROM users LIKE 'last_login_at'")
if (!userLastLoginColumns.length) {
await query('ALTER TABLE users ADD COLUMN last_login_at BIGINT NOT NULL DEFAULT 0 AFTER avatar_src')
await query('UPDATE users SET last_login_at = created_at WHERE last_login_at = 0')
}
await query(`
CREATE TABLE IF NOT EXISTS email_verification_tokens (
id VARCHAR(64) PRIMARY KEY,
@@ -348,25 +331,16 @@ async function ensureSchema() {
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 '',
is_public TINYINT(1) NOT NULL DEFAULT 1,
display_rank INT NULL DEFAULT NULL,
created_at BIGINT NOT NULL
created_at BIGINT NOT NULL,
UNIQUE KEY uq_topics_slug (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const topicIsPublicColumns = await query("SHOW COLUMNS FROM topics LIKE 'is_public'")
if (!topicIsPublicColumns.length) {
await query('ALTER TABLE topics ADD COLUMN is_public TINYINT(1) NOT NULL DEFAULT 1 AFTER thumbnail_src')
await query('UPDATE topics SET is_public = 1 WHERE is_public IS NULL')
}
const displayRankColumns = await query("SHOW COLUMNS FROM topics LIKE 'display_rank'")
if (!displayRankColumns.length) {
await query('ALTER TABLE topics ADD COLUMN display_rank INT NULL DEFAULT NULL AFTER thumbnail_src')
}
await query(`
CREATE TABLE IF NOT EXISTS topic_items (
id VARCHAR(64) PRIMARY KEY,
@@ -380,11 +354,6 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const topicItemDisplayOrderColumns = await query("SHOW COLUMNS FROM topic_items LIKE 'display_order'")
if (!topicItemDisplayOrderColumns.length) {
await query('ALTER TABLE topic_items ADD COLUMN display_order INT NULL DEFAULT NULL AFTER label')
}
await query(`
CREATE TABLE IF NOT EXISTS custom_items (
id VARCHAR(64) PRIMARY KEY,
@@ -479,11 +448,6 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const imageAssetLabelColumns = await query("SHOW COLUMNS FROM image_assets LIKE 'label_override'")
if (!imageAssetLabelColumns.length) {
await query("ALTER TABLE image_assets ADD COLUMN label_override VARCHAR(120) NOT NULL DEFAULT '' AFTER src")
}
await query(`
CREATE TABLE IF NOT EXISTS image_optimization_jobs (
id VARCHAR(64) PRIMARY KEY,
@@ -524,125 +488,14 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const templateRequestSourceTierListColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'source_tierlist_id'")
if (templateRequestSourceTierListColumns[0]?.Null !== 'YES') {
await query('ALTER TABLE template_requests MODIFY source_tierlist_id VARCHAR(64) NULL')
}
const templateRequestTypeColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'request_type'")
if (!templateRequestTypeColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN request_type VARCHAR(20) NOT NULL DEFAULT 'create' AFTER id")
}
const hasSourceTopicId = await columnExists('template_requests', 'source_topic_id')
if (!hasSourceTopicId) {
await query("ALTER TABLE template_requests ADD COLUMN source_topic_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER source_tierlist_id")
}
const hasTargetTopicId = await columnExists('template_requests', 'target_topic_id')
if (!hasTargetTopicId) {
await query("ALTER TABLE template_requests ADD COLUMN target_topic_id VARCHAR(120) NOT NULL DEFAULT '' AFTER source_topic_id")
}
const templateRequestStatusColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'status'")
if (!templateRequestStatusColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'pending' AFTER target_topic_id")
}
const templateRequestGroupsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'groups_json'")
if (!templateRequestGroupsColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN groups_json LONGTEXT NOT NULL AFTER items_json")
}
const templateRequestBoardItemsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'board_items_json'")
if (!templateRequestBoardItemsColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN board_items_json LONGTEXT NOT NULL AFTER groups_json")
}
const templateRequestShowNamesColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'show_character_names_snapshot'")
if (!templateRequestShowNamesColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN show_character_names_snapshot TINYINT(1) NOT NULL DEFAULT 0 AFTER board_items_json")
}
const tierListThumbnailColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'thumbnail_src'")
if (!tierListThumbnailColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN thumbnail_src VARCHAR(255) NOT NULL DEFAULT '' AFTER title")
}
const tierListTopicIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'topic_id'")
if (!tierListTopicIdColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN topic_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER author_id")
}
const tierListShowNamesColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'show_character_names'")
if (!tierListShowNamesColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN show_character_names TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public")
}
const tierListFeaturedColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'is_featured'")
if (!tierListFeaturedColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN is_featured TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public")
}
const tierListFeaturedAtColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'featured_at'")
if (!tierListFeaturedAtColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN featured_at BIGINT NOT NULL DEFAULT 0 AFTER is_featured")
}
const tierListFeaturedByColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'featured_by'")
if (!tierListFeaturedByColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN featured_by VARCHAR(64) NOT NULL DEFAULT '' AFTER featured_at")
}
const tierListIconSizeColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'icon_size'")
if (!tierListIconSizeColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN icon_size INT NOT NULL DEFAULT 80 AFTER show_character_names")
}
const tierListSourceIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_tierlist_id'")
if (!tierListSourceIdColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN source_tierlist_id VARCHAR(64) NULL DEFAULT NULL AFTER icon_size")
} else if (tierListSourceIdColumns[0]?.Null !== 'YES') {
await query('ALTER TABLE tierlists MODIFY source_tierlist_id VARCHAR(64) NULL DEFAULT NULL')
}
const tierListSourceTitleColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_snapshot_title'")
if (!tierListSourceTitleColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN source_snapshot_title VARCHAR(120) NOT NULL DEFAULT '' AFTER source_tierlist_id")
}
const tierListSourceAuthorColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_snapshot_author'")
if (!tierListSourceAuthorColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN source_snapshot_author VARCHAR(120) NOT NULL DEFAULT '' AFTER source_snapshot_title")
}
await query(
`
INSERT INTO topics (id, name, thumbnail_src, created_at)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name)
INSERT INTO topics (id, slug, name, thumbnail_src, is_public, created_at)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE slug = VALUES(slug), name = VALUES(name), is_public = VALUES(is_public)
`,
[FREEFORM_TOPIC_ID, '직접 티어표 만들기', '', now()]
[FREEFORM_TOPIC_ID, FREEFORM_TOPIC_ID, '직접 티어표 만들기', '', 1, now()]
)
const countRows = await query('SELECT COUNT(*) AS count FROM topics')
if (Number(countRows[0]?.count || 0) <= 1) {
const createdAt = now()
await query(
`
INSERT INTO topics (id, name, thumbnail_src, created_at)
VALUES
(?, ?, ?, ?),
(?, ?, ?, ?)
`,
['example-topic', '예시 주제', '', createdAt, 'another-topic', '다른 예시 주제', '', createdAt]
)
await query(
`
INSERT INTO topic_items (id, topic_id, src, label, created_at)
VALUES
(?, ?, ?, ?, ?),
(?, ?, ?, ?, ?)
`,
[
'img-1',
'example-topic',
'/uploads/seeds/example1.png',
'샘플 1',
createdAt,
'img-2',
'example-topic',
'/uploads/seeds/example2.png',
'샘플 2',
createdAt,
]
)
}
})()
return initPromise
@@ -954,7 +807,7 @@ async function listTopics(currentUserId = '', options = {}) {
const includePrivate = !!options.includePrivate
const rows = await query(
`
SELECT id, name, thumbnail_src, is_public, display_rank, created_at
SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at
FROM topics
WHERE id <> ?
${includePrivate ? '' : 'AND is_public = 1'}
@@ -978,7 +831,39 @@ async function listTopics(currentUserId = '', options = {}) {
}
async function findTopicById(id) {
const rows = await query('SELECT id, name, thumbnail_src, is_public, display_rank, created_at FROM topics WHERE id = ? LIMIT 1', [id])
const rows = await query(
'SELECT id, slug, name, thumbnail_src, 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, 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, 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])
}
@@ -1004,23 +889,37 @@ async function findTopicItemById(itemId) {
return mapTopicItemRow(rows[0])
}
async function getTopicDetail(topicId) {
const topic = await findTopicById(topicId)
async function getTopicDetail(topicRef) {
const topic = await findTopicByIdentifier(topicRef)
if (!topic) return null
const items = await listTopicItems(topicId)
const items = await listTopicItems(topic.id)
return { topic, template: topic, items }
}
async function createTopic({ id, name, isPublic = true }) {
await query('INSERT INTO topics (id, name, thumbnail_src, is_public, display_rank, created_at) VALUES (?, ?, ?, ?, ?, ?)', [
id,
async function createTopic({ slug, name, isPublic = true }) {
const topicId = nanoid()
const topicSlug = assertTopicSlug(slug)
await query('INSERT INTO topics (id, slug, name, thumbnail_src, is_public, display_rank, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', [
topicId,
topicSlug,
name,
'',
isPublic ? 1 : 0,
null,
now(),
])
return findTopicById(id)
return findTopicById(topicId)
}
async function updateTopicMeta(topicId, { slug, name, isPublic }) {
const topicSlug = assertTopicSlug(slug)
await query('UPDATE topics SET slug = ?, name = ?, is_public = ? WHERE id = ?', [
topicSlug,
name,
isPublic ? 1 : 0,
topicId,
])
return findTopicById(topicId)
}
async function updateTopicThumbnail(topicId, thumbnailSrc) {
@@ -2106,8 +2005,15 @@ 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(topicId)
params.push(topic.id)
}
if ((queryText || '').trim()) {
const search = `%${queryText.trim()}%`
@@ -2120,6 +2026,8 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '')
SELECT
t.id,
t.topic_id,
tp.slug AS topic_slug,
tp.name AS topic_name,
t.title,
t.thumbnail_src,
t.is_featured,
@@ -2133,6 +2041,7 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '')
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
@@ -2143,6 +2052,8 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '')
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,
@@ -2202,6 +2113,7 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited
t.id,
t.author_id,
t.topic_id,
tp.slug AS topic_slug,
tp.name AS topic_name,
t.title,
t.thumbnail_src,
@@ -2252,6 +2164,8 @@ async function listUserTierLists(userId) {
SELECT
t.id,
t.topic_id,
tp.slug AS topic_slug,
tp.name AS topic_name,
t.title,
t.thumbnail_src,
t.created_at,
@@ -2262,6 +2176,7 @@ async function listUserTierLists(userId) {
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
`,
@@ -2271,6 +2186,8 @@ async function listUserTierLists(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),
@@ -2369,6 +2286,7 @@ async function listPublicTierListsByAuthor(authorId, currentUserId = '', queryTe
SELECT
t.id,
t.topic_id,
tp.slug AS topic_slug,
tp.name AS topic_name,
t.title,
t.thumbnail_src,
@@ -2395,6 +2313,7 @@ async function listPublicTierListsByAuthor(authorId, currentUserId = '', queryTe
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 || '',
@@ -2430,6 +2349,7 @@ async function listFollowingTierLists(userId, queryText = '') {
SELECT
t.id,
t.topic_id,
tp.slug AS topic_slug,
tp.name AS topic_name,
t.title,
t.thumbnail_src,
@@ -2458,6 +2378,7 @@ async function listFollowingTierLists(userId, queryText = '') {
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 || '',
@@ -2536,11 +2457,12 @@ async function listAdminTierLists({
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)
params.push(search, search, search, search, search, search)
}
const whereClause = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''
@@ -2551,6 +2473,7 @@ async function listAdminTierLists({
t.id,
t.author_id,
t.topic_id,
tp.slug AS topic_slug,
tp.name AS topic_name,
t.title,
t.thumbnail_src,
@@ -2648,11 +2571,12 @@ async function summarizeAdminTierLists({ queryText = '', topicId = '', minFavori
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)
params.push(search, search, search, search, search, search)
}
const whereClause = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''
@@ -2693,6 +2617,7 @@ async function findTierListById(id, currentUserId = '') {
t.id,
t.author_id,
t.topic_id,
tp.slug AS topic_slug,
tp.name AS topic_name,
t.title,
t.thumbnail_src,
@@ -2831,7 +2756,9 @@ async function findTemplateRequestById(id) {
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
@@ -2873,7 +2800,9 @@ async function listAdminTemplateRequests({ status = 'pending', statuses = [] } =
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
@@ -3084,10 +3013,13 @@ module.exports = {
adminDeleteUser,
listTopics,
findTopicById,
findTopicBySlug,
findTopicByIdentifier,
listTopicItems,
findTopicItemById,
getTopicDetail,
createTopic,
updateTopicMeta,
updateTopicThumbnail,
updateTopicVisibility,
findImageAssetByHash,

View File

@@ -10,13 +10,14 @@ const {
findUserByEmail,
findUserByNickname,
findTopicById,
findTopicBySlug,
findTopicItemById,
listTopicItems,
findImageAssetById,
createTopic,
listTopics,
updateTopicMeta,
updateTopicThumbnail,
updateTopicVisibility,
createTopicItem,
updateTopicItemLabel,
updateTopicItemDisplayOrder,
@@ -119,16 +120,23 @@ function canManageAdminRole(actingUser, primaryAdmin) {
router.post('/templates', requireAdmin, async (req, res) => {
const schema = z.object({
id: z.string().min(1),
slug: z.string().trim().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
name: z.string().min(1).max(60),
isPublic: z.boolean().optional().default(false),
thumbnailSrc: z.string().max(255).optional().default(''),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const exists = await findTopicById(parsed.data.id)
if (exists) return res.status(409).json({ error: 'topic_id_taken' })
const template = await createTopic({ id: parsed.data.id, name: parsed.data.name, isPublic: parsed.data.isPublic })
const exists = await findTopicBySlug(parsed.data.slug)
if (exists) return res.status(409).json({ error: 'topic_slug_taken' })
let template
try {
template = await createTopic({ slug: parsed.data.slug, name: parsed.data.name, isPublic: parsed.data.isPublic })
} catch (error) {
if (error?.code === 'TOPIC_SLUG_INVALID') return res.status(400).json({ error: 'topic_slug_invalid' })
if (error?.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'topic_slug_taken' })
throw error
}
if (parsed.data.thumbnailSrc) {
const copiedThumb = await copyUploadIntoTopicAsset(parsed.data.thumbnailSrc)
await updateTopicThumbnail(template.id, copiedThumb)
@@ -139,7 +147,9 @@ router.post('/templates', requireAdmin, async (req, res) => {
router.patch('/templates/:templateId', requireAdmin, async (req, res) => {
const schema = z.object({
isPublic: z.boolean(),
slug: z.string().trim().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(),
name: z.string().trim().min(1).max(60).optional(),
isPublic: z.boolean().optional(),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
@@ -148,8 +158,21 @@ router.patch('/templates/:templateId', requireAdmin, async (req, res) => {
const template = await findTopicById(templateId)
if (!template) return res.status(404).json({ error: 'not_found' })
const updated = await updateTopicVisibility(template.id, parsed.data.isPublic)
res.json({ template: updated })
try {
const updated =
typeof parsed.data.name === 'string' || typeof parsed.data.slug === 'string' || typeof parsed.data.isPublic === 'boolean'
? await updateTopicMeta(template.id, {
slug: parsed.data.slug || template.slug,
name: parsed.data.name || template.name,
isPublic: typeof parsed.data.isPublic === 'boolean' ? parsed.data.isPublic : template.isPublic,
})
: await findTopicById(template.id)
return res.json({ template: updated })
} catch (error) {
if (error?.code === 'TOPIC_SLUG_INVALID') return res.status(400).json({ error: 'topic_slug_invalid' })
if (error?.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'topic_slug_taken' })
throw error
}
})
router.patch('/templates/display-order', requireAdmin, async (req, res) => {
@@ -632,11 +655,11 @@ function pickTemplateRequestItems(templateRequest, itemIds = [], itemLabels = {}
})
}
async function createTemplateFromTierList({ tierList, templateId, templateName }) {
await createTopic({ id: templateId, name: templateName, isPublic: false })
async function createTemplateFromTierList({ tierList, templateSlug, templateName }) {
const template = await createTopic({ slug: templateSlug, name: templateName, isPublic: false })
if (tierList.thumbnailSrc) {
const copiedThumb = await copyUploadIntoTopicAsset(tierList.thumbnailSrc)
await updateTopicThumbnail(templateId, copiedThumb)
await updateTopicThumbnail(template.id, copiedThumb)
}
const createdItems = []
@@ -645,30 +668,30 @@ async function createTemplateFromTierList({ tierList, templateId, templateName }
createdItems.push(
await createTopicItem({
id: nanoid(),
topicId: templateId,
topicId: template.id,
src: copiedSrc,
label: item.label,
})
)
}
return { template: await findTopicById(templateId), items: createdItems }
return { template: await findTopicById(template.id), items: createdItems }
}
async function createTemplateFromRequest({ templateRequest, templateId, templateName }) {
await createTopic({ id: templateId, name: templateName, isPublic: false })
async function createTemplateFromRequest({ templateRequest, templateSlug, templateName }) {
const template = await createTopic({ slug: templateSlug, name: templateName, isPublic: false })
if (templateRequest.thumbnailSrc) {
const copiedThumb = await copyUploadIntoTopicAsset(templateRequest.thumbnailSrc)
await updateTopicThumbnail(templateId, copiedThumb)
await updateTopicThumbnail(template.id, copiedThumb)
}
const items = await promoteSnapshotItemsToTemplate({
items: templateRequest.items || [],
templateId,
templateId: template.id,
})
return { template: await findTopicById(templateId), items }
return { template: await findTopicById(template.id), items }
}
router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
@@ -761,28 +784,34 @@ router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, re
router.post('/tierlists/:tierListId/create-template', requireAdmin, async (req, res) => {
const schema = z.object({
topicId: z.string().trim().min(1).max(120),
slug: z.string().trim().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
name: z.string().trim().min(1).max(120),
itemIds: z.array(z.string().min(1)).optional().default([]),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const exists = await findTopicById(parsed.data.topicId)
if (exists) return res.status(409).json({ error: 'topic_id_taken' })
const exists = await findTopicBySlug(parsed.data.slug)
if (exists) return res.status(409).json({ error: 'topic_slug_taken' })
const tierList = await findTierListById(req.params.tierListId)
if (!tierList) return res.status(404).json({ error: 'not_found' })
const result = await createTemplateFromTierList({
tierList: {
...tierList,
pool: parsed.data.itemIds.length ? (tierList.pool || []).filter((item) => parsed.data.itemIds.includes(item.id)) : tierList.pool,
},
templateId: parsed.data.topicId,
templateName: parsed.data.name,
})
res.json(result)
try {
const result = await createTemplateFromTierList({
tierList: {
...tierList,
pool: parsed.data.itemIds.length ? (tierList.pool || []).filter((item) => parsed.data.itemIds.includes(item.id)) : tierList.pool,
},
templateSlug: parsed.data.slug,
templateName: parsed.data.name,
})
return res.json(result)
} catch (error) {
if (error?.code === 'TOPIC_SLUG_INVALID') return res.status(400).json({ error: 'topic_slug_invalid' })
if (error?.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'topic_slug_taken' })
throw error
}
})
router.patch('/tierlists/:tierListId', requireAdmin, async (req, res) => {
@@ -832,20 +861,27 @@ router.post('/template-requests/:requestId/approve', requireAdmin, async (req, r
}
const schema = z.object({
topicId: z.string().trim().min(1).max(120),
slug: z.string().trim().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
name: z.string().trim().min(1).max(120),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const exists = await findTopicById(parsed.data.topicId)
if (exists) return res.status(409).json({ error: 'topic_id_taken' })
const exists = await findTopicBySlug(parsed.data.slug)
if (exists) return res.status(409).json({ error: 'topic_slug_taken' })
const result = await createTemplateFromRequest({
templateRequest,
templateId: parsed.data.topicId,
templateName: parsed.data.name,
})
let result
try {
result = await createTemplateFromRequest({
templateRequest,
templateSlug: parsed.data.slug,
templateName: parsed.data.name,
})
} catch (error) {
if (error?.code === 'TOPIC_SLUG_INVALID') return res.status(400).json({ error: 'topic_slug_invalid' })
if (error?.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'topic_slug_taken' })
throw error
}
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' })
res.json({ request, ...result })
})

View File

@@ -12,6 +12,7 @@ const {
createCustomItem,
createTemplateRequest,
findUserById,
findTopicByIdentifier,
favoriteTierList,
unfavoriteTierList,
duplicateTierListForUser,
@@ -234,14 +235,15 @@ router.post('/template-request', requireAuth, async (req, res) => {
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const payload = parsed.data
const topicId = payload.topicId
const topic = await findTopicByIdentifier(payload.topicId)
if (!topic) return res.status(404).json({ error: 'not_found' })
const normalizedBoardItems = payload.boardItems.map(normalizePoolItem)
const customItems = normalizedBoardItems.filter((item) => item?.origin === 'custom')
if (!customItems.length) return res.status(400).json({ error: 'custom_items_required' })
if (payload.type === 'create') {
if (topicId !== FREEFORM_TOPIC_ID) return res.status(400).json({ error: 'freeform_required' })
} else if (topicId === FREEFORM_TOPIC_ID) {
if (topic.id !== FREEFORM_TOPIC_ID) return res.status(400).json({ error: 'freeform_required' })
} else if (topic.id === FREEFORM_TOPIC_ID) {
return res.status(400).json({ error: 'topic_template_required' })
}
@@ -260,8 +262,8 @@ router.post('/template-request', requireAuth, async (req, res) => {
type: payload.type,
requesterId: req.session.userId,
sourceTierListId: sourceTierList?.id || '',
sourceTopicId: topicId,
targetTopicId: payload.type === 'update' ? topicId : '',
sourceTopicId: topic.id,
targetTopicId: payload.type === 'update' ? topic.id : '',
title: payload.requestTitle,
description: payload.requestDescription,
thumbnailSrc: payload.thumbnailSrc || '',
@@ -283,7 +285,8 @@ router.post('/', requireAuth, async (req, res) => {
const parsed = tierListUpsertSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const payload = parsed.data
const topicId = payload.topicId
const topic = await findTopicByIdentifier(payload.topicId)
if (!topic) return res.status(404).json({ error: 'not_found' })
const normalizedPool = payload.pool.map(normalizePoolItem)
let existing = null
@@ -313,7 +316,7 @@ router.post('/', requireAuth, async (req, res) => {
const created = await saveTierList({
id: nanoid(),
authorId: req.session.userId,
topicId,
topicId: topic.id,
title: payload.title,
thumbnailSrc: payload.thumbnailSrc || '',
description: payload.description || '',

View File

@@ -1,5 +1,5 @@
const express = require('express')
const { listTopics, getTopicDetail, findTopicById, favoriteTopic, unfavoriteTopic } = require('../db')
const { listTopics, getTopicDetail, findTopicByIdentifier, favoriteTopic, unfavoriteTopic } = require('../db')
const { requireAuth } = require('../middleware/auth')
const router = express.Router()
@@ -10,7 +10,7 @@ router.get('/', async (req, res) => {
})
router.post('/:topicId/favorite', requireAuth, async (req, res) => {
const topic = await findTopicById(req.params.topicId)
const topic = await findTopicByIdentifier(req.params.topicId)
if (!topic || topic.id === 'freeform') return res.status(404).json({ error: 'not_found' })
await favoriteTopic({ userId: req.session.userId, topicId: topic.id })
const topics = await listTopics(req.session.userId)
@@ -19,7 +19,7 @@ router.post('/:topicId/favorite', requireAuth, async (req, res) => {
})
router.delete('/:topicId/favorite', requireAuth, async (req, res) => {
const topic = await findTopicById(req.params.topicId)
const topic = await findTopicByIdentifier(req.params.topicId)
if (!topic || topic.id === 'freeform') return res.status(404).json({ error: 'not_found' })
await unfavoriteTopic({ userId: req.session.userId, topicId: topic.id })
const topics = await listTopics(req.session.userId)