// 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 { CreateOrganizationDto } from './dto/create-organization.dto'; import { JwtPayload } from './interfaces/jwt-payload.interface'; const ALL_PERMISSIONS = [ 'TAB_TODAY_READ', 'TAB_TODAY_EDIT', 'TAB_PATIENTS_READ', 'TAB_PATIENTS_EDIT', 'TAB_APPOINTMENTS_READ', 'TAB_APPOINTMENTS_EDIT', 'TAB_STAFF_READ', 'TAB_STAFF_EDIT', 'TAB_LAB_READ', 'TAB_LAB_EDIT', 'TAB_BILLING_READ', 'TAB_BILLING_EDIT', 'TAB_REPORTS_READ', 'TAB_REPORTS_EDIT', ]; @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) plan: true, } }, 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) || [], plan: membership.organization.plan ? { name: membership.organization.plan.name, maxUsers: membership.organization.plan.maxUsers, } : undefined, })) || []; 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, organizationEmail, organizationType } = registerDto; // 1. Check existing user const existingUser = await this.prisma.user.findUnique({ where: { email }, }); if (existingUser) { throw new ConflictException('User already exists. Please login and create a new organization from your account.'); } // 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, trialUsedAt: new Date(), }, }); // Create organization const organization = await tx.organization.create({ data: { name: organizationName, email: organizationEmail, owner: { connect: { id: user.id }, }, plan: { connect: { name: 'trial' }, // make sure this exists in DB }, type: { connect: { name: 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); } async createOrganization(userId: string, dto: CreateOrganizationDto) { const owner = await this.prisma.user.findUnique({ where: { id: userId }, select: { id: true, trialUsedAt: true }, }); if (!owner) { throw new UnauthorizedException('User not found'); } const planName = dto.planName?.trim() || 'Small'; const effectivePlanName = owner.trialUsedAt ? planName : 'trial'; const organization = await this.prisma.$transaction(async (tx) => { const createdOrganization = await tx.organization.create({ data: { name: dto.organizationName, email: dto.organizationEmail, owner: { connect: { id: userId }, }, plan: { connect: { name: effectivePlanName }, }, type: { connect: { name: dto.organizationType }, }, }, }); await tx.membership.create({ data: { userId, organizationId: createdOrganization.id, isOwner: true, }, }); if (!owner.trialUsedAt) { await tx.user.update({ where: { id: userId }, data: { trialUsedAt: new Date() }, }); } return createdOrganization; }); return { success: true, data: { organization: { id: organization.id, name: organization.name, email: organization.email, }, }, }; } /** * 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, plan: 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.isOwner ? ALL_PERMISSIONS : membership.permissions?.map(p => p.permission.name) || [], plan: membership.organization.plan ? { name: membership.organization.plan.name, maxUsers: membership.organization.plan.maxUsers, } : undefined, })) || []; 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, plan: 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.isOwner ? ALL_PERMISSIONS : membership.permissions?.map(p => p.permission.name) || [], plan: membership.organization.plan ? { name: membership.organization.plan.name, maxUsers: membership.organization.plan.maxUsers, } : undefined, })) || []; 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, plan: 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.isOwner ? ALL_PERMISSIONS : membership.permissions?.map(p => p.permission.name) || [], plan: membership.organization.plan ? { name: membership.organization.plan.name, maxUsers: membership.organization.plan.maxUsers, } : undefined, })) || []; 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: { user: true, 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.user.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.isOwner ? ALL_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, isOwner: membership.isOwner, plan: membership.organization.plan ? { name: membership.organization.plan.name, maxUsers: membership.organization.plan.maxUsers, } : undefined, }, permissions, }, }; } /** * Owner-only subscription / seat alerts for the current org (from JWT). * Used for a subtle warning indicator in the app shell (not staff-facing banners). */ async getOwnerSubscriptionAlert(userId: string, organizationId: string | undefined) { if (!organizationId) { return { success: true, data: { showWarning: false, seatsLow: false, trialEndingSoon: false, trialExpired: false, }, }; } const membership = await this.prisma.membership.findFirst({ where: { userId, organizationId }, include: { organization: { include: { plan: true }, }, }, }); if (!membership || !membership.isOwner) { return { success: true, data: { showWarning: false, seatsLow: false, trialEndingSoon: false, trialExpired: false, }, }; } const org = membership.organization; const plan = org.plan; const maxUsers = plan.maxUsers; const seatsUsed = await this.prisma.membership.count({ where: { organizationId: org.id }, }); const unlimited = maxUsers >= 999999; const remaining = unlimited ? Infinity : maxUsers - seatsUsed; const seatsLow = !unlimited && remaining >= 0 && remaining <= 2 && maxUsers > 0; let trialEndingSoon = false; let trialExpired = false; let daysUntilTrialEnd: number | null = null; let trialEndsAt: string | null = null; if (plan.name === 'trial') { const end = new Date(org.createdAt); end.setDate(end.getDate() + 30); trialEndsAt = end.toISOString(); const ms = end.getTime() - Date.now(); daysUntilTrialEnd = Math.ceil(ms / (1000 * 60 * 60 * 24)); if (daysUntilTrialEnd <= 0) { trialExpired = true; } else if (daysUntilTrialEnd <= 7) { trialEndingSoon = true; } } const showWarning = seatsLow || trialEndingSoon || trialExpired; return { success: true, data: { showWarning, seatsLow, trialEndingSoon, trialExpired, seatsUsed, seatsLimit: maxUsers, daysUntilTrialEnd, trialEndsAt, }, }; } }