import { and, eq, sql } from 'drizzle-orm' import { z } from 'zod' import { db } from '../db/client.js' import { findAuthenticatedUser } from '../lib/authSession.js' import { authSessions, users } from '../db/schema.js' const adminUserIdSchema = z.coerce.number().int().positive() const adminStatusSchema = z.object({ disabled: z.boolean(), }) async function requireAdminUser(request, reply) { const user = await findAuthenticatedUser(request) if (!user) { reply.code(401).send({ message: '인증이 필요합니다.', }) return null } if (user.role !== 'admin') { reply.code(403).send({ message: '관리자만 접근할 수 있습니다.', }) return null } return user } export async function registerAdminRoutes(app) { app.get('/api/admin/overview', async (request, reply) => { const adminUser = await requireAdminUser(request, reply) if (!adminUser) { return } const [summary] = await db .select({ totalUsers: sql`count(*)::int`, totalAdmins: sql`count(*) filter (where ${users.role} = 'admin')::int`, disabledUsers: sql`count(*) filter (where ${users.disabledAt} is not null)::int`, verifiedUsers: sql`count(*) filter (where ${users.emailVerifiedAt} is not null)::int`, activeUsers30d: sql`count(*) filter (where ${users.lastLoginAt} >= now() - interval '30 days')::int`, newUsers7d: sql`count(*) filter (where ${users.createdAt} >= now() - interval '7 days')::int`, }) .from(users) const plannerCountResult = await db.execute( sql`select count(*)::int as count from planner_entries`, ) const goalCountResult = await db.execute( sql`select count(*)::int as count from goals`, ) const userRowsResult = await db.execute(sql` select u.id, u.nickname, u.email, u.role, u.disabled_at as "disabledAt", u.created_at as "createdAt", u.email_verified_at as "emailVerifiedAt", u.last_login_at as "lastLoginAt", count(distinct pe.id)::int as "plannerEntryCount", count(distinct g.id)::int as "goalCount", count(distinct s.id)::int as "activeSessionCount", max(pe.entry_date) as "lastEntryDate", max(pe.updated_at) as "lastEntryUpdatedAt" from users u left join planner_entries pe on pe.user_id = u.id left join goals g on g.user_id = u.id left join auth_sessions s on s.user_id = u.id and s.expires_at > now() group by u.id order by coalesce(u.last_login_at, u.created_at) desc, u.id desc `) const recentLoginsResult = await db.execute(sql` select id, nickname, email, role, last_login_at as "lastLoginAt" from users where last_login_at is not null order by last_login_at desc limit 5 `) return { summary: { totalUsers: summary?.totalUsers ?? 0, totalAdmins: summary?.totalAdmins ?? 0, verifiedUsers: summary?.verifiedUsers ?? 0, activeUsers30d: summary?.activeUsers30d ?? 0, newUsers7d: summary?.newUsers7d ?? 0, disabledUsers: summary?.disabledUsers ?? 0, totalPlannerEntries: plannerCountResult.rows[0]?.count ?? 0, totalGoals: goalCountResult.rows[0]?.count ?? 0, }, users: userRowsResult.rows.map((row) => ({ ...row, isActiveRecently: row.lastLoginAt ? new Date(row.lastLoginAt).getTime() >= Date.now() - 30 * 24 * 60 * 60 * 1000 : false, })), recentLogins: recentLoginsResult.rows, } }) app.get('/api/admin/users/:userId/detail', async (request, reply) => { const adminUser = await requireAdminUser(request, reply) if (!adminUser) { return } const userIdResult = adminUserIdSchema.safeParse(request.params.userId) if (!userIdResult.success) { return reply.code(400).send({ message: '대상 사용자 값이 올바르지 않습니다.', }) } const userId = userIdResult.data const detailResult = await db.execute(sql` select u.id, u.nickname, u.email, u.role, u.disabled_at as "disabledAt", u.created_at as "createdAt", u.updated_at as "updatedAt", u.email_verified_at as "emailVerifiedAt", u.last_login_at as "lastLoginAt", count(distinct pe.id)::int as "plannerEntryCount", count(distinct g.id)::int as "goalCount", count(distinct s.id)::int as "activeSessionCount" from users u left join planner_entries pe on pe.user_id = u.id left join goals g on g.user_id = u.id left join auth_sessions s on s.user_id = u.id and s.expires_at > now() where u.id = ${userId} group by u.id limit 1 `) const detailUser = detailResult.rows[0] if (!detailUser) { return reply.code(404).send({ message: '대상 사용자를 찾을 수 없습니다.', }) } const plannerEntriesResult = await db.execute(sql` select entry_date as "entryDate", payload, created_at as "createdAt", updated_at as "updatedAt" from planner_entries where user_id = ${userId} order by entry_date desc limit 12 `) const goalsResult = await db.execute(sql` select id, title, target_date as "targetDate", active_from as "activeFrom", active_until as "activeUntil", color, created_at as "createdAt", updated_at as "updatedAt" from goals where user_id = ${userId} order by updated_at desc, id desc limit 20 `) return { user: detailUser, plannerEntries: plannerEntriesResult.rows, goals: goalsResult.rows, } }) app.put('/api/admin/users/:userId/status', async (request, reply) => { const adminUser = await requireAdminUser(request, reply) if (!adminUser) { return } const userIdResult = adminUserIdSchema.safeParse(request.params.userId) const payload = adminStatusSchema.safeParse(request.body) if (!userIdResult.success || !payload.success) { return reply.code(400).send({ message: '관리자 요청 값이 올바르지 않습니다.', }) } const userId = userIdResult.data const [targetUser] = await db .select() .from(users) .where(eq(users.id, userId)) .limit(1) if (!targetUser) { return reply.code(404).send({ message: '대상 사용자를 찾을 수 없습니다.', }) } if (targetUser.role === 'admin') { return reply.code(403).send({ message: '관리자 계정 상태는 여기서 변경할 수 없습니다.', }) } const disabled = payload.data.disabled const now = new Date() const [updatedUser] = await db .update(users) .set({ disabledAt: disabled ? now : null, updatedAt: now, }) .where(eq(users.id, userId)) .returning() if (disabled) { await db.delete(authSessions).where(eq(authSessions.userId, userId)) } return { message: disabled ? '계정을 비활성화했습니다.' : '계정을 다시 사용할 수 있게 했습니다.', user: updatedUser, } }) app.post('/api/admin/users/:userId/revoke-sessions', async (request, reply) => { const adminUser = await requireAdminUser(request, reply) if (!adminUser) { return } const userIdResult = adminUserIdSchema.safeParse(request.params.userId) if (!userIdResult.success) { return reply.code(400).send({ message: '대상 사용자 값이 올바르지 않습니다.', }) } const userId = userIdResult.data const [targetUser] = await db .select() .from(users) .where(eq(users.id, userId)) .limit(1) if (!targetUser) { return reply.code(404).send({ message: '대상 사용자를 찾을 수 없습니다.', }) } if (targetUser.role === 'admin') { return reply.code(403).send({ message: '관리자 계정 세션은 여기서 정리하지 않습니다.', }) } const deletedSessions = await db .delete(authSessions) .where(eq(authSessions.userId, userId)) .returning({ id: authSessions.id }) return { message: deletedSessions.length > 0 ? '해당 사용자의 로그인 세션을 모두 종료했습니다.' : '종료할 로그인 세션이 없습니다.', revokedCount: deletedSessions.length, } }) app.delete('/api/admin/users/:userId', async (request, reply) => { const adminUser = await requireAdminUser(request, reply) if (!adminUser) { return } const userIdResult = adminUserIdSchema.safeParse(request.params.userId) if (!userIdResult.success) { return reply.code(400).send({ message: '대상 사용자 값이 올바르지 않습니다.', }) } const userId = userIdResult.data const [targetUser] = await db .select() .from(users) .where(eq(users.id, userId)) .limit(1) if (!targetUser) { return reply.code(404).send({ message: '대상 사용자를 찾을 수 없습니다.', }) } if (targetUser.role === 'admin') { return reply.code(403).send({ message: '관리자 계정은 여기서 삭제할 수 없습니다.', }) } await db.delete(users).where(and(eq(users.id, userId), eq(users.role, 'user'))) return { message: '사용자 계정과 관련 데이터를 삭제했습니다.', } }) }