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

@@ -55,8 +55,10 @@ REDIS_HOST=localhost
REDIS_PORT=6379
# --- Notification broker (RabbitMQ) ---------------------------------------------
# SMS OTP / notifications are queued to RabbitMQ (consumed by the shared SMS service).
# Set RABBITMQ_ENABLED=false to skip the broker entirely (dev without a local broker).
# SMS/email OTP + notifications are queued to RabbitMQ (consumed by the shared
# SMS/email services). Set RABBITMQ_ENABLED=false to skip the broker entirely
# (dev without a local broker).
RABBITMQ_ENABLED=false
RABBITMQ_URL=amqp://localhost:5672
SMS_QUEUE=sms_queue
EMAIL_QUEUE=email_queue

View File

@@ -0,0 +1,30 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEmail, IsNotEmpty, IsOptional, IsString } from "class-validator";
export class SendEmailDto {
@ApiProperty({
description: "Recipient email address",
example: "customer@example.com",
})
@IsEmail()
@IsNotEmpty()
to!: string;
@ApiProperty({
description: "Email subject",
example: "Your EDR Freight verification code",
})
@IsString()
@IsNotEmpty()
subject!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
text?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
html?: string;
}

View File

@@ -0,0 +1,51 @@
import {
Inject,
Injectable,
Logger,
OnApplicationBootstrap,
} from "@nestjs/common";
import { ClientProxy } from "@nestjs/microservices";
import { SendEmailDto } from "./dtos/email.dto";
@Injectable()
export class EmailClientService implements OnApplicationBootstrap {
private readonly logger = new Logger(EmailClientService.name);
constructor(
@Inject("EMAIL_SERVICE")
private readonly emailClient: ClientProxy,
) {}
private readonly enabled = process.env.RABBITMQ_ENABLED !== "false";
async onApplicationBootstrap() {
if (!this.enabled) return;
this.emailClient
.connect()
.then(() => this.logger.log("connected to Email service"))
.catch((err) => {
console.error("Error happened at Email service", err);
});
}
async sendEmail(dto: SendEmailDto): Promise<{ queued: boolean }> {
if (!this.enabled) {
this.logger.warn(`RABBITMQ disabled — skipped EMAIL to=${dto.to}`);
return { queued: false };
}
this.emailClient.emit("send-email", {
to: dto.to,
subject: dto.subject,
text: dto.text,
html: dto.html,
appKey: "IFHCRS-LICENSE-MANAGEMENT",
});
// Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery.
this.logger.log(
`EMAIL queued to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email'`,
);
// Recipient + content are PII — debug only.
this.logger.debug(`EMAIL payload to=${dto.to} subject="${dto.subject}"`);
return { queued: true };
}
}

View File

@@ -4,6 +4,7 @@ import { ClientsModule, Transport } from "@nestjs/microservices";
import { NotificationsService } from "./notifications.service";
import { SmsClientService } from "./sms-client.service";
import { EmailClientService } from "./email-client.service";
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
@@ -20,10 +21,25 @@ import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"
queueOptions: { durable: true },
},
},
{
name: "EMAIL_SERVICE",
transport: Transport.RMQ,
options: {
urls: [process.env.RABBITMQ_URL as string],
queue: process.env.EMAIL_QUEUE ?? "email_queue",
queueOptions: { durable: true },
},
},
]),
],
controllers: [],
providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService, SmsClientService],
exports: [NotificationsService, SmsClientService],
providers: [
EmailNotificationStrategy,
SmsNotificationStrategy,
NotificationsService,
SmsClientService,
EmailClientService,
],
exports: [NotificationsService, SmsClientService, EmailClientService],
})
export class NotificationsModule {}

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 };
}
}