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, type ResetTicket, } from "./forgot-password.service"; import { maskOtpTarget } from "./mask-target.util"; import { isDomesticPhone, type OtpTarget } 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; return this.sendResetLinkToUser(resolved.userId, channel, { scope: `company ${companyId}`, }); } /** * Mint and deliver a reset link to a specific IAM account. * * The delivery half of {@link sendResetLinkToCustomer}, split out so callers * that resolve their target differently can reuse it: a customer is found via * the company's primary contact, while a shipping line has no contact row at * all and resolves straight off its own record. Everything below the lookup — * active-account gating, the domestic-SMS rule, mint-before-send, the * undelivered-link diagnostic — is identical for both and must stay that way. * * `scope` only labels the log line with whatever the caller resolved from. * * `allowWithoutCredential` relaxes the lookup for first-time activation: * the default gate requires an existing active credential (so a reset cannot * revive a suspended account), but an account that has never set a password * has no credential row yet and would be excluded from its own activation * link. Callers pass it only when the account is expected to be * password-less — see ShippingLineCompaniesService. */ async sendResetLinkToUser( userId: string, channel: ResetChannel, options?: { scope?: string; allowWithoutCredential?: boolean }, ): Promise { const sent = await this.sendResetLinkToUserOnChannels(userId, [channel], options); return sent[0] ?? null; } /** * One ticket, several channels. Minting retires every earlier ticket for the * user (`mintResetTicket`), so sending email and SMS as two separate mints * makes the first link dead on arrival — the same link must go to both. * Returns one entry per channel that was actually sent (unreachable channels * are skipped, not errors). */ async sendResetLinkToUserOnChannels( userId: string, channels: ResetChannel[], options?: { scope?: string; allowWithoutCredential?: boolean }, ): Promise { const user = options?.allowWithoutCredential ? await this.forgotPasswordService.resolveActivatableUserById(userId) : await this.forgotPasswordService.resolveActiveUserById(userId); if (!user?.id) { this.logger.warn( `User ${userId} is not an active account${ options?.allowWithoutCredential ? "" : " (or has no active credential — pass allowWithoutCredential for first-time activation)" }`, ); return []; } // Mint once, before any send: 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. let ticket: ResetTicket | null = null; const sent: SentResetLink[] = []; for (const channel of channels) { const target = this.forgotPasswordService.targetFor(user, channel); if (!target) continue; // The gateway silently drops foreign numbers — treat 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`, ); continue; } ticket ??= await this.forgotPasswordService.mintResetTicket( user.id, RESET_LINK_TTL_MS, ); const result = await this.deliverResetLink( target, user.id, channel, ticket, options?.scope, ); if (result) sent.push(result); } return sent; } /** * Shared tail: send the already-minted ticket to a resolved target → report. */ private async deliverResetLink( target: OtpTarget, userId: string, channel: ResetChannel, ticket: ResetTicket, scope?: string, ): Promise { // 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 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 shipping line ${channel} reset link sent to user ${userId}${ scope ? ` (${scope})` : "" } queued=${queued}`, ); // SECURITY: logs a live password-reset credential in cleartext. Anyone with // read access to the log stream can set the password for the account named // on the same line — including on sends that succeeded, not just failures. // Kept deliberately: log aggregation is the debugging path for flaky // email/SMS here, the same tradeoff otp.service.ts makes for OTP codes. If // that is ever revisited, gate this on an env flag rather than deleting it, // so dev keeps its workflow. this.logger.warn( `reset-link.cleartext channel=${channel} user=${userId}${ scope ? ` (${scope})` : "" } link=${link}`, ); 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)}`; } }