Skip to content
Merged
35 changes: 35 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ enum DocumentType {
FLOOR_PLAN
}

// Verification document status
enum VerificationStatus {
PENDING
APPROVED
REJECTED
}

// User model
model User {
id String @id @default(uuid())
Expand All @@ -77,6 +84,9 @@ model User {
twoFactorSecret String? @map("two_factor_secret")
twoFactorBackupCodes String[] @default([]) @map("two_factor_backup_codes")
avatar String?
pendingEmail String? @map("pending_email")
emailVerificationToken String? @map("email_verification_token")
emailVerificationExpires DateTime? @map("email_verification_expires")
trustScore Int @default(0) @map("trust_score")
lastTrustScoreUpdate DateTime? @map("last_trust_score_update")
createdAt DateTime @default(now()) @map("created_at")
Expand All @@ -98,6 +108,7 @@ model User {
blacklistedTokens BlacklistedToken[]
preferences UserPreferences?
activityLogs ActivityLog[]
verificationDocuments VerificationDocument[]
sessions Session[]
passwordResetTokens PasswordResetToken[]
loginHistory LoginHistory[]
Expand Down Expand Up @@ -367,3 +378,27 @@ model Session {
@@index([isRevoked])
@@map("sessions")
}

// Verification Document model
model VerificationDocument {
id String @id @default(uuid())
userId String @map("user_id")
documentType String @map("document_type") // ID_CARD, PASSPORT, DRIVER_LICENSE, PROOF_OF_ADDRESS, etc.
fileName String @map("file_name")
fileUrl String @map("file_url")
fileSize Int @map("file_size") // in bytes
mimeType String @map("mime_type")
status VerificationStatus @default(PENDING)
adminNotes String? @map("admin_notes")
reviewedBy String? @map("reviewed_by")
reviewedAt DateTime? @map("reviewed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@index([userId])
@@index([status])
@@index([documentType])
@@map("verification_documents")
}
10 changes: 10 additions & 0 deletions src/users/dto/email-change.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { IsEmail } from 'class-validator';

export class ChangeEmailDto {
@IsEmail()
newEmail: string;
}

export class VerifyEmailDto {
token: string;
}
42 changes: 42 additions & 0 deletions src/users/dto/verification-document.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
import { VerificationStatus } from '@prisma/client';

export class CreateVerificationDocumentDto {
@IsString()
documentType: string;

@IsString()
fileName: string;

@IsString()
fileUrl: string;

@IsString()
fileSize: string;

@IsString()
mimeType: string;
}

export class ReviewVerificationDocumentDto {
@IsEnum(VerificationStatus)
status: VerificationStatus;

@IsOptional()
@IsString()
adminNotes?: string;
}

export class UpdateVerificationDocumentDto {
@IsOptional()
@IsString()
documentType?: string;

@IsOptional()
@IsString()
fileName?: string;

@IsOptional()
@IsString()
fileUrl?: string;
}
26 changes: 26 additions & 0 deletions src/users/email-verification.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
import { EmailVerificationService } from './email-verification.service';
import { ChangeEmailDto, VerifyEmailDto } from './dto/email-change.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';

@UseGuards(JwtAuthGuard)
@Controller('users/email')
export class EmailVerificationController {
constructor(private readonly emailVerificationService: EmailVerificationService) {}

@Post('change')
requestEmailChange(@CurrentUser() user: any, @Body() changeEmailDto: ChangeEmailDto) {
return this.emailVerificationService.requestEmailChange(user.id, changeEmailDto);
}

@Post('verify')
verifyEmailChange(@CurrentUser() user: any, @Body() verifyEmailDto: VerifyEmailDto) {
return this.emailVerificationService.verifyEmailChange(user.id, verifyEmailDto.token);
}

@Post('cancel-change')
cancelEmailChange(@CurrentUser() user: any) {
return this.emailVerificationService.cancelEmailChange(user.id);
}
}
138 changes: 138 additions & 0 deletions src/users/email-verification.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../database/prisma.service';
import { ChangeEmailDto } from './dto/email-change.dto';
import { randomBytes } from 'crypto';

@Injectable()
export class EmailVerificationService {
constructor(private prisma: PrismaService) {}

async requestEmailChange(userId: string, data: ChangeEmailDto) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
});

if (!user) {
throw new NotFoundException('User not found');
}

// Check if new email is already in use
const existingUser = await this.prisma.user.findUnique({
where: { email: data.newEmail },
});

if (existingUser) {
throw new BadRequestException('Email is already in use');
}

// Generate verification token
const token = randomBytes(32).toString('hex');
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24); // Token expires in 24 hours

// Store pending email and token
await this.prisma.user.update({
where: { id: userId },
data: {
pendingEmail: data.newEmail,
emailVerificationToken: token,
emailVerificationExpires: expiresAt,
},
});

// TODO: Send verification email with token
// In production, integrate with email service (SendGrid, AWS SES, etc.)
console.log(`Verification token for ${data.newEmail}: ${token}`);

return {
message: 'Verification email sent. Please check your new email to verify the change.',
pendingEmail: data.newEmail,
};
}

async verifyEmailChange(userId: string, token: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
});

if (!user) {
throw new NotFoundException('User not found');
}

if (!user.emailVerificationToken || !user.emailVerificationExpires) {
throw new BadRequestException('No pending email change');
}

// Check if token is expired
if (new Date() > user.emailVerificationExpires) {
// Clear expired token
await this.prisma.user.update({
where: { id: userId },
data: {
pendingEmail: null,
emailVerificationToken: null,
emailVerificationExpires: null,
},
});
throw new BadRequestException('Verification token has expired');
}

// Verify token
if (user.emailVerificationToken !== token) {
throw new BadRequestException('Invalid verification token');
}

if (!user.pendingEmail) {
throw new BadRequestException('No pending email to verify');
}

// Update email and clear verification fields
const updatedUser = await this.prisma.user.update({
where: { id: userId },
data: {
email: user.pendingEmail,
pendingEmail: null,
emailVerificationToken: null,
emailVerificationExpires: null,
},
select: {
id: true,
email: true,
firstName: true,
lastName: true,
updatedAt: true,
},
});

return {
message: 'Email changed successfully',
user: updatedUser,
};
}

async cancelEmailChange(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
});

if (!user) {
throw new NotFoundException('User not found');
}

if (!user.pendingEmail) {
throw new BadRequestException('No pending email change');
}

// Clear pending email and token
await this.prisma.user.update({
where: { id: userId },
data: {
pendingEmail: null,
emailVerificationToken: null,
emailVerificationExpires: null,
},
});

return { message: 'Email change cancelled successfully' };
}
}
78 changes: 78 additions & 0 deletions src/users/verification-documents.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Controller, Get, Post, Body, Param, Put, Delete, UseGuards, Query } from '@nestjs/common';
import { VerificationDocumentsService } from './verification-documents.service';
import {
CreateVerificationDocumentDto,
ReviewVerificationDocumentDto,
UpdateVerificationDocumentDto,
} from './dto/verification-document.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';

@UseGuards(JwtAuthGuard)
@Controller('users/verification-documents')
export class VerificationDocumentsController {
constructor(private readonly verificationService: VerificationDocumentsService) {}

@Post()
createDocument(@CurrentUser() user: any, @Body() createDto: CreateVerificationDocumentDto) {
return this.verificationService.create(user.id, createDto);
}

@Get()
getAllDocuments(@CurrentUser() user: any) {
return this.verificationService.findAllByUserId(user.id);
}

@Get(':id')
getDocument(@CurrentUser() user: any, @Param('id') id: string) {
return this.verificationService.findOne(id, user.id);
}

@Put(':id')
updateDocument(
@CurrentUser() user: any,
@Param('id') id: string,
@Body() updateDto: UpdateVerificationDocumentDto,
) {
return this.verificationService.update(id, user.id, updateDto);
}

@Delete(':id')
removeDocument(@CurrentUser() user: any, @Param('id') id: string) {
return this.verificationService.remove(id, user.id);
}
}

// Admin controller for reviewing verification documents
@UseGuards(JwtAuthGuard)
@Controller('admin/verification-documents')
export class AdminVerificationDocumentsController {
constructor(private readonly verificationService: VerificationDocumentsService) {}

@Get()
getAllDocumentsForAdmin(
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('status') status?: string,
) {
return this.verificationService.findAllForAdmin(
page ? parseInt(page) : 1,
limit ? parseInt(limit) : 20,
status,
);
}

@Get(':id')
getDocumentForAdmin(@Param('id') id: string) {
return this.verificationService.findOne(id, '');
}

@Put(':id/review')
reviewDocument(
@CurrentUser() user: any,
@Param('id') id: string,
@Body() reviewDto: ReviewVerificationDocumentDto,
) {
return this.verificationService.review(id, user.id, reviewDto);
}
}
Loading
Loading