import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; import { ExternalProfile } from "../companies/entities/external-profile.entity"; import { EmailClientService } from "../notifications/email-client.service"; import { SmsClientService } from "../notifications/sms-client.service"; import { ResetChannel } from "./dto/forgot-password.dto"; import { ForgotPasswordService, RESET_LINK_TTL_MS, } from "./forgot-password.service"; import { maskOtpTarget } from "./mask-target.util"; import { isDomesticPhone } from "../otp/otp.service"; /** The account a staff-triggered reset would land on. */ export interface CustomerResetTarget { userId: string; name: string; email: string | null; phone: string | null; /** * Whether the SMS gateway (domestic-only) can reach `phone`. `null` when * there is no phone. The backoffice uses this to disable the SMS channel for * foreign numbers instead of sending a link that will never arrive. */ phoneIsDomestic: boolean | null; } export interface SentResetLink { channel: ResetChannel; maskedTarget: string; expiresAt: string; } @Injectable() export class CustomerResetService { private readonly logger = new Logger(CustomerResetService.name); constructor( @InjectRepository(ExternalProfile) private readonly externalProfileRepository: Repository, private readonly forgotPasswordService: ForgotPasswordService, private readonly emailClient: EmailClientService, private readonly smsClient: SmsClientService, private readonly config: ConfigService, ) {} /** * The IAM account a reset would actually reach. The backoffice shows these * values rather than `company.email` / `company.phone`: the company row holds * business contact detail, while the link is delivered to the primary * contact's own login credentials — the two drift apart routinely, and showing * the wrong one has staff telling customers to check an inbox nothing was sent * to. */ async getResetTarget(companyId: string): Promise { const resolved = await this.resolvePrimaryContactUser(companyId); if (!resolved) return null; const { profile, user, userId } = resolved; return { userId, name: `${profile.firstName} ${profile.lastName}`.trim(), email: user.email ?? null, phone: user.phoneNumber ?? null, phoneIsDomestic: user.phoneNumber ? isDomesticPhone(user.phoneNumber) : null, }; } /** * Mint a password-reset link and send it to the company's primary contact. * Returns the masked destination, or null when there is no eligible account * for that channel. * * Unlike the public flow this reports failure honestly — the caller is an * authenticated staff member, so there is nothing to enumerate. */ async sendResetLinkToCustomer( companyId: string, channel: ResetChannel, ): Promise { const resolved = await this.resolvePrimaryContactUser(companyId); if (!resolved) return null; const { user, userId } = resolved; const target = this.forgotPasswordService.targetFor(user, channel); if (!target) return null; // A foreign number is unreachable by the domestic-only SMS gateway — treat // it like a missing phone rather than reporting "link sent" for a message // that will never arrive. The backoffice disables the channel up front via // `phoneIsDomestic`; this guards direct API calls. if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) { this.logger.warn( `Staff reset via SMS refused for user ${userId} — non-domestic phone`, ); return null; } // Mint first, send second: a failed send leaves an unused ticket that simply // expires, whereas sending a link before the ticket exists would hand the // customer a URL that is dead on arrival. const ticket = await this.forgotPasswordService.mintResetTicket( userId, RESET_LINK_TTL_MS, ); const link = this.buildResetLink(ticket.userId, ticket.verificationCode); const expiresAt = new Date(Date.now() + RESET_LINK_TTL_MS); const { queued } = target.email ? await this.emailClient.sendEmail({ to: target.email, subject: "Reset your EDR Freight password", text: "A password reset was started for your EDR Freight account.\n\n" + `Open this link to choose a new password:\n${link}\n\n` + "The link expires in 24 hours and can only be used once. If you did " + "not expect this, ignore this message — your password stays unchanged.", }) : await this.smsClient.sendSms({ to: target.phone as string, message: `Reset your EDR Freight password: ${link} (expires in 24 hours, single use)`, }); this.logger.log( `Staff-triggered ${channel} reset link sent to user ${userId} (company ${companyId}) queued=${queued}`, ); if (!queued) { // The ticket is committed and the backoffice is about to say "link sent", // but nothing left this process — with RABBITMQ_ENABLED=false both clients // are no-ops. Without this line the only symptom is a customer who never // receives anything, indistinguishable from carrier loss. this.logger.error( `reset-link.dispatch.dropped channel=${channel} user=${userId} rabbitmqEnabled=${ process.env.RABBITMQ_ENABLED ?? "unset" } — transport reported no hand-off; no link will arrive`, ); // SECURITY: logs a live password-reset credential in cleartext. Same // deliberate tradeoff the OTP service makes — this is the only way to // complete a reset on an environment with no broker. Only reached when // delivery already failed. this.logger.warn(`Undelivered reset link for user ${userId}: ${link}`); } return { channel, maskedTarget: maskOtpTarget(target), expiresAt: expiresAt.toISOString(), }; } /** * The company's primary contact, gated on the same active-account rule the * public flow uses — so a suspended customer cannot be reactivated by a * staff-triggered reset (IAM's `set-password` flips `isActive` back on). */ private async resolvePrimaryContactUser(companyId: string) { const profile = await this.externalProfileRepository.findOne({ where: { companyId, isPrimaryContact: true }, }); if (!profile) { this.logger.warn(`Company ${companyId} has no primary contact profile`); return null; } const user = await this.forgotPasswordService.resolveActiveUserById( profile.userId, ); if (!user?.id) { this.logger.warn( `Primary contact ${profile.userId} of company ${companyId} is not an active account`, ); return null; } return { profile, user, userId: user.id }; } /** * The portal route that trades the token for a set-password form. Params are * URL-encoded because the token is base64url — safe as-is, but the encoding * keeps this correct if the token format ever changes. */ private buildResetLink(userId: string, token: string): string { const base = this.config.get("app.portalBaseUrl"); return `${base}/reset-password?uid=${encodeURIComponent( userId, )}&token=${encodeURIComponent(token)}`; } }