Files
tier-maker/backend/src/routes/admin.js

763 lines
26 KiB
JavaScript

const fs = require('fs/promises')
const path = require('path')
const express = require('express')
const multer = require('multer')
const bcrypt = require('bcryptjs')
const { z } = require('zod')
const { nanoid } = require('nanoid')
const {
findUserById,
findGameById,
findGameItemById,
findImageAssetById,
createGame,
listGames,
updateGameThumbnail,
createGameItem,
updateGameItemLabel,
updateCustomItemLabel,
updateImageAssetLabel,
deleteGameItem,
deleteGame,
updateGameDisplayOrder,
listCustomItems,
findCustomItemById,
findUnusedCustomItems,
findCustomItemsByIds,
deleteCustomItems,
listUsers,
findPrimaryAdminUser,
listAdminTierLists,
findTierListById,
listAdminTemplateRequests,
findTemplateRequestById,
updateTemplateRequestStatus,
adminUpdateUser,
adminUpdateUserPassword,
adminDeleteUser,
listUnusedImageAssets,
deleteImageAssets,
getImageAssetStats,
listRecentImageOptimizationJobs,
clearImageOptimizationJobs,
} = require('../db')
const { requireAdmin } = require('../middleware/auth')
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
const router = express.Router()
function buildUploadFilename(file) {
const ext = path.extname(file.originalname || '').toLowerCase()
const safeExt = ext && /^[.a-z0-9]+$/.test(ext) ? ext : ''
return `${Date.now()}-${nanoid()}${safeExt}`
}
function buildItemLabelFromFilename(file) {
const originalName = file?.originalname || ''
const base = path.basename(originalName, path.extname(originalName))
const normalized = base
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 60)
return normalized || 'item'
}
const upload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024, maxCount: 50 })
const avatarUpload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 })
function decorateAdminUser(user, primaryAdmin) {
if (!user) return null
const isPrimaryAdmin = !!user.isAdmin && primaryAdmin?.id === user.id
return {
...user,
isPrimaryAdmin,
isOperator: !!user.isAdmin && !isPrimaryAdmin,
role: isPrimaryAdmin ? 'owner' : user.isAdmin ? 'operator' : 'user',
}
}
async function getAdminUserContext(targetUserId, actingUserId) {
const [targetUser, actingUser, primaryAdmin] = await Promise.all([
findUserById(targetUserId),
findUserById(actingUserId),
findPrimaryAdminUser(),
])
return { targetUser, actingUser, primaryAdmin }
}
function canManageAdminRole(actingUser, primaryAdmin) {
return !!actingUser?.isAdmin && primaryAdmin?.id === actingUser.id
}
router.post('/games', requireAdmin, async (req, res) => {
const schema = z.object({ id: z.string().min(1), name: z.string().min(1).max(60) })
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const exists = await findGameById(parsed.data.id)
if (exists) return res.status(409).json({ error: 'game_id_taken' })
const game = await createGame({ id: parsed.data.id, name: parsed.data.name })
res.json({ game })
})
router.patch('/games/display-order', requireAdmin, async (req, res) => {
const schema = z.object({
gameIds: z.array(z.string().min(1)).max(50),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const games = await listGames()
const validGameIds = new Set(games.map((game) => game.id))
const filteredIds = parsed.data.gameIds.filter((gameId) => validGameIds.has(gameId))
const updatedGames = await updateGameDisplayOrder(filteredIds)
res.json({ games: updatedGames })
})
router.post('/games/:gameId/thumbnail', requireAdmin, upload.single('thumbnail'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'file_required' })
const game = await findGameById(req.params.gameId)
if (!game) return res.status(404).json({ error: 'not_found' })
const optimized = await writeOptimizedImage({
file: req.file,
directory: 'games',
width: 1280,
height: 1280,
fit: 'inside',
quality: 84,
})
const updated = await updateGameThumbnail(req.params.gameId, optimized.src)
res.json({ game: updated })
})
router.post('/games/:gameId/images', requireAdmin, upload.array('images', 50), async (req, res) => {
const files = Array.isArray(req.files) ? req.files : []
if (!files.length) return res.status(400).json({ error: 'file_required' })
const game = await findGameById(req.params.gameId)
if (!game) return res.status(404).json({ error: 'not_found' })
const labelsRaw = req.body?.labels
const labels = Array.isArray(labelsRaw) ? labelsRaw : labelsRaw ? [labelsRaw] : []
const normalizedLabels = labels.map((label) => (typeof label === 'string' ? label.trim().slice(0, 60) : ''))
if (normalizedLabels.some((label) => label.length > 60)) return res.status(400).json({ error: 'bad_request' })
const items = await Promise.all(
files.map(async (file, index) => {
const optimized = await writeOptimizedImage({
file,
directory: 'games',
width: 512,
height: 512,
fit: 'inside',
quality: 84,
})
return createGameItem({
id: nanoid(),
gameId: game.id,
src: optimized.src,
label: normalizedLabels[index] || buildItemLabelFromFilename(file),
})
})
)
res.json({ item: items[0], items })
})
router.delete('/games/:gameId/items/:itemId', requireAdmin, async (req, res) => {
const game = await findGameById(req.params.gameId)
if (!game) return res.status(404).json({ error: 'not_found' })
await deleteGameItem(req.params.itemId)
res.json({ ok: true })
})
router.patch('/games/:gameId/items/:itemId', requireAdmin, async (req, res) => {
const schema = z.object({ label: z.string().trim().min(1).max(60) })
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const game = await findGameById(req.params.gameId)
if (!game) return res.status(404).json({ error: 'not_found' })
const updated = await updateGameItemLabel(req.params.itemId, parsed.data.label)
if (!updated || updated.gameId !== game.id) return res.status(404).json({ error: 'not_found' })
res.json({ item: updated })
})
router.delete('/games/:gameId', requireAdmin, async (req, res) => {
const game = await findGameById(req.params.gameId)
if (!game) return res.status(404).json({ error: 'not_found' })
await deleteGame(req.params.gameId)
res.json({ ok: true })
})
router.patch('/custom-items/:itemId/label', requireAdmin, async (req, res) => {
const schema = z.object({
label: z.string().trim().min(1).max(60),
sourceType: z.enum(['template', 'user']).optional().default('user'),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const itemId = req.params.itemId
if (itemId.startsWith('asset:')) {
const updated = await updateImageAssetLabel(itemId.slice(6), parsed.data.label)
if (!updated) return res.status(404).json({ error: 'not_found' })
return res.json({ item: updated })
}
if (parsed.data.sourceType === 'template') {
const updated = await updateGameItemLabel(itemId, parsed.data.label)
if (!updated) return res.status(404).json({ error: 'not_found' })
return res.json({ item: updated })
}
const updated = await updateCustomItemLabel(itemId, parsed.data.label)
if (!updated) return res.status(404).json({ error: 'not_found' })
return res.json({ item: updated })
})
router.get('/custom-items', requireAdmin, async (req, res) => {
const schema = z.object({
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' })
const result = await listCustomItems({
queryText: parsed.data.q,
page: parsed.data.page,
limit: parsed.data.limit,
orphanOnly: parsed.data.orphanOnly,
})
res.json(result)
})
router.get('/tierlists', requireAdmin, async (req, res) => {
const schema = z.object({
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),
})
const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const result = await listAdminTierLists({
queryText: parsed.data.q,
page: parsed.data.page,
limit: parsed.data.limit,
currentUserId: req.session?.userId || '',
})
res.json(result)
})
router.get('/template-requests', requireAdmin, async (req, res) => {
const requests = await listAdminTemplateRequests({ status: 'pending' })
res.json({ requests })
})
router.get('/image-assets/orphans', requireAdmin, async (req, res) => {
const schema = z.object({
limit: z.coerce.number().int().min(1).max(500).optional().default(100),
minAgeHours: z.coerce.number().min(0).max(24 * 365).optional().default(24),
})
const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const assets = await listUnusedImageAssets(parsed.data)
res.json({ assets })
})
async function removeImageAssetFiles(assets) {
await Promise.all(
(assets || []).map(async (asset) => {
if (!asset?.src || !asset.src.startsWith('/uploads/')) return
const absolutePath = path.join(__dirname, '..', '..', asset.src.replace(/^\//, ''))
try {
await fs.unlink(absolutePath)
} catch (error) {
if (error?.code !== 'ENOENT') throw error
}
})
)
}
router.post('/image-assets/cleanup', requireAdmin, async (req, res) => {
const schema = z.object({
limit: z.coerce.number().int().min(1).max(500).optional().default(100),
minAgeHours: z.coerce.number().min(0).max(24 * 365).optional().default(24),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const assets = await listUnusedImageAssets(parsed.data)
const deleted = await deleteImageAssets(assets.map((asset) => asset.id))
await removeImageAssetFiles(deleted)
res.json({ deletedCount: deleted.length, assets: deleted })
})
router.get('/image-assets/stats', requireAdmin, async (req, res) => {
const schema = z.object({
month: z.string().regex(/^\d{4}-\d{2}$/).optional(),
limit: z.coerce.number().int().min(1).max(24).optional().default(12),
})
const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const filters = { month: parsed.data.month }
const [stats, recentJobs] = await Promise.all([
getImageAssetStats(filters),
listRecentImageOptimizationJobs(parsed.data.limit, filters),
])
res.json({
stats,
filters,
queue: getImageOptimizationQueueState(),
recentJobs,
})
})
router.post('/image-assets/stats/reset', requireAdmin, async (req, res) => {
const schema = z.object({
month: z.string().regex(/^\d{4}-\d{2}$/).optional().nullable(),
})
const parsed = schema.safeParse(req.body || {})
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const deletedCount = await clearImageOptimizationJobs({ month: parsed.data.month || undefined })
res.json({ deletedCount })
})
async function removeUploadFiles(srcs) {
await Promise.all(
(srcs || []).map(async (src) => {
if (!src || !src.startsWith('/uploads/')) return
const absolutePath = path.join(__dirname, '..', '..', src.replace(/^\//, ''))
try {
await fs.unlink(absolutePath)
} catch (e) {
if (e?.code !== 'ENOENT') throw e
}
})
)
}
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
}
})
)
}
async function promoteLibraryItemToGameItem({ item, gameId }) {
return createGameItem({
id: nanoid(),
gameId,
src: item.src || '',
label: item.label,
})
}
async function copyUploadIntoGameAsset(src) {
if (typeof src !== 'string' || !src.startsWith('/uploads/')) return src || ''
if (src.startsWith('/uploads/assets/')) return src
return src
}
function uniqueTierListPoolItems(tierList) {
const seen = new Set()
return (tierList?.pool || []).filter((item) => {
if (!item?.id || seen.has(item.id)) return false
seen.add(item.id)
return true
})
}
async function promoteTierListItemsToGame({ tierList, gameId, itemIds = [] }) {
const allowedIds = new Set((itemIds || []).filter(Boolean))
const sourceItems = uniqueTierListPoolItems(tierList).filter((item) => item.origin === 'custom')
const itemsToCopy = allowedIds.size ? sourceItems.filter((item) => allowedIds.has(item.id)) : sourceItems
const createdItems = []
for (const item of itemsToCopy) {
const copiedSrc = await copyUploadIntoGameAsset(item.src)
createdItems.push(
await createGameItem({
id: nanoid(),
gameId,
src: copiedSrc,
label: item.label,
})
)
}
return createdItems
}
async function promoteSnapshotItemsToGame({ items, gameId }) {
const createdItems = []
for (const item of items || []) {
const copiedSrc = await copyUploadIntoGameAsset(item.src)
createdItems.push(
await createGameItem({
id: nanoid(),
gameId,
src: copiedSrc,
label: item.label,
})
)
}
return createdItems
}
async function createGameTemplateFromTierList({ tierList, gameId, gameName }) {
await createGame({ id: gameId, name: gameName })
if (tierList.thumbnailSrc) {
const copiedThumb = await copyUploadIntoGameAsset(tierList.thumbnailSrc)
await updateGameThumbnail(gameId, copiedThumb)
}
const createdItems = []
for (const item of uniqueTierListPoolItems(tierList)) {
const copiedSrc = await copyUploadIntoGameAsset(item.src)
createdItems.push(
await createGameItem({
id: nanoid(),
gameId,
src: copiedSrc,
label: item.label,
})
)
}
return { game: await findGameById(gameId), items: createdItems }
}
async function createGameTemplateFromRequest({ templateRequest, gameId, gameName }) {
await createGame({ id: gameId, name: gameName })
if (templateRequest.thumbnailSrc) {
const copiedThumb = await copyUploadIntoGameAsset(templateRequest.thumbnailSrc)
await updateGameThumbnail(gameId, copiedThumb)
}
const items = await promoteSnapshotItemsToGame({
items: templateRequest.items || [],
gameId,
})
return { game: await findGameById(gameId), items }
}
router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
const result = await listCustomItems({ page: 1, limit: 10000, 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.sourceType === 'template') {
if (String(target.id || '').startsWith('asset:')) {
const assetId = String(target.id).slice('asset:'.length)
const asset = await findImageAssetById(assetId)
if (!asset) return res.status(404).json({ error: 'not_found' })
await deleteImageAssets([assetId])
await removeUploadFiles([asset.src])
return res.json({ ok: true, sourceType: 'template-asset' })
}
await deleteGameItem(target.id)
return res.json({ ok: true, sourceType: 'template' })
}
if (!target.canDelete) return res.status(409).json({ error: 'item_locked' })
if (target.linkedGames.length > 0) return res.status(409).json({ error: 'item_linked' })
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, sourceType: 'user' })
})
router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => {
const schema = z.object({
gameId: z.string().min(1),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const game = await findGameById(parsed.data.gameId)
if (!game) return res.status(404).json({ error: 'game_not_found' })
const customItem = await findCustomItemById(req.params.itemId)
const gameItem = customItem ? null : await findGameItemById(req.params.itemId)
const sourceItem = customItem || gameItem
if (!sourceItem) return res.status(404).json({ error: 'not_found' })
const item = await promoteLibraryItemToGameItem({ item: sourceItem, gameId: game.id })
res.json({ item })
})
router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, res) => {
const schema = z.object({
gameId: z.string().min(1),
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 game = await findGameById(parsed.data.gameId)
if (!game) return res.status(404).json({ error: 'game_not_found' })
const tierList = await findTierListById(req.params.tierListId)
if (!tierList) return res.status(404).json({ error: 'not_found' })
const items = await promoteTierListItemsToGame({
tierList,
gameId: game.id,
itemIds: parsed.data.itemIds,
})
res.json({ items })
})
router.post('/tierlists/:tierListId/create-game-template', requireAdmin, async (req, res) => {
const schema = z.object({
gameId: z.string().trim().min(1).max(120),
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 findGameById(parsed.data.gameId)
if (exists) return res.status(409).json({ error: 'game_id_taken' })
const tierList = await findTierListById(req.params.tierListId)
if (!tierList) return res.status(404).json({ error: 'not_found' })
const result = await createGameTemplateFromTierList({
tierList: {
...tierList,
pool: parsed.data.itemIds.length ? (tierList.pool || []).filter((item) => parsed.data.itemIds.includes(item.id)) : tierList.pool,
},
gameId: parsed.data.gameId,
gameName: parsed.data.name,
})
res.json(result)
})
router.post('/template-requests/:requestId/approve', requireAdmin, async (req, res) => {
const templateRequest = await findTemplateRequestById(req.params.requestId)
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
if (templateRequest.status !== 'pending') return res.status(409).json({ error: 'request_already_handled' })
if (templateRequest.type === 'update') {
const targetGameId = templateRequest.targetGameId || templateRequest.sourceGameId
const game = await findGameById(targetGameId)
if (!game) return res.status(404).json({ error: 'game_not_found' })
const items = await promoteSnapshotItemsToGame({
items: templateRequest.items || [],
gameId: game.id,
})
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' })
return res.json({ request, items })
}
const schema = z.object({
gameId: z.string().trim().min(1).max(120),
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 findGameById(parsed.data.gameId)
if (exists) return res.status(409).json({ error: 'game_id_taken' })
const result = await createGameTemplateFromRequest({
templateRequest,
gameId: parsed.data.gameId,
gameName: parsed.data.name,
})
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' })
res.json({ request, ...result })
})
router.post('/template-requests/:requestId/reject', requireAdmin, async (req, res) => {
const templateRequest = await findTemplateRequestById(req.params.requestId)
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
if (templateRequest.status !== 'pending') return res.status(409).json({ error: 'request_already_handled' })
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'rejected' })
res.json({ request })
})
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 schema = z.object({
q: z.string().trim().max(120).optional().default(''),
sort: z.enum(['recent', 'created', 'tierlists']).optional().default('recent'),
direction: z.enum(['asc', 'desc']).optional().default('desc'),
})
const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const [users, primaryAdmin] = await Promise.all([
listUsers({ queryText: parsed.data.q, sort: parsed.data.sort, direction: parsed.data.direction }),
findPrimaryAdminUser(),
])
res.json({ users: users.map((user) => decorateAdminUser(user, primaryAdmin)) })
})
router.patch('/users/:userId', requireAdmin, async (req, res) => {
const schema = z.object({
email: z.string().email(),
nickname: z.string().trim().max(40).default(''),
isAdmin: z.boolean(),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
if (!targetUser) return res.status(404).json({ error: 'not_found' })
const actingIsPrimaryAdmin = canManageAdminRole(actingUser, primaryAdmin)
const targetIsPrimaryAdmin = primaryAdmin?.id === targetUser.id
const roleChanged = parsed.data.isAdmin !== !!targetUser.isAdmin
if (req.params.userId === req.session.userId && !parsed.data.isAdmin) {
return res.status(400).json({ error: 'self_admin_required' })
}
if (targetIsPrimaryAdmin && !actingIsPrimaryAdmin) {
return res.status(403).json({ error: 'primary_admin_protected' })
}
if (targetIsPrimaryAdmin && !parsed.data.isAdmin) {
return res.status(400).json({ error: 'primary_admin_required' })
}
if (roleChanged && !actingIsPrimaryAdmin) {
return res.status(403).json({ error: 'primary_admin_only' })
}
try {
const updated = await adminUpdateUser({
id: targetUser.id,
email: parsed.data.email,
nickname: parsed.data.nickname,
isAdmin: parsed.data.isAdmin,
})
res.json({ user: decorateAdminUser(updated, primaryAdmin) })
} catch (e) {
if (e && e.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ error: 'email_taken' })
}
throw e
}
})
router.post('/users/:userId/avatar', requireAdmin, avatarUpload.single('avatar'), async (req, res) => {
const schema = z.object({
removeAvatar: z.union([z.literal('1'), z.undefined()]).optional(),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
if (!targetUser) return res.status(404).json({ error: 'not_found' })
if (primaryAdmin?.id === targetUser.id && !canManageAdminRole(actingUser, primaryAdmin)) {
return res.status(403).json({ error: 'primary_admin_protected' })
}
const optimized = req.file
? await writeOptimizedImage({
file: req.file,
directory: 'avatars',
width: 512,
height: 512,
fit: 'cover',
quality: 82,
})
: null
const shouldRemoveAvatar = parsed.data.removeAvatar === '1'
const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || targetUser.avatarSrc || ''
const updated = await adminUpdateUser({
id: targetUser.id,
email: targetUser.email,
nickname: targetUser.nickname || '',
isAdmin: !!targetUser.isAdmin,
avatarSrc: nextAvatarSrc,
})
res.json({ user: decorateAdminUser(updated, primaryAdmin) })
})
router.delete('/users/:userId', requireAdmin, async (req, res) => {
if (req.params.userId === req.session.userId) {
return res.status(400).json({ error: 'cannot_delete_self' })
}
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
if (!targetUser) return res.status(404).json({ error: 'not_found' })
const actingIsPrimaryAdmin = canManageAdminRole(actingUser, primaryAdmin)
const targetIsPrimaryAdmin = primaryAdmin?.id === targetUser.id
if (targetIsPrimaryAdmin) {
return res.status(400).json({ error: 'cannot_delete_primary_admin' })
}
if (targetUser.isAdmin && !actingIsPrimaryAdmin) {
return res.status(403).json({ error: 'primary_admin_only' })
}
await adminDeleteUser(targetUser.id)
res.json({ ok: true })
})
router.patch('/users/:userId/password', requireAdmin, async (req, res) => {
const schema = z.object({
password: z.string().min(6).max(120),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
if (!targetUser) return res.status(404).json({ error: 'not_found' })
if (primaryAdmin?.id === targetUser.id && !canManageAdminRole(actingUser, primaryAdmin)) {
return res.status(403).json({ error: 'primary_admin_protected' })
}
const passwordHash = await bcrypt.hash(parsed.data.password, 10)
await adminUpdateUserPassword({ id: targetUser.id, passwordHash })
res.json({ ok: true })
})
module.exports = router