feat: 관리자 대체 아이템 전용 필터 추가

This commit is contained in:
2026-04-06 11:09:48 +09:00
parent b134431d91
commit a2fc8f8cd4
7 changed files with 141 additions and 7 deletions

View File

@@ -1152,6 +1152,21 @@ function replaceItemSrc(items, fromSrc, toSrc, toLabel = '') {
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) : ''
@@ -1222,6 +1237,40 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc, toLabel = '', upd
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'
@@ -1567,6 +1616,14 @@ async function markCustomItemReplaced({ itemId, replacedByItemId = '', replacedB
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])
@@ -1941,7 +1998,9 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
case 'library':
return item.sourceType === 'user' || (item.sourceType === 'template' && !item.isAssetLibraryItem)
case 'unused-user':
return item.sourceType === 'user' && item.usageCount === 0 && item.linkedTemplates.length === 0
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:
@@ -1998,7 +2057,7 @@ async function findUnusedCustomItems({ queryText = '' } = {}) {
ownerEmail: row.email,
usageCount: usageMap.get(row.id) || 0,
}))
.filter((item) => item.usageCount === 0)
.filter((item) => item.usageCount === 0 || !!item.replacedAt)
}
async function getFavoriteStatsForTierListIds(tierListIds, userId = '') {
@@ -3075,6 +3134,7 @@ module.exports = {
listReferencedUploadSources,
listReferencedUploadUsage,
replaceUploadSourceReferences,
updateCustomItemDisplayReferences,
clearImageOptimizationJobs,
getImageAssetStats,
cleanupMissingUploadReferences,
@@ -3086,6 +3146,7 @@ module.exports = {
deleteTopic,
updateTopicDisplayOrder,
updateCustomItemLabel,
clearCustomItemReplacement,
markCustomItemReplaced,
updateImageAssetLabel,
createCustomItem,

View File

@@ -55,6 +55,8 @@ const {
clearImageOptimizationJobs,
cleanupMissingUploadReferences,
replaceUploadSourceReferences,
updateCustomItemDisplayReferences,
clearCustomItemReplacement,
} = require('../db')
const { requireAdmin } = require('../middleware/auth')
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
@@ -358,7 +360,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
page: z.coerce.number().int().min(1).optional().default(1),
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
filter: z
.enum(['library', 'all', 'user', 'template', 'asset', 'thumbnail', 'avatar', 'unused-user', 'unused-admin'])
.enum(['library', 'all', 'user', 'template', 'asset', 'thumbnail', 'avatar', 'unused-user', 'replaced-user', 'unused-admin'])
.optional()
.default('library'),
})
@@ -837,6 +839,11 @@ router.post('/custom-items/:itemId/replace', requireAdmin, async (req, res) => {
toLabel: targetItem.label || '',
updateCustomItemsBySrc: false,
})
const displayResult = await updateCustomItemDisplayReferences({
itemId: sourceItem.id,
src: targetItem.src,
label: targetItem.label || '',
})
await markCustomItemReplaced({
itemId: sourceItem.id,
replacedByItemId: targetItem.id || '',
@@ -846,12 +853,31 @@ router.post('/custom-items/:itemId/replace', requireAdmin, async (req, res) => {
res.json({
ok: true,
updatedRows: result.updatedRows || 0,
updatedRows: (result.updatedRows || 0) + (displayResult.updatedRows || 0),
sourceItem,
targetItem,
})
})
router.post('/custom-items/:itemId/restore', requireAdmin, async (req, res) => {
const sourceItem = await findCustomItemById(req.params.itemId)
if (!sourceItem?.id) return res.status(404).json({ error: 'not_found' })
if (!sourceItem.replacedAt) return res.status(409).json({ error: 'not_replaced' })
const restored = await updateCustomItemDisplayReferences({
itemId: sourceItem.id,
src: sourceItem.src,
label: sourceItem.label || '',
})
await clearCustomItemReplacement(sourceItem.id)
res.json({
ok: true,
restoredRows: restored.updatedRows || 0,
item: await findCustomItemById(sourceItem.id),
})
})
router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, res) => {
const schema = z.object({
topicId: z.string().min(1),