Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"dependencies": {
"@aws-sdk/client-ses": "^3.929.0",
"@nestjs/axios": "^4.0.0",
"@nestjs/common": "^11.0.11",
"@nestjs/config": "^4.0.1",
Expand Down Expand Up @@ -30,6 +31,7 @@
"reflect-metadata": "^0.2.2",
"rrule": "^2.8.1",
"rxjs": "^7.8.2",
"ses": "^1.14.0",
"stripe": "^18.1.1",
"twilio": "^5.7.0",
"winston": "^3.17.0",
Expand Down
971 changes: 971 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { AuthService } from '@/modules/auth/auth.service';
import { LoginDto } from '@/modules/auth/dto/login.dto';
import { CreateUserDto } from '@/modules/auth/dto/signup.dto';
import { UserResponseDto } from '@/modules/auth/dto/user-response.dto';
import { ResetPasswordDto } from '@/modules/auth/dto/reset-password.dto';
import { UserStatus } from '@/modules/user/enum/userStatus.enum';
import { generateCSRFToken } from '@/utils/csrf.util';

Expand Down Expand Up @@ -213,6 +214,18 @@ export class AuthController {
);
}

@ApiOperation({
summary: 'Forgot Password',
description: 'Send a password reset link to the user\'s email',
})
@ApiResponse({ status: 200, description: 'If that email is registered, a reset link has been sent.' })
@Post('forgot-password')
@SkipCSRF()
async forgotPassword(@Body('email') email: string): Promise<{ message: string }> {
await this.authService.forgotPassword(email);
return { message: 'If that email is registered, a reset link has been sent.' };
}

@ApiOperation({
summary: 'User Logout',
description: 'Logout and clear authentication cookies',
Expand Down Expand Up @@ -312,4 +325,17 @@ export class AuthController {
};
return { user: safeUser };
}

@ApiOperation({
summary: 'Reset Password',
description: 'Reset password using a valid reset token',
})
@ApiResponse({ status: 200, description: 'Password reset successful' })
@ApiResponse({ status: 400, description: 'Invalid token or password' })
@Post('reset-password')
@SkipCSRF()
async resetPassword(@Body() dto: ResetPasswordDto): Promise<{ message: string }> {
await this.authService.resetPassword(dto);
return { message: 'Password reset successful' };
}
}
3 changes: 2 additions & 1 deletion src/modules/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import { AuthService } from '@/modules/auth/auth.service';
import { GoogleStrategy } from '@/modules/auth/strategies/google.strategy';
import { JwtStrategy } from '@/modules/auth/strategies/jwt.strategy';
import { DatabaseModule } from '@/modules/database/database.module';
import { SesModule } from '@/modules/ses/ses.module';
import { User, userSchema } from '@/modules/user/schema/user.schema';
import { UserModule } from '@/modules/user/user.module';

@Module({
imports: [
PassportModule,
Expand All @@ -27,6 +27,7 @@ import { UserModule } from '@/modules/user/user.module';
MongooseModule.forFeature([{ name: User.name, schema: userSchema }]),
DatabaseModule,
UserModule,
SesModule,
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, GoogleStrategy],
Expand Down
75 changes: 72 additions & 3 deletions src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,39 @@
import process from 'process';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectModel } from '@nestjs/mongoose';
import * as bcrypt from 'bcryptjs';
import * as crypto from 'crypto';
import * as fs from 'fs';
import { Model } from 'mongoose';
import * as path from 'path';

import { EUserRole } from '@/common/constants/user.constant';
import { SALT_ROUNDS } from '@/modules/auth/auth.config';
import { LoginDto } from '@/modules/auth/dto/login.dto';
import { ResetPasswordDto } from '@/modules/auth/dto/reset-password.dto';
import { CreateUserDto } from '@/modules/auth/dto/signup.dto';
import { SesService } from '@/modules/ses/ses.service';
import { User, UserDocument } from '@/modules/user/schema/user.schema';
import { generateCSRFToken } from '@/utils/csrf.util';
@Injectable()
export class AuthService {
private emailTemplate: string;

constructor(
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
private readonly jwtService: JwtService,
) {}
private readonly sesService: SesService,
) {
const templatePath = path.join(process.cwd(), 'templates', 'email.html');
this.emailTemplate = fs.readFileSync(templatePath, 'utf-8');
}

async validateUser(email: string, password: string): Promise<User> {
const user = await this.userModel
Expand Down Expand Up @@ -96,11 +110,66 @@ export class AuthService {

async checkUserExists(email: string): Promise<boolean> {
const user = await this.userModel.findOne({ email });
return !!user;
return user !== null;
}

async getUserById(userId: string): Promise<User | null> {
const user = await this.userModel.findById(userId).exec();
return user ? (user.toObject() as User) : null;
if (user !== null) {
return user.toObject() as User;
}
return null;
}

async forgotPassword(email: string): Promise<void> {
const user = await this.userModel.findOne({ email });
if (user === null) return;

// Generate a secure random token
const token = crypto.randomBytes(32).toString('hex');
user.resetPasswordToken = token;
user.resetPasswordExpires = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes

await user.save();

const resetLink = `http://localhost:3000/reset-password?token=${token}`;
const userName = user.firstName || user.email;

// Replace variables in the template
const html = this.emailTemplate
.replace(/\$\{userName\}/g, userName)
.replace(/\$\{resetLink\}/g, resetLink);

await this.sesService.sendEmail({
to: user.email,
subject: 'Reset your Dispatch AI password',
html,
});
}

async resetPassword(dto: ResetPasswordDto): Promise<void> {
const { token, password, confirmPassword } = dto;

if (!token || !password || !confirmPassword) {
throw new BadRequestException('Missing required fields');
}
if (password !== confirmPassword) {
throw new BadRequestException('Passwords do not match');
}
if (password.length < 6) {
throw new BadRequestException('Password must be at least 6 characters');
}
const user = await this.userModel.findOne({
resetPasswordToken: token,
resetPasswordExpires: { $gt: new Date() },
});

if (user === null) {
throw new NotFoundException('Invalid or expired reset token');
}
user.password = await bcrypt.hash(password, SALT_ROUNDS);
user.resetPasswordToken = undefined;
user.resetPasswordExpires = undefined;
await user.save();
}
}
18 changes: 18 additions & 0 deletions src/modules/auth/dto/reset-password.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, MinLength } from 'class-validator';

export class ResetPasswordDto {
@ApiProperty()
@IsString()
token!: string;

@ApiProperty()
@IsString()
@MinLength(6)
password!: string;

@ApiProperty()
@IsString()
@MinLength(6)
confirmPassword!: string;
}
9 changes: 9 additions & 0 deletions src/modules/ses/ses.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';

import { SesService } from '@/modules/ses/ses.service';

@Module({
providers: [SesService],
exports: [SesService],
})
export class SesModule {}
52 changes: 52 additions & 0 deletions src/modules/ses/ses.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { SendEmailCommand, SESClient } from '@aws-sdk/client-ses';
import { Injectable, Logger } from '@nestjs/common';
import process from 'process';

@Injectable()
export class SesService {
private readonly sesClient: SESClient;
private readonly logger = new Logger(SesService.name);

constructor() {
this.sesClient = new SESClient({
region: process.env.AWS_REGION ?? 'us-east-1',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? '',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? '',
},
});
}

async sendEmail({
to,
subject,
html,
from,
}: {
to: string;
subject: string;
html: string;
from?: string;
}): Promise<void> {
const sender =
from ?? process.env.SES_FROM_EMAIL ?? 'no-reply@dispatchai.com';
const params = {
Destination: { ToAddresses: [to] },
Message: {
Body: { Html: { Charset: 'UTF-8', Data: html } },
Subject: { Charset: 'UTF-8', Data: subject },
},
Source: sender,
};

try {
await this.sesClient.send(new SendEmailCommand(params));
this.logger.log(`Email sent to ${to}`);
} catch (error) {
this.logger.error(
`Failed to send email to ${to}: ${error instanceof Error ? error.message : String(error)}`,
);
throw error;
}
}
}
8 changes: 7 additions & 1 deletion src/modules/user/schema/user.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Your team is not available to take the call right now.

I can take a message for you, or help you book an appointment with your team. What can I do for you today?

你也可以和我说普通话。`;
Thank you!`;

@Schema({ timestamps: true })
export class User extends Document {
Expand Down Expand Up @@ -53,6 +53,12 @@ export class User extends Document {
@Prop()
statusReason!: string;

@Prop({ required: false })
resetPasswordToken?: string;

@Prop({ required: false })
resetPasswordExpires?: Date;

@Prop()
position!: string;

Expand Down
64 changes: 64 additions & 0 deletions templates/email.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<html>
<body style="margin:0;padding:0;background:#f6f8fa;font-family:Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="padding:32px 0;">
<tr>
<td align="center">
<table width="700" cellpadding="0" cellspacing="0"
style="border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,0.04);overflow:hidden;background:url('https://getdispatch.ai/email/topbox.png') top center no-repeat;background-size:cover;">
<tr>
<td style="padding:32px 40px 16px 40px;background:rgba(255,255,255,0.95);">
<img src="https://getdispatch.ai/email/logo.png" alt="DispatchAI Logo" height="32"
style="vertical-align:middle;">
</td>
</tr>
<tr>
<td style="padding:32px 40px 24px 40px;background:rgba(255,255,255,0.95);">
<h2 style="font-size:24px;margin:24px 0 40px 0;color:#222;font-weight:700;">Reset your
password</h2>
<hr style="border:none;border-top:1px solid #eaeaea;margin:0 0 24px 0;">
<p style="font-size:16px;color:#222;margin:40px 0 16px 0;">Hi ${escapeHtml(userName)},</p>
<p style="font-size:16px;color:#222;margin:0 0 16px 0;line-height: 40px;">
We've received a request to reset your password.<br>
If you didn't make the request, just ignore this message. Otherwise, you can reset your
password below.
</p>
<div style="margin:16px 0 39px 0;text-align:left;">
<a href="${escapeHtml(resetLink)}"
style="display:inline-block;padding:14px 32px;background:#111;color:#fff;font-size:14px;font-weight:600;text-decoration:none;border-radius:6px;">
Reset your password
</a>
</div>
<p style="font-size:15px;color:#111;margin:0 0 8px 0;">Thank you for using Dispatch AI!</p>
<p style="font-size:15px;color:#111;margin:0;">The Dispatch AI Team</p>
</td>
</tr>
<tr>
<td style="padding:32px 40px 24px 40px;background:rgba(255,255,255,0.95);">
<hr style="border:none;border-top:1px solid #eaeaea;margin:0 0 24px 0;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td style="font-size:13px;color:#111;text-align:left;">
Dispatch AI &nbsp;&nbsp;|&nbsp;&nbsp; Smart Call Handling AI Agent
&nbsp;&nbsp;|&nbsp;&nbsp; <a href="https://getdispatch.ai"
style="color:#111;text-decoration:none;">getdispatch.ai</a>
</td>
<td style="text-align:right;">
<img src="https://getdispatch.ai/email/bottompic.png" alt="" height="90px"
style="vertical-align:middle;">
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="center">
<img src="https://getdispatch.ai/email/bottomline.png" alt="bottom line"
style="display:block;width:700px;max-width:100%;margin:0 auto;">
</td>
</tr>
</table>
</body>
</html>
Loading