344 lines
9.5 KiB
JavaScript
344 lines
9.5 KiB
JavaScript
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: '사용자 계정과 관련 데이터를 삭제했습니다.',
|
|
}
|
|
})
|
|
}
|