@restnfeel/agentc-starter-kit
Version:
한국어 기업용 CMS 모듈 - Task Master AI와 함께 빠르게 웹사이트를 구현할 수 있는 재사용 가능한 컴포넌트 시스템
758 lines (651 loc) • 18.9 kB
text/typescript
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}`);
}
}