File

apps/recallassess/recallassess-api/src/api/client/auth/auth.service.ts

Index

Properties
Methods

Constructor

constructor(prisma: BNestPrismaService, jwt: JwtService, config: ConfigService, eventEmitter: EventEmitter2, emailSender: BNestEmailSenderService)
Parameters :
Name Type Optional
prisma BNestPrismaService No
jwt JwtService No
config ConfigService No
eventEmitter EventEmitter2 No
emailSender BNestEmailSenderService No

Methods

Private Async generateAccessToken
generateAccessToken(participantId: number, email: string)
Parameters :
Name Type Optional
participantId number No
email string No
Returns : Promise<string>
Private generateEmailVerificationToken
generateEmailVerificationToken()

Generate a secure email verification token

Returns : string
Private generatePasswordSetupToken
generatePasswordSetupToken()

Generate a secure password setup token

Returns : string
Private getFormattedParticipant
getFormattedParticipant(participant: Record<string | unknown>)
Parameters :
Name Type Optional
participant Record<string | unknown> No
Async requestPasswordReset
requestPasswordReset(dto: RequestPasswordResetDto)

Request password reset - generates a password setup token and sends email

Parameters :
Name Type Optional
dto RequestPasswordResetDto No
Returns : Promise<literal type>
Async resendEmailVerification
resendEmailVerification(email: string)

Resend email verification token (lookup by email; for users who are not yet verified / cannot authenticate)

Parameters :
Name Type Optional
email string No
Returns : Promise<literal type>
Private Async sendEmailVerificationEmail
sendEmailVerificationEmail(data: literal type)

Send email verification email

Parameters :
Name Type Optional
data literal type No
Returns : Promise<void>
Private Async sendPasswordResetEmail
sendPasswordResetEmail(data: literal type)

Send password reset email to a participant

Parameters :
Name Type Optional
data literal type No
Returns : Promise<void>
Async setPasswordForLoggedInUser
setPasswordForLoggedInUser(participantId: number, password: string)

Initial password setup for a logged-in participant who has no password yet (e.g. invited user). Does not replace admin reset or email token flows: if a password already exists, callers must use request-password-reset or an admin reset instead—this avoids session hijackers changing an existing password.

Parameters :
Name Type Optional
participantId number No
password string No
Returns : Promise<literal type>
Async setupPassword
setupPassword(dto: SetupPasswordDto)

Setup password using setup token

Parameters :
Name Type Optional
dto SetupPasswordDto No
Returns : Promise<literal type>
Async signIn
signIn(dto: ParticipantSignInDto)
Parameters :
Name Type Optional
dto ParticipantSignInDto No
Returns : Promise<literal type>
Async signInWithToken
signInWithToken(accessToken: string)
Parameters :
Name Type Optional
accessToken string No
Returns : Promise<literal type>
Async signUp
signUp(dto: ParticipantSignUpDto)
Parameters :
Name Type Optional
dto ParticipantSignUpDto No
Returns : Promise<literal type>
Async validateSetupToken
validateSetupToken(token: string)

Validate password setup token without setting password

Parameters :
Name Type Optional
token string No
Returns : Promise<literal type>
Async verifyEmail
verifyEmail(token: string)

Verify email address using verification token

Parameters :
Name Type Optional
token string No
Returns : Promise<literal type>

Properties

Private Readonly logger
Type : unknown
Default value : new Logger(CLAuthService.name)
import { BNestEmailSenderService, requireEnv } from "@bish-nest/core";
import { BNestPrismaService } from "@bish-nest/core/services";
import {
  BadRequestException,
  ForbiddenException,
  Injectable,
  Logger,
  NotFoundException,
  UnauthorizedException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { JwtService } from "@nestjs/jwt";
import { Prisma } from "@prisma/client";
import * as argon from "argon2";
import * as crypto from "crypto";
import { PARTICIPANT_EVENTS, ParticipantActivityEvent } from "../participant/events/participant.events";
import { ParticipantActivityType } from "../participant/participant-activity.service";
import { ParticipantSignInDto, ParticipantSignUpDto, RequestPasswordResetDto } from "./dto";
import { SetupPasswordDto } from "./dto/password-setup.dto";

@Injectable()
export class CLAuthService {
  private readonly logger = new Logger(CLAuthService.name);

  constructor(
    private prisma: BNestPrismaService,
    private jwt: JwtService,
    private config: ConfigService,
    private eventEmitter: EventEmitter2,
    private emailSender: BNestEmailSenderService,
  ) {}

  async signIn(dto: ParticipantSignInDto): Promise<{
    accessToken: string;
    tokenType: "participant";
    user: Record<string, unknown>;
    emailVerified: boolean;
  }> {
    // Normalize email to lowercase for case-insensitive lookup
    const normalizedEmail = dto.email.toLowerCase().trim();
    
    // Find the participant by email (case-insensitive)
    const participant = await this.prisma.client.participant.findUnique({
      where: {
        email: normalizedEmail,
      },
      include: {
        company: {
          select: {
            id: true,
            name: true,
            is_active: true,
          },
        },
      },
    });

    // If participant does not exist, throw exception
    if (!participant) {
      throw new UnauthorizedException("Invalid credentials");
    }

    // Check if participant is active
    if (!participant.is_active) {
      throw new ForbiddenException("Your account has been deactivated. Please contact support.");
    }

    // Check if the participant's company is active
    if (!participant.company?.is_active) {
      this.logger.warn(`[LOGIN BLOCKED] Participant ${participant.id} login blocked — Company "${participant.company?.name}" is inactive.`);
      throw new ForbiddenException("Your account access is currently unavailable. Please contact your Administrator for assistance.");
    }

    // Check if participant has a password hash
    if (!participant.password_hash) {
      throw new UnauthorizedException(
        "No password set for this account. Please request a password reset or contact support.",
      );
    }

    // Compare password
    const pwMatches = await argon.verify(participant.password_hash, dto.password);

    // If password incorrect, throw exception
    if (!pwMatches) {
      throw new UnauthorizedException("Invalid credentials");
    }

    // Emit activity event (listener will update last_login)
    this.eventEmitter.emit(
      PARTICIPANT_EVENTS.ACTIVITY,
      new ParticipantActivityEvent(participant.id, ParticipantActivityType.LOGIN, {
        email: normalizedEmail,
        company_id: participant.company_id,
      }),
    );

    // Generate access token
    const accessToken = await this.generateAccessToken(participant.id, participant.email);

    // Format user data (remove sensitive information)
    const user = this.getFormattedParticipant(participant);

    return {
      accessToken,
      tokenType: "participant",
      user,
      emailVerified: participant.email_verified, // Include email verification status
    };
  }

  async signUp(dto: ParticipantSignUpDto): Promise<{
    success: boolean;
    message: string;
  }> {
    try {
      // Normalize email to lowercase for case-insensitive handling
      const normalizedEmail = dto.email.toLowerCase().trim();
      
      // Check if company exists and is active
      const company = await this.prisma.client.company.findUnique({
        where: { id: dto.company_id },
      });

      if (!company) {
        throw new ForbiddenException("Invalid company");
      }

      if (!company.is_active) {
        throw new ForbiddenException("Company is not active");
      }

      // Check if email already exists (case-insensitive)
      const existingParticipant = await this.prisma.client.participant.findUnique({
        where: { email: normalizedEmail },
      });

      if (existingParticipant) {
        throw new ForbiddenException("Email already registered");
      }

      // Generate password hash
      const password_hash = await argon.hash(dto.password);

      // Create participant with normalized email
      await this.prisma.client.participant.create({
        data: {
          company_id: dto.company_id,
          first_name: dto.first_name,
          last_name: dto.last_name,
          email: normalizedEmail,
          phone: dto.phone,
          password_hash,
          email_verified: dto.email_verified ?? false,
          is_active: true,
        },
      });

      return {
        success: true,
        message: "Account created successfully. Please sign in.",
      };
    } catch (error) {
      if (error instanceof Prisma.PrismaClientKnownRequestError) {
        if (error.code === "P2002") {
          throw new ForbiddenException("Email already registered");
        }
      }
      throw error;
    }
  }

  async signInWithToken(accessToken: string): Promise<{
    accessToken: string;
    tokenType: "participant";
    user: Record<string, unknown>;
    emailVerified?: boolean;
  }> {
    try {
      const payload = this.jwt.verify(accessToken, {
        secret: this.config.get("JWT_SECRET"),
      }) as { email?: string; sub?: number };

      if (!payload || !payload.email) {
        throw new UnauthorizedException("Invalid token");
      }

      // Normalize email to lowercase for case-insensitive lookup
      const email = payload.email.toLowerCase().trim();

      const participant = await this.prisma.client.participant.findUnique({
        where: { email },
        include: {
          company: {
            select: {
              id: true,
              name: true,
              is_active: true,
            },
          },
        },
      });

      if (!participant) {
        throw new UnauthorizedException("Participant not found");
      }

      if (!participant.is_active) {
        throw new ForbiddenException("Your account has been deactivated");
      }

      // Check if the participant's company is active
      if (!participant.company?.is_active) {
        this.logger.warn(`[TOKEN REFRESH BLOCKED] Participant ${participant.id} token refresh blocked — Company "${participant.company?.name}" is inactive.`);
        throw new ForbiddenException("Your account access is currently unavailable. Please contact your Administrator for assistance.");
      }

      // Generate new access token
      const newAccessToken = await this.generateAccessToken(participant.id, participant.email);

      // Format user data
      const user = this.getFormattedParticipant(participant);

      return {
        accessToken: newAccessToken,
        tokenType: "participant",
        user,
        emailVerified: participant.email_verified, // Include email verification status
      };
    } catch {
      throw new UnauthorizedException("Invalid token");
    }
  }

  private async generateAccessToken(participantId: number, email: string): Promise<string> {
    const payload = {
      sub: participantId,
      email,
      type: "participant", // Distinguish from admin users
    };

    const secret = this.config.get("JWT_SECRET");

    const token = await this.jwt.signAsync(payload, {
      expiresIn: "10h",
      secret: secret,
    });

    return token;
  }

  /**
   * Setup password using setup token
   */
  async setupPassword(dto: SetupPasswordDto): Promise<{ success: boolean; message: string }> {
    try {
      // Find the token
      const tokenRecord = await this.prisma.client.passwordSetupToken.findUnique({
        where: { token: dto.token },
        include: { participant: true },
      });

      if (!tokenRecord) {
        throw new BadRequestException("Invalid or expired password setup token");
      }

      // Check if token is already used
      if (tokenRecord.used_at) {
        throw new BadRequestException("This password setup link has already been used");
      }

      // Check if token is expired
      if (new Date() > tokenRecord.expires_at) {
        throw new BadRequestException("Password setup link has expired. Please request a new one.");
      }

      // Check if participant is active
      if (!tokenRecord.participant.is_active) {
        throw new ForbiddenException("Your account has been deactivated");
      }

      // Hash the new password
      const passwordHash = await argon.hash(dto.password);

      // Update participant password, verify email (since they accessed the email link), and mark token as used in a transaction
      await this.prisma.client.$transaction(async (tx) => {
        // Update participant password and automatically verify email
        // This is safe because they've proven access to their email by clicking the password setup link
        await tx.participant.update({
          where: { id: tokenRecord.participant_id },
          data: {
            password_hash: passwordHash,
            email_verified: true, // Auto-verify email when password is set up via email link
            email_verified_at: new Date(),
          },
        });

        // Mark token as used
        await tx.passwordSetupToken.update({
          where: { id: tokenRecord.id },
          data: { used_at: new Date() },
        });

        // If there's an email verification token, mark it as used as well (since email is now verified)
        const emailVerificationToken = await tx.emailVerificationToken.findUnique({
          where: { participant_id: tokenRecord.participant_id },
        });

        if (emailVerificationToken && !emailVerificationToken.used_at) {
          await tx.emailVerificationToken.update({
            where: { id: emailVerificationToken.id },
            data: { used_at: new Date() },
          });
        }
      });

      return {
        success: true,
        message: "Password has been set successfully. You can now sign in.",
      };
    } catch (error) {
      if (error instanceof BadRequestException || error instanceof ForbiddenException) {
        throw error;
      }
      throw new BadRequestException("Failed to setup password. Please try again.");
    }
  }

  /**
   * Validate password setup token without setting password
   */
  async validateSetupToken(token: string): Promise<{ valid: boolean; message?: string }> {
    try {
      if (!token) {
        return { valid: false, message: "Token is required" };
      }

      // Find the token
      const tokenRecord = await this.prisma.client.passwordSetupToken.findUnique({
        where: { token },
        include: { participant: true },
      });

      if (!tokenRecord) {
        return { valid: false, message: "Invalid or expired password setup token" };
      }

      // Check if token is already used
      if (tokenRecord.used_at) {
        return { valid: false, message: "This password setup link has already been used" };
      }

      // Check if token is expired
      if (new Date() > tokenRecord.expires_at) {
        return { valid: false, message: "Password setup link has expired" };
      }

      // Check if participant is active
      if (!tokenRecord.participant.is_active) {
        return { valid: false, message: "Your account has been deactivated" };
      }

      return { valid: true };
    } catch {
      return { valid: false, message: "Failed to validate token" };
    }
  }

  /**
   * Initial password setup for a logged-in participant who has no password yet (e.g. invited user).
   * Does not replace admin reset or email token flows: if a password already exists, callers must use
   * request-password-reset or an admin reset instead—this avoids session hijackers changing an existing password.
   */
  async setPasswordForLoggedInUser(
    participantId: number,
    password: string,
  ): Promise<{ success: boolean; message: string }> {
    // Find the participant
    const participant = await this.prisma.client.participant.findUnique({
      where: { id: participantId },
    });

    if (!participant) {
      throw new NotFoundException("Participant not found");
    }

    if (participant.password_hash) {
      throw new ForbiddenException(
        "A password is already set for this account. Use Forgot password or contact your administrator to reset it.",
      );
    }

    // Hash the new password
    const passwordHash = await argon.hash(password);

    // Update participant password
    await this.prisma.client.participant.update({
      where: { id: participantId },
      data: { password_hash: passwordHash },
    });

    return {
      success: true,
      message: "Password has been set successfully",
    };
  }

  /**
   * Request password reset - generates a password setup token and sends email
   */
  async requestPasswordReset(dto: RequestPasswordResetDto): Promise<{ success: boolean; message: string }> {
    try {
      // Normalize email to lowercase for case-insensitive lookup
      const normalizedEmail = dto.email.toLowerCase().trim();
      
      // Find the participant by email (case-insensitive)
      const participant = await this.prisma.client.participant.findUnique({
        where: { email: normalizedEmail },
        include: {
          company: {
            select: {
              id: true,
              name: true,
            },
          },
        },
      });

      // Validate that email exists in database
      if (!participant) {
        this.logger.warn(`Password reset requested for non-existent email: ${normalizedEmail}`);
        throw new BadRequestException("No account found with this email address. Please check your email and try again.");
      }

      // Check if participant is active
      if (!participant.is_active) {
        this.logger.warn(`Password reset requested for inactive participant: ${normalizedEmail}`);
        throw new BadRequestException("This account is inactive. Please contact support for assistance.");
      }

      // Check if password setup token already exists and is still valid
      const existingToken = await this.prisma.client.passwordSetupToken.findUnique({
        where: { participant_id: participant.id },
      });

      let setupToken: string;
      if (existingToken && !existingToken.used_at && new Date() < existingToken.expires_at) {
        // Use existing valid token
        setupToken = existingToken.token;
        this.logger.log(`Reusing existing password setup token for participant: ${participant.id}`);
      } else {
        // Generate new token (7 days expiry)
        setupToken = this.generatePasswordSetupToken();
        const tokenExpiry = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);

        // Create or update password setup token
        await this.prisma.client.passwordSetupToken.upsert({
          where: { participant_id: participant.id },
          create: {
            participant_id: participant.id,
            token: setupToken,
            expires_at: tokenExpiry,
          },
          update: {
            token: setupToken,
            expires_at: tokenExpiry,
            used_at: null, // Reset if previously used
          },
        });

        this.logger.log(`Generated new password setup token for participant: ${participant.id}`);
      }

      // Send password reset email
      const frontendUrl = requireEnv("FRONTEND_URL");
      const passwordSetupUrl = `${frontendUrl}/setup-password?token=${setupToken}`;

      await this.sendPasswordResetEmail({
        participantEmail: participant.email,
        participantFirstName: participant.first_name,
        participantLastName: participant.last_name,
        companyName: participant.company.name,
        passwordSetupUrl,
      });

      return {
        success: true,
        message: "Password reset link has been sent to your email address.",
      };
    } catch (error) {
      this.logger.error(`Failed to process password reset request for ${dto.email}:`, error);
      // Re-throw BadRequestException (validation errors) to show proper error message
      if (error instanceof BadRequestException) {
        throw error;
      }
      // For other errors, throw a generic error
      throw new BadRequestException("Failed to send password reset link. Please try again later.");
    }
  }

  /**
   * Generate a secure password setup token
   */
  private generatePasswordSetupToken(): string {
    // Generate a secure random token (32 bytes = 64 hex characters)
    return crypto.randomBytes(32).toString("hex");
  }

  /**
   * Send password reset email to a participant
   */
  private async sendPasswordResetEmail(data: {
    participantEmail: string;
    participantFirstName: string;
    participantLastName: string;
    companyName: string;
    passwordSetupUrl: string;
  }): Promise<void> {
    try {
      await this.emailSender.sendTemplatedEmail({
        to: data.participantEmail,
        templateKey: "account.password.reset",
        variables: {
          "user.name": data.participantFirstName,
          "reset.url": data.passwordSetupUrl,
        },
        metadata: {
          companyName: data.companyName,
          triggeredBy: "password_reset_request",
        },
      });

      this.logger.log(`Password reset email sent to: ${data.participantEmail}`);
    } catch (emailError) {
      this.logger.error("Failed to send password reset email:", emailError);
      throw emailError;
    }
  }

  /**
   * Verify email address using verification token
   */
  async verifyEmail(token: string): Promise<{ success: boolean; message: string }> {
    try {
      // Find the token
      const tokenRecord = await this.prisma.client.emailVerificationToken.findUnique({
        where: { token },
        include: { participant: true },
      });

      if (!tokenRecord) {
        throw new BadRequestException("Invalid or expired email verification token");
      }

      // Check if token is already used
      if (tokenRecord.used_at) {
        throw new BadRequestException("This email verification link has already been used");
      }

      // Check if token is expired
      if (new Date() > tokenRecord.expires_at) {
        throw new BadRequestException("Email verification link has expired. Please request a new one.");
      }

      // Check if participant is active
      if (!tokenRecord.participant.is_active) {
        throw new ForbiddenException("Your account has been deactivated");
      }

      // Update participant email verification status and mark token as used in a transaction
      await this.prisma.client.$transaction(async (tx) => {
        // Update participant email verification
        await tx.participant.update({
          where: { id: tokenRecord.participant_id },
          data: {
            email_verified: true,
            email_verified_at: new Date(),
          },
        });

        // Mark token as used
        await tx.emailVerificationToken.update({
          where: { id: tokenRecord.id },
          data: { used_at: new Date() },
        });
      });

      return {
        success: true,
        message: "Email has been verified successfully. You can now access your dashboard.",
      };
    } catch (error) {
      if (error instanceof BadRequestException || error instanceof ForbiddenException) {
        throw error;
      }
      throw new BadRequestException("Failed to verify email. Please try again.");
    }
  }

  /**
   * Resend email verification token (lookup by email; for users who are not yet verified / cannot authenticate)
   */
  async resendEmailVerification(email: string): Promise<{ success: boolean; message: string }> {
    try {
      const normalizedEmail = email.toLowerCase().trim();

      const participant = await this.prisma.client.participant.findUnique({
        where: { email: normalizedEmail },
        include: { company: true },
      });

      if (!participant) {
        this.logger.warn(`Resend verification requested for unknown email: ${normalizedEmail}`);
        throw new BadRequestException("No account found with this email address. Please check your email and try again.");
      }

      if (!participant.is_active) {
        this.logger.warn(`Resend verification requested for inactive participant: ${normalizedEmail}`);
        throw new BadRequestException("This account is inactive. Please contact support for assistance.");
      }

      const participantId = participant.id;

      if (participant.email_verified) {
        return {
          success: true,
          message: "Email is already verified",
        };
      }

      // Generate new email verification token
      const token = this.generateEmailVerificationToken();
      const tokenExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours

      // Create or update email verification token
      await this.prisma.client.emailVerificationToken.upsert({
        where: { participant_id: participantId },
        create: {
          participant_id: participantId,
          token,
          expires_at: tokenExpiry,
        },
        update: {
          token,
          expires_at: tokenExpiry,
          used_at: null, // Reset if previously used
        },
      });

      // Send verification email
      const fromConfig = this.config.get<string>("FRONTEND_URL")?.trim() ?? "";
      const frontendUrl = fromConfig !== "" ? fromConfig : requireEnv("FRONTEND_URL");
      const verificationUrl = `${frontendUrl}/verify-email?token=${token}`;

      await this.sendEmailVerificationEmail({
        email: participant.email,
        firstName: participant.first_name,
        lastName: participant.last_name,
        verificationUrl,
      });

      return {
        success: true,
        message: "Email verification link has been sent to your email address",
      };
    } catch (error) {
      if (error instanceof BadRequestException) {
        throw error;
      }
      throw new BadRequestException("Failed to resend verification email. Please try again.");
    }
  }

  /**
   * Generate a secure email verification token
   */
  private generateEmailVerificationToken(): string {
    const crypto = require("crypto");
    return crypto.randomBytes(32).toString("hex");
  }

  /**
   * Send email verification email
   */
  private async sendEmailVerificationEmail(data: {
    email: string;
    firstName: string;
    lastName: string;
    verificationUrl: string;
  }): Promise<void> {
    try {
      await this.emailSender.sendTemplatedEmail({
        to: data.email,
        templateKey: "account.email.verification",
        variables: {
          "user.name": `${data.firstName} ${data.lastName}`,
          "verification.url": data.verificationUrl,
        },
        metadata: {
          triggeredBy: "email_verification_resend",
        },
      });

      this.logger.log(`Email verification email sent to: ${data.email}`);
    } catch (emailError) {
      this.logger.error("Failed to send email verification email:", emailError);
      throw emailError;
    }
  }

  private getFormattedParticipant(participant: Record<string, unknown>): Record<string, unknown> {
    const formatted = { ...participant };

    delete formatted["password_hash"]; // Remove sensitive data
    return formatted;
  }
}

results matching ""

    No results matching ""