feat: Introduce agent types module, replacing categories and verification modules, and updating agent-related DTOs, services, and Prisma schema.
This commit is contained in:
@@ -33,18 +33,6 @@ enum AuthProvider {
|
|||||||
TWITTER
|
TWITTER
|
||||||
}
|
}
|
||||||
|
|
||||||
enum VerificationStatus {
|
|
||||||
PENDING
|
|
||||||
APPROVED
|
|
||||||
REJECTED
|
|
||||||
}
|
|
||||||
|
|
||||||
enum VerificationDocType {
|
|
||||||
LICENSE
|
|
||||||
ID_CARD
|
|
||||||
CERTIFICATE
|
|
||||||
OTHER
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===========================================
|
// ===========================================
|
||||||
// USER - Authentication Only
|
// USER - Authentication Only
|
||||||
@@ -120,6 +108,7 @@ model AgentProfile {
|
|||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String @unique
|
userId String @unique
|
||||||
slug String @unique
|
slug String @unique
|
||||||
|
agentTypeId String?
|
||||||
|
|
||||||
// Basic Info
|
// Basic Info
|
||||||
firstName String?
|
firstName String?
|
||||||
@@ -170,10 +159,10 @@ model AgentProfile {
|
|||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
specializations AgentSpecialization[]
|
agentType AgentType? @relation(fields: [agentTypeId], references: [id])
|
||||||
verificationRequests VerificationRequest[]
|
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
|
@@index([agentTypeId])
|
||||||
@@index([slug])
|
@@index([slug])
|
||||||
@@index([city, state])
|
@@index([city, state])
|
||||||
@@index([isVerified])
|
@@index([isVerified])
|
||||||
@@ -183,111 +172,26 @@ model AgentProfile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===========================================
|
// ===========================================
|
||||||
// CATEGORY - Main categories for agents
|
// AGENT TYPE - Dynamic agent types (Professional, Lender, etc.)
|
||||||
// ===========================================
|
// ===========================================
|
||||||
|
|
||||||
model Category {
|
model AgentType {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String @unique
|
name String @unique
|
||||||
slug String @unique
|
description String?
|
||||||
description String?
|
icon String?
|
||||||
icon String?
|
isActive Boolean @default(true)
|
||||||
image String?
|
sortOrder Int @default(0)
|
||||||
isActive Boolean @default(true)
|
|
||||||
sortOrder Int @default(0)
|
|
||||||
|
|
||||||
// Timestamps
|
// Timestamps
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
specializations Specialization[]
|
agents AgentProfile[]
|
||||||
|
|
||||||
@@index([slug])
|
|
||||||
@@index([isActive])
|
@@index([isActive])
|
||||||
@@map("categories")
|
@@map("agent_types")
|
||||||
}
|
|
||||||
|
|
||||||
// ===========================================
|
|
||||||
// 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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===========================================
|
// ===========================================
|
||||||
|
|||||||
@@ -12,7 +12,48 @@ async function main() {
|
|||||||
|
|
||||||
console.log('🌱 Starting database seeding...\n');
|
console.log('🌱 Starting database seeding...\n');
|
||||||
|
|
||||||
// Admin credentials
|
// =============================================
|
||||||
|
// Seed Agent Types
|
||||||
|
// =============================================
|
||||||
|
console.log('📋 Seeding Agent Types...');
|
||||||
|
|
||||||
|
const agentTypes = [
|
||||||
|
{
|
||||||
|
name: 'Professional',
|
||||||
|
description: 'Licensed real estate professionals',
|
||||||
|
icon: 'briefcase',
|
||||||
|
sortOrder: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Lender',
|
||||||
|
description: 'Mortgage and loan specialists',
|
||||||
|
icon: 'bank',
|
||||||
|
sortOrder: 2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const agentType of agentTypes) {
|
||||||
|
const existing = await prisma.agentType.findUnique({
|
||||||
|
where: { name: agentType.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
console.log(` ⚠️ Agent type "${agentType.name}" already exists`);
|
||||||
|
} else {
|
||||||
|
await prisma.agentType.create({
|
||||||
|
data: agentType,
|
||||||
|
});
|
||||||
|
console.log(` ✅ Created agent type: ${agentType.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Seed Admin User
|
||||||
|
// =============================================
|
||||||
|
console.log('👤 Seeding Admin User...');
|
||||||
|
|
||||||
const adminEmail = process.env.ADMIN_EMAIL || 'admin@realestate.com';
|
const adminEmail = process.env.ADMIN_EMAIL || 'admin@realestate.com';
|
||||||
const adminPassword = process.env.ADMIN_PASSWORD || 'Admin@123456';
|
const adminPassword = process.env.ADMIN_PASSWORD || 'Admin@123456';
|
||||||
|
|
||||||
@@ -25,7 +66,7 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (existingAdmin) {
|
if (existingAdmin) {
|
||||||
console.log(`⚠️ Admin user already exists: ${adminEmail}`);
|
console.log(` ⚠️ Admin user already exists: ${adminEmail}`);
|
||||||
} else {
|
} else {
|
||||||
// Create admin user
|
// Create admin user
|
||||||
const admin = await prisma.user.create({
|
const admin = await prisma.user.create({
|
||||||
@@ -40,7 +81,7 @@ async function main() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('✅ Admin user created successfully!');
|
console.log(' ✅ Admin user created successfully!');
|
||||||
console.log(' ───────────────────────────────');
|
console.log(' ───────────────────────────────');
|
||||||
console.log(` 📧 Email: ${adminEmail}`);
|
console.log(` 📧 Email: ${adminEmail}`);
|
||||||
console.log(` 🔑 Password: ${adminPassword}`);
|
console.log(` 🔑 Password: ${adminPassword}`);
|
||||||
@@ -49,13 +90,17 @@ async function main() {
|
|||||||
console.log(' ───────────────────────────────\n');
|
console.log(' ───────────────────────────────\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================
|
||||||
// Summary
|
// Summary
|
||||||
|
// =============================================
|
||||||
const userCount = await prisma.user.count();
|
const userCount = await prisma.user.count();
|
||||||
const adminCount = await prisma.user.count({ where: { role: UserRole.ADMIN } });
|
const adminCount = await prisma.user.count({ where: { role: UserRole.ADMIN } });
|
||||||
|
const agentTypeCount = await prisma.agentType.count();
|
||||||
|
|
||||||
console.log('📊 Database Summary:');
|
console.log('📊 Database Summary:');
|
||||||
console.log(` Total Users: ${userCount}`);
|
console.log(` Total Users: ${userCount}`);
|
||||||
console.log(` Admins: ${adminCount}`);
|
console.log(` Admins: ${adminCount}`);
|
||||||
|
console.log(` Agent Types: ${agentTypeCount}`);
|
||||||
console.log('\n🌱 Seeding completed!\n');
|
console.log('\n🌱 Seeding completed!\n');
|
||||||
|
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
|
|||||||
80
src/agent-types/agent-types.controller.ts
Normal file
80
src/agent-types/agent-types.controller.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
Patch,
|
||||||
|
Param,
|
||||||
|
Delete,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||||
|
import { AgentTypesService } from './agent-types.service';
|
||||||
|
import { CreateAgentTypeDto, UpdateAgentTypeDto } from './dto';
|
||||||
|
import { JwtAuthGuard, RolesGuard } from '../auth/guards';
|
||||||
|
import { Roles, Public } from '../auth/decorators';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
|
||||||
|
@ApiTags('Agent Types')
|
||||||
|
@Controller('agent-types')
|
||||||
|
export class AgentTypesController {
|
||||||
|
constructor(private readonly agentTypesService: AgentTypesService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Create a new agent type (Admin only)' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Agent type created successfully' })
|
||||||
|
@ApiResponse({ status: 409, description: 'Agent type already exists' })
|
||||||
|
create(@Body() createAgentTypeDto: CreateAgentTypeDto) {
|
||||||
|
return this.agentTypesService.create(createAgentTypeDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: 'Get all agent types' })
|
||||||
|
@ApiQuery({ name: 'includeInactive', required: false, type: Boolean })
|
||||||
|
@ApiResponse({ status: 200, description: 'List of agent types' })
|
||||||
|
findAll(@Query('includeInactive') includeInactive?: string) {
|
||||||
|
return this.agentTypesService.findAll(includeInactive === 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: 'Get a single agent type by ID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Agent type found' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Agent type not found' })
|
||||||
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.agentTypesService.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Update an agent type (Admin only)' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Agent type updated successfully' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Agent type not found' })
|
||||||
|
@ApiResponse({ status: 409, description: 'Agent type with this name already exists' })
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() updateAgentTypeDto: UpdateAgentTypeDto,
|
||||||
|
) {
|
||||||
|
return this.agentTypesService.update(id, updateAgentTypeDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Delete an agent type (Admin only)' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Agent type deleted successfully' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Agent type not found' })
|
||||||
|
@ApiResponse({ status: 409, description: 'Cannot delete - agents are using this type' })
|
||||||
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.agentTypesService.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/agent-types/agent-types.module.ts
Normal file
12
src/agent-types/agent-types.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AgentTypesController } from './agent-types.controller';
|
||||||
|
import { AgentTypesService } from './agent-types.service';
|
||||||
|
import { PrismaModule } from '../prisma';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
controllers: [AgentTypesController],
|
||||||
|
providers: [AgentTypesService],
|
||||||
|
exports: [AgentTypesService],
|
||||||
|
})
|
||||||
|
export class AgentTypesModule {}
|
||||||
118
src/agent-types/agent-types.service.ts
Normal file
118
src/agent-types/agent-types.service.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma';
|
||||||
|
import { CreateAgentTypeDto, UpdateAgentTypeDto } from './dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AgentTypesService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new agent type
|
||||||
|
*/
|
||||||
|
async create(createAgentTypeDto: CreateAgentTypeDto) {
|
||||||
|
const { name, ...rest } = createAgentTypeDto;
|
||||||
|
|
||||||
|
// Check if name already exists
|
||||||
|
const existing = await this.prisma.agentType.findUnique({
|
||||||
|
where: { name },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException('Agent type with this name already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.agentType.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
...rest,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all agent types (optionally filter by active status)
|
||||||
|
*/
|
||||||
|
async findAll(includeInactive = false) {
|
||||||
|
const where = includeInactive ? {} : { isActive: true };
|
||||||
|
|
||||||
|
return this.prisma.agentType.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { agents: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single agent type by ID
|
||||||
|
*/
|
||||||
|
async findOne(id: string) {
|
||||||
|
const agentType = await this.prisma.agentType.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { agents: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!agentType) {
|
||||||
|
throw new NotFoundException(`Agent type with ID ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return agentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an agent type
|
||||||
|
*/
|
||||||
|
async update(id: string, updateAgentTypeDto: UpdateAgentTypeDto) {
|
||||||
|
// Check if agent type exists
|
||||||
|
await this.findOne(id);
|
||||||
|
|
||||||
|
const { name, ...rest } = updateAgentTypeDto;
|
||||||
|
|
||||||
|
// If name is being updated, check for conflicts
|
||||||
|
if (name) {
|
||||||
|
const existing = await this.prisma.agentType.findFirst({
|
||||||
|
where: {
|
||||||
|
AND: [{ id: { not: id } }, { name }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException('Agent type with this name already exists');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.agentType.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(name && { name }),
|
||||||
|
...rest,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an agent type
|
||||||
|
*/
|
||||||
|
async remove(id: string) {
|
||||||
|
// Check if agent type exists
|
||||||
|
const agentType = await this.findOne(id);
|
||||||
|
|
||||||
|
// Check if any agents are using this type
|
||||||
|
if (agentType._count.agents > 0) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Cannot delete agent type. ${agentType._count.agents} agent(s) are using this type.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.agentType.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/agent-types/dto/create-agent-type.dto.ts
Normal file
31
src/agent-types/dto/create-agent-type.dto.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { IsString, IsOptional, IsBoolean, IsInt, MinLength, MaxLength } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class CreateAgentTypeDto {
|
||||||
|
@ApiProperty({ description: 'Agent type name', example: 'Professional' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(50)
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Agent type description' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Icon URL or icon name' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Whether the agent type is active', default: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Sort order for display', default: 0 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
2
src/agent-types/dto/index.ts
Normal file
2
src/agent-types/dto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './create-agent-type.dto';
|
||||||
|
export * from './update-agent-type.dto';
|
||||||
4
src/agent-types/dto/update-agent-type.dto.ts
Normal file
4
src/agent-types/dto/update-agent-type.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateAgentTypeDto } from './create-agent-type.dto';
|
||||||
|
|
||||||
|
export class UpdateAgentTypeDto extends PartialType(CreateAgentTypeDto) {}
|
||||||
4
src/agent-types/index.ts
Normal file
4
src/agent-types/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './agent-types.module';
|
||||||
|
export * from './agent-types.service';
|
||||||
|
export * from './agent-types.controller';
|
||||||
|
export * from './dto';
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
ForbiddenException,
|
|
||||||
ConflictException,
|
ConflictException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
@@ -35,21 +34,13 @@ export class AgentsService {
|
|||||||
throw new ConflictException('User already has an agent profile');
|
throw new ConflictException('User already has an agent profile');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { specializationIds, ...profileData } = dto;
|
|
||||||
const slug = generateSlug(dto.firstName, dto.lastName);
|
const slug = generateSlug(dto.firstName, dto.lastName);
|
||||||
|
|
||||||
const profile = await this.prisma.agentProfile.create({
|
const profile = await this.prisma.agentProfile.create({
|
||||||
data: {
|
data: {
|
||||||
...profileData,
|
...dto,
|
||||||
userId,
|
userId,
|
||||||
slug,
|
slug,
|
||||||
specializations: specializationIds?.length
|
|
||||||
? {
|
|
||||||
create: specializationIds.map((specId) => ({
|
|
||||||
specializationId: specId,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
user: {
|
user: {
|
||||||
@@ -59,15 +50,7 @@ export class AgentsService {
|
|||||||
avatar: true,
|
avatar: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
specializations: {
|
agentType: true,
|
||||||
include: {
|
|
||||||
specialization: {
|
|
||||||
include: {
|
|
||||||
category: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,19 +68,7 @@ export class AgentsService {
|
|||||||
avatar: true,
|
avatar: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
specializations: {
|
agentType: true,
|
||||||
include: {
|
|
||||||
specialization: {
|
|
||||||
include: {
|
|
||||||
category: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
verificationRequests: {
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
take: 5,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -119,15 +90,7 @@ export class AgentsService {
|
|||||||
avatar: true,
|
avatar: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
specializations: {
|
agentType: true,
|
||||||
include: {
|
|
||||||
specialization: {
|
|
||||||
include: {
|
|
||||||
category: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -147,29 +110,9 @@ export class AgentsService {
|
|||||||
throw new NotFoundException('Agent profile not found');
|
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({
|
const updatedProfile = await this.prisma.agentProfile.update({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
data: profileData,
|
data: dto,
|
||||||
include: {
|
include: {
|
||||||
user: {
|
user: {
|
||||||
select: {
|
select: {
|
||||||
@@ -178,15 +121,7 @@ export class AgentsService {
|
|||||||
avatar: true,
|
avatar: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
specializations: {
|
agentType: true,
|
||||||
include: {
|
|
||||||
specialization: {
|
|
||||||
include: {
|
|
||||||
category: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -199,8 +134,7 @@ export class AgentsService {
|
|||||||
city,
|
city,
|
||||||
state,
|
state,
|
||||||
country,
|
country,
|
||||||
categoryId,
|
agentTypeId,
|
||||||
specializationIds,
|
|
||||||
isVerified,
|
isVerified,
|
||||||
page = 1,
|
page = 1,
|
||||||
limit = 10,
|
limit = 10,
|
||||||
@@ -239,24 +173,9 @@ export class AgentsService {
|
|||||||
where.isVerified = isVerified;
|
where.isVerified = isVerified;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by category
|
// Filter by agent type
|
||||||
if (categoryId) {
|
if (agentTypeId) {
|
||||||
where.specializations = {
|
where.agentTypeId = agentTypeId;
|
||||||
some: {
|
|
||||||
specialization: {
|
|
||||||
categoryId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by specializations
|
|
||||||
if (specializationIds?.length) {
|
|
||||||
where.specializations = {
|
|
||||||
some: {
|
|
||||||
specializationId: { in: specializationIds },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build orderBy
|
// Build orderBy
|
||||||
@@ -285,15 +204,7 @@ export class AgentsService {
|
|||||||
avatar: true,
|
avatar: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
specializations: {
|
agentType: true,
|
||||||
include: {
|
|
||||||
specialization: {
|
|
||||||
include: {
|
|
||||||
category: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.agentProfile.count({ where }),
|
this.prisma.agentProfile.count({ where }),
|
||||||
@@ -326,15 +237,7 @@ export class AgentsService {
|
|||||||
avatar: true,
|
avatar: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
specializations: {
|
agentType: true,
|
||||||
include: {
|
|
||||||
specialization: {
|
|
||||||
include: {
|
|
||||||
category: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -357,15 +260,7 @@ export class AgentsService {
|
|||||||
avatar: true,
|
avatar: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
specializations: {
|
agentType: true,
|
||||||
include: {
|
|
||||||
specialization: {
|
|
||||||
include: {
|
|
||||||
category: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -382,26 +277,9 @@ export class AgentsService {
|
|||||||
throw new NotFoundException('Agent profile not found');
|
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({
|
const updatedProfile = await this.prisma.agentProfile.update({
|
||||||
where: { id: profileId },
|
where: { id: profileId },
|
||||||
data: profileData,
|
data: dto,
|
||||||
include: {
|
include: {
|
||||||
user: {
|
user: {
|
||||||
select: {
|
select: {
|
||||||
@@ -410,15 +288,7 @@ export class AgentsService {
|
|||||||
avatar: true,
|
avatar: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
specializations: {
|
agentType: true,
|
||||||
include: {
|
|
||||||
specialization: {
|
|
||||||
include: {
|
|
||||||
category: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -434,15 +304,6 @@ export class AgentsService {
|
|||||||
throw new NotFoundException('Agent profile not found');
|
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({
|
await this.prisma.agentProfile.delete({
|
||||||
where: { id: profileId },
|
where: { id: profileId },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|||||||
import {
|
import {
|
||||||
IsString,
|
IsString,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsArray,
|
|
||||||
IsInt,
|
IsInt,
|
||||||
IsUrl,
|
IsUrl,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
@@ -13,6 +12,14 @@ import {
|
|||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class CreateAgentProfileDto {
|
export class CreateAgentProfileDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 'uuid-agent-type-id',
|
||||||
|
description: 'Agent type ID (Professional, Lender, Advisor, etc.)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
agentTypeId?: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'John' })
|
@ApiProperty({ example: 'John' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
@@ -116,13 +123,4 @@ export class CreateAgentProfileDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUrl()
|
@IsUrl()
|
||||||
instagramUrl?: string;
|
instagramUrl?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
example: ['uuid-1', 'uuid-2'],
|
|
||||||
description: 'Array of specialization IDs',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsArray()
|
|
||||||
@IsUUID('4', { each: true })
|
|
||||||
specializationIds?: string[];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
IsArray,
|
|
||||||
Min,
|
Min,
|
||||||
Max,
|
Max,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
@@ -32,20 +31,13 @@ export class SearchAgentsDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
country?: string;
|
country?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'uuid-of-category' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsUUID()
|
|
||||||
categoryId?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: ['uuid-1', 'uuid-2'],
|
example: 'uuid-of-agent-type',
|
||||||
description: 'Filter by specialization IDs',
|
description: 'Filter by agent type ID',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsUUID()
|
||||||
@IsUUID('4', { each: true })
|
agentTypeId?: string;
|
||||||
@Transform(({ value }) => (typeof value === 'string' ? [value] : value))
|
|
||||||
specializationIds?: string[];
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: true })
|
@ApiPropertyOptional({ example: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -10,9 +10,8 @@ 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 { AgentTypesModule } from './agent-types';
|
||||||
import { AgentsModule } from './agents';
|
import { AgentsModule } from './agents';
|
||||||
import { VerificationModule } from './verification';
|
|
||||||
import { UploadModule } from './upload';
|
import { UploadModule } from './upload';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
@@ -47,9 +46,8 @@ import { AppService } from './app.service';
|
|||||||
AuthModule,
|
AuthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
EmailModule,
|
EmailModule,
|
||||||
CategoriesModule,
|
AgentTypesModule,
|
||||||
AgentsModule,
|
AgentsModule,
|
||||||
VerificationModule,
|
|
||||||
UploadModule,
|
UploadModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
|
|||||||
@@ -1,197 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
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' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export * from './create-category.dto';
|
|
||||||
export * from './update-category.dto';
|
|
||||||
export * from './create-specialization.dto';
|
|
||||||
export * from './update-specialization.dto';
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { PartialType } from '@nestjs/swagger';
|
|
||||||
import { CreateCategoryDto } from './create-category.dto';
|
|
||||||
|
|
||||||
export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { PartialType, OmitType } from '@nestjs/swagger';
|
|
||||||
import { CreateSpecializationDto } from './create-specialization.dto';
|
|
||||||
|
|
||||||
export class UpdateSpecializationDto extends PartialType(
|
|
||||||
OmitType(CreateSpecializationDto, ['categoryId'] as const),
|
|
||||||
) {}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export * from './categories.module';
|
|
||||||
export * from './categories.service';
|
|
||||||
export * from './categories.controller';
|
|
||||||
export * from './dto';
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export * from './create-verification-request.dto';
|
|
||||||
export * from './review-verification-request.dto';
|
|
||||||
export * from './search-verification-requests.dto';
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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';
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export * from './verification.module';
|
|
||||||
export * from './verification.service';
|
|
||||||
export * from './verification.controller';
|
|
||||||
export * from './dto';
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
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-only endpoints - requires AGENT role
|
|
||||||
|
|
||||||
@Post('request')
|
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
||||||
@Roles(UserRole.AGENT)
|
|
||||||
@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, RolesGuard)
|
|
||||||
@Roles(UserRole.AGENT)
|
|
||||||
@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, RolesGuard)
|
|
||||||
@Roles(UserRole.AGENT)
|
|
||||||
@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, RolesGuard)
|
|
||||||
@Roles(UserRole.AGENT)
|
|
||||||
@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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@@ -1,336 +0,0 @@
|
|||||||
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