v0.1.40 - 관리자 대시보드 기본 구조 추가
This commit is contained in:
@@ -14,6 +14,7 @@ const envSchema = z.object({
|
||||
CORS_ORIGIN: z.string().default('http://localhost:5173'),
|
||||
SESSION_TTL_DAYS: z.coerce.number().default(30),
|
||||
APP_BASE_URL: z.string().default('http://localhost:5173'),
|
||||
ADMIN_EMAILS: z.string().default('zenn.message@gmail.com'),
|
||||
})
|
||||
|
||||
export const env = envSchema.parse(process.env)
|
||||
|
||||
@@ -7,14 +7,22 @@ export async function ensureDatabaseSchema() {
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
nickname VARCHAR(60) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'user',
|
||||
email_verified_at TIMESTAMPTZ,
|
||||
last_login_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS role VARCHAR(20) NOT NULL DEFAULT 'user';
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_sessions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -14,7 +14,9 @@ export const users = pgTable('users', {
|
||||
email: varchar('email', { length: 255 }).notNull().unique(),
|
||||
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
|
||||
nickname: varchar('nickname', { length: 60 }).notNull(),
|
||||
role: varchar('role', { length: 20 }).notNull().default('user'),
|
||||
emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true }),
|
||||
lastLoginAt: timestamp('last_login_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull(),
|
||||
})
|
||||
|
||||
103
backend/src/routes/admin.js
Normal file
103
backend/src/routes/admin.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { db } from '../db/client.js'
|
||||
import { findAuthenticatedUser } from '../lib/authSession.js'
|
||||
import { users } from '../db/schema.js'
|
||||
|
||||
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<number>`count(*)::int`,
|
||||
totalAdmins: sql<number>`count(*) filter (where ${users.role} = 'admin')::int`,
|
||||
verifiedUsers: sql<number>`count(*) filter (where ${users.emailVerifiedAt} is not null)::int`,
|
||||
activeUsers30d: sql<number>`count(*) filter (where ${users.lastLoginAt} >= now() - interval '30 days')::int`,
|
||||
newUsers7d: sql<number>`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.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",
|
||||
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
|
||||
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,
|
||||
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,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -45,6 +45,15 @@ const passwordResetConfirmSchema = z.object({
|
||||
})
|
||||
|
||||
const TOKEN_TTL_MS = 1000 * 60 * 30
|
||||
const adminEmails = new Set(
|
||||
env.ADMIN_EMAILS.split(',')
|
||||
.map((email) => email.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
)
|
||||
|
||||
function resolveUserRole(email) {
|
||||
return adminEmails.has(email.toLowerCase()) ? 'admin' : 'user'
|
||||
}
|
||||
|
||||
function buildPreviewUrl(pathname, token) {
|
||||
const url = new URL(pathname, env.APP_BASE_URL)
|
||||
@@ -99,7 +108,9 @@ function sanitizeUser(user) {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
nickname: user.nickname,
|
||||
role: user.role,
|
||||
emailVerifiedAt: user.emailVerifiedAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
}
|
||||
@@ -133,6 +144,7 @@ export async function registerAuthRoutes(app) {
|
||||
|
||||
const now = new Date()
|
||||
const passwordHash = await hashPassword(password)
|
||||
const role = resolveUserRole(normalizedEmail)
|
||||
|
||||
const [user] = await db
|
||||
.insert(users)
|
||||
@@ -140,7 +152,9 @@ export async function registerAuthRoutes(app) {
|
||||
email: normalizedEmail,
|
||||
passwordHash,
|
||||
nickname,
|
||||
role,
|
||||
emailVerifiedAt: null,
|
||||
lastLoginAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
@@ -189,12 +203,25 @@ export async function registerAuthRoutes(app) {
|
||||
})
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const role = resolveUserRole(user.email)
|
||||
|
||||
const [updatedUser] = await db
|
||||
.update(users)
|
||||
.set({
|
||||
role,
|
||||
lastLoginAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(users.id, user.id))
|
||||
.returning()
|
||||
|
||||
const { token } = await createSession(user.id)
|
||||
|
||||
return {
|
||||
message: '로그인에 성공했습니다.',
|
||||
token,
|
||||
user: sanitizeUser(user),
|
||||
user: sanitizeUser(updatedUser),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -207,6 +234,23 @@ export async function registerAuthRoutes(app) {
|
||||
})
|
||||
}
|
||||
|
||||
const resolvedRole = resolveUserRole(user.email)
|
||||
|
||||
if (user.role !== resolvedRole) {
|
||||
const [updatedUser] = await db
|
||||
.update(users)
|
||||
.set({
|
||||
role: resolvedRole,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, user.id))
|
||||
.returning()
|
||||
|
||||
return {
|
||||
user: sanitizeUser(updatedUser),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
user: sanitizeUser(user),
|
||||
}
|
||||
@@ -249,6 +293,7 @@ export async function registerAuthRoutes(app) {
|
||||
.set({
|
||||
email: normalizedEmail,
|
||||
nickname: payload.data.nickname,
|
||||
role: resolveUserRole(normalizedEmail),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, user.id))
|
||||
|
||||
@@ -4,6 +4,7 @@ import { env } from './config.js'
|
||||
import { pool } from './db/client.js'
|
||||
import { ensureDatabaseSchema } from './db/init.js'
|
||||
import { registerAuthRoutes } from './routes/auth.js'
|
||||
import { registerAdminRoutes } from './routes/admin.js'
|
||||
import { registerGoalRoutes } from './routes/goals.js'
|
||||
import { registerPlannerRoutes } from './routes/planner.js'
|
||||
|
||||
@@ -19,6 +20,7 @@ await app.register(cors, {
|
||||
})
|
||||
|
||||
await registerAuthRoutes(app)
|
||||
await registerAdminRoutes(app)
|
||||
await registerGoalRoutes(app)
|
||||
await registerPlannerRoutes(app)
|
||||
|
||||
@@ -42,6 +44,7 @@ app.get('/api/meta', async () => ({
|
||||
orm: 'drizzle',
|
||||
notes: [
|
||||
'회원가입, 로그인, 현재 사용자 확인 API가 준비되어 있습니다.',
|
||||
'관리자용 사용자 현황 요약 API가 준비되어 있습니다.',
|
||||
'사용자별 목표 목록, 수정, 삭제 API가 준비되어 있습니다.',
|
||||
'사용자별 플래너 저장 및 조회 API가 준비되어 있습니다.',
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user