// otp.service.ts import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { OtpRepository } from "./otp.repository"; import { SmsClientService } from "../notifications/sms-client.service"; import { EmailClientService } from "../notifications/email-client.service"; // Exactly one of phone/email is set — enforced by the controller before it // reaches here. export type OtpTarget = { phone?: string; email?: string }; @Injectable() export class OtpService { logger = new Logger(OtpService.name); constructor( private readonly otpRepository: OtpRepository, private readonly smsClient: SmsClientService, private readonly emailClient: EmailClientService, ) { } // --------------------------------------------------------------------------- // Generate OTP // --------------------------------------------------------------------------- generateOtp(): string { return Math.floor(100000 + Math.random() * 900000).toString(); } // --------------------------------------------------------------------------- // Send OTP // --------------------------------------------------------------------------- async sendOtp(target: OtpTarget) { try { // The verification code is generated server-side — never supplied by the // caller — so the OTP stays a secret known only to the server and the // recipient of the SMS/email. const otp = this.generateOtp(); // find existing row for this channel const existing = await this.otpRepository.findByTarget(target); // update existing otp if (existing) { await this.otpRepository.updateOtp(existing, otp); } else { // create new otp await this.otpRepository.createOtp(target, otp); } if (target.email) { // send email (queued to RabbitMQ via the shared Email service) await this.emailClient.sendEmail({ to: target.email, subject: "Your EDR Freight verification code", text: `Your verification code is ${otp}`, }); } else { // send sms (queued to RabbitMQ via the shared SMS service) await this.smsClient.sendSms({ to: target.phone as string, message: `Your verification code is ${otp}`, }); } this.logger.log(`OTP send for ${target.email ?? target.phone}: ${otp}`); return { success: true, message: "OTP sent successfully", }; } catch (error) { // Log the real cause (DB/SMS/email failure) with its stack so a deployed // "Failed to send OTP" 400 is diagnosable from the API logs, not opaque. this.logger.error( `Failed to send OTP to ${target.email ?? target.phone}: ${ error instanceof Error ? error.message : String(error) }`, error instanceof Error ? error.stack : undefined, ); throw new BadRequestException("Failed to send OTP"); } } // --------------------------------------------------------------------------- // Verify OTP // --------------------------------------------------------------------------- async verifyOtp(target: OtpTarget, otp: string) { // find the channel's row const otpData = await this.otpRepository.findByTarget(target); // not found if (!otpData) { throw new BadRequestException( target.email ? "Email address not found" : "Phone number not found", ); } // invalid otp if (otpData.otp !== otp) { throw new BadRequestException("Invalid OTP"); } // mark verified await this.otpRepository.markVerified(otpData); return { success: true, message: target.email ? "Email verified successfully" : "Phone verified successfully", }; } // --------------------------------------------------------------------------- // Verify OTP for a sensitive action (sudo mode) // --------------------------------------------------------------------------- // Fresh, single-use challenge gating a sensitive action (e.g. applying a // contract signature). Unlike verifyOtp above — which marks a phone verified // and leaves the code in place — this enforces a short TTL and consumes the // code on success so it can never be replayed. private readonly ACTION_OTP_TTL_MS = 5 * 60 * 1000; async verifyOtpForAction(phone: string, otp: string) { const otpData = await this.otpRepository.findByPhone(phone); if (!otpData) { throw new BadRequestException( "No verification code was requested for this phone", ); } const ageMs = Date.now() - new Date(otpData.updatedAt).getTime(); if (ageMs > this.ACTION_OTP_TTL_MS) { await this.otpRepository.deleteOtp(otpData); throw new BadRequestException( "Verification code has expired. Request a new one.", ); } if (otpData.otp !== otp) { throw new BadRequestException("Invalid verification code"); } // single-use: consume on success await this.otpRepository.deleteOtp(otpData); return { success: true }; } }