// otp.repository.ts import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { FindOptionsWhere, Repository } from "typeorm"; import { OtpVerification } from "./otp.entity"; type Target = { phone?: string; email?: string }; @Injectable() export class OtpRepository { constructor( @InjectRepository( OtpVerification ) private readonly repository: Repository ) {} // --------------------------------------------------------------------------- // Find By Phone // --------------------------------------------------------------------------- async findByPhone( phone: string ) { return this.repository.findOne({ where: { phone, }, }); } // --------------------------------------------------------------------------- // Find By Email // --------------------------------------------------------------------------- async findByEmail( email: string ) { return this.repository.findOne({ where: { email, }, }); } // --------------------------------------------------------------------------- // Find By Target (any named channel) // --------------------------------------------------------------------------- /** * OR across every channel the target names. A code sent to both phone and * email lives in ONE row carrying both values, so a verify that quotes either * one resolves the same row — that is what makes "sent to both, verify with * either" work. */ private whereForTarget( target: Target ): FindOptionsWhere[] { const where: FindOptionsWhere[] = []; if (target.email) where.push({ email: target.email, }); if (target.phone) where.push({ phone: target.phone, }); return where; } async findAllByTarget( target: Target ) { const where = this.whereForTarget(target); if (!where.length) return []; // Newest first: a target that somehow overlaps two legacy single-channel // rows should resolve to the most recently issued code, not an arbitrary one. return this.repository.find({ where, order: { updatedAt: "DESC" }, }); } async findByTarget( target: Target ) { const [ newest, ] = await this.findAllByTarget( target ); return newest ?? null; } // --------------------------------------------------------------------------- // Replace OTP (upsert across every channel the target names) // --------------------------------------------------------------------------- /** * Drop every row this target overlaps and write a single fresh one holding * all its channels. * * `phone` and `email` are each UNIQUE, so a dual-channel send can collide with * up to two pre-existing single-channel rows (say an old signup code on the * phone and a reset code on the email). Merging into one row instead of * updating in place is what keeps that from raising a unique violation, and it * preserves the single-use guarantee: consuming the code deletes one row and * kills every channel it was sent to at once. * * "Last code sent wins" was already the behaviour between any two flows * sharing this table — this only widens it from one channel to all of them. */ async replaceOtp( target: Target, otp: string ): Promise<{ record: OtpVerification; rotated: boolean; }> { const existing = await this.findAllByTarget( target ); if (existing.length) { await this.repository.remove( existing ); } const record = await this.repository.save( this.repository.create({ phone: target.phone, email: target.email, otp, verified: false, }) ); return { record, rotated: existing.length > 0, }; } // --------------------------------------------------------------------------- // Mark Verified // --------------------------------------------------------------------------- async markVerified( otpVerification: OtpVerification ) { otpVerification.verified = true; return this.repository.save( otpVerification ); } // --------------------------------------------------------------------------- // Delete OTP (single-use consume) // --------------------------------------------------------------------------- // Hard delete so the unique `phone`/`email` rows are freed and a fresh code can // be requested for the same target on the next action. async deleteOtp( otpVerification: OtpVerification ) { return this.repository.remove( otpVerification ); } }