agent and category Dto creation
This commit is contained in:
1405
package-lock.json
generated
1405
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -27,9 +27,9 @@
|
|||||||
"db:reset": "npx prisma migrate reset"
|
"db:reset": "npx prisma migrate reset"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.954.0",
|
"@aws-sdk/client-s3": "^3.962.0",
|
||||||
"@aws-sdk/lib-storage": "^3.954.0",
|
"@aws-sdk/lib-storage": "^3.954.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.954.0",
|
"@aws-sdk/s3-request-presigner": "^3.962.0",
|
||||||
"@keyv/redis": "^5.1.5",
|
"@keyv/redis": "^5.1.5",
|
||||||
"@nestjs-modules/mailer": "^2.0.2",
|
"@nestjs-modules/mailer": "^2.0.2",
|
||||||
"@nestjs/axios": "^4.0.1",
|
"@nestjs/axios": "^4.0.1",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Real Estate Agent Platform - Database Schema
|
// Real Estate Agent Platform - Database Schema
|
||||||
// Milestone 1: Foundation & Authentication
|
// Milestone 1 & 2: Foundation, Authentication & Agent Features
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
@@ -33,6 +33,19 @@ enum AuthProvider {
|
|||||||
TWITTER
|
TWITTER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum VerificationStatus {
|
||||||
|
PENDING
|
||||||
|
APPROVED
|
||||||
|
REJECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VerificationDocType {
|
||||||
|
LICENSE
|
||||||
|
ID_CARD
|
||||||
|
CERTIFICATE
|
||||||
|
OTHER
|
||||||
|
}
|
||||||
|
|
||||||
// ===========================================
|
// ===========================================
|
||||||
// USER - Authentication Only
|
// USER - Authentication Only
|
||||||
// ===========================================
|
// ===========================================
|
||||||
@@ -45,6 +58,7 @@ model User {
|
|||||||
status UserStatus @default(ACTIVE)
|
status UserStatus @default(ACTIVE)
|
||||||
emailVerified Boolean @default(false)
|
emailVerified Boolean @default(false)
|
||||||
emailVerifiedAt DateTime?
|
emailVerifiedAt DateTime?
|
||||||
|
avatar String? // Profile picture URL
|
||||||
|
|
||||||
// Social Auth
|
// Social Auth
|
||||||
authProvider AuthProvider @default(LOCAL)
|
authProvider AuthProvider @default(LOCAL)
|
||||||
@@ -112,7 +126,7 @@ model AgentProfile {
|
|||||||
lastName String?
|
lastName String?
|
||||||
phone String?
|
phone String?
|
||||||
avatar String?
|
avatar String?
|
||||||
bio String?
|
bio String? @db.Text
|
||||||
headline String?
|
headline String?
|
||||||
|
|
||||||
// Location
|
// Location
|
||||||
@@ -120,15 +134,35 @@ model AgentProfile {
|
|||||||
state String?
|
state String?
|
||||||
country String?
|
country String?
|
||||||
address String?
|
address String?
|
||||||
|
zipCode String?
|
||||||
|
latitude Float?
|
||||||
|
longitude Float?
|
||||||
|
|
||||||
// Professional Info
|
// Professional Info
|
||||||
yearsOfExperience Int?
|
yearsOfExperience Int?
|
||||||
licenseNumber String?
|
licenseNumber String?
|
||||||
companyName String?
|
companyName String?
|
||||||
|
website String?
|
||||||
|
|
||||||
|
// Social Links
|
||||||
|
facebookUrl String?
|
||||||
|
twitterUrl String?
|
||||||
|
linkedinUrl String?
|
||||||
|
instagramUrl String?
|
||||||
|
|
||||||
|
// Verification Status
|
||||||
|
isVerified Boolean @default(false)
|
||||||
|
verifiedAt DateTime?
|
||||||
|
|
||||||
// Profile Status
|
// Profile Status
|
||||||
isProfileComplete Boolean @default(false)
|
isProfileComplete Boolean @default(false)
|
||||||
profileCompleteness Int @default(0)
|
profileCompleteness Int @default(0)
|
||||||
|
isPublic Boolean @default(true)
|
||||||
|
isFeatured Boolean @default(false)
|
||||||
|
|
||||||
|
// Stats (denormalized for performance)
|
||||||
|
totalReviews Int @default(0)
|
||||||
|
averageRating Float @default(0)
|
||||||
|
|
||||||
// Timestamps
|
// Timestamps
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -136,13 +170,126 @@ model AgentProfile {
|
|||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
specializations AgentSpecialization[]
|
||||||
|
verificationRequests VerificationRequest[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([slug])
|
@@index([slug])
|
||||||
@@index([city, state])
|
@@index([city, state])
|
||||||
|
@@index([isVerified])
|
||||||
|
@@index([isPublic])
|
||||||
|
@@index([isFeatured])
|
||||||
@@map("agent_profiles")
|
@@map("agent_profiles")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// CATEGORY - Main categories for agents
|
||||||
|
// ===========================================
|
||||||
|
|
||||||
|
model Category {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String @unique
|
||||||
|
slug String @unique
|
||||||
|
description String?
|
||||||
|
icon String?
|
||||||
|
image String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
specializations Specialization[]
|
||||||
|
|
||||||
|
@@index([slug])
|
||||||
|
@@index([isActive])
|
||||||
|
@@map("categories")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// SPECIALIZATION - Sub-categories under Category
|
||||||
|
// ===========================================
|
||||||
|
|
||||||
|
model Specialization {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
categoryId String
|
||||||
|
name String
|
||||||
|
slug String @unique
|
||||||
|
description String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
||||||
|
agents AgentSpecialization[]
|
||||||
|
|
||||||
|
@@unique([categoryId, name])
|
||||||
|
@@index([categoryId])
|
||||||
|
@@index([slug])
|
||||||
|
@@map("specializations")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// AGENT SPECIALIZATION - Many-to-Many pivot
|
||||||
|
// ===========================================
|
||||||
|
|
||||||
|
model AgentSpecialization {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
agentProfileId String
|
||||||
|
specializationId String
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
agentProfile AgentProfile @relation(fields: [agentProfileId], references: [id], onDelete: Cascade)
|
||||||
|
specialization Specialization @relation(fields: [specializationId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([agentProfileId, specializationId])
|
||||||
|
@@index([agentProfileId])
|
||||||
|
@@index([specializationId])
|
||||||
|
@@map("agent_specializations")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// VERIFICATION REQUEST - Agent verification
|
||||||
|
// ===========================================
|
||||||
|
|
||||||
|
model VerificationRequest {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
agentProfileId String
|
||||||
|
|
||||||
|
// Document Info
|
||||||
|
documentType VerificationDocType
|
||||||
|
documentUrl String
|
||||||
|
documentName String?
|
||||||
|
notes String? @db.Text
|
||||||
|
|
||||||
|
// Status
|
||||||
|
status VerificationStatus @default(PENDING)
|
||||||
|
reviewedAt DateTime?
|
||||||
|
reviewedBy String? // Admin user ID
|
||||||
|
adminNotes String? @db.Text
|
||||||
|
rejectionReason String?
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
agentProfile AgentProfile @relation(fields: [agentProfileId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([agentProfileId])
|
||||||
|
@@index([status])
|
||||||
|
@@map("verification_requests")
|
||||||
|
}
|
||||||
|
|
||||||
// ===========================================
|
// ===========================================
|
||||||
// SESSION - JWT Token Management
|
// SESSION - JWT Token Management
|
||||||
// ===========================================
|
// ===========================================
|
||||||
|
|||||||
176
src/agents/agents.controller.ts
Normal file
176
src/agents/agents.controller.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { AgentsService } from './agents.service';
|
||||||
|
import {
|
||||||
|
CreateAgentProfileDto,
|
||||||
|
UpdateAgentProfileDto,
|
||||||
|
SearchAgentsDto,
|
||||||
|
} from './dto';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
|
||||||
|
@ApiTags('agents')
|
||||||
|
@Controller('agents')
|
||||||
|
export class AgentsController {
|
||||||
|
constructor(private readonly agentsService: AgentsService) {}
|
||||||
|
|
||||||
|
// Public endpoints
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Search and list agents' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Agents retrieved successfully' })
|
||||||
|
async searchAgents(@Query() dto: SearchAgentsDto) {
|
||||||
|
return this.agentsService.searchAgents(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('featured')
|
||||||
|
@ApiOperation({ summary: 'Get featured agents' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Featured agents retrieved successfully',
|
||||||
|
})
|
||||||
|
async getFeaturedAgents(@Query('limit') limit?: number) {
|
||||||
|
return this.agentsService.getFeaturedAgents(limit || 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('top-rated')
|
||||||
|
@ApiOperation({ summary: 'Get top rated agents' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Top rated agents retrieved successfully',
|
||||||
|
})
|
||||||
|
async getTopRatedAgents(@Query('limit') limit?: number) {
|
||||||
|
return this.agentsService.getTopRatedAgents(limit || 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticated endpoints - must come before :id route
|
||||||
|
|
||||||
|
@Get('profile/me')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Get current user agent profile' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Profile retrieved successfully' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Profile not found' })
|
||||||
|
async getMyProfile(@CurrentUser('id') userId: string) {
|
||||||
|
return this.agentsService.getProfile(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parameterized route - must come after specific routes
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get agent profile by ID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Agent profile retrieved successfully' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Agent profile not found' })
|
||||||
|
async getAgentById(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.agentsService.getProfileById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('profile')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Create agent profile' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Profile created successfully' })
|
||||||
|
@ApiResponse({ status: 409, description: 'Profile already exists' })
|
||||||
|
async createProfile(
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
@Body() dto: CreateAgentProfileDto,
|
||||||
|
) {
|
||||||
|
return this.agentsService.createProfile(userId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('profile')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Update current user agent profile' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Profile updated successfully' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Profile not found' })
|
||||||
|
async updateProfile(
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
@Body() dto: UpdateAgentProfileDto,
|
||||||
|
) {
|
||||||
|
return this.agentsService.updateProfile(userId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin endpoints
|
||||||
|
|
||||||
|
@Get('admin/list')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Admin: List all agents' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Agents retrieved successfully' })
|
||||||
|
async adminListAgents(@Query() dto: SearchAgentsDto) {
|
||||||
|
return this.agentsService.getAllAgentsAdmin(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('admin/:id')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Admin: Update agent profile' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Profile updated successfully' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Profile not found' })
|
||||||
|
async adminUpdateProfile(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: UpdateAgentProfileDto,
|
||||||
|
) {
|
||||||
|
return this.agentsService.adminUpdateProfile(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('admin/:id')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Admin: Delete agent profile' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Profile deleted successfully' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Profile not found' })
|
||||||
|
async adminDeleteProfile(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.agentsService.adminDeleteProfile(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('admin/:id/verify')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Admin: Verify/unverify agent' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Verification status updated' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Profile not found' })
|
||||||
|
async adminVerifyAgent(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body('isVerified') isVerified: boolean,
|
||||||
|
) {
|
||||||
|
return this.agentsService.adminVerifyAgent(id, isVerified);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('admin/:id/featured')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Admin: Set agent as featured' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Featured status updated' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Profile not found' })
|
||||||
|
async adminSetFeatured(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body('isFeatured') isFeatured: boolean,
|
||||||
|
) {
|
||||||
|
return this.agentsService.adminSetFeatured(id, isFeatured);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/agents/agents.module.ts
Normal file
12
src/agents/agents.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AgentsController } from './agents.controller';
|
||||||
|
import { AgentsService } from './agents.service';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
controllers: [AgentsController],
|
||||||
|
providers: [AgentsService],
|
||||||
|
exports: [AgentsService],
|
||||||
|
})
|
||||||
|
export class AgentsModule {}
|
||||||
509
src/agents/agents.service.ts
Normal file
509
src/agents/agents.service.ts
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
ForbiddenException,
|
||||||
|
ConflictException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
CreateAgentProfileDto,
|
||||||
|
UpdateAgentProfileDto,
|
||||||
|
SearchAgentsDto,
|
||||||
|
} from './dto';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
function generateSlug(firstName: string, lastName: string): string {
|
||||||
|
const base = `${firstName}-${lastName}`
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
const uniqueSuffix = Math.random().toString(36).substring(2, 8);
|
||||||
|
return `${base}-${uniqueSuffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AgentsService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async createProfile(userId: string, dto: CreateAgentProfileDto) {
|
||||||
|
// Check if user already has an agent profile
|
||||||
|
const existingProfile = await this.prisma.agentProfile.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingProfile) {
|
||||||
|
throw new ConflictException('User already has an agent profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { specializationIds, ...profileData } = dto;
|
||||||
|
const slug = generateSlug(dto.firstName, dto.lastName);
|
||||||
|
|
||||||
|
const profile = await this.prisma.agentProfile.create({
|
||||||
|
data: {
|
||||||
|
...profileData,
|
||||||
|
userId,
|
||||||
|
slug,
|
||||||
|
specializations: specializationIds?.length
|
||||||
|
? {
|
||||||
|
create: specializationIds.map((specId) => ({
|
||||||
|
specializationId: specId,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
specializations: {
|
||||||
|
include: {
|
||||||
|
specialization: {
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProfile(userId: string) {
|
||||||
|
const profile = await this.prisma.agentProfile.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
specializations: {
|
||||||
|
include: {
|
||||||
|
specialization: {
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
verificationRequests: {
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
throw new NotFoundException('Agent profile not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProfileById(profileId: string) {
|
||||||
|
const profile = await this.prisma.agentProfile.findUnique({
|
||||||
|
where: { id: profileId },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
specializations: {
|
||||||
|
include: {
|
||||||
|
specialization: {
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
throw new NotFoundException('Agent profile not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProfile(userId: string, dto: UpdateAgentProfileDto) {
|
||||||
|
const profile = await this.prisma.agentProfile.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
throw new NotFoundException('Agent profile not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { specializationIds, ...profileData } = dto;
|
||||||
|
|
||||||
|
// If specializationIds is provided, update the specializations
|
||||||
|
if (specializationIds !== undefined) {
|
||||||
|
// Delete existing specializations
|
||||||
|
await this.prisma.agentSpecialization.deleteMany({
|
||||||
|
where: { agentProfileId: profile.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create new specializations
|
||||||
|
if (specializationIds.length > 0) {
|
||||||
|
await this.prisma.agentSpecialization.createMany({
|
||||||
|
data: specializationIds.map((specId) => ({
|
||||||
|
agentProfileId: profile.id,
|
||||||
|
specializationId: specId,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedProfile = await this.prisma.agentProfile.update({
|
||||||
|
where: { userId },
|
||||||
|
data: profileData,
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
specializations: {
|
||||||
|
include: {
|
||||||
|
specialization: {
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchAgents(dto: SearchAgentsDto) {
|
||||||
|
const {
|
||||||
|
search,
|
||||||
|
city,
|
||||||
|
state,
|
||||||
|
country,
|
||||||
|
categoryId,
|
||||||
|
specializationIds,
|
||||||
|
isVerified,
|
||||||
|
page = 1,
|
||||||
|
limit = 10,
|
||||||
|
sortBy = 'createdAt',
|
||||||
|
sortOrder = 'desc',
|
||||||
|
} = dto;
|
||||||
|
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
// Build where clause
|
||||||
|
const where: Prisma.AgentProfileWhereInput = {};
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ firstName: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ lastName: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ bio: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ headline: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ companyName: { contains: search, mode: 'insensitive' } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (city) {
|
||||||
|
where.city = { contains: city, mode: 'insensitive' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state) {
|
||||||
|
where.state = { contains: state, mode: 'insensitive' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (country) {
|
||||||
|
where.country = { contains: country, mode: 'insensitive' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVerified !== undefined) {
|
||||||
|
where.isVerified = isVerified;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by category
|
||||||
|
if (categoryId) {
|
||||||
|
where.specializations = {
|
||||||
|
some: {
|
||||||
|
specialization: {
|
||||||
|
categoryId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by specializations
|
||||||
|
if (specializationIds?.length) {
|
||||||
|
where.specializations = {
|
||||||
|
some: {
|
||||||
|
specializationId: { in: specializationIds },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build orderBy
|
||||||
|
const orderBy: Prisma.AgentProfileOrderByWithRelationInput = {};
|
||||||
|
if (sortBy === 'averageRating') {
|
||||||
|
orderBy.averageRating = sortOrder;
|
||||||
|
} else if (sortBy === 'totalReviews') {
|
||||||
|
orderBy.totalReviews = sortOrder;
|
||||||
|
} else if (sortBy === 'firstName') {
|
||||||
|
orderBy.firstName = sortOrder;
|
||||||
|
} else {
|
||||||
|
orderBy.createdAt = sortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [agents, total] = await Promise.all([
|
||||||
|
this.prisma.agentProfile.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
orderBy,
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
specializations: {
|
||||||
|
include: {
|
||||||
|
specialization: {
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.agentProfile.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: agents,
|
||||||
|
meta: {
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFeaturedAgents(limit = 10) {
|
||||||
|
const agents = await this.prisma.agentProfile.findMany({
|
||||||
|
where: {
|
||||||
|
isVerified: true,
|
||||||
|
isFeatured: true,
|
||||||
|
},
|
||||||
|
take: limit,
|
||||||
|
orderBy: [{ averageRating: 'desc' }, { totalReviews: 'desc' }],
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
specializations: {
|
||||||
|
include: {
|
||||||
|
specialization: {
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return agents;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTopRatedAgents(limit = 10) {
|
||||||
|
const agents = await this.prisma.agentProfile.findMany({
|
||||||
|
where: {
|
||||||
|
isVerified: true,
|
||||||
|
totalReviews: { gte: 5 },
|
||||||
|
},
|
||||||
|
take: limit,
|
||||||
|
orderBy: [{ averageRating: 'desc' }, { totalReviews: 'desc' }],
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
specializations: {
|
||||||
|
include: {
|
||||||
|
specialization: {
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return agents;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin methods
|
||||||
|
async adminUpdateProfile(profileId: string, dto: UpdateAgentProfileDto) {
|
||||||
|
const profile = await this.prisma.agentProfile.findUnique({
|
||||||
|
where: { id: profileId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
throw new NotFoundException('Agent profile not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { specializationIds, ...profileData } = dto;
|
||||||
|
|
||||||
|
if (specializationIds !== undefined) {
|
||||||
|
await this.prisma.agentSpecialization.deleteMany({
|
||||||
|
where: { agentProfileId: profileId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (specializationIds.length > 0) {
|
||||||
|
await this.prisma.agentSpecialization.createMany({
|
||||||
|
data: specializationIds.map((specId) => ({
|
||||||
|
agentProfileId: profileId,
|
||||||
|
specializationId: specId,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedProfile = await this.prisma.agentProfile.update({
|
||||||
|
where: { id: profileId },
|
||||||
|
data: profileData,
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
specializations: {
|
||||||
|
include: {
|
||||||
|
specialization: {
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
async adminDeleteProfile(profileId: string) {
|
||||||
|
const profile = await this.prisma.agentProfile.findUnique({
|
||||||
|
where: { id: profileId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
throw new NotFoundException('Agent profile not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete related records first
|
||||||
|
await this.prisma.agentSpecialization.deleteMany({
|
||||||
|
where: { agentProfileId: profileId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.prisma.verificationRequest.deleteMany({
|
||||||
|
where: { agentProfileId: profileId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.prisma.agentProfile.delete({
|
||||||
|
where: { id: profileId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { message: 'Agent profile deleted successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async adminVerifyAgent(profileId: string, isVerified: boolean) {
|
||||||
|
const profile = await this.prisma.agentProfile.findUnique({
|
||||||
|
where: { id: profileId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
throw new NotFoundException('Agent profile not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedProfile = await this.prisma.agentProfile.update({
|
||||||
|
where: { id: profileId },
|
||||||
|
data: { isVerified },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
async adminSetFeatured(profileId: string, isFeatured: boolean) {
|
||||||
|
const profile = await this.prisma.agentProfile.findUnique({
|
||||||
|
where: { id: profileId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
throw new NotFoundException('Agent profile not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedProfile = await this.prisma.agentProfile.update({
|
||||||
|
where: { id: profileId },
|
||||||
|
data: { isFeatured },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllAgentsAdmin(dto: SearchAgentsDto) {
|
||||||
|
// Same as searchAgents but returns all data for admin
|
||||||
|
return this.searchAgents(dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
128
src/agents/dto/create-agent-profile.dto.ts
Normal file
128
src/agents/dto/create-agent-profile.dto.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsArray,
|
||||||
|
IsInt,
|
||||||
|
IsUrl,
|
||||||
|
IsUUID,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateAgentProfileDto {
|
||||||
|
@ApiProperty({ example: 'John' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(100)
|
||||||
|
firstName: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Doe' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(100)
|
||||||
|
lastName: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '+1234567890' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Experienced real estate agent...' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
bio?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Top Real Estate Agent in California' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
headline?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Los Angeles' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
city?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'California' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
state?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'USA' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
country?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '123 Main St' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
address?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '90001' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
zipCode?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 10 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(100)
|
||||||
|
yearsOfExperience?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'LIC-12345' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
licenseNumber?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'ABC Realty' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
companyName?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'https://mywebsite.com' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl()
|
||||||
|
website?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'https://facebook.com/johndoe' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl()
|
||||||
|
facebookUrl?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'https://twitter.com/johndoe' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl()
|
||||||
|
twitterUrl?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'https://linkedin.com/in/johndoe' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl()
|
||||||
|
linkedinUrl?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'https://instagram.com/johndoe' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl()
|
||||||
|
instagramUrl?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: ['uuid-1', 'uuid-2'],
|
||||||
|
description: 'Array of specialization IDs',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
specializationIds?: string[];
|
||||||
|
}
|
||||||
3
src/agents/dto/index.ts
Normal file
3
src/agents/dto/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './create-agent-profile.dto';
|
||||||
|
export * from './update-agent-profile.dto';
|
||||||
|
export * from './search-agents.dto';
|
||||||
83
src/agents/dto/search-agents.dto.ts
Normal file
83
src/agents/dto/search-agents.dto.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsBoolean,
|
||||||
|
IsInt,
|
||||||
|
IsUUID,
|
||||||
|
IsArray,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class SearchAgentsDto {
|
||||||
|
@ApiPropertyOptional({ example: 'John' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Los Angeles' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
city?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'California' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
state?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'USA' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
country?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'uuid-of-category' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: ['uuid-1', 'uuid-2'],
|
||||||
|
description: 'Filter by specialization IDs',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? [value] : value))
|
||||||
|
specializationIds?: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: true })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => value === 'true' || value === true)
|
||||||
|
@IsBoolean()
|
||||||
|
isVerified?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 1, default: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 10, default: 10 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
limit?: number = 10;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 'createdAt',
|
||||||
|
enum: ['createdAt', 'averageRating', 'totalReviews', 'firstName'],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sortBy?: string = 'createdAt';
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'desc', enum: ['asc', 'desc'] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sortOrder?: 'asc' | 'desc' = 'desc';
|
||||||
|
}
|
||||||
4
src/agents/dto/update-agent-profile.dto.ts
Normal file
4
src/agents/dto/update-agent-profile.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateAgentProfileDto } from './create-agent-profile.dto';
|
||||||
|
|
||||||
|
export class UpdateAgentProfileDto extends PartialType(CreateAgentProfileDto) {}
|
||||||
4
src/agents/index.ts
Normal file
4
src/agents/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './agents.module';
|
||||||
|
export * from './agents.service';
|
||||||
|
export * from './agents.controller';
|
||||||
|
export * from './dto';
|
||||||
@@ -10,6 +10,10 @@ import { PrismaModule } from './prisma';
|
|||||||
import { AuthModule, JwtAuthGuard, RolesGuard } from './auth';
|
import { AuthModule, JwtAuthGuard, RolesGuard } from './auth';
|
||||||
import { UsersModule } from './users';
|
import { UsersModule } from './users';
|
||||||
import { EmailModule } from './email';
|
import { EmailModule } from './email';
|
||||||
|
import { CategoriesModule } from './categories';
|
||||||
|
import { AgentsModule } from './agents';
|
||||||
|
import { VerificationModule } from './verification';
|
||||||
|
import { UploadModule } from './upload';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
@@ -43,6 +47,10 @@ import { AppService } from './app.service';
|
|||||||
AuthModule,
|
AuthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
EmailModule,
|
EmailModule,
|
||||||
|
CategoriesModule,
|
||||||
|
AgentsModule,
|
||||||
|
VerificationModule,
|
||||||
|
UploadModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
197
src/categories/categories.controller.ts
Normal file
197
src/categories/categories.controller.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiQuery,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { CategoriesService } from './categories.service';
|
||||||
|
import {
|
||||||
|
CreateCategoryDto,
|
||||||
|
UpdateCategoryDto,
|
||||||
|
CreateSpecializationDto,
|
||||||
|
UpdateSpecializationDto,
|
||||||
|
} from './dto';
|
||||||
|
import { JwtAuthGuard, RolesGuard } from '../auth/guards';
|
||||||
|
import { Public, Roles } from '../auth/decorators';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
|
||||||
|
@ApiTags('Categories')
|
||||||
|
@Controller('categories')
|
||||||
|
export class CategoriesController {
|
||||||
|
constructor(private readonly categoriesService: CategoriesService) {}
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// CATEGORY ENDPOINTS
|
||||||
|
// ===========================================
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Get all categories with specializations' })
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'includeInactive',
|
||||||
|
required: false,
|
||||||
|
type: Boolean,
|
||||||
|
description: 'Include inactive categories (admin only)',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'List of categories',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: true,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'uuid',
|
||||||
|
name: 'Real Estate',
|
||||||
|
slug: 'real-estate',
|
||||||
|
description: 'Real estate agents',
|
||||||
|
icon: 'home-icon',
|
||||||
|
isActive: true,
|
||||||
|
specializations: [
|
||||||
|
{
|
||||||
|
id: 'uuid',
|
||||||
|
name: 'Residential Sales',
|
||||||
|
slug: 'real-estate-residential-sales',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
async findAll(@Query('includeInactive') includeInactive?: string) {
|
||||||
|
return this.categoriesService.findAllCategories(
|
||||||
|
includeInactive === 'true',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get category by ID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Category details' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Category not found' })
|
||||||
|
async findOne(@Param('id') id: string) {
|
||||||
|
return this.categoriesService.findCategoryById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('slug/:slug')
|
||||||
|
@ApiOperation({ summary: 'Get category by slug' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Category details' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Category not found' })
|
||||||
|
async findBySlug(@Param('slug') slug: string) {
|
||||||
|
return this.categoriesService.findCategoryBySlug(slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiBearerAuth('JWT-auth')
|
||||||
|
@ApiOperation({ summary: 'Create a new category (Admin only)' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Category created' })
|
||||||
|
@ApiResponse({ status: 409, description: 'Category already exists' })
|
||||||
|
async create(@Body() dto: CreateCategoryDto) {
|
||||||
|
return this.categoriesService.createCategory(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiBearerAuth('JWT-auth')
|
||||||
|
@ApiOperation({ summary: 'Update a category (Admin only)' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Category updated' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Category not found' })
|
||||||
|
async update(@Param('id') id: string, @Body() dto: UpdateCategoryDto) {
|
||||||
|
return this.categoriesService.updateCategory(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiBearerAuth('JWT-auth')
|
||||||
|
@ApiOperation({ summary: 'Delete a category (Admin only)' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Category deleted' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Category not found' })
|
||||||
|
async remove(@Param('id') id: string) {
|
||||||
|
return this.categoriesService.deleteCategory(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// SPECIALIZATION ENDPOINTS
|
||||||
|
// ===========================================
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('specializations/all')
|
||||||
|
@ApiOperation({ summary: 'Get all specializations' })
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'categoryId',
|
||||||
|
required: false,
|
||||||
|
description: 'Filter by category ID',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: 'List of specializations' })
|
||||||
|
async findAllSpecializations(@Query('categoryId') categoryId?: string) {
|
||||||
|
return this.categoriesService.findAllSpecializations(categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('specializations/:id')
|
||||||
|
@ApiOperation({ summary: 'Get specialization by ID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Specialization details' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Specialization not found' })
|
||||||
|
async findSpecialization(@Param('id') id: string) {
|
||||||
|
return this.categoriesService.findSpecializationById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('specializations')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiBearerAuth('JWT-auth')
|
||||||
|
@ApiOperation({ summary: 'Create a new specialization (Admin only)' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Specialization created' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Category not found' })
|
||||||
|
@ApiResponse({ status: 409, description: 'Specialization already exists' })
|
||||||
|
async createSpecialization(@Body() dto: CreateSpecializationDto) {
|
||||||
|
return this.categoriesService.createSpecialization(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('specializations/:id')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiBearerAuth('JWT-auth')
|
||||||
|
@ApiOperation({ summary: 'Update a specialization (Admin only)' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Specialization updated' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Specialization not found' })
|
||||||
|
async updateSpecialization(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateSpecializationDto,
|
||||||
|
) {
|
||||||
|
return this.categoriesService.updateSpecialization(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('specializations/:id')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiBearerAuth('JWT-auth')
|
||||||
|
@ApiOperation({ summary: 'Delete a specialization (Admin only)' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Specialization deleted' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Specialization not found' })
|
||||||
|
async removeSpecialization(@Param('id') id: string) {
|
||||||
|
return this.categoriesService.deleteSpecialization(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/categories/categories.module.ts
Normal file
12
src/categories/categories.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CategoriesController } from './categories.controller';
|
||||||
|
import { CategoriesService } from './categories.service';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
controllers: [CategoriesController],
|
||||||
|
providers: [CategoriesService],
|
||||||
|
exports: [CategoriesService],
|
||||||
|
})
|
||||||
|
export class CategoriesModule {}
|
||||||
328
src/categories/categories.service.ts
Normal file
328
src/categories/categories.service.ts
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
ConflictException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
CreateCategoryDto,
|
||||||
|
UpdateCategoryDto,
|
||||||
|
CreateSpecializationDto,
|
||||||
|
UpdateSpecializationDto,
|
||||||
|
} from './dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CategoriesService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// CATEGORY METHODS
|
||||||
|
// ===========================================
|
||||||
|
|
||||||
|
private generateSlug(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/(^-|-$)/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCategory(dto: CreateCategoryDto) {
|
||||||
|
const slug = this.generateSlug(dto.name);
|
||||||
|
|
||||||
|
// Check if category with same name or slug exists
|
||||||
|
const existing = await this.prisma.category.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [{ name: dto.name }, { slug }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException('Category with this name already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = await this.prisma.category.create({
|
||||||
|
data: {
|
||||||
|
...dto,
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
specializations: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: category,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllCategories(includeInactive = false) {
|
||||||
|
const categories = await this.prisma.category.findMany({
|
||||||
|
where: includeInactive ? {} : { isActive: true },
|
||||||
|
include: {
|
||||||
|
specializations: {
|
||||||
|
where: includeInactive ? {} : { isActive: true },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: categories,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findCategoryById(id: string) {
|
||||||
|
const category = await this.prisma.category.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
specializations: {
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
throw new NotFoundException('Category not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: category,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findCategoryBySlug(slug: string) {
|
||||||
|
const category = await this.prisma.category.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
include: {
|
||||||
|
specializations: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
throw new NotFoundException('Category not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: category,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCategory(id: string, dto: UpdateCategoryDto) {
|
||||||
|
const category = await this.prisma.category.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
throw new NotFoundException('Category not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If name is being updated, generate new slug
|
||||||
|
let slug = category.slug;
|
||||||
|
if (dto.name && dto.name !== category.name) {
|
||||||
|
slug = this.generateSlug(dto.name);
|
||||||
|
|
||||||
|
// Check if new name/slug conflicts
|
||||||
|
const existing = await this.prisma.category.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [{ name: dto.name }, { slug }],
|
||||||
|
NOT: { id },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException('Category with this name already exists');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.prisma.category.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...dto,
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
specializations: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: updated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCategory(id: string) {
|
||||||
|
const category = await this.prisma.category.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
throw new NotFoundException('Category not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.category.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { message: 'Category deleted successfully' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// SPECIALIZATION METHODS
|
||||||
|
// ===========================================
|
||||||
|
|
||||||
|
async createSpecialization(dto: CreateSpecializationDto) {
|
||||||
|
// Check if category exists
|
||||||
|
const category = await this.prisma.category.findUnique({
|
||||||
|
where: { id: dto.categoryId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
throw new NotFoundException('Category not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = this.generateSlug(`${category.slug}-${dto.name}`);
|
||||||
|
|
||||||
|
// Check if specialization with same name exists in category
|
||||||
|
const existing = await this.prisma.specialization.findFirst({
|
||||||
|
where: {
|
||||||
|
categoryId: dto.categoryId,
|
||||||
|
name: dto.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Specialization with this name already exists in this category',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const specialization = await this.prisma.specialization.create({
|
||||||
|
data: {
|
||||||
|
...dto,
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: specialization,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllSpecializations(categoryId?: string) {
|
||||||
|
const specializations = await this.prisma.specialization.findMany({
|
||||||
|
where: categoryId ? { categoryId, isActive: true } : { isActive: true },
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
},
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: specializations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findSpecializationById(id: string) {
|
||||||
|
const specialization = await this.prisma.specialization.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!specialization) {
|
||||||
|
throw new NotFoundException('Specialization not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: specialization,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSpecialization(id: string, dto: UpdateSpecializationDto) {
|
||||||
|
const specialization = await this.prisma.specialization.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { category: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!specialization) {
|
||||||
|
throw new NotFoundException('Specialization not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If name is being updated, generate new slug
|
||||||
|
let slug = specialization.slug;
|
||||||
|
if (dto.name && dto.name !== specialization.name) {
|
||||||
|
slug = this.generateSlug(
|
||||||
|
`${specialization.category.slug}-${dto.name}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if new name conflicts
|
||||||
|
const existing = await this.prisma.specialization.findFirst({
|
||||||
|
where: {
|
||||||
|
categoryId: specialization.categoryId,
|
||||||
|
name: dto.name,
|
||||||
|
NOT: { id },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Specialization with this name already exists in this category',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.prisma.specialization.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...dto,
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: updated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSpecialization(id: string) {
|
||||||
|
const specialization = await this.prisma.specialization.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!specialization) {
|
||||||
|
throw new NotFoundException('Specialization not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.specialization.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { message: 'Specialization deleted successfully' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/categories/dto/create-category.dto.ts
Normal file
43
src/categories/dto/create-category.dto.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsBoolean,
|
||||||
|
IsInt,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateCategoryDto {
|
||||||
|
@ApiProperty({ example: 'Real Estate' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(100)
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Agents specializing in real estate' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'home-icon' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'https://example.com/image.jpg' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
image?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: true, default: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 0 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
38
src/categories/dto/create-specialization.dto.ts
Normal file
38
src/categories/dto/create-specialization.dto.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsBoolean,
|
||||||
|
IsInt,
|
||||||
|
IsUUID,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateSpecializationDto {
|
||||||
|
@ApiProperty({ example: 'uuid-of-category' })
|
||||||
|
@IsUUID()
|
||||||
|
categoryId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Residential Sales' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(100)
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Specializing in residential property sales' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: true, default: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 0 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
4
src/categories/dto/index.ts
Normal file
4
src/categories/dto/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './create-category.dto';
|
||||||
|
export * from './update-category.dto';
|
||||||
|
export * from './create-specialization.dto';
|
||||||
|
export * from './update-specialization.dto';
|
||||||
4
src/categories/dto/update-category.dto.ts
Normal file
4
src/categories/dto/update-category.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateCategoryDto } from './create-category.dto';
|
||||||
|
|
||||||
|
export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {}
|
||||||
6
src/categories/dto/update-specialization.dto.ts
Normal file
6
src/categories/dto/update-specialization.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { PartialType, OmitType } from '@nestjs/swagger';
|
||||||
|
import { CreateSpecializationDto } from './create-specialization.dto';
|
||||||
|
|
||||||
|
export class UpdateSpecializationDto extends PartialType(
|
||||||
|
OmitType(CreateSpecializationDto, ['categoryId'] as const),
|
||||||
|
) {}
|
||||||
4
src/categories/index.ts
Normal file
4
src/categories/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './categories.module';
|
||||||
|
export * from './categories.service';
|
||||||
|
export * from './categories.controller';
|
||||||
|
export * from './dto';
|
||||||
1
src/upload/dto/index.ts
Normal file
1
src/upload/dto/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './upload.dto';
|
||||||
53
src/upload/dto/upload.dto.ts
Normal file
53
src/upload/dto/upload.dto.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsString, IsEnum, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
|
export enum UploadFolder {
|
||||||
|
AVATARS = 'avatars',
|
||||||
|
DOCUMENTS = 'documents',
|
||||||
|
PROPERTIES = 'properties',
|
||||||
|
VERIFICATION = 'verification',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PresignedUrlDto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'document.pdf',
|
||||||
|
description: 'Original filename',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
filename: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'application/pdf',
|
||||||
|
description: 'MIME type of the file',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
contentType: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'documents',
|
||||||
|
enum: UploadFolder,
|
||||||
|
description: 'Folder to upload to',
|
||||||
|
})
|
||||||
|
@IsEnum(UploadFolder)
|
||||||
|
folder: UploadFolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PresignedUrlResponseDto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'https://s3.amazonaws.com/bucket/...',
|
||||||
|
description: 'Pre-signed URL for uploading',
|
||||||
|
})
|
||||||
|
uploadUrl: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'https://cdn.example.com/documents/abc123.pdf',
|
||||||
|
description: 'Public URL after upload completes',
|
||||||
|
})
|
||||||
|
publicUrl: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'documents/abc123.pdf',
|
||||||
|
description: 'File key in storage',
|
||||||
|
})
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
4
src/upload/index.ts
Normal file
4
src/upload/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './upload.module';
|
||||||
|
export * from './upload.service';
|
||||||
|
export * from './upload.controller';
|
||||||
|
export * from './dto';
|
||||||
47
src/upload/upload.controller.ts
Normal file
47
src/upload/upload.controller.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { UploadService } from './upload.service';
|
||||||
|
import { PresignedUrlDto, PresignedUrlResponseDto } from './dto';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
|
||||||
|
@ApiTags('upload')
|
||||||
|
@Controller('upload')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class UploadController {
|
||||||
|
constructor(private readonly uploadService: UploadService) {}
|
||||||
|
|
||||||
|
@Post('presigned-url')
|
||||||
|
@ApiOperation({ summary: 'Get a pre-signed URL for file upload' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Pre-signed URL generated successfully',
|
||||||
|
type: PresignedUrlResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: 'Invalid content type' })
|
||||||
|
async getPresignedUrl(
|
||||||
|
@Body() dto: PresignedUrlDto,
|
||||||
|
): Promise<PresignedUrlResponseDto> {
|
||||||
|
return this.uploadService.getPresignedUrl(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':key')
|
||||||
|
@ApiOperation({ summary: 'Delete an uploaded file' })
|
||||||
|
@ApiResponse({ status: 200, description: 'File deleted successfully' })
|
||||||
|
async deleteFile(@Param('key') key: string) {
|
||||||
|
await this.uploadService.deleteFile(key);
|
||||||
|
return { message: 'File deleted successfully' };
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/upload/upload.module.ts
Normal file
10
src/upload/upload.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { UploadController } from './upload.controller';
|
||||||
|
import { UploadService } from './upload.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [UploadController],
|
||||||
|
providers: [UploadService],
|
||||||
|
exports: [UploadService],
|
||||||
|
})
|
||||||
|
export class UploadModule {}
|
||||||
119
src/upload/upload.service.ts
Normal file
119
src/upload/upload.service.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import {
|
||||||
|
S3Client,
|
||||||
|
PutObjectCommand,
|
||||||
|
DeleteObjectCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
} from '@aws-sdk/client-s3';
|
||||||
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { PresignedUrlDto, PresignedUrlResponseDto, UploadFolder } from './dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UploadService {
|
||||||
|
private s3Client: S3Client;
|
||||||
|
private bucket: string;
|
||||||
|
private region: string;
|
||||||
|
|
||||||
|
constructor(private configService: ConfigService) {
|
||||||
|
this.region = this.configService.get<string>('AWS_REGION') || 'us-east-1';
|
||||||
|
this.bucket = this.configService.get<string>('AWS_S3_BUCKET') || '';
|
||||||
|
|
||||||
|
this.s3Client = new S3Client({
|
||||||
|
region: this.region,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: this.configService.get<string>('AWS_ACCESS_KEY_ID') || '',
|
||||||
|
secretAccessKey:
|
||||||
|
this.configService.get<string>('AWS_SECRET_ACCESS_KEY') || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFileExtension(filename: string): string {
|
||||||
|
const parts = filename.split('.');
|
||||||
|
return parts.length > 1 ? parts.pop()!.toLowerCase() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateContentType(contentType: string, folder: UploadFolder): void {
|
||||||
|
const allowedTypes: Record<UploadFolder, string[]> = {
|
||||||
|
[UploadFolder.AVATARS]: ['image/jpeg', 'image/png', 'image/webp'],
|
||||||
|
[UploadFolder.DOCUMENTS]: [
|
||||||
|
'application/pdf',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
],
|
||||||
|
[UploadFolder.PROPERTIES]: [
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp',
|
||||||
|
'video/mp4',
|
||||||
|
'video/quicktime',
|
||||||
|
],
|
||||||
|
[UploadFolder.VERIFICATION]: [
|
||||||
|
'application/pdf',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const allowed = allowedTypes[folder];
|
||||||
|
if (!allowed.includes(contentType)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Content type ${contentType} is not allowed for ${folder}. Allowed types: ${allowed.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPresignedUrl(dto: PresignedUrlDto): Promise<PresignedUrlResponseDto> {
|
||||||
|
this.validateContentType(dto.contentType, dto.folder);
|
||||||
|
|
||||||
|
const extension = this.getFileExtension(dto.filename);
|
||||||
|
const uniqueId = uuidv4();
|
||||||
|
const key = `${dto.folder}/${uniqueId}${extension ? `.${extension}` : ''}`;
|
||||||
|
|
||||||
|
const command = new PutObjectCommand({
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Key: key,
|
||||||
|
ContentType: dto.contentType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadUrl = await getSignedUrl(this.s3Client, command, {
|
||||||
|
expiresIn: 3600, // 1 hour
|
||||||
|
});
|
||||||
|
|
||||||
|
const publicUrl = `https://${this.bucket}.s3.${this.region}.amazonaws.com/${key}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploadUrl,
|
||||||
|
publicUrl,
|
||||||
|
key,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFile(key: string): Promise<void> {
|
||||||
|
const command = new DeleteObjectCommand({
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Key: key,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.s3Client.send(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSignedDownloadUrl(key: string): Promise<string> {
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Key: key,
|
||||||
|
});
|
||||||
|
|
||||||
|
return getSignedUrl(this.s3Client, command, {
|
||||||
|
expiresIn: 3600, // 1 hour
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getPublicUrl(key: string): string {
|
||||||
|
return `https://${this.bucket}.s3.${this.region}.amazonaws.com/${key}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/verification/dto/create-verification-request.dto.ts
Normal file
30
src/verification/dto/create-verification-request.dto.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsString, IsEnum, IsOptional, IsUrl, MaxLength } from 'class-validator';
|
||||||
|
import { VerificationDocType } from '@prisma/client';
|
||||||
|
|
||||||
|
export class CreateVerificationRequestDto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'LICENSE',
|
||||||
|
enum: VerificationDocType,
|
||||||
|
description: 'Type of verification document',
|
||||||
|
})
|
||||||
|
@IsEnum(VerificationDocType)
|
||||||
|
documentType: VerificationDocType;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'https://s3.amazonaws.com/bucket/license.pdf',
|
||||||
|
description: 'URL of the uploaded document',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsUrl()
|
||||||
|
documentUrl: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 'My real estate license from California',
|
||||||
|
description: 'Additional notes about the verification request',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
3
src/verification/dto/index.ts
Normal file
3
src/verification/dto/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './create-verification-request.dto';
|
||||||
|
export * from './review-verification-request.dto';
|
||||||
|
export * from './search-verification-requests.dto';
|
||||||
31
src/verification/dto/review-verification-request.dto.ts
Normal file
31
src/verification/dto/review-verification-request.dto.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsString, IsEnum, IsOptional, MaxLength } from 'class-validator';
|
||||||
|
import { VerificationStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
export class ReviewVerificationRequestDto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'APPROVED',
|
||||||
|
enum: ['APPROVED', 'REJECTED'],
|
||||||
|
description: 'New status for the verification request',
|
||||||
|
})
|
||||||
|
@IsEnum(['APPROVED', 'REJECTED'] as const)
|
||||||
|
status: 'APPROVED' | 'REJECTED';
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 'License verified successfully',
|
||||||
|
description: 'Admin notes about the review decision',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
adminNotes?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 'Document is blurry, please resubmit',
|
||||||
|
description: 'Rejection reason (required if status is REJECTED)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
rejectionReason?: string;
|
||||||
|
}
|
||||||
57
src/verification/dto/search-verification-requests.dto.ts
Normal file
57
src/verification/dto/search-verification-requests.dto.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, IsEnum, IsInt, Min, Max, IsString } from 'class-validator';
|
||||||
|
import { Type, Transform } from 'class-transformer';
|
||||||
|
import { VerificationStatus, VerificationDocType } from '@prisma/client';
|
||||||
|
|
||||||
|
export class SearchVerificationRequestsDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 'PENDING',
|
||||||
|
enum: VerificationStatus,
|
||||||
|
description: 'Filter by status',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(VerificationStatus)
|
||||||
|
status?: VerificationStatus;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 'LICENSE',
|
||||||
|
enum: VerificationDocType,
|
||||||
|
description: 'Filter by document type',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(VerificationDocType)
|
||||||
|
documentType?: VerificationDocType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'uuid-of-agent-profile' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
agentProfileId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 1, default: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 10, default: 10 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
limit?: number = 10;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 'createdAt',
|
||||||
|
enum: ['createdAt', 'updatedAt', 'status'],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sortBy?: string = 'createdAt';
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'desc', enum: ['asc', 'desc'] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sortOrder?: 'asc' | 'desc' = 'desc';
|
||||||
|
}
|
||||||
4
src/verification/index.ts
Normal file
4
src/verification/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './verification.module';
|
||||||
|
export * from './verification.service';
|
||||||
|
export * from './verification.controller';
|
||||||
|
export * from './dto';
|
||||||
150
src/verification/verification.controller.ts
Normal file
150
src/verification/verification.controller.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { VerificationService } from './verification.service';
|
||||||
|
import {
|
||||||
|
CreateVerificationRequestDto,
|
||||||
|
ReviewVerificationRequestDto,
|
||||||
|
SearchVerificationRequestsDto,
|
||||||
|
} from './dto';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
|
||||||
|
@ApiTags('verification')
|
||||||
|
@Controller('verification')
|
||||||
|
export class VerificationController {
|
||||||
|
constructor(private readonly verificationService: VerificationService) {}
|
||||||
|
|
||||||
|
// Agent endpoints
|
||||||
|
|
||||||
|
@Post('request')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Submit a verification request' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Request submitted successfully' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Pending request already exists' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Agent profile not found' })
|
||||||
|
async createRequest(
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
@Body() dto: CreateVerificationRequestDto,
|
||||||
|
) {
|
||||||
|
return this.verificationService.createRequest(userId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('my-requests')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Get my verification requests' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Requests retrieved successfully' })
|
||||||
|
async getMyRequests(@CurrentUser('id') userId: string) {
|
||||||
|
return this.verificationService.getMyRequests(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('request/:id')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Get verification request by ID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Request retrieved successfully' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Request not found' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Access denied' })
|
||||||
|
async getRequestById(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
) {
|
||||||
|
return this.verificationService.getRequestById(id, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('request/:id')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Cancel a pending verification request' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Request cancelled successfully' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Request is not pending' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Request not found' })
|
||||||
|
async cancelRequest(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
) {
|
||||||
|
return this.verificationService.cancelRequest(id, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin endpoints
|
||||||
|
|
||||||
|
@Get('admin/list')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Admin: List all verification requests' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Requests retrieved successfully' })
|
||||||
|
async searchRequests(@Query() dto: SearchVerificationRequestsDto) {
|
||||||
|
return this.verificationService.searchRequests(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('admin/pending')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Admin: Get all pending verification requests' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Pending requests retrieved successfully',
|
||||||
|
})
|
||||||
|
async getPendingRequests() {
|
||||||
|
return this.verificationService.getPendingRequests();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('admin/stats')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Admin: Get verification statistics' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Stats retrieved successfully' })
|
||||||
|
async getStats() {
|
||||||
|
return this.verificationService.getStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('admin/:id')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Admin: Get verification request by ID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Request retrieved successfully' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Request not found' })
|
||||||
|
async adminGetRequestById(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.verificationService.getRequestById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('admin/:id/review')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Admin: Review a verification request' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Request reviewed successfully' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Request already reviewed' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Request not found' })
|
||||||
|
async reviewRequest(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser('id') adminId: string,
|
||||||
|
@Body() dto: ReviewVerificationRequestDto,
|
||||||
|
) {
|
||||||
|
return this.verificationService.reviewRequest(id, adminId, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/verification/verification.module.ts
Normal file
12
src/verification/verification.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { VerificationController } from './verification.controller';
|
||||||
|
import { VerificationService } from './verification.service';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
controllers: [VerificationController],
|
||||||
|
providers: [VerificationService],
|
||||||
|
exports: [VerificationService],
|
||||||
|
})
|
||||||
|
export class VerificationModule {}
|
||||||
336
src/verification/verification.service.ts
Normal file
336
src/verification/verification.service.ts
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
ForbiddenException,
|
||||||
|
BadRequestException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
CreateVerificationRequestDto,
|
||||||
|
ReviewVerificationRequestDto,
|
||||||
|
SearchVerificationRequestsDto,
|
||||||
|
} from './dto';
|
||||||
|
import { Prisma, VerificationStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class VerificationService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async createRequest(userId: string, dto: CreateVerificationRequestDto) {
|
||||||
|
// Get the agent profile for this user
|
||||||
|
const agentProfile = await this.prisma.agentProfile.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!agentProfile) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
'Agent profile not found. Create an agent profile first.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there's already a pending request for this document type
|
||||||
|
const existingRequest = await this.prisma.verificationRequest.findFirst({
|
||||||
|
where: {
|
||||||
|
agentProfileId: agentProfile.id,
|
||||||
|
documentType: dto.documentType,
|
||||||
|
status: VerificationStatus.PENDING,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingRequest) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`A pending verification request for ${dto.documentType} already exists`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = await this.prisma.verificationRequest.create({
|
||||||
|
data: {
|
||||||
|
agentProfileId: agentProfile.id,
|
||||||
|
documentType: dto.documentType,
|
||||||
|
documentUrl: dto.documentUrl,
|
||||||
|
notes: dto.notes,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
agentProfile: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMyRequests(userId: string) {
|
||||||
|
const agentProfile = await this.prisma.agentProfile.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!agentProfile) {
|
||||||
|
throw new NotFoundException('Agent profile not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const requests = await this.prisma.verificationRequest.findMany({
|
||||||
|
where: { agentProfileId: agentProfile.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return requests;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRequestById(id: string, userId?: string) {
|
||||||
|
const request = await this.prisma.verificationRequest.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
agentProfile: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
throw new NotFoundException('Verification request not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If userId is provided, check ownership (for non-admin access)
|
||||||
|
if (userId) {
|
||||||
|
const agentProfile = await this.prisma.agentProfile.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!agentProfile || agentProfile.id !== request.agentProfileId) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'You do not have access to this verification request',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelRequest(id: string, userId: string) {
|
||||||
|
const request = await this.getRequestById(id, userId);
|
||||||
|
|
||||||
|
if (request.status !== VerificationStatus.PENDING) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Only pending requests can be cancelled',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.verificationRequest.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { message: 'Verification request cancelled successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin methods
|
||||||
|
|
||||||
|
async searchRequests(dto: SearchVerificationRequestsDto) {
|
||||||
|
const {
|
||||||
|
status,
|
||||||
|
documentType,
|
||||||
|
agentProfileId,
|
||||||
|
page = 1,
|
||||||
|
limit = 10,
|
||||||
|
sortBy = 'createdAt',
|
||||||
|
sortOrder = 'desc',
|
||||||
|
} = dto;
|
||||||
|
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where: Prisma.VerificationRequestWhereInput = {};
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
where.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (documentType) {
|
||||||
|
where.documentType = documentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agentProfileId) {
|
||||||
|
where.agentProfileId = agentProfileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderBy: Prisma.VerificationRequestOrderByWithRelationInput = {};
|
||||||
|
if (sortBy === 'status') {
|
||||||
|
orderBy.status = sortOrder;
|
||||||
|
} else if (sortBy === 'updatedAt') {
|
||||||
|
orderBy.updatedAt = sortOrder;
|
||||||
|
} else {
|
||||||
|
orderBy.createdAt = sortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [requests, total] = await Promise.all([
|
||||||
|
this.prisma.verificationRequest.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
orderBy,
|
||||||
|
include: {
|
||||||
|
agentProfile: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.verificationRequest.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: requests,
|
||||||
|
meta: {
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPendingRequests() {
|
||||||
|
const requests = await this.prisma.verificationRequest.findMany({
|
||||||
|
where: { status: VerificationStatus.PENDING },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
include: {
|
||||||
|
agentProfile: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return requests;
|
||||||
|
}
|
||||||
|
|
||||||
|
async reviewRequest(
|
||||||
|
id: string,
|
||||||
|
adminId: string,
|
||||||
|
dto: ReviewVerificationRequestDto,
|
||||||
|
) {
|
||||||
|
const request = await this.prisma.verificationRequest.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
agentProfile: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
throw new NotFoundException('Verification request not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.status !== VerificationStatus.PENDING) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'This verification request has already been reviewed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.status === 'REJECTED' && !dto.rejectionReason) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Rejection reason is required when rejecting a request',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedRequest = await this.prisma.verificationRequest.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: dto.status as VerificationStatus,
|
||||||
|
reviewedBy: adminId,
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
adminNotes: dto.adminNotes,
|
||||||
|
rejectionReason: dto.rejectionReason,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
agentProfile: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// If approved, check if all required documents are verified
|
||||||
|
// and auto-verify the agent profile
|
||||||
|
if (dto.status === 'APPROVED') {
|
||||||
|
await this.checkAndUpdateAgentVerification(request.agentProfileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkAndUpdateAgentVerification(agentProfileId: string) {
|
||||||
|
// Get all verification requests for this agent
|
||||||
|
const requests = await this.prisma.verificationRequest.findMany({
|
||||||
|
where: { agentProfileId },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if at least one LICENSE document is approved
|
||||||
|
const hasApprovedLicense = requests.some(
|
||||||
|
(r) =>
|
||||||
|
r.documentType === 'LICENSE' && r.status === VerificationStatus.APPROVED,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasApprovedLicense) {
|
||||||
|
await this.prisma.agentProfile.update({
|
||||||
|
where: { id: agentProfileId },
|
||||||
|
data: { isVerified: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStats() {
|
||||||
|
const [pending, approved, rejected, total] = await Promise.all([
|
||||||
|
this.prisma.verificationRequest.count({
|
||||||
|
where: { status: VerificationStatus.PENDING },
|
||||||
|
}),
|
||||||
|
this.prisma.verificationRequest.count({
|
||||||
|
where: { status: VerificationStatus.APPROVED },
|
||||||
|
}),
|
||||||
|
this.prisma.verificationRequest.count({
|
||||||
|
where: { status: VerificationStatus.REJECTED },
|
||||||
|
}),
|
||||||
|
this.prisma.verificationRequest.count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pending,
|
||||||
|
approved,
|
||||||
|
rejected,
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user