// backend/src/modules/auth/auth.service.ts import { Injectable, UnauthorizedException, BadRequestException, ConflictException, InternalServerErrorException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import * as bcrypt from 'bcrypt'; import { PrismaService } from '../../../prisma/prisma.service'; import { LoginDto } from './dto/login.dto'; import { RegisterDto } from './dto/register.dto'; import { JwtPayload } from './interfaces/jwt-payload.interface'; const ALL_PERMISSIONS = [ 'VIEW_PATIENTS', 'CREATE_PATIENTS', 'EDIT_PATIENTS', 'DELETE_PATIENTS', 'VIEW_ORDERS', 'CREATE_ORDERS', 'EDIT_ORDERS', 'DELETE_ORDERS', 'TRACK_ORDERS', 'VIEW_CASES', 'CREATE_CASES', 'EDIT_CASES', 'DELETE_CASES', 'VIEW_REPORTS', 'EXPORT_REPORTS', 'INVITE_USERS', 'REMOVE_USERS', 'MANAGE_PERMISSIONS', 'VIEW_INVOICES', 'CREATE_INVOICES', 'MANAGE_PAYMENTS', ]; @Injectable() export class AuthService { constructor( private prisma: PrismaService, private jwtService: JwtService, private configService: ConfigService, ) { } /** * Validate user credentials (used by LocalStrategy) * @param email - User's email * @param password - User's password * @returns User object without passwordHash or null if invalid */ async validateUser(email: string, password: string): Promise { try { const user = await this.prisma.user.findUnique({ where: { email }, include: { memberships: { include: { organization: { include: { type: true, // Include organization type (CLINIC/LAB) } }, permissions: { include: { permission: true, // Include permission details }, }, }, }, }, }); if (!user) { return null; } // Check if user has a password (might be OAuth only, but we're not using OAuth) if (!user.passwordHash) { return null; } const isPasswordValid = await bcrypt.compare(password, user.passwordHash); if (!isPasswordValid) { return null; } // Remove sensitive data const { passwordHash, ...result } = user; return result; } catch (error) { throw new InternalServerErrorException('Error validating user'); } } /** * Login user and generate tokens * @param loginDto - Login credentials (email, password) * @param user - Validated user object from LocalStrategy * @returns Access token, refresh token, user info, and organizations */ async login(loginDto: LoginDto, user: any) { try { // Generate access token (short-lived) const accessPayload: JwtPayload = { sub: user.id, email: user.email, type: 'access' }; // Generate refresh token (long-lived) const refreshPayload: JwtPayload = { sub: user.id, email: user.email, type: 'refresh' }; const [accessToken, refreshToken] = await Promise.all([ this.jwtService.signAsync(accessPayload, { secret: this.configService.get('JWT_SECRET'), expiresIn: this.configService.get('JWT_EXPIRES_IN'), }), this.jwtService.signAsync(refreshPayload, { secret: this.configService.get('JWT_REFRESH_SECRET'), expiresIn: this.configService.get('JWT_REFRESH_EXPIRES_IN'), }), ]); // Store session in database await this.prisma.session.create({ data: { userId: user.id, token: accessToken, refreshToken: refreshToken, expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days }, }); // Transform memberships to include organization info and permissions const organizations = user.memberships?.map(membership => ({ id: membership.organization.id, name: membership.organization.name, type: membership.organization.type.name, // 'CLINIC' or 'LAB' isOwner: membership.isOwner, permissions: membership.isOwner ? ALL_PERMISSIONS : membership.permissions?.map(p => p.permission.name) || [], })) || []; return { success: true, data: { accessToken, refreshToken, user: { id: user.id, email: user.email, name: user.name, }, organizations, }, }; } catch (error) { //throw new InternalServerErrorException('Login failed'); console.error('🔥 LOGIN ERROR FULL:', error); throw error; } } /** * Register a new user * @param registerDto - Registration data (email, password, name) * @returns Created user info without password */ async register(registerDto: RegisterDto) { const { email, password, name, organizationName, organizationType } = registerDto; // 1. Check existing user const existingUser = await this.prisma.user.findUnique({ where: { email }, }); if (existingUser) { throw new ConflictException('User already exists'); } // 2. Hash password const hashedPassword = await bcrypt.hash(password, 10); // 3. Transaction (IMPORTANT) const result = await this.prisma.$transaction(async (tx) => { // Create user const user = await tx.user.create({ data: { email, passwordHash: hashedPassword, name, }, }); // Create organization const organization = await tx.organization.create({ data: { name: registerDto.organizationName, // REQUIRED FIELDS 👇 email: registerDto.email, // or separate org email if you have one owner: { connect: { id: user.id }, }, plan: { connect: { name: 'trial' }, // make sure this exists in DB }, type: { connect: { name: registerDto.organizationType, // 'CLINIC' | 'LAB' }, }, }, }); // Create membership (owner) await tx.membership.create({ data: { userId: user.id, organizationId: organization.id, isOwner: true, }, }); return { user, organization }; }); // 4. Generate tokens (reuse login logic) const validatedUser = await this.validateUser(email, password); if (!validatedUser) { throw new UnauthorizedException('Auto-login failed'); } return this.login({ email, password } as any, validatedUser); } /** * Get user profile with all memberships and permissions * @param userId - User ID from JWT token * @returns User profile with organizations and permissions */ async getProfile(userId: string) { try { const user = await this.prisma.user.findUnique({ where: { id: userId }, include: { memberships: { include: { organization: { include: { type: true, }, }, permissions: { include: { permission: true, }, }, }, }, }, }); if (!user) { throw new UnauthorizedException('User not found'); } const { passwordHash, ...result } = user; // Transform memberships for frontend consumption const organizations = user.memberships?.map(membership => ({ id: membership.organization.id, name: membership.organization.name, type: membership.organization.type.name, isOwner: membership.isOwner, permissions: membership.permissions?.map(p => p.permission.name) || [], })) || []; return { success: true, data: { ...result, organizations, }, }; } catch (error) { throw new InternalServerErrorException('Failed to get profile'); } } /** * Logout user by invalidating their session * @param token - Access token to invalidate * @returns Success message */ async logout(token: string) { try { await this.prisma.session.deleteMany({ where: { token }, }); return { success: true, message: 'Logged out successfully', }; } catch (error) { throw new InternalServerErrorException('Logout failed'); } } /** * Refresh access token using refresh token * @param refreshToken - Valid refresh token * @returns New access token */ async refreshToken(refreshToken: string) { try { // Verify the refresh token const payload = await this.jwtService.verifyAsync(refreshToken, { secret: this.configService.get('jwt.refreshSecret'), }); // Ensure this is a refresh token if (payload.type !== 'refresh') { throw new UnauthorizedException('Invalid token type'); } // Find session with this refresh token const session = await this.prisma.session.findFirst({ where: { refreshToken, expiresAt: { gt: new Date() } }, include: { user: { include: { memberships: { include: { organization: { include: { type: true, }, }, permissions: { include: { permission: true, }, }, }, }, }, }, }, }); if (!session) { throw new UnauthorizedException('Invalid refresh token'); } // Generate new access token const newAccessPayload: JwtPayload = { sub: session.user.id, email: session.user.email, type: 'access', }; const newAccessToken = await this.jwtService.signAsync(newAccessPayload, { secret: this.configService.get('jwt.secret'), expiresIn: this.configService.get('jwt.expiresIn'), }); // Update session with new access token await this.prisma.session.update({ where: { id: session.id }, data: { token: newAccessToken, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days }, }); // Transform memberships for response const organizations = session.user.memberships?.map(membership => ({ id: membership.organization.id, name: membership.organization.name, type: membership.organization.type.name, isOwner: membership.isOwner, permissions: membership.permissions?.map(p => p.permission.name) || [], })) || []; return { success: true, data: { accessToken: newAccessToken, user: { id: session.user.id, email: session.user.email, name: session.user.name, }, organizations, }, }; } catch (error) { if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') { throw new UnauthorizedException('Invalid or expired refresh token'); } throw new UnauthorizedException('Refresh token failed'); } } /** * Change user password * @param userId - User ID * @param oldPassword - Current password * @param newPassword - New password * @returns Success message */ async changePassword(userId: string, oldPassword: string, newPassword: string) { try { const user = await this.prisma.user.findUnique({ where: { id: userId }, }); if (!user || !user.passwordHash) { throw new BadRequestException('User not found or invalid password method'); } // Verify old password const isPasswordValid = await bcrypt.compare(oldPassword, user.passwordHash); if (!isPasswordValid) { throw new UnauthorizedException('Current password is incorrect'); } // Hash new password const hashedPassword = await bcrypt.hash(newPassword, 10); // Update password await this.prisma.user.update({ where: { id: userId }, data: { passwordHash: hashedPassword }, }); // Invalidate all sessions for this user (force re-login) await this.prisma.session.deleteMany({ where: { userId }, }); return { success: true, message: 'Password changed successfully. Please login again.', }; } catch (error) { if (error instanceof UnauthorizedException || error instanceof BadRequestException) { throw error; } throw new InternalServerErrorException('Failed to change password'); } } /** * Get all active sessions for a user * @param userId - User ID * @returns List of active sessions */ async getUserSessions(userId: string) { try { const sessions = await this.prisma.session.findMany({ where: { userId, expiresAt: { gt: new Date() }, }, orderBy: { createdAt: 'desc' }, }); return { success: true, data: sessions, }; } catch (error) { throw new InternalServerErrorException('Failed to get sessions'); } } /** * Revoke a specific session * @param userId - User ID * @param sessionId - Session ID to revoke * @returns Success message */ async revokeSession(userId: string, sessionId: string) { try { await this.prisma.session.delete({ where: { id: sessionId, userId, // Ensure session belongs to user }, }); return { success: true, message: 'Session revoked successfully', }; } catch (error) { throw new InternalServerErrorException('Failed to revoke session'); } } /** * Revoke all sessions for a user (except current) * @param userId - User ID * @param currentToken - Current access token to keep * @returns Success message */ async revokeAllSessions(userId: string, currentToken: string) { try { await this.prisma.session.deleteMany({ where: { userId, token: { not: currentToken }, // Keep current session }, }); return { success: true, message: 'All other sessions revoked successfully', }; } catch (error) { throw new InternalServerErrorException('Failed to revoke sessions'); } } /** * Validate token and return user * @param token - JWT token * @returns User info if token is valid */ async validateToken(token: string) { try { const payload = await this.jwtService.verifyAsync(token, { secret: this.configService.get('jwt.secret'), }); if (payload.type !== 'access') { throw new UnauthorizedException('Invalid token type'); } const session = await this.prisma.session.findFirst({ where: { token, expiresAt: { gt: new Date() } }, include: { user: { include: { memberships: { include: { organization: { include: { type: true, }, }, permissions: { include: { permission: true, }, }, }, }, }, }, }, }); if (!session) { throw new UnauthorizedException('Session not found or expired'); } const { passwordHash, ...user } = session.user; const organizations = session.user.memberships?.map(membership => ({ id: membership.organization.id, name: membership.organization.name, type: membership.organization.type.name, isOwner: membership.isOwner, permissions: membership.permissions?.map(p => p.permission.name) || [], })) || []; return { success: true, data: { user, organizations, }, }; } catch (error) { throw new UnauthorizedException('Invalid token'); } } async selectOrganization(userId: string, organizationId: string) { // 1. Verify membership const membership = await this.prisma.membership.findFirst({ where: { userId, organizationId, }, include: { organization: { include: { type: true, plan: true, }, }, permissions: { include: { permission: true, }, }, }, }); if (!membership) { throw new UnauthorizedException('Access denied to this organization'); } // 2. Build payload WITH org context const payload = { sub: userId, email: membership.organization.email, organizationId: membership.organizationId, type: 'access', }; // 3. Generate new token const accessToken = await this.jwtService.signAsync(payload, { secret: this.configService.get('JWT_SECRET'), expiresIn: this.configService.get('JWT_EXPIRES_IN'), }); // 4. Format permissions const permissions = membership.permissions.map(p => p.permission.name); return { success: true, data: { accessToken, organization: { id: membership.organization.id, name: membership.organization.name, type: membership.organization.type.name, }, permissions, }, }; } }