diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2532927b..3ff33773 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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()) @@ -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") @@ -98,6 +108,7 @@ model User { blacklistedTokens BlacklistedToken[] preferences UserPreferences? activityLogs ActivityLog[] + verificationDocuments VerificationDocument[] sessions Session[] passwordResetTokens PasswordResetToken[] loginHistory LoginHistory[] @@ -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") +} diff --git a/src/users/dto/email-change.dto.ts b/src/users/dto/email-change.dto.ts new file mode 100644 index 00000000..2a6480c1 --- /dev/null +++ b/src/users/dto/email-change.dto.ts @@ -0,0 +1,10 @@ +import { IsEmail } from 'class-validator'; + +export class ChangeEmailDto { + @IsEmail() + newEmail: string; +} + +export class VerifyEmailDto { + token: string; +} diff --git a/src/users/dto/verification-document.dto.ts b/src/users/dto/verification-document.dto.ts new file mode 100644 index 00000000..19632912 --- /dev/null +++ b/src/users/dto/verification-document.dto.ts @@ -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; +} diff --git a/src/users/email-verification.controller.ts b/src/users/email-verification.controller.ts new file mode 100644 index 00000000..00bdd043 --- /dev/null +++ b/src/users/email-verification.controller.ts @@ -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); + } +} diff --git a/src/users/email-verification.service.ts b/src/users/email-verification.service.ts new file mode 100644 index 00000000..b970f5b9 --- /dev/null +++ b/src/users/email-verification.service.ts @@ -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' }; + } +} diff --git a/src/users/verification-documents.controller.ts b/src/users/verification-documents.controller.ts new file mode 100644 index 00000000..bd79a5f3 --- /dev/null +++ b/src/users/verification-documents.controller.ts @@ -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); + } +} diff --git a/src/users/verification-documents.service.ts b/src/users/verification-documents.service.ts new file mode 100644 index 00000000..745d4458 --- /dev/null +++ b/src/users/verification-documents.service.ts @@ -0,0 +1,132 @@ +import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { PrismaService } from '../database/prisma.service'; +import { + CreateVerificationDocumentDto, + ReviewVerificationDocumentDto, + UpdateVerificationDocumentDto, +} from './dto/verification-document.dto'; +import { VerificationStatus } from '@prisma/client'; + +@Injectable() +export class VerificationDocumentsService { + constructor(private prisma: PrismaService) {} + + async create(userId: string, data: CreateVerificationDocumentDto) { + return this.prisma.verificationDocument.create({ + data: { + userId, + documentType: data.documentType, + fileName: data.fileName, + fileUrl: data.fileUrl, + fileSize: parseInt(data.fileSize), + mimeType: data.mimeType, + status: 'PENDING', + }, + }); + } + + async findAllByUserId(userId: string) { + return this.prisma.verificationDocument.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + }); + } + + async findOne(id: string, userId: string) { + const document = await this.prisma.verificationDocument.findUnique({ + where: { id }, + }); + + if (!document) { + throw new NotFoundException('Verification document not found'); + } + + if (document.userId !== userId) { + throw new ForbiddenException('You do not have access to this document'); + } + + return document; + } + + async update(id: string, userId: string, data: UpdateVerificationDocumentDto) { + const document = await this.findOne(id, userId); + + // Only allow updates if document is still pending + if (document.status !== 'PENDING') { + throw new ForbiddenException('Cannot update a document that has been reviewed'); + } + + return this.prisma.verificationDocument.update({ + where: { id }, + data, + }); + } + + async review(id: string, adminId: string, data: ReviewVerificationDocumentDto) { + const document = await this.prisma.verificationDocument.findUnique({ + where: { id }, + }); + + if (!document) { + throw new NotFoundException('Verification document not found'); + } + + return this.prisma.verificationDocument.update({ + where: { id }, + data: { + status: data.status, + adminNotes: data.adminNotes, + reviewedBy: adminId, + reviewedAt: new Date(), + }, + }); + } + + async remove(id: string, userId: string) { + const document = await this.findOne(id, userId); + + // Only allow deletion if document is still pending + if (document.status !== 'PENDING') { + throw new ForbiddenException('Cannot delete a document that has been reviewed'); + } + + return this.prisma.verificationDocument.delete({ + where: { id }, + }); + } + + // Admin methods + async findAllForAdmin(page = 1, limit = 20, status?: string) { + const skip = (page - 1) * limit; + + const where: any = status ? { status: status as VerificationStatus } : {}; + + const [documents, total] = await Promise.all([ + this.prisma.verificationDocument.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: 'desc' }, + include: { + user: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + }, + }, + }, + }), + this.prisma.verificationDocument.count({ where }), + ]); + + return { + documents, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } +}