// 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_STAFF_READ', 'TAB_STAFF_EDIT', 'TAB_LAB_READ', 'TAB_LAB_EDIT', 'TAB_PATIENTS_READ', 'TAB_PATIENTS_EDIT', 'TAB_APPOINTMENTS_READ', 'TAB_APPOINTMENTS_EDIT', 'TAB_TREATMENT_READ', 'TAB_TREATMENT_EDIT', 'TAB_BILLING_READ', 'TAB_BILLING_EDIT', 'TAB_REPORTS_READ', 'TAB_REPORTS_EDIT', ]; const READ_ONLY_PERMISSIONS = [ 'TAB_TODAY_READ', 'TAB_STAFF_READ', 'TAB_LAB_READ', 'TAB_PATIENTS_READ', 'TAB_APPOINTMENTS_READ', 'TAB_TREATMENT_READ', 'TAB_BILLING_READ', 'TAB_REPORTS_READ', ]; @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 normalizedEmail = email.trim().toLowerCase(); const user = await this.prisma.user.findUnique({ where: { email: normalizedEmail }, 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 = this.toActiveOrganizations(user.memberships).map(membership => ({ id: membership.organization.id, name: membership.organization.name, type: membership.organization.type.name, // 'CLINIC' or 'LAB' isOwner: membership.isOwner, permissions: this.getMembershipPermissions(membership), plan: membership.organization.plan ? { name: membership.organization.plan.name, maxUsers: membership.organization.plan.maxUsers, price: membership.organization.plan.price, } : 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 { password, name, organizationName, organizationEmail, organizationType } = registerDto; const email = registerDto.email.trim().toLowerCase(); // 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 }, }); if (!owner) { throw new UnauthorizedException('User not found'); } 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 }, }, type: { connect: { name: dto.organizationType }, }, }, }); await tx.membership.create({ data: { userId, organizationId: createdOrganization.id, isOwner: true, }, }); 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 = this.toActiveOrganizations(user.memberships).map(membership => ({ id: membership.organization.id, name: membership.organization.name, type: membership.organization.type.name, isOwner: membership.isOwner, permissions: this.getMembershipPermissions(membership), plan: membership.organization.plan ? { name: membership.organization.plan.name, maxUsers: membership.organization.plan.maxUsers, price: membership.organization.plan.price, } : 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 = this.toActiveOrganizations(session.user.memberships).map(membership => ({ id: membership.organization.id, name: membership.organization.name, type: membership.organization.type.name, isOwner: membership.isOwner, permissions: this.getMembershipPermissions(membership), plan: membership.organization.plan ? { name: membership.organization.plan.name, maxUsers: membership.organization.plan.maxUsers, price: membership.organization.plan.price, } : 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 = this.toActiveOrganizations(session.user.memberships).map(membership => ({ id: membership.organization.id, name: membership.organization.name, type: membership.organization.type.name, isOwner: membership.isOwner, permissions: this.getMembershipPermissions(membership), plan: membership.organization.plan ? { name: membership.organization.plan.name, maxUsers: membership.organization.plan.maxUsers, price: membership.organization.plan.price, } : 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'); } if (!membership.isOwner && !membership.isActive) { throw new UnauthorizedException('Your invitation is still pending activation'); } // 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 = this.getMembershipPermissions(membership); return { success: true, data: { accessToken, organization: { id: membership.organization.id, name: membership.organization.name, type: membership.organization.type.name, isOwner: membership.isOwner, permissions, plan: membership.organization.plan ? { name: membership.organization.plan.name, maxUsers: membership.organization.plan.maxUsers, price: membership.organization.plan.price, } : undefined, }, }, }; } private toActiveOrganizations( memberships: Array<{ isOwner: boolean; isActive: boolean; organization: { id: string; name: string; type: { name: string }; plan?: { name: string; maxUsers: number; price: number } | null; }; permissions?: Array<{ permission: { name: string } }>; }> = [], ) { return memberships.filter((m) => m.isOwner || m.isActive); } private getMembershipPermissions(membership: { isOwner: boolean; organization: { plan?: { name: string; maxUsers: number; price: number } | null; }; permissions?: Array<{ permission: { name: string } }>; }): string[] { if (membership.isOwner) { return membership.organization.plan ? ALL_PERMISSIONS : READ_ONLY_PERMISSIONS; } return membership.permissions?.map((p) => p.permission.name) || []; } /** * 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, noActiveSubscription: false, seatsLow: false, trialEndingSoon: false, trialExpired: false, daysUntilPlanEnd: null, planEndsAt: null, }, }; } 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, noActiveSubscription: false, seatsLow: false, trialEndingSoon: false, trialExpired: false, daysUntilPlanEnd: null, planEndsAt: null, }, }; } const org = membership.organization; const plan = org.plan; if (!plan) { return { success: true, data: { showWarning: true, noActiveSubscription: true, seatsLow: false, trialEndingSoon: false, trialExpired: false, seatsUsed: 0, seatsLimit: null, daysUntilTrialEnd: null, trialEndsAt: null, daysUntilPlanEnd: null, planEndsAt: null, }, }; } const maxUsers = plan.maxUsers; const seatsUsed = await this.prisma.membership.count({ where: { organizationId: org.id, OR: [{ isOwner: true }, { isActive: true }], }, }); const unlimited = maxUsers >= 999999; const remaining = unlimited ? Infinity : maxUsers - seatsUsed; const seatsLow = !unlimited && remaining >= 0 && remaining <= 2 && maxUsers > 0; // Current pricing model: trial lasts 30 days; paid plans last 90 days. const durationDays = plan.name === 'trial' ? 30 : 90; const end = new Date(org.createdAt); end.setDate(end.getDate() + durationDays); const planEndsAt = end.toISOString(); const ms = end.getTime() - Date.now(); const daysUntilPlanEnd = Math.ceil(ms / (1000 * 60 * 60 * 24)); const trialExpired = plan.name === 'trial' && daysUntilPlanEnd <= 0; const trialEndingSoon = plan.name === 'trial' && daysUntilPlanEnd > 0 && daysUntilPlanEnd <= 7; const showWarning = seatsLow || trialEndingSoon || trialExpired; return { success: true, data: { showWarning, noActiveSubscription: false, seatsLow, trialEndingSoon, trialExpired, seatsUsed, seatsLimit: maxUsers, daysUntilTrialEnd: plan.name === 'trial' ? daysUntilPlanEnd : null, trialEndsAt: plan.name === 'trial' ? planEndsAt : null, daysUntilPlanEnd, planEndsAt, }, }; } }