feat: add email to notification and otp

This commit is contained in:
Nathnael
2026-07-03 10:59:06 +00:00
parent 20abc0288d
commit 4e6e614b48
9 changed files with 278 additions and 38 deletions

View File

@@ -1,15 +1,24 @@
// otp.controller.ts
import {
BadRequestException,
Body,
Controller,
Post,
} from "@nestjs/common";
import { OtpService } from "./otp.service";
import { OtpService, OtpTarget } from "./otp.service";
import { Public } from "@edr/api-common";
// Exactly one of phone/email must be present per request — the channel the
// code is sent through / checked against.
function toTarget(phone?: string, email?: string): OtpTarget {
if (email) return { email };
if (phone) return { phone };
throw new BadRequestException("phone or email is required");
}
@Controller("otp")
@Public()
export class OtpController {
@@ -24,9 +33,12 @@ export class OtpController {
@Post("send")
async sendOtp(
@Body("phone")
phone: string
phone?: string,
@Body("email")
email?: string
) {
return this.otpService.sendOtp(phone);
return this.otpService.sendOtp(toTarget(phone, email));
}
// ---------------------------------------------------------------------------
@@ -36,13 +48,16 @@ export class OtpController {
@Post("verify")
async verifyOtp(
@Body("phone")
phone: string,
phone: string | undefined,
@Body("email")
email: string | undefined,
@Body("otp")
otp: string
) {
return this.otpService.verifyOtp(
phone,
toTarget(phone, email),
otp
);
}

View File

@@ -10,10 +10,19 @@ import { BaseEntity } from "@edr/api-common";
name: "otp_verifications",
})
export class OtpVerification extends BaseEntity{
// Exactly one of phone/email is set per row — the channel the code was sent
// through.
@Column({
unique: true,
nullable: true,
})
phone!: string;
phone?: string;
@Column({
unique: true,
nullable: true,
})
email?: string;
@Column()
otp!: string;

View File

@@ -31,6 +31,7 @@ import { NotificationsModule } from "../notifications/notifications.module";
exports: [
OtpRepository,
OtpService,
],
})
export class OtpModule {}

View File

@@ -31,17 +31,44 @@ export class OtpRepository {
});
}
// ---------------------------------------------------------------------------
// Find By Email
// ---------------------------------------------------------------------------
async findByEmail(
email: string
) {
return this.repository.findOne({
where: {
email,
},
});
}
// ---------------------------------------------------------------------------
// Find By Target (either channel)
// ---------------------------------------------------------------------------
async findByTarget(
target: { phone?: string; email?: string }
) {
return target.email
? this.findByEmail(target.email)
: this.findByPhone(target.phone!);
}
// ---------------------------------------------------------------------------
// Create OTP
// ---------------------------------------------------------------------------
async createOtp(
phone: string,
target: { phone?: string; email?: string },
otp: string
) {
const entity =
this.repository.create({
phone,
phone: target.phone,
email: target.email,
otp,
verified: false,
});
@@ -70,10 +97,10 @@ export class OtpRepository {
}
// ---------------------------------------------------------------------------
// Verify Phone
// Mark Verified
// ---------------------------------------------------------------------------
async verifyPhone(
async markVerified(
otpVerification: OtpVerification
) {
otpVerification.verified =
@@ -83,4 +110,18 @@ export class OtpRepository {
otpVerification
);
}
// ---------------------------------------------------------------------------
// Delete OTP (single-use consume)
// ---------------------------------------------------------------------------
// Hard delete so the unique `phone` row is freed and a fresh code can be
// requested for the same number on the next action.
async deleteOtp(
otpVerification: OtpVerification
) {
return this.repository.remove(
otpVerification
);
}
}

View File

@@ -8,12 +8,18 @@ import {
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 {
constructor(
private readonly otpRepository: OtpRepository,
private readonly smsClient: SmsClientService
private readonly smsClient: SmsClientService,
private readonly emailClient: EmailClientService
) {}
// ---------------------------------------------------------------------------
@@ -30,38 +36,47 @@ export class OtpService {
// Send OTP
// ---------------------------------------------------------------------------
async sendOtp(phone: string) {
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.
// recipient of the SMS/email.
const otp = this.generateOtp();
// find existing phone
const existingPhone =
await this.otpRepository.findByPhone(
phone
// find existing row for this channel
const existing =
await this.otpRepository.findByTarget(
target
);
// update existing otp
if (existingPhone) {
if (existing) {
await this.otpRepository.updateOtp(
existingPhone,
existing,
otp
);
} else {
// create new otp
await this.otpRepository.createOtp(
phone,
target,
otp
);
}
// send sms (queued to RabbitMQ via the shared SMS service)
await this.smsClient.sendSms({
to: phone,
message: `Your verification code is ${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}`,
});
}
return {
success: true,
@@ -83,19 +98,21 @@ export class OtpService {
// ---------------------------------------------------------------------------
async verifyOtp(
phone: string,
target: OtpTarget,
otp: string
) {
// find phone
// find the channel's row
const otpData =
await this.otpRepository.findByPhone(
phone
await this.otpRepository.findByTarget(
target
);
// phone not found
// not found
if (!otpData) {
throw new BadRequestException(
"Phone number not found"
target.email
? "Email address not found"
: "Phone number not found"
);
}
@@ -106,8 +123,8 @@ export class OtpService {
);
}
// verify phone
await this.otpRepository.verifyPhone(
// mark verified
await this.otpRepository.markVerified(
otpData
);
@@ -115,7 +132,65 @@ export class OtpService {
success: true,
message:
"Phone verified successfully",
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 };
}
}