릴리스: v1.3.13 템플릿 요청 스냅샷과 저장 분리

This commit is contained in:
2026-04-01 11:50:54 +09:00
parent b2a838ff34
commit 7fe4eff7b7
7 changed files with 366 additions and 90 deletions

View File

@@ -154,7 +154,7 @@ function mapTemplateRequestRow(row) {
requesterName: getUserDisplayName(row),
requesterAccountName: getUserAccountName(row),
requesterAvatarSrc: row.requester_avatar_src || '',
sourceTierListId: row.source_tierlist_id,
sourceTierListId: row.source_tierlist_id || '',
sourceGameId: row.source_game_id,
sourceGameName: row.source_game_name || '',
sourceTierListTitle: row.title_snapshot || '',
@@ -164,6 +164,9 @@ function mapTemplateRequestRow(row) {
targetGameName: row.target_game_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),
}
@@ -389,6 +392,23 @@ 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 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")
@@ -754,7 +774,7 @@ async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) {
query("SELECT src FROM game_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 FROM template_requests"),
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)
@@ -770,6 +790,7 @@ async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) {
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))
@@ -806,7 +827,7 @@ async function listReferencedUploadUsage() {
query("SELECT src FROM game_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 FROM template_requests"),
query("SELECT id, thumbnail_src_snapshot, items_json, board_items_json FROM template_requests"),
])
for (const row of userRows) addUsage(row.avatar_src, 'avatar')
@@ -822,6 +843,7 @@ async function listReferencedUploadUsage() {
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())
@@ -874,7 +896,7 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
}
}
const requestRows = await query('SELECT id, thumbnail_src_snapshot, items_json FROM template_requests')
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
@@ -884,12 +906,14 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
}
const replacedItems = replaceItemSrc(parseJson(row.items_json, []), fromSrc, toSrc)
if (replacedItems.changed) changed = true
const replacedBoardItems = replaceItemSrc(parseJson(row.board_items_json, []), fromSrc, toSrc)
if (replacedItems.changed || replacedBoardItems.changed) changed = true
if (changed) {
await query('UPDATE template_requests SET thumbnail_src_snapshot = ?, items_json = ?, updated_at = ? WHERE id = ?', [
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,
])
@@ -1636,19 +1660,24 @@ async function createTemplateRequest({
id,
type,
requesterId,
sourceTierListId,
sourceTierListId = '',
sourceGameId,
targetGameId = '',
title,
description = '',
thumbnailSrc = '',
items = [],
groups = [],
boardItems = [],
showCharacterNames = false,
}) {
const existing = await findPendingTemplateRequestByTierList({ sourceTierListId, type })
if (existing) {
const err = new Error('template_request_exists')
err.code = 'TEMPLATE_REQUEST_EXISTS'
throw err
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()
@@ -1666,22 +1695,28 @@ async function createTemplateRequest({
description_snapshot,
thumbnail_src_snapshot,
items_json,
groups_json,
board_items_json,
show_character_names_snapshot,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[
id,
type,
requesterId,
sourceTierListId,
sourceTierListId || null,
sourceGameId,
targetGameId,
title,
description,
thumbnailSrc,
serializeJson(items),
serializeJson(groups),
serializeJson(boardItems),
showCharacterNames ? 1 : 0,
createdAt,
createdAt,
]
@@ -1704,6 +1739,9 @@ async function findTemplateRequestById(id) {
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,
@@ -1739,6 +1777,9 @@ async function listAdminTemplateRequests({ status = 'pending' } = {}) {
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,

View File

@@ -58,6 +58,33 @@ function getCustomTemplateItems(tierList) {
const upload = createMemoryUpload(multer, { fileSize: 6 * 1024 * 1024 })
const thumbnailUpload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024 })
const templateRequestSchema = z.object({
type: z.enum(['create', 'update']),
sourceTierListId: z.string().max(64).optional().default(''),
gameId: z.string().min(1).max(120),
requestTitle: z.string().trim().min(1).max(120),
requestDescription: z.string().trim().min(1).max(1000),
thumbnailSrc: z.string().max(255).optional().default(''),
isPublic: z.boolean().optional().default(false),
showCharacterNames: z.boolean().optional().default(false),
saveToMyTierList: z.boolean().optional().default(true),
groups: z.array(
z.object({
id: z.string().min(1),
name: z.string().min(1).max(16),
itemIds: z.array(z.string()),
})
),
boardItems: z.array(
z.object({
id: z.string().min(1),
src: z.string().min(1),
label: z.string().min(1).max(60),
origin: z.enum(['game', 'custom']).default('game'),
})
),
})
const tierListUpsertSchema = z.object({
id: z.string().optional(),
gameId: z.string().min(1),
@@ -194,42 +221,64 @@ router.post('/thumbnail', requireAuth, thumbnailUpload.single('thumbnail'), asyn
res.json({ thumbnailSrc: optimized.src })
})
router.post('/:id/template-request', requireAuth, async (req, res) => {
const schema = z.object({
type: z.enum(['create', 'update']),
requestTitle: z.string().trim().min(1).max(80),
requestDescription: z.string().trim().min(1).max(240),
})
const parsed = schema.safeParse(req.body)
router.post('/template-request', requireAuth, async (req, res) => {
const parsed = templateRequestSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const tierList = await findTierListById(req.params.id, req.session.userId)
if (!tierList) return res.status(404).json({ error: 'not_found' })
if (tierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' })
const customItems = getCustomTemplateItems(tierList)
const payload = parsed.data
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 (parsed.data.type === 'create') {
if (tierList.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
} else {
if (tierList.gameId === FREEFORM_GAME_ID) return res.status(400).json({ error: 'game_template_required' })
if (payload.type === 'create') {
if (payload.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
} else if (payload.gameId === FREEFORM_GAME_ID) {
return res.status(400).json({ error: 'game_template_required' })
}
let sourceTierList = null
if (payload.sourceTierListId) {
sourceTierList = await findTierListById(payload.sourceTierListId, req.session.userId)
if (!sourceTierList) return res.status(404).json({ error: 'not_found' })
if (sourceTierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' })
}
let savedTierList = null
if (payload.saveToMyTierList) {
savedTierList = await saveTierList({
id: sourceTierList?.id || undefined,
authorId: req.session.userId,
gameId: payload.gameId,
title: payload.requestTitle,
thumbnailSrc: payload.thumbnailSrc || '',
description: payload.requestDescription || '',
isPublic: !!payload.isPublic,
showCharacterNames: !!payload.showCharacterNames,
sourceTierListId: sourceTierList?.sourceTierListId || '',
sourceSnapshotTitle: sourceTierList?.sourceSnapshotTitle || '',
sourceSnapshotAuthor: sourceTierList?.sourceSnapshotAuthor || '',
groups: payload.groups,
pool: normalizedBoardItems,
})
}
try {
const request = await createTemplateRequest({
id: nanoid(),
type: parsed.data.type,
type: payload.type,
requesterId: req.session.userId,
sourceTierListId: tierList.id,
sourceGameId: tierList.gameId,
targetGameId: parsed.data.type === 'update' ? tierList.gameId : '',
title: parsed.data.requestTitle,
description: parsed.data.requestDescription,
thumbnailSrc: tierList.thumbnailSrc || '',
sourceTierListId: savedTierList?.id || sourceTierList?.id || '',
sourceGameId: payload.gameId,
targetGameId: payload.type === 'update' ? payload.gameId : '',
title: payload.requestTitle,
description: payload.requestDescription,
thumbnailSrc: payload.thumbnailSrc || '',
items: customItems,
groups: payload.groups,
boardItems: normalizedBoardItems,
showCharacterNames: !!payload.showCharacterNames,
})
return res.json({ request })
return res.json({ request, savedTierList: savedTierList ? normalizeTierList(savedTierList) : null })
} catch (e) {
if (e?.code === 'TEMPLATE_REQUEST_EXISTS') {
return res.status(409).json({ error: 'template_request_exists' })