UNPKG

@restnfeel/agentc-starter-kit

Version:

한국어 기업용 CMS 모듈 - Task Master AI와 함께 빠르게 웹사이트를 구현할 수 있는 재사용 가능한 컴포넌트 시스템

758 lines (651 loc) 18.9 kB
import { PrismaClient } from "@prisma/client"; import bcrypt from "bcryptjs"; import crypto from "crypto"; import { UserProfile, CreateUserRequest, UpdateUserRequest, UserListFilters, UserListSort, PaginationParams, UserListResponse, PasswordResetRequest, PasswordResetConfirm, EmailVerificationRequest, EmailVerificationConfirm, AccountUnlockRequest, ProfileUpdateRequest, UserStats, UserFetchOptions, BulkUserOperation, BulkOperationResult, UserManagementEventData, Role, } from "./types"; export class UserService { private prisma: PrismaClient; constructor(prisma: PrismaClient) { this.prisma = prisma; } // 사용자 생성 async createUser( data: CreateUserRequest, adminId?: string ): Promise<UserProfile> { const { email, password, name, role = Role.VIEWER, tenantId, sendVerificationEmail = true, } = data; // 이메일 중복 확인 const existingUser = await this.prisma.user.findUnique({ where: { email }, }); if (existingUser) { throw new Error("이미 존재하는 이메일입니다."); } // 비밀번호 해시 const passwordHash = await bcrypt.hash(password, 12); // 이메일 인증 토큰 생성 const emailVerificationToken = sendVerificationEmail ? crypto.randomBytes(32).toString("hex") : null; // 사용자 생성 const user = await this.prisma.user.create({ data: { email, passwordHash, name, role, tenantId, emailVerificationToken, isActive: true, failedLoginAttempts: 0, loginCount: 0, }, include: { tenant: true, }, }); // 감사 로그 기록 await this.logUserEvent({ event: "user.created", userId: user.id, adminId, data: { email, name, role: role as string, tenantId: tenantId || "none", }, timestamp: new Date(), }); // 이메일 인증 메일 발송 (실제 구현에서는 이메일 서비스 사용) if (sendVerificationEmail && emailVerificationToken) { await this.sendVerificationEmail(email, emailVerificationToken); } // 비밀번호 해시 제거 후 반환 // eslint-disable-next-line @typescript-eslint/no-unused-vars const { passwordHash: _, ...userProfile } = user; return userProfile as UserProfile; } // 사용자 조회 async getUserById( id: string, options: UserFetchOptions = {} ): Promise<UserProfile | null> { const user = await this.prisma.user.findUnique({ where: { id }, include: { tenant: options.includeTenant, _count: options.includeStats ? { auditLogs: true, sessions: true, } : undefined, }, }); if (!user) return null; // eslint-disable-next-line @typescript-eslint/no-unused-vars const { passwordHash: _, ...userProfile } = user; return userProfile as UserProfile; } // 이메일로 사용자 조회 async getUserByEmail(email: string): Promise<UserProfile | null> { const user = await this.prisma.user.findUnique({ where: { email }, include: { tenant: true, }, }); if (!user) return null; // eslint-disable-next-line @typescript-eslint/no-unused-vars const { passwordHash: _, ...userProfile } = user; return userProfile as UserProfile; } // 사용자 목록 조회 async getUsers( filters: UserListFilters = {}, sort: UserListSort = { field: "createdAt", direction: "desc" }, pagination: PaginationParams = { page: 1, limit: 20 } ): Promise<UserListResponse> { const { page, limit } = pagination; const skip = (page - 1) * limit; // 필터 조건 구성 const where = this.buildUserFilters(filters); // 정렬 조건 구성 const orderBy = { [sort.field]: sort.direction }; // 총 개수 조회 const total = await this.prisma.user.count({ where }); // 사용자 목록 조회 const users = await this.prisma.user.findMany({ where, orderBy, skip, take: limit, include: { tenant: true, _count: { auditLogs: true, sessions: true, }, }, }); // 비밀번호 해시 제거 const userProfiles = users.map( (user: { passwordHash: string; [key: string]: unknown }) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { passwordHash: _, ...userProfile } = user; return userProfile as unknown as UserProfile; } ); const totalPages = Math.ceil(total / limit); return { users: userProfiles, pagination: { page, limit, total, totalPages, hasNext: page < totalPages, hasPrev: page > 1, }, filters, sort, }; } // 사용자 업데이트 async updateUser( id: string, data: UpdateUserRequest, adminId?: string ): Promise<UserProfile> { const existingUser = await this.prisma.user.findUnique({ where: { id }, }); if (!existingUser) { throw new Error("사용자를 찾을 수 없습니다."); } // 이메일 변경 시 중복 확인 if (data.email && data.email !== existingUser.email) { const emailExists = await this.prisma.user.findUnique({ where: { email: data.email }, }); if (emailExists) { throw new Error("이미 존재하는 이메일입니다."); } } const updatedUser = await this.prisma.user.update({ where: { id }, data, include: { tenant: true, }, }); // 감사 로그 기록 const logData: Record<string, string | number | boolean> = {}; if (data.name) logData.name = data.name; if (data.email) logData.email = data.email; if (data.role) logData.role = data.role as string; if (data.isActive !== undefined) logData.isActive = data.isActive; await this.logUserEvent({ event: "user.updated", userId: id, adminId, data: logData, timestamp: new Date(), }); // eslint-disable-next-line @typescript-eslint/no-unused-vars const { passwordHash: _, ...userProfile } = updatedUser; return userProfile as UserProfile; } // 사용자 삭제 async deleteUser(id: string, adminId?: string): Promise<void> { const user = await this.prisma.user.findUnique({ where: { id }, }); if (!user) { throw new Error("사용자를 찾을 수 없습니다."); } await this.prisma.user.delete({ where: { id }, }); // 감사 로그 기록 await this.logUserEvent({ event: "user.deleted", userId: id, adminId, data: { email: user.email, name: user.name }, timestamp: new Date(), }); } // 비밀번호 재설정 요청 async requestPasswordReset(data: PasswordResetRequest): Promise<void> { const { email } = data; const user = await this.prisma.user.findUnique({ where: { email }, }); if (!user) { // 보안상 사용자가 존재하지 않아도 성공으로 응답 return; } const resetToken = crypto.randomBytes(32).toString("hex"); const resetExpires = new Date(Date.now() + 3600000); // 1시간 후 만료 await this.prisma.user.update({ where: { id: user.id }, data: { passwordResetToken: resetToken, passwordResetExpires: resetExpires, }, }); // 비밀번호 재설정 이메일 발송 await this.sendPasswordResetEmail(email, resetToken); // 감사 로그 기록 await this.logUserEvent({ event: "user.password_reset", userId: user.id, data: { email }, timestamp: new Date(), }); } // 비밀번호 재설정 확인 async confirmPasswordReset(data: PasswordResetConfirm): Promise<void> { const { token, newPassword } = data; const user = await this.prisma.user.findFirst({ where: { passwordResetToken: token, passwordResetExpires: { gt: new Date(), }, }, }); if (!user) { throw new Error("유효하지 않거나 만료된 토큰입니다."); } const passwordHash = await bcrypt.hash(newPassword, 12); await this.prisma.user.update({ where: { id: user.id }, data: { passwordHash, passwordResetToken: null, passwordResetExpires: null, failedLoginAttempts: 0, accountLockedUntil: null, }, }); // 감사 로그 기록 await this.logUserEvent({ event: "user.password_reset", userId: user.id, data: { email: user.email }, timestamp: new Date(), }); } // 이메일 인증 요청 async requestEmailVerification( data: EmailVerificationRequest ): Promise<void> { const { email } = data; const user = await this.prisma.user.findUnique({ where: { email }, }); if (!user) { throw new Error("사용자를 찾을 수 없습니다."); } if (user.emailVerified) { throw new Error("이미 인증된 이메일입니다."); } const verificationToken = crypto.randomBytes(32).toString("hex"); await this.prisma.user.update({ where: { id: user.id }, data: { emailVerificationToken: verificationToken, }, }); // 이메일 인증 메일 발송 await this.sendVerificationEmail(email, verificationToken); } // 이메일 인증 확인 async confirmEmailVerification( data: EmailVerificationConfirm ): Promise<void> { const { token } = data; const user = await this.prisma.user.findFirst({ where: { emailVerificationToken: token, }, }); if (!user) { throw new Error("유효하지 않은 인증 토큰입니다."); } await this.prisma.user.update({ where: { id: user.id }, data: { emailVerified: new Date(), emailVerificationToken: null, }, }); // 감사 로그 기록 await this.logUserEvent({ event: "user.email_verified", userId: user.id, data: { email: user.email }, timestamp: new Date(), }); } // 계정 잠금 해제 async unlockAccount( data: AccountUnlockRequest, adminId?: string ): Promise<void> { const { userId, reason } = data; const user = await this.prisma.user.findUnique({ where: { id: userId }, }); if (!user) { throw new Error("사용자를 찾을 수 없습니다."); } await this.prisma.user.update({ where: { id: userId }, data: { failedLoginAttempts: 0, accountLockedUntil: null, }, }); // 감사 로그 기록 await this.logUserEvent({ event: "user.unlocked", userId, adminId, data: { reason: reason || "no reason provided", email: user.email, }, timestamp: new Date(), }); } // 사용자 프로필 업데이트 async updateProfile( userId: string, data: ProfileUpdateRequest ): Promise<UserProfile> { const { name, email, currentPassword, newPassword } = data; const user = await this.prisma.user.findUnique({ where: { id: userId }, }); if (!user) { throw new Error("사용자를 찾을 수 없습니다."); } // 비밀번호 변경 시 현재 비밀번호 확인 if (newPassword) { if (!currentPassword) { throw new Error("현재 비밀번호가 필요합니다."); } const isValidPassword = await bcrypt.compare( currentPassword, user.passwordHash ); if (!isValidPassword) { throw new Error("현재 비밀번호가 올바르지 않습니다."); } } const updateData: Record<string, unknown> = {}; if (name) updateData.name = name; if (email && email !== user.email) { // 이메일 중복 확인 const emailExists = await this.prisma.user.findUnique({ where: { email }, }); if (emailExists) { throw new Error("이미 존재하는 이메일입니다."); } updateData.email = email; updateData.emailVerified = null; // 새 이메일은 재인증 필요 } if (newPassword) { updateData.passwordHash = await bcrypt.hash(newPassword, 12); } const updatedUser = await this.prisma.user.update({ where: { id: userId }, data: updateData, include: { tenant: true, }, }); const { passwordHash: _, ...userProfile } = updatedUser; return userProfile as UserProfile; } // 사용자 통계 조회 async getUserStats(): Promise<UserStats> { const [ totalUsers, activeUsers, verifiedUsers, lockedUsers, usersByRole, usersByTenant, recentRegistrations, recentLogins, ] = await Promise.all([ this.prisma.user.count(), this.prisma.user.count({ where: { isActive: true } }), this.prisma.user.count({ where: { emailVerified: { not: null } } }), this.prisma.user.count({ where: { accountLockedUntil: { gt: new Date() } }, }), this.getUsersByRole(), this.getUsersByTenant(), this.prisma.user.count({ where: { createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // 지난 7일 }, }, }), this.prisma.user.count({ where: { lastLogin: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // 지난 7일 }, }, }), ]); return { totalUsers, activeUsers, verifiedUsers, lockedUsers, usersByRole, usersByTenant, recentRegistrations, recentLogins, }; } // 대량 사용자 작업 async bulkUserOperation( operation: BulkUserOperation, adminId?: string ): Promise<BulkOperationResult> { const { userIds, operation: op, data } = operation; const result: BulkOperationResult = { success: 0, failed: 0, errors: [], }; for (const userId of userIds) { try { switch (op) { case "activate": await this.updateUser(userId, { isActive: true }, adminId); break; case "deactivate": await this.updateUser(userId, { isActive: false }, adminId); break; case "delete": await this.deleteUser(userId, adminId); break; case "unlock": await this.unlockAccount({ userId }, adminId); break; case "change_role": if (data?.role) { await this.updateUser( userId, { role: data.role as Role }, adminId ); } break; } result.success++; } catch (error) { result.failed++; result.errors.push({ userId, error: error instanceof Error ? error.message : "알 수 없는 오류", }); } } return result; } // 필터 조건 구성 private buildUserFilters(filters: UserListFilters) { const where: Record<string, unknown> = {}; if (filters.search) { where.OR = [ { name: { contains: filters.search, mode: "insensitive" } }, { email: { contains: filters.search, mode: "insensitive" } }, ]; } if (filters.role) { where.role = filters.role; } if (filters.isActive !== undefined) { where.isActive = filters.isActive; } if (filters.emailVerified !== undefined) { where.emailVerified = filters.emailVerified ? { not: null } : null; } if (filters.tenantId) { where.tenantId = filters.tenantId; } if (filters.createdAfter) { where.createdAt = { ...(where.createdAt as object), gte: filters.createdAfter, }; } if (filters.createdBefore) { where.createdAt = { ...(where.createdAt as object), lte: filters.createdBefore, }; } if (filters.lastLoginAfter) { where.lastLogin = { ...(where.lastLogin as object), gte: filters.lastLoginAfter, }; } if (filters.lastLoginBefore) { where.lastLogin = { ...(where.lastLogin as object), lte: filters.lastLoginBefore, }; } return where; } // 역할별 사용자 수 조회 private async getUsersByRole(): Promise<Record<Role, number>> { const result = await this.prisma.user.groupBy({ by: ["role"], _count: true, }); const usersByRole: Record<Role, number> = { [Role.ADMIN]: 0, [Role.EDITOR]: 0, [Role.VIEWER]: 0, [Role.GUEST]: 0, }; result.forEach((item: { role: string; _count: number }) => { usersByRole[item.role as Role] = item._count; }); return usersByRole; } // 테넌트별 사용자 수 조회 private async getUsersByTenant(): Promise<Record<string, number>> { const result = await this.prisma.user.groupBy({ by: ["tenantId"], _count: true, where: { tenantId: { not: null }, }, }); const usersByTenant: Record<string, number> = {}; result.forEach((item: { tenantId: string | null; _count: number }) => { if (item.tenantId) { usersByTenant[item.tenantId] = item._count; } }); return usersByTenant; } // 사용자 이벤트 로깅 private async logUserEvent( eventData: UserManagementEventData ): Promise<void> { await this.prisma.auditLog.create({ data: { userId: eventData.userId, action: eventData.event, resource: "USER", resourceId: eventData.userId, details: eventData.data, ipAddress: eventData.ipAddress, userAgent: eventData.userAgent, performedBy: eventData.adminId, }, }); } // 이메일 인증 메일 발송 (실제 구현에서는 이메일 서비스 사용) private async sendVerificationEmail( email: string, token: string ): Promise<void> { // TODO: 실제 이메일 서비스 구현 // eslint-disable-next-line @typescript-eslint/no-unused-vars console.log(`이메일 인증 메일 발송: ${email}, 토큰: ${token}`); } // 비밀번호 재설정 메일 발송 (실제 구현에서는 이메일 서비스 사용) private async sendPasswordResetEmail( email: string, token: string ): Promise<void> { // TODO: 실제 이메일 서비스 구현 // eslint-disable-next-line @typescript-eslint/no-unused-vars console.log(`비밀번호 재설정 메일 발송: ${email}, 토큰: ${token}`); } }