import { Injectable, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { AuthPrismaService } from '../auth-prisma/auth-prisma.service'; import { ListUserSessionsQuery } from './dto'; type UserSessionRow = { id: string; sessionId: string | null; userId: string; user: string; email: string; avatarUrl: string | null; role: string; session: string; createdAt: Date | null; expiresAt: Date | null; lastSeen: Date | null; state: string; primaryAffiliation: { id: string; organizationId: string; organizationName: string; organizationType: string; department: string | null; position: string | null; } | null; }; @Injectable() export class UsersService { constructor( private readonly authPrisma: AuthPrismaService, private readonly configService: ConfigService, ) {} async listSessions(query: ListUserSessionsQuery) { const keyword = query.keyword?.trim(); const now = new Date(); const users = await this.authPrisma.user.findMany({ where: keyword ? { OR: [ { email: { contains: keyword, mode: 'insensitive' } }, { name: { contains: keyword, mode: 'insensitive' } }, ], } : undefined, orderBy: [{ lastLoginAt: 'desc' }, { createdAt: 'desc' }], include: { refreshTokens: { where: { revokedAt: null, expiresAt: { gt: now }, }, orderBy: { createdAt: 'desc' }, }, affiliations: { where: { OR: [{ endsAt: null }, { endsAt: { gt: now } }], }, orderBy: [{ isPrimary: 'desc' }, { createdAt: 'desc' }], take: 1, include: { organization: { select: { id: true, name: true, type: true, }, }, }, }, }, }); const rows: UserSessionRow[] = users.flatMap((user): UserSessionRow[] => { const avatarUrl = getUserAvatarUrl( user.id, user.profileImageMime, this.configService.get('AUTH_PUBLIC_URL') ?? this.configService.get('ATNMY_AUTH_URL') ?? this.configService.get('AUTH_SERVER_URL'), ); const primaryAffiliation = user.affiliations[0] ? { id: user.affiliations[0].id, organizationId: user.affiliations[0].organizationId, organizationName: user.affiliations[0].organization.name, organizationType: user.affiliations[0].organization.type, department: user.affiliations[0].department, position: user.affiliations[0].position, } : null; if (user.refreshTokens.length === 0) { return [ { id: `user-${user.id}`, sessionId: null, userId: user.id, user: user.name || user.email, email: user.email, avatarUrl, role: user.role, session: '세션 없음', createdAt: null, expiresAt: null, lastSeen: user.lastLoginAt, state: user.isActive ? '세션 없음' : '잠김', primaryAffiliation, }, ]; } return user.refreshTokens.map((token) => ({ id: token.id, sessionId: token.id, userId: user.id, user: user.name || user.email, email: user.email, avatarUrl, role: user.role, session: token.tokenHash.slice(0, 10), createdAt: token.createdAt, expiresAt: token.expiresAt, lastSeen: user.lastLoginAt ?? token.createdAt, state: getSessionState(user.isActive, token.revokedAt, token.expiresAt, now), primaryAffiliation, })); }); const filteredRows = query.state ? rows.filter((row) => row.state === query.state) : rows; return { sessions: filteredRows.map((row, index) => ({ ...row, no: filteredRows.length - index, })), }; } async lockUser(userId: string) { const user = await this.authPrisma.user.findUnique({ where: { id: userId }, select: { id: true }, }); if (!user) { throw new NotFoundException('사용자를 찾을 수 없습니다.'); } await this.authPrisma.$transaction([ this.authPrisma.user.update({ where: { id: userId }, data: { isActive: false }, }), this.authPrisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: new Date() }, }), ]); return { ok: true }; } async unlockUser(userId: string) { const user = await this.authPrisma.user.findUnique({ where: { id: userId }, select: { id: true }, }); if (!user) { throw new NotFoundException('사용자를 찾을 수 없습니다.'); } await this.authPrisma.user.update({ where: { id: userId }, data: { isActive: true }, }); return { ok: true }; } async terminateSession(sessionId: string) { const session = await this.authPrisma.refreshToken.findUnique({ where: { id: sessionId }, select: { id: true, userId: true }, }); if (!session) { throw new NotFoundException('세션을 찾을 수 없습니다.'); } await this.authPrisma.refreshToken.updateMany({ where: { userId: session.userId, revokedAt: null, expiresAt: { gt: new Date() }, }, data: { revokedAt: new Date() }, }); return { ok: true }; } } function getUserAvatarUrl( userId: string, mimeType?: string | null, authBaseUrl?: string | null, ) { if (!mimeType || !authBaseUrl) { return null; } const normalizedBaseUrl = authBaseUrl.replace(/\/+$/, ''); return `${normalizedBaseUrl}/auth/users/${encodeURIComponent(userId)}/avatar`; } function getSessionState( isUserActive: boolean, revokedAt: Date | null, expiresAt: Date, now: Date, ) { if (!isUserActive) { return '잠김'; } if (revokedAt) { return '종료'; } if (expiresAt < now) { return '만료'; } return '유효토큰'; }