릴리스: v0.1.17 티어표 삭제와 미사용 이미지 정리 추가

This commit is contained in:
2026-03-19 17:52:09 +09:00
parent 85ee6e3649
commit 8af2726574
11 changed files with 305 additions and 34 deletions

View File

@@ -404,25 +404,38 @@ async function createCustomItem({ id, ownerId, src, label }) {
return { id, ownerId, src, label, origin: 'custom', createdAt }
}
async function listCustomItems({ queryText = '', page = 1, limit = 50 } = {}) {
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 offset = (normalizedPage - 1) * normalizedLimit
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 countRows = await query(
`
SELECT COUNT(*) AS count
FROM custom_items c
INNER JOIN users u ON u.id = c.owner_id
${whereClause}
`,
params
)
const rows = await query(
`
SELECT
@@ -437,13 +450,13 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50 } = {}) {
INNER JOIN users u ON u.id = c.owner_id
${whereClause}
ORDER BY c.created_at DESC
LIMIT ? OFFSET ?
`,
[...params, normalizedLimit, offset]
params
)
return {
items: rows.map((row) => ({
const usageMap = await getCustomItemUsageMap()
const allItems = rows
.map((row) => ({
id: row.id,
ownerId: row.owner_id,
src: row.src,
@@ -451,13 +464,61 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50 } = {}) {
createdAt: Number(row.created_at),
ownerName: row.nickname || row.email,
ownerEmail: row.email,
})),
total: Number(countRows[0]?.count || 0),
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 listPublicTierLists(gameId) {
const params = []
let whereClause = 'WHERE t.is_public = 1'
@@ -531,6 +592,37 @@ async function findTierListById(id) {
return mapTierListRow(rows[0])
}
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, description, isPublic, groups, pool }) {
const existing = id ? await findTierListById(id) : null
@@ -582,8 +674,12 @@ module.exports = {
deleteGame,
createCustomItem,
listCustomItems,
findUnusedCustomItems,
listPublicTierLists,
listUserTierLists,
findTierListById,
deleteTierList,
findCustomItemsByIds,
deleteCustomItems,
saveTierList,
}

View File

@@ -1,3 +1,4 @@
const fs = require('fs/promises')
const path = require('path')
const express = require('express')
const multer = require('multer')
@@ -13,6 +14,9 @@ const {
deleteGameItem,
deleteGame,
listCustomItems,
findUnusedCustomItems,
findCustomItemsByIds,
deleteCustomItems,
listUsers,
adminUpdateUser,
adminUpdateUserPassword,
@@ -89,6 +93,11 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
q: z.string().trim().max(120).optional().default(''),
page: z.coerce.number().int().min(1).optional().default(1),
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
orphanOnly: z
.union([z.literal('true'), z.literal('false'), z.boolean()])
.optional()
.default('false')
.transform((value) => value === true || value === 'true'),
})
const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
@@ -97,10 +106,51 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
queryText: parsed.data.q,
page: parsed.data.page,
limit: parsed.data.limit,
orphanOnly: parsed.data.orphanOnly,
})
res.json(result)
})
async function removeCustomItemFiles(items) {
await Promise.all(
items.map(async (item) => {
if (!item?.src || !item.src.startsWith('/uploads/custom/')) return
const absolutePath = path.join(__dirname, '..', '..', item.src.replace(/^\//, ''))
try {
await fs.unlink(absolutePath)
} catch (e) {
if (e?.code !== 'ENOENT') throw e
}
})
)
}
router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
const result = await listCustomItems({ page: 1, limit: 200, orphanOnly: false })
const target = result.items.find((item) => item.id === req.params.itemId)
if (!target) return res.status(404).json({ error: 'not_found' })
if (target.usageCount > 0) return res.status(409).json({ error: 'item_in_use' })
const items = await findCustomItemsByIds([target.id])
await deleteCustomItems([target.id])
await removeCustomItemFiles(items)
res.json({ ok: true })
})
router.delete('/custom-items', requireAdmin, async (req, res) => {
const schema = z.object({
q: z.string().trim().max(120).optional().default(''),
})
const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const items = await findUnusedCustomItems({ queryText: parsed.data.q })
const ids = items.map((item) => item.id)
await deleteCustomItems(ids)
await removeCustomItemFiles(items)
res.json({ ok: true, deletedCount: ids.length })
})
router.get('/users', requireAdmin, async (req, res) => {
const users = await listUsers()
res.json({ users })

View File

@@ -7,6 +7,7 @@ const {
findTierListById,
listPublicTierLists,
listUserTierLists,
deleteTierList,
saveTierList,
createCustomItem,
} = require('../db')
@@ -94,6 +95,15 @@ router.get('/:id', async (req, res) => {
res.json({ tierList: normalizeTierList(t) })
})
router.delete('/:id', requireAuth, async (req, res) => {
const tierList = await findTierListById(req.params.id)
if (!tierList) return res.status(404).json({ error: 'not_found' })
if (tierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' })
await deleteTierList(tierList.id)
res.json({ ok: true })
})
router.post('/custom-items', requireAuth, upload.single('image'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'file_required' })