diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5f7beb2..da1ccb9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -56,6 +56,12 @@ enum VerificationStatus { REJECTED // Admin rejected verification } +enum ConnectionStatus { + PENDING + ACCEPTED + REJECTED +} + // =========================================== // USER - Authentication Only @@ -89,6 +95,9 @@ model User { // Auth Relations sessions Session[] + // Connection Requests + connectionRequests ConnectionRequest[] + @@index([email]) @@index([role]) @@index([status]) @@ -188,6 +197,7 @@ model AgentProfile { user User @relation(fields: [userId], references: [id], onDelete: Cascade) agentType AgentType? @relation(fields: [agentTypeId], references: [id]) fieldValues AgentProfileFieldValue[] + connectionRequests ConnectionRequest[] @@index([userId]) @@index([agentTypeId]) @@ -366,3 +376,30 @@ model Session { @@index([token]) @@map("sessions") } + +// =========================================== +// CONNECTION REQUEST - User to Agent connections +// =========================================== + +model ConnectionRequest { + id String @id @default(uuid()) + userId String // User sending the request + agentProfileId String // Agent receiving the request + status ConnectionStatus @default(PENDING) + message String? @db.Text // Optional message from user + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + respondedAt DateTime? // When agent responded + + // Relations + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + agentProfile AgentProfile @relation(fields: [agentProfileId], references: [id], onDelete: Cascade) + + @@unique([userId, agentProfileId]) // One request per user-agent pair + @@index([agentProfileId, status]) + @@index([userId, status]) + @@index([status]) + @@map("connection_requests") +} diff --git a/src/app.module.ts b/src/app.module.ts index 3835061..8cdc2c5 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -14,6 +14,7 @@ import { AgentTypesModule } from './agent-types'; import { AgentsModule } from './agents'; import { UploadModule } from './upload'; import { ProfileFieldsModule } from './profile-fields'; +import { ConnectionRequestsModule } from './connection-requests'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @@ -51,6 +52,7 @@ import { AppService } from './app.service'; AgentsModule, UploadModule, ProfileFieldsModule, + ConnectionRequestsModule, ], controllers: [AppController], providers: [ diff --git a/src/connection-requests/connection-requests.controller.ts b/src/connection-requests/connection-requests.controller.ts new file mode 100644 index 0000000..f51a2cf --- /dev/null +++ b/src/connection-requests/connection-requests.controller.ts @@ -0,0 +1,191 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { ConnectionRequestsService } from './connection-requests.service'; +import { CreateConnectionRequestDto, RespondConnectionRequestDto } 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, ConnectionStatus } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; + +@ApiTags('Connection Requests') +@Controller('connection-requests') +@UseGuards(JwtAuthGuard, RolesGuard) +@ApiBearerAuth() +export class ConnectionRequestsController { + constructor( + private readonly connectionRequestsService: ConnectionRequestsService, + private readonly prisma: PrismaService, + ) {} + + @Post() + @Roles(UserRole.USER) + @ApiOperation({ summary: 'Create a connection request to an agent' }) + @ApiResponse({ status: 201, description: 'Connection request created successfully' }) + @ApiResponse({ status: 400, description: 'Agent is not available or invalid request' }) + @ApiResponse({ status: 409, description: 'Connection request already exists' }) + async createRequest( + @CurrentUser() user: { id: string }, + @Body() dto: CreateConnectionRequestDto, + ) { + // Return raw data - TransformInterceptor will wrap it + return this.connectionRequestsService.createRequest(user.id, dto); + } + + @Get('my-requests') + @Roles(UserRole.USER) + @ApiOperation({ summary: 'Get all connection requests sent by the current user' }) + @ApiQuery({ name: 'status', enum: ConnectionStatus, required: false }) + @ApiResponse({ status: 200, description: 'Connection requests retrieved successfully' }) + async getMyRequests( + @CurrentUser() user: { id: string }, + @Query('status') status?: ConnectionStatus, + ) { + // Return raw data - TransformInterceptor will wrap it + return this.connectionRequestsService.getMyRequests(user.id, status); + } + + @Get('received') + @Roles(UserRole.AGENT) + @ApiOperation({ summary: 'Get all connection requests received by the current agent' }) + @ApiQuery({ name: 'status', enum: ConnectionStatus, required: false }) + @ApiResponse({ status: 200, description: 'Connection requests retrieved successfully' }) + async getReceivedRequests( + @CurrentUser() user: { id: string }, + @Query('status') status?: ConnectionStatus, + ) { + // Get agent profile for the current user + const agentProfile = await this.prisma.agentProfile.findUnique({ + where: { userId: user.id }, + select: { id: true }, + }); + + if (!agentProfile) { + // Return empty array - TransformInterceptor will wrap it + return []; + } + + // Return raw data - TransformInterceptor will wrap it + return this.connectionRequestsService.getReceivedRequests( + agentProfile.id, + status, + ); + } + + @Get('received/counts') + @Roles(UserRole.AGENT) + @ApiOperation({ summary: 'Get connection request counts for the current agent' }) + @ApiResponse({ status: 200, description: 'Request counts retrieved successfully' }) + async getRequestCounts(@CurrentUser() user: { id: string }) { + const agentProfile = await this.prisma.agentProfile.findUnique({ + where: { userId: user.id }, + select: { id: true }, + }); + + if (!agentProfile) { + // Return default counts - TransformInterceptor will wrap it + return { pending: 0, accepted: 0, rejected: 0, total: 0 }; + } + + // Return raw data - TransformInterceptor will wrap it + return this.connectionRequestsService.getRequestCounts(agentProfile.id); + } + + @Get('status/:agentProfileId') + @Roles(UserRole.USER, UserRole.AGENT) + @ApiOperation({ summary: 'Get connection status between current user and an agent' }) + @ApiResponse({ status: 200, description: 'Connection status retrieved successfully' }) + async getConnectionStatus( + @CurrentUser() user: { id: string }, + @Param('agentProfileId') agentProfileId: string, + ) { + // Return raw data - TransformInterceptor will wrap it + return this.connectionRequestsService.getConnectionStatus( + user.id, + agentProfileId, + ); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a specific connection request by ID' }) + @ApiResponse({ status: 200, description: 'Connection request retrieved successfully' }) + @ApiResponse({ status: 404, description: 'Connection request not found' }) + async getRequestById( + @CurrentUser() user: { id: string; role: UserRole }, + @Param('id') id: string, + ) { + let agentProfileId: string | undefined; + + if (user.role === UserRole.AGENT) { + const agentProfile = await this.prisma.agentProfile.findUnique({ + where: { userId: user.id }, + select: { id: true }, + }); + agentProfileId = agentProfile?.id; + } + + // Return raw data - TransformInterceptor will wrap it + return this.connectionRequestsService.getRequestById( + id, + user.id, + agentProfileId, + ); + } + + @Patch(':id/respond') + @Roles(UserRole.AGENT) + @ApiOperation({ summary: 'Respond to a connection request (accept/reject)' }) + @ApiResponse({ status: 200, description: 'Response recorded successfully' }) + @ApiResponse({ status: 400, description: 'Request already responded to' }) + @ApiResponse({ status: 403, description: 'Not authorized to respond to this request' }) + @ApiResponse({ status: 404, description: 'Connection request not found' }) + async respondToRequest( + @CurrentUser() user: { id: string }, + @Param('id') id: string, + @Body() dto: RespondConnectionRequestDto, + ) { + const agentProfile = await this.prisma.agentProfile.findUnique({ + where: { userId: user.id }, + select: { id: true }, + }); + + if (!agentProfile) { + // Throw an error instead of returning non-standard response + throw new Error('Agent profile not found'); + } + + // Return raw data - TransformInterceptor will wrap it + return this.connectionRequestsService.respondToRequest( + id, + agentProfile.id, + dto, + ); + } + + @Delete(':id') + @Roles(UserRole.USER) + @ApiOperation({ summary: 'Cancel a pending connection request' }) + @ApiResponse({ status: 200, description: 'Connection request cancelled successfully' }) + @ApiResponse({ status: 400, description: 'Only pending requests can be cancelled' }) + @ApiResponse({ status: 403, description: 'Not authorized to cancel this request' }) + @ApiResponse({ status: 404, description: 'Connection request not found' }) + async cancelRequest( + @CurrentUser() user: { id: string }, + @Param('id') id: string, + ) { + await this.connectionRequestsService.cancelRequest(id, user.id); + // Return null/undefined - TransformInterceptor will wrap it as { success: true, data: null } + return null; + } +} diff --git a/src/connection-requests/connection-requests.module.ts b/src/connection-requests/connection-requests.module.ts new file mode 100644 index 0000000..7ca5f9d --- /dev/null +++ b/src/connection-requests/connection-requests.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ConnectionRequestsController } from './connection-requests.controller'; +import { ConnectionRequestsService } from './connection-requests.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [ConnectionRequestsController], + providers: [ConnectionRequestsService], + exports: [ConnectionRequestsService], +}) +export class ConnectionRequestsModule {} diff --git a/src/connection-requests/connection-requests.service.ts b/src/connection-requests/connection-requests.service.ts new file mode 100644 index 0000000..a506d60 --- /dev/null +++ b/src/connection-requests/connection-requests.service.ts @@ -0,0 +1,318 @@ +import { + Injectable, + NotFoundException, + ConflictException, + ForbiddenException, + BadRequestException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { ConnectionStatus } from '@prisma/client'; +import { CreateConnectionRequestDto, RespondConnectionRequestDto } from './dto'; + +@Injectable() +export class ConnectionRequestsService { + constructor(private prisma: PrismaService) {} + + /** + * Create a new connection request from user to agent + */ + async createRequest(userId: string, dto: CreateConnectionRequestDto) { + // Check if agent profile exists and is available + const agentProfile = await this.prisma.agentProfile.findUnique({ + where: { id: dto.agentProfileId }, + select: { + id: true, + isAvailable: true, + isPublic: true, + firstName: true, + lastName: true, + userId: true, + }, + }); + + if (!agentProfile) { + throw new NotFoundException('Agent profile not found'); + } + + if (!agentProfile.isPublic) { + throw new BadRequestException('This agent profile is not public'); + } + + if (!agentProfile.isAvailable) { + throw new BadRequestException('This agent is currently not available for connections'); + } + + // Prevent users from connecting to their own agent profile + if (agentProfile.userId === userId) { + throw new BadRequestException('You cannot send a connection request to yourself'); + } + + // Check for existing request + const existingRequest = await this.prisma.connectionRequest.findUnique({ + where: { + userId_agentProfileId: { + userId, + agentProfileId: dto.agentProfileId, + }, + }, + }); + + if (existingRequest) { + if (existingRequest.status === ConnectionStatus.PENDING) { + throw new ConflictException('You already have a pending request to this agent'); + } + if (existingRequest.status === ConnectionStatus.ACCEPTED) { + throw new ConflictException('You are already connected with this agent'); + } + if (existingRequest.status === ConnectionStatus.REJECTED) { + // Allow re-requesting after rejection by updating the existing request + return this.prisma.connectionRequest.update({ + where: { id: existingRequest.id }, + data: { + status: ConnectionStatus.PENDING, + message: dto.message, + respondedAt: null, + updatedAt: new Date(), + }, + include: { + agentProfile: { + select: { + id: true, + firstName: true, + lastName: true, + avatar: true, + }, + }, + }, + }); + } + } + + // Create new connection request + return this.prisma.connectionRequest.create({ + data: { + userId, + agentProfileId: dto.agentProfileId, + message: dto.message, + status: ConnectionStatus.PENDING, + }, + include: { + agentProfile: { + select: { + id: true, + firstName: true, + lastName: true, + avatar: true, + }, + }, + }, + }); + } + + /** + * Get all connection requests sent by a user + */ + async getMyRequests(userId: string, status?: ConnectionStatus) { + const where: { userId: string; status?: ConnectionStatus } = { userId }; + if (status) { + where.status = status; + } + + return this.prisma.connectionRequest.findMany({ + where, + include: { + agentProfile: { + select: { + id: true, + firstName: true, + lastName: true, + avatar: true, + headline: true, + city: true, + state: true, + isVerified: true, + agentType: { + select: { + name: true, + }, + }, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + } + + /** + * Get all connection requests received by an agent + */ + async getReceivedRequests(agentProfileId: string, status?: ConnectionStatus) { + const where: { agentProfileId: string; status?: ConnectionStatus } = { agentProfileId }; + if (status) { + where.status = status; + } + + return this.prisma.connectionRequest.findMany({ + where, + include: { + user: { + select: { + id: true, + email: true, + avatar: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + } + + /** + * Respond to a connection request (accept/reject) + */ + async respondToRequest( + requestId: string, + agentProfileId: string, + dto: RespondConnectionRequestDto, + ) { + const request = await this.prisma.connectionRequest.findUnique({ + where: { id: requestId }, + }); + + if (!request) { + throw new NotFoundException('Connection request not found'); + } + + if (request.agentProfileId !== agentProfileId) { + throw new ForbiddenException('You are not authorized to respond to this request'); + } + + if (request.status !== ConnectionStatus.PENDING) { + throw new BadRequestException('This request has already been responded to'); + } + + return this.prisma.connectionRequest.update({ + where: { id: requestId }, + data: { + status: dto.status as ConnectionStatus, + respondedAt: new Date(), + }, + include: { + user: { + select: { + id: true, + email: true, + avatar: true, + }, + }, + }, + }); + } + + /** + * Get a single connection request by ID + */ + async getRequestById(requestId: string, userId: string, agentProfileId?: string) { + const request = await this.prisma.connectionRequest.findUnique({ + where: { id: requestId }, + include: { + user: { + select: { + id: true, + email: true, + avatar: true, + }, + }, + agentProfile: { + select: { + id: true, + firstName: true, + lastName: true, + avatar: true, + headline: true, + }, + }, + }, + }); + + if (!request) { + throw new NotFoundException('Connection request not found'); + } + + // Check authorization - either the user who sent it or the agent who received it + const isUserRequest = request.userId === userId; + const isAgentRequest = agentProfileId && request.agentProfileId === agentProfileId; + + if (!isUserRequest && !isAgentRequest) { + throw new ForbiddenException('You are not authorized to view this request'); + } + + return request; + } + + /** + * Cancel a pending connection request (user only) + */ + async cancelRequest(requestId: string, userId: string) { + const request = await this.prisma.connectionRequest.findUnique({ + where: { id: requestId }, + }); + + if (!request) { + throw new NotFoundException('Connection request not found'); + } + + if (request.userId !== userId) { + throw new ForbiddenException('You are not authorized to cancel this request'); + } + + if (request.status !== ConnectionStatus.PENDING) { + throw new BadRequestException('Only pending requests can be cancelled'); + } + + return this.prisma.connectionRequest.delete({ + where: { id: requestId }, + }); + } + + /** + * Get connection status between a user and an agent + */ + async getConnectionStatus(userId: string, agentProfileId: string) { + const request = await this.prisma.connectionRequest.findUnique({ + where: { + userId_agentProfileId: { + userId, + agentProfileId, + }, + }, + select: { + id: true, + status: true, + createdAt: true, + respondedAt: true, + }, + }); + + return request || null; + } + + /** + * Get counts for agent's connection requests + */ + async getRequestCounts(agentProfileId: string) { + const [pending, accepted, rejected] = await Promise.all([ + this.prisma.connectionRequest.count({ + where: { agentProfileId, status: ConnectionStatus.PENDING }, + }), + this.prisma.connectionRequest.count({ + where: { agentProfileId, status: ConnectionStatus.ACCEPTED }, + }), + this.prisma.connectionRequest.count({ + where: { agentProfileId, status: ConnectionStatus.REJECTED }, + }), + ]); + + return { pending, accepted, rejected, total: pending + accepted + rejected }; + } +} diff --git a/src/connection-requests/dto/create-connection-request.dto.ts b/src/connection-requests/dto/create-connection-request.dto.ts new file mode 100644 index 0000000..c738410 --- /dev/null +++ b/src/connection-requests/dto/create-connection-request.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, IsUUID, MaxLength } from 'class-validator'; + +export class CreateConnectionRequestDto { + @ApiProperty({ + example: 'uuid-agent-profile-id', + description: 'The ID of the agent profile to connect with', + }) + @IsUUID('4') + agentProfileId: string; + + @ApiPropertyOptional({ + example: 'Hi, I am interested in your services for buying a home.', + description: 'Optional message to send with the connection request', + }) + @IsOptional() + @IsString() + @MaxLength(1000) + message?: string; +} diff --git a/src/connection-requests/dto/index.ts b/src/connection-requests/dto/index.ts new file mode 100644 index 0000000..e9cc694 --- /dev/null +++ b/src/connection-requests/dto/index.ts @@ -0,0 +1,2 @@ +export * from './create-connection-request.dto'; +export * from './respond-connection-request.dto'; diff --git a/src/connection-requests/dto/respond-connection-request.dto.ts b/src/connection-requests/dto/respond-connection-request.dto.ts new file mode 100644 index 0000000..37ad3f4 --- /dev/null +++ b/src/connection-requests/dto/respond-connection-request.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn } from 'class-validator'; + +export class RespondConnectionRequestDto { + @ApiProperty({ + enum: ['ACCEPTED', 'REJECTED'], + example: 'ACCEPTED', + description: 'The response status (ACCEPTED or REJECTED)', + }) + @IsIn(['ACCEPTED', 'REJECTED'], { + message: 'Status must be ACCEPTED or REJECTED', + }) + status: 'ACCEPTED' | 'REJECTED'; +} diff --git a/src/connection-requests/index.ts b/src/connection-requests/index.ts new file mode 100644 index 0000000..f1cbac4 --- /dev/null +++ b/src/connection-requests/index.ts @@ -0,0 +1,3 @@ +export * from './connection-requests.module'; +export * from './connection-requests.service'; +export * from './connection-requests.controller'; diff --git a/src/profile-fields/profile-fields.service.ts b/src/profile-fields/profile-fields.service.ts index a092878..ca1bc70 100644 --- a/src/profile-fields/profile-fields.service.ts +++ b/src/profile-fields/profile-fields.service.ts @@ -228,7 +228,8 @@ export class ProfileFieldsService { await this.prisma.$transaction(operations); - return { success: true, updated: updates.length }; + // Return raw data - TransformInterceptor will wrap it + return { updated: updates.length }; } /**