feat: Introduce agent types module, replacing categories and verification modules, and updating agent-related DTOs, services, and Prisma schema.

This commit is contained in:
pradeepkumar
2026-01-20 12:16:01 +05:30
parent c04ca2bcef
commit 3df8ea067a
30 changed files with 345 additions and 1559 deletions

View 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);
}
}

View 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 {}

View 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 },
});
}
}

View 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;
}

View File

@@ -0,0 +1,2 @@
export * from './create-agent-type.dto';
export * from './update-agent-type.dto';

View 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
View File

@@ -0,0 +1,4 @@
export * from './agent-types.module';
export * from './agent-types.service';
export * from './agent-types.controller';
export * from './dto';

View File

@@ -1,7 +1,6 @@
import {
Injectable,
NotFoundException,
ForbiddenException,
ConflictException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@@ -35,21 +34,13 @@ export class AgentsService {
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,
...dto,
userId,
slug,
specializations: specializationIds?.length
? {
create: specializationIds.map((specId) => ({
specializationId: specId,
})),
}
: undefined,
},
include: {
user: {
@@ -59,15 +50,7 @@ export class AgentsService {
avatar: true,
},
},
specializations: {
include: {
specialization: {
include: {
category: true,
},
},
},
},
agentType: true,
},
});
@@ -85,19 +68,7 @@ export class AgentsService {
avatar: true,
},
},
specializations: {
include: {
specialization: {
include: {
category: true,
},
},
},
},
verificationRequests: {
orderBy: { createdAt: 'desc' },
take: 5,
},
agentType: true,
},
});
@@ -119,15 +90,7 @@ export class AgentsService {
avatar: true,
},
},
specializations: {
include: {
specialization: {
include: {
category: true,
},
},
},
},
agentType: true,
},
});
@@ -147,29 +110,9 @@ export class AgentsService {
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,
data: dto,
include: {
user: {
select: {
@@ -178,15 +121,7 @@ export class AgentsService {
avatar: true,
},
},
specializations: {
include: {
specialization: {
include: {
category: true,
},
},
},
},
agentType: true,
},
});
@@ -199,8 +134,7 @@ export class AgentsService {
city,
state,
country,
categoryId,
specializationIds,
agentTypeId,
isVerified,
page = 1,
limit = 10,
@@ -239,24 +173,9 @@ export class AgentsService {
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 },
},
};
// Filter by agent type
if (agentTypeId) {
where.agentTypeId = agentTypeId;
}
// Build orderBy
@@ -285,15 +204,7 @@ export class AgentsService {
avatar: true,
},
},
specializations: {
include: {
specialization: {
include: {
category: true,
},
},
},
},
agentType: true,
},
}),
this.prisma.agentProfile.count({ where }),
@@ -326,15 +237,7 @@ export class AgentsService {
avatar: true,
},
},
specializations: {
include: {
specialization: {
include: {
category: true,
},
},
},
},
agentType: true,
},
});
@@ -357,15 +260,7 @@ export class AgentsService {
avatar: true,
},
},
specializations: {
include: {
specialization: {
include: {
category: true,
},
},
},
},
agentType: true,
},
});
@@ -382,26 +277,9 @@ export class AgentsService {
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,
data: dto,
include: {
user: {
select: {
@@ -410,15 +288,7 @@ export class AgentsService {
avatar: true,
},
},
specializations: {
include: {
specialization: {
include: {
category: true,
},
},
},
},
agentType: true,
},
});
@@ -434,15 +304,6 @@ export class AgentsService {
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 },
});

View File

@@ -2,7 +2,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsOptional,
IsArray,
IsInt,
IsUrl,
IsUUID,
@@ -13,6 +12,14 @@ import {
} from 'class-validator';
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' })
@IsString()
@MinLength(1)
@@ -116,13 +123,4 @@ export class CreateAgentProfileDto {
@IsOptional()
@IsUrl()
instagramUrl?: string;
@ApiPropertyOptional({
example: ['uuid-1', 'uuid-2'],
description: 'Array of specialization IDs',
})
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
specializationIds?: string[];
}

View File

@@ -5,7 +5,6 @@ import {
IsBoolean,
IsInt,
IsUUID,
IsArray,
Min,
Max,
} from 'class-validator';
@@ -32,20 +31,13 @@ export class SearchAgentsDto {
@IsString()
country?: string;
@ApiPropertyOptional({ example: 'uuid-of-category' })
@IsOptional()
@IsUUID()
categoryId?: string;
@ApiPropertyOptional({
example: ['uuid-1', 'uuid-2'],
description: 'Filter by specialization IDs',
example: 'uuid-of-agent-type',
description: 'Filter by agent type ID',
})
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
@Transform(({ value }) => (typeof value === 'string' ? [value] : value))
specializationIds?: string[];
@IsUUID()
agentTypeId?: string;
@ApiPropertyOptional({ example: true })
@IsOptional()

View File

@@ -10,9 +10,8 @@ import { PrismaModule } from './prisma';
import { AuthModule, JwtAuthGuard, RolesGuard } from './auth';
import { UsersModule } from './users';
import { EmailModule } from './email';
import { CategoriesModule } from './categories';
import { AgentTypesModule } from './agent-types';
import { AgentsModule } from './agents';
import { VerificationModule } from './verification';
import { UploadModule } from './upload';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@@ -47,9 +46,8 @@ import { AppService } from './app.service';
AuthModule,
UsersModule,
EmailModule,
CategoriesModule,
AgentTypesModule,
AgentsModule,
VerificationModule,
UploadModule,
],
controllers: [AppController],

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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' },
};
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -1,4 +0,0 @@
export * from './create-category.dto';
export * from './update-category.dto';
export * from './create-specialization.dto';
export * from './update-specialization.dto';

View File

@@ -1,4 +0,0 @@
import { PartialType } from '@nestjs/swagger';
import { CreateCategoryDto } from './create-category.dto';
export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {}

View File

@@ -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),
) {}

View File

@@ -1,4 +0,0 @@
export * from './categories.module';
export * from './categories.service';
export * from './categories.controller';
export * from './dto';

View File

@@ -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;
}

View File

@@ -1,3 +0,0 @@
export * from './create-verification-request.dto';
export * from './review-verification-request.dto';
export * from './search-verification-requests.dto';

View File

@@ -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;
}

View File

@@ -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';
}

View File

@@ -1,4 +0,0 @@
export * from './verification.module';
export * from './verification.service';
export * from './verification.controller';
export * from './dto';

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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,
};
}
}