diff --git a/package.json b/package.json index 0f49038..15e7b33 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "@sendgrid/mail": "^8.1.6", "argon2": "^0.44.0", "axios": "^1.13.2", - "bull": "^4.16.5", + "bull": "^4.16.5", "bullmq": "^5.66.1", "cache-manager": "^6.3.2", "cacheable": "^1.8.8", @@ -100,7 +100,7 @@ "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", - "@types/cookie-parser": "^1.4.10", + "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/multer": "^2.0.0", diff --git a/src/app.module.ts b/src/app.module.ts index 4b51ca1..3bc32f4 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,6 +9,7 @@ import { configuration } from './config'; import { PrismaModule } from './prisma'; import { AuthModule, JwtAuthGuard, RolesGuard } from './auth'; import { UsersModule } from './users'; +import { EmailModule } from './email'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @@ -41,6 +42,7 @@ import { AppService } from './app.service'; // Feature Modules AuthModule, UsersModule, + EmailModule, ], controllers: [AppController], providers: [ diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 147f9cb..dae85c6 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -25,6 +25,7 @@ import { ForgotPasswordDto, ResetPasswordDto, RefreshTokenDto, + VerifyEmailDto, } from './dto'; import { JwtAuthGuard } from './guards'; import { Public, CurrentUser } from './decorators'; @@ -189,6 +190,56 @@ export class AuthController { return this.authService.resetPassword(dto); } + // ========================================== + // VERIFY EMAIL + // ========================================== + @Public() + @Post('verify-email') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Verify email with token' }) + @ApiBody({ type: VerifyEmailDto }) + @ApiResponse({ + status: 200, + description: 'Email verified successfully', + schema: { + example: { + success: true, + data: { + message: 'Email verified successfully', + }, + }, + }, + }) + @ApiResponse({ status: 400, description: 'Invalid or expired token' }) + async verifyEmail(@Body() dto: VerifyEmailDto) { + return this.authService.verifyEmail(dto); + } + + // ========================================== + // RESEND VERIFICATION EMAIL + // ========================================== + @Post('resend-verification') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Resend email verification' }) + @ApiResponse({ + status: 200, + description: 'Verification email sent', + schema: { + example: { + success: true, + data: { + message: 'Verification email sent', + }, + }, + }, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async resendVerification(@CurrentUser('id') userId: string) { + return this.authService.resendVerificationEmail(userId); + } + // ========================================== // REFRESH TOKEN // ========================================== diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index ddd81b2..6b58647 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -19,6 +19,7 @@ import { ForgotPasswordDto, ResetPasswordDto, RefreshTokenDto, + VerifyEmailDto, } from './dto'; import { UserRole, AuthProvider, UserStatus } from '@prisma/client'; @@ -88,11 +89,13 @@ export class AuthService { // Create session await this.createSession(user.id, tokens.accessToken, tokens.refreshToken); - // Emit event for email verification (can be handled by email service) + // Emit event for email verification (handled by email service) this.eventEmitter.emit('user.registered', { userId: user.id, email: user.email, role: user.role, + firstName: dto.firstName, + lastName: dto.lastName, }); return { @@ -404,6 +407,110 @@ export class AuthService { } } + // ========================================== + // VERIFY EMAIL + // ========================================== + async verifyEmail(dto: VerifyEmailDto) { + try { + const payload = this.jwtService.verify(dto.token, { + secret: this.configService.get('jwt.secret'), + }); + + if (payload.type !== 'email-verification') { + throw new BadRequestException('Invalid verification token'); + } + + const user = await this.prisma.user.findUnique({ + where: { id: payload.sub }, + include: { + userProfile: true, + agentProfile: true, + }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + if (user.emailVerified) { + return { + message: 'Email is already verified', + }; + } + + // Update user as verified + await this.prisma.user.update({ + where: { id: user.id }, + data: { + emailVerified: true, + emailVerifiedAt: new Date(), + }, + }); + + // Get profile for name + const profile = + user.role === UserRole.AGENT ? user.agentProfile : user.userProfile; + + // Emit event for welcome email + this.eventEmitter.emit('user.email-verified', { + email: user.email, + name: profile?.firstName, + }); + + return { + message: 'Email verified successfully', + }; + } catch (error) { + if (error.name === 'TokenExpiredError') { + throw new BadRequestException('Verification token has expired'); + } + if (error.name === 'JsonWebTokenError') { + throw new BadRequestException('Invalid verification token'); + } + throw error; + } + } + + // ========================================== + // RESEND VERIFICATION EMAIL + // ========================================== + async resendVerificationEmail(userId: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + include: { + userProfile: true, + agentProfile: true, + }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + if (user.emailVerified) { + return { + message: 'Email is already verified', + }; + } + + // Get profile for name + const profile = + user.role === UserRole.AGENT ? user.agentProfile : user.userProfile; + + // Emit event for email service to send verification + this.eventEmitter.emit('user.registered', { + userId: user.id, + email: user.email, + role: user.role, + firstName: profile?.firstName, + lastName: profile?.lastName, + }); + + return { + message: 'Verification email sent', + }; + } + // ========================================== // REFRESH TOKEN // ========================================== @@ -597,8 +704,16 @@ export class AuthService { ), ); - await this.prisma.session.create({ - data: { + // Use upsert to handle potential duplicate tokens + await this.prisma.session.upsert({ + where: { token: accessToken }, + update: { + refreshToken, + userAgent, + ipAddress, + expiresAt, + }, + create: { userId, token: accessToken, refreshToken, diff --git a/src/auth/dto/index.ts b/src/auth/dto/index.ts index 5cff2b0..139c7ba 100644 --- a/src/auth/dto/index.ts +++ b/src/auth/dto/index.ts @@ -4,3 +4,4 @@ export * from './social-auth.dto'; export * from './forgot-password.dto'; export * from './reset-password.dto'; export * from './refresh-token.dto'; +export * from './verify-email.dto'; diff --git a/src/auth/dto/verify-email.dto.ts b/src/auth/dto/verify-email.dto.ts new file mode 100644 index 0000000..e6760de --- /dev/null +++ b/src/auth/dto/verify-email.dto.ts @@ -0,0 +1,12 @@ +import { IsString, IsNotEmpty } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class VerifyEmailDto { + @ApiProperty({ + description: 'Email verification token', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }) + @IsString() + @IsNotEmpty() + token: string; +} diff --git a/src/common/filters/http-exception.filter.ts b/src/common/filters/http-exception.filter.ts index ab9ce0e..14307ff 100644 --- a/src/common/filters/http-exception.filter.ts +++ b/src/common/filters/http-exception.filter.ts @@ -8,6 +8,17 @@ import { } from '@nestjs/common'; import { Request, Response } from 'express'; +interface ErrorResponse { + success: false; + statusCode: number; + timestamp: string; + path: string; + method: string; + error: string; + message: string | string[]; + errors?: Array<{ field: string; errors: string[] }>; +} + @Catch() export class AllExceptionsFilter implements ExceptionFilter { private readonly logger = new Logger(AllExceptionsFilter.name); @@ -18,19 +29,26 @@ export class AllExceptionsFilter implements ExceptionFilter { const request = ctx.getRequest(); let status: number; - let message: string | object; + let message: string | string[]; let error: string; + let validationErrors: Array<{ field: string; errors: string[] }> | undefined; if (exception instanceof HttpException) { status = exception.getStatus(); const exceptionResponse = exception.getResponse(); if (typeof exceptionResponse === 'object') { - message = (exceptionResponse as any).message || exception.message; - error = (exceptionResponse as any).error || 'Error'; + const responseObj = exceptionResponse as any; + message = responseObj.message || exception.message; + error = responseObj.error || this.getErrorNameByStatus(status); + + // Include validation errors if present + if (responseObj.errors) { + validationErrors = responseObj.errors; + } } else { message = exceptionResponse; - error = 'Error'; + error = this.getErrorNameByStatus(status); } } else { status = HttpStatus.INTERNAL_SERVER_ERROR; @@ -44,7 +62,8 @@ export class AllExceptionsFilter implements ExceptionFilter { ); } - const errorResponse = { + const errorResponse: ErrorResponse = { + success: false, statusCode: status, timestamp: new Date().toISOString(), path: request.url, @@ -53,6 +72,28 @@ export class AllExceptionsFilter implements ExceptionFilter { message, }; + if (validationErrors) { + errorResponse.errors = validationErrors; + } + response.status(status).json(errorResponse); } + + private getErrorNameByStatus(status: number): string { + const statusNames: Record = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 405: 'Method Not Allowed', + 409: 'Conflict', + 422: 'Unprocessable Entity', + 429: 'Too Many Requests', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + }; + + return statusNames[status] || 'Error'; + } } diff --git a/src/email/email.listener.ts b/src/email/email.listener.ts new file mode 100644 index 0000000..6b87b9f --- /dev/null +++ b/src/email/email.listener.ts @@ -0,0 +1,102 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { EmailService } from './email.service'; + +interface UserRegisteredEvent { + userId: string; + email: string; + role: string; + firstName?: string; + lastName?: string; +} + +interface PasswordResetRequestedEvent { + userId: string; + email: string; + resetToken: string; + resetUrl: string; +} + +interface PasswordResetCompletedEvent { + userId: string; + email: string; +} + +@Injectable() +export class EmailListener { + private readonly logger = new Logger(EmailListener.name); + + constructor( + private readonly emailService: EmailService, + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + ) {} + + @OnEvent('user.registered') + async handleUserRegistered(payload: UserRegisteredEvent) { + this.logger.log(`User registered event received for: ${payload.email}`); + + try { + // Generate verification token + const verificationToken = this.jwtService.sign( + { sub: payload.userId, email: payload.email, type: 'email-verification' }, + { + secret: this.configService.get('jwt.secret'), + expiresIn: '24h', + }, + ); + + const name = payload.firstName + ? `${payload.firstName} ${payload.lastName || ''}`.trim() + : undefined; + + // Send verification email + await this.emailService.sendVerificationEmail( + payload.email, + verificationToken, + name, + ); + + this.logger.log(`Verification email sent to: ${payload.email}`); + } catch (error) { + this.logger.error(`Failed to send verification email to ${payload.email}:`, error); + } + } + + @OnEvent('password.reset-requested') + async handlePasswordResetRequested(payload: PasswordResetRequestedEvent) { + this.logger.log(`Password reset requested for: ${payload.email}`); + + try { + await this.emailService.sendPasswordResetEmail( + payload.email, + payload.resetUrl, + ); + + this.logger.log(`Password reset email sent to: ${payload.email}`); + } catch (error) { + this.logger.error(`Failed to send password reset email to ${payload.email}:`, error); + } + } + + @OnEvent('password.reset-completed') + async handlePasswordResetCompleted(payload: PasswordResetCompletedEvent) { + this.logger.log(`Password reset completed for: ${payload.email}`); + // Optionally send a confirmation email that password was changed + } + + @OnEvent('user.email-verified') + async handleEmailVerified(payload: { email: string; name?: string }) { + this.logger.log(`Email verified for: ${payload.email}`); + + try { + // Send welcome email after verification + await this.emailService.sendWelcomeEmail(payload.email, payload.name); + this.logger.log(`Welcome email sent to: ${payload.email}`); + } catch (error) { + this.logger.error(`Failed to send welcome email to ${payload.email}:`, error); + } + } +} diff --git a/src/email/email.module.ts b/src/email/email.module.ts new file mode 100644 index 0000000..d5df619 --- /dev/null +++ b/src/email/email.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; +import { EmailService } from './email.service'; +import { EmailListener } from './email.listener'; + +@Module({ + imports: [ + ConfigModule, + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => ({ + secret: configService.get('jwt.secret'), + }), + inject: [ConfigService], + }), + ], + providers: [EmailService, EmailListener], + exports: [EmailService], +}) +export class EmailModule {} diff --git a/src/email/email.service.ts b/src/email/email.service.ts new file mode 100644 index 0000000..578b385 --- /dev/null +++ b/src/email/email.service.ts @@ -0,0 +1,136 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as nodemailer from 'nodemailer'; +import { Transporter } from 'nodemailer'; + +import { + getVerificationEmailTemplate, + getPasswordResetTemplate, + getWelcomeEmailTemplate, +} from './templates'; + +export interface EmailOptions { + to: string; + subject: string; + html: string; + text?: string; +} + +@Injectable() +export class EmailService { + private readonly logger = new Logger(EmailService.name); + private transporter: Transporter; + + constructor(private readonly configService: ConfigService) { + this.createTransporter(); + } + + private createTransporter() { + const host = this.configService.get('SMTP_HOST'); + const port = this.configService.get('SMTP_PORT'); + const user = this.configService.get('SMTP_USER'); + const pass = this.configService.get('SMTP_PASS'); + + if (!host || !user || !pass) { + this.logger.warn('SMTP configuration incomplete. Emails will be logged only.'); + return; + } + + this.transporter = nodemailer.createTransport({ + host, + port: port || 465, + secure: true, // SSL/TLS + auth: { + user, + pass, + }, + }); + + // Verify connection + this.transporter.verify((error) => { + if (error) { + this.logger.error('SMTP connection failed:', error); + } else { + this.logger.log('SMTP connection established successfully'); + } + }); + } + + async sendEmail(options: EmailOptions): Promise { + const from = this.configService.get('SMTP_FROM') || + this.configService.get('SMTP_USER'); + + if (!this.transporter) { + this.logger.warn('No SMTP transporter. Logging email instead:'); + this.logger.log(`To: ${options.to}`); + this.logger.log(`Subject: ${options.subject}`); + this.logger.log(`Body: ${options.text || 'HTML email'}`); + return false; + } + + try { + const info = await this.transporter.sendMail({ + from: `"RE-QuestN" <${from}>`, + to: options.to, + subject: options.subject, + html: options.html, + text: options.text, + }); + + this.logger.log(`Email sent successfully: ${info.messageId}`); + return true; + } catch (error) { + this.logger.error(`Failed to send email to ${options.to}:`, error); + return false; + } + } + + // ========================================== + // EMAIL VERIFICATION + // ========================================== + async sendVerificationEmail(email: string, token: string, name?: string): Promise { + const frontendUrl = this.configService.get('FRONTEND_URL') || 'http://localhost:3000'; + const verificationUrl = `${frontendUrl}/verify-email?token=${token}`; + + const html = getVerificationEmailTemplate({ verificationUrl, name }); + const text = `Welcome to RE-QuestN! Please verify your email by clicking this link: ${verificationUrl}`; + + return this.sendEmail({ + to: email, + subject: 'Verify Your Email - RE-QuestN', + html, + text, + }); + } + + // ========================================== + // PASSWORD RESET + // ========================================== + async sendPasswordResetEmail(email: string, resetUrl: string): Promise { + const html = getPasswordResetTemplate({ resetUrl }); + const text = `Reset your password by clicking this link: ${resetUrl}. This link will expire in 1 hour.`; + + return this.sendEmail({ + to: email, + subject: 'Reset Your Password - RE-QuestN', + html, + text, + }); + } + + // ========================================== + // WELCOME EMAIL + // ========================================== + async sendWelcomeEmail(email: string, name?: string): Promise { + const frontendUrl = this.configService.get('FRONTEND_URL') || 'http://localhost:3000'; + const html = getWelcomeEmailTemplate({ name, frontendUrl }); + const text = `Welcome to RE-QuestN, ${name || 'User'}! Your real estate journey starts now.`; + + return this.sendEmail({ + to: email, + subject: 'Welcome to RE-QuestN!', + html, + text, + }); + } +} diff --git a/src/email/index.ts b/src/email/index.ts new file mode 100644 index 0000000..50519b4 --- /dev/null +++ b/src/email/index.ts @@ -0,0 +1,3 @@ +export * from './email.module'; +export * from './email.service'; +export * from './email.listener'; diff --git a/src/email/templates/base.template.ts b/src/email/templates/base.template.ts new file mode 100644 index 0000000..dc936cc --- /dev/null +++ b/src/email/templates/base.template.ts @@ -0,0 +1,133 @@ +/** + * Base HTML email template with RE-QuestN branding + * All email templates use this as their foundation + */ +export function getBaseTemplate(content: string): string { + return ` + + + + + + RE-QuestN + + + + + + + `; +} diff --git a/src/email/templates/index.ts b/src/email/templates/index.ts new file mode 100644 index 0000000..e84eb9b --- /dev/null +++ b/src/email/templates/index.ts @@ -0,0 +1,7 @@ +// Email Templates +// Each template is in its own file for easy maintenance and review + +export * from './base.template'; +export * from './verification.template'; +export * from './password-reset.template'; +export * from './welcome.template'; diff --git a/src/email/templates/password-reset.template.ts b/src/email/templates/password-reset.template.ts new file mode 100644 index 0000000..338a4a3 --- /dev/null +++ b/src/email/templates/password-reset.template.ts @@ -0,0 +1,29 @@ +import { getBaseTemplate } from './base.template'; + +/** + * Password reset template + * Sent when a user requests a password reset + */ +export interface PasswordResetTemplateData { + resetUrl: string; +} + +export function getPasswordResetTemplate(data: PasswordResetTemplateData): string { + const { resetUrl } = data; + + const content = ` +

Reset Your Password

+

Hi,

+

We received a request to reset your password for your RE-QuestN account. Click the button below to create a new password:

+ +

Or copy and paste this link into your browser:

+

${resetUrl}

+
+ Important: This link will expire in 1 hour. If you didn't request a password reset, please ignore this email or contact support if you're concerned about your account security. +
+ `; + + return getBaseTemplate(content); +} diff --git a/src/email/templates/verification.template.ts b/src/email/templates/verification.template.ts new file mode 100644 index 0000000..0b7f1ca --- /dev/null +++ b/src/email/templates/verification.template.ts @@ -0,0 +1,30 @@ +import { getBaseTemplate } from './base.template'; + +/** + * Email verification template + * Sent when a new user registers + */ +export interface VerificationTemplateData { + verificationUrl: string; + name?: string; +} + +export function getVerificationEmailTemplate(data: VerificationTemplateData): string { + const { verificationUrl, name } = data; + + const content = ` +

Verify Your Email

+

Hi ${name || 'there'},

+

Thank you for signing up for RE-QuestN! To complete your registration and start your real estate journey, please verify your email address by clicking the button below:

+ +

Or copy and paste this link into your browser:

+

${verificationUrl}

+
+ Note: This verification link will expire in 24 hours. If you didn't create an account with RE-QuestN, please ignore this email. +
+ `; + + return getBaseTemplate(content); +} diff --git a/src/email/templates/welcome.template.ts b/src/email/templates/welcome.template.ts new file mode 100644 index 0000000..72544fb --- /dev/null +++ b/src/email/templates/welcome.template.ts @@ -0,0 +1,33 @@ +import { getBaseTemplate } from './base.template'; + +/** + * Welcome email template + * Sent after email verification is complete + */ +export interface WelcomeTemplateData { + name?: string; + frontendUrl?: string; +} + +export function getWelcomeEmailTemplate(data: WelcomeTemplateData): string { + const { name, frontendUrl } = data; + + const content = ` +

Welcome to RE-QuestN!

+

Hi ${name || 'there'},

+

We're thrilled to have you join RE-QuestN - your trusted real estate platform!

+

Here's what you can do:

+
    +
  • Browse thousands of property listings
  • +
  • Connect with top real estate agents
  • +
  • Save your favorite properties
  • +
  • Get personalized recommendations
  • +
+ +

If you have any questions, our support team is always here to help!

+ `; + + return getBaseTemplate(content); +} diff --git a/src/main.ts b/src/main.ts index 0b04cc5..92db57a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ import { NestFactory } from '@nestjs/core'; -import { ValidationPipe, Logger } from '@nestjs/common'; +import { ValidationPipe, Logger, BadRequestException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import helmet from 'helmet'; @@ -41,6 +41,21 @@ async function bootstrap() { transformOptions: { enableImplicitConversion: true, }, + exceptionFactory: (errors) => { + const messages = errors.map((error) => { + const constraints = error.constraints + ? Object.values(error.constraints) + : [`${error.property} is invalid`]; + return { + field: error.property, + errors: constraints, + }; + }); + return new BadRequestException({ + message: 'Validation failed', + errors: messages, + }); + }, }), );