diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6c5bf74..4841026 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -235,8 +235,12 @@ model AgentProfile { user User @relation(fields: [userId], references: [id], onDelete: Cascade) agentType AgentType? @relation(fields: [agentTypeId], references: [id]) fieldValues AgentProfileFieldValue[] + // Testimonial Link + testimonialToken String? @unique + connectionRequests ConnectionRequest[] conversations Conversation[] + testimonials Testimonial[] @@index([userId]) @@index([agentTypeId]) @@ -547,3 +551,24 @@ model CmsContent { @@index([pageSlug]) @@map("cms_contents") } + +// =========================================== +// TESTIMONIALS +// =========================================== + +model Testimonial { + id String @id @default(uuid()) + agentProfileId String + rating Int // 1-5 + text String @db.Text + authorName String + authorRole String // "Home Buyer", "Investor", etc. + isPublished Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + agentProfile AgentProfile @relation(fields: [agentProfileId], references: [id], onDelete: Cascade) + + @@index([agentProfileId]) + @@map("testimonials") +} diff --git a/src/app.module.ts b/src/app.module.ts index 6db9b1b..1db696e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -18,6 +18,7 @@ import { ConnectionRequestsModule } from './connection-requests'; import { MessagesModule } from './messages'; import { CmsModule } from './cms'; import { NotificationsModule } from './notifications'; +import { TestimonialsModule } from './testimonials'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @@ -59,6 +60,7 @@ import { AppService } from './app.service'; MessagesModule, CmsModule, NotificationsModule, + TestimonialsModule, ], controllers: [AppController], providers: [ diff --git a/src/testimonials/dto/index.ts b/src/testimonials/dto/index.ts new file mode 100644 index 0000000..46bee8c --- /dev/null +++ b/src/testimonials/dto/index.ts @@ -0,0 +1 @@ +export * from './submit-testimonial.dto'; diff --git a/src/testimonials/dto/submit-testimonial.dto.ts b/src/testimonials/dto/submit-testimonial.dto.ts new file mode 100644 index 0000000..9f76146 --- /dev/null +++ b/src/testimonials/dto/submit-testimonial.dto.ts @@ -0,0 +1,28 @@ +import { IsString, IsInt, Min, Max, MaxLength, IsNotEmpty } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class SubmitTestimonialDto { + @ApiProperty({ description: 'Rating from 1 to 5', minimum: 1, maximum: 5 }) + @IsInt() + @Min(1) + @Max(5) + rating: number; + + @ApiProperty({ description: 'Testimonial text' }) + @IsString() + @IsNotEmpty() + @MaxLength(2000) + text: string; + + @ApiProperty({ description: 'Author name' }) + @IsString() + @IsNotEmpty() + @MaxLength(100) + authorName: string; + + @ApiProperty({ description: 'Author role, e.g. Home Buyer, Investor' }) + @IsString() + @IsNotEmpty() + @MaxLength(100) + authorRole: string; +} diff --git a/src/testimonials/index.ts b/src/testimonials/index.ts new file mode 100644 index 0000000..91777d9 --- /dev/null +++ b/src/testimonials/index.ts @@ -0,0 +1,3 @@ +export * from './testimonials.module'; +export * from './testimonials.service'; +export * from './testimonials.controller'; diff --git a/src/testimonials/testimonials.controller.ts b/src/testimonials/testimonials.controller.ts new file mode 100644 index 0000000..4a583e6 --- /dev/null +++ b/src/testimonials/testimonials.controller.ts @@ -0,0 +1,84 @@ +import { + Controller, + Get, + Post, + Body, + Param, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { TestimonialsService } from './testimonials.service'; +import { SubmitTestimonialDto } 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 { Public } from '../auth/decorators/public.decorator'; +import { UserRole } from '@prisma/client'; + +@ApiTags('Testimonials') +@Controller('testimonials') +export class TestimonialsController { + constructor(private readonly testimonialsService: TestimonialsService) {} + + @Post('generate-link') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.AGENT) + @ApiBearerAuth() + @ApiOperation({ summary: 'Generate a shareable testimonial link' }) + @ApiResponse({ status: 201, description: 'Link generated successfully' }) + async generateLink(@CurrentUser() user: { id: string }) { + return this.testimonialsService.generateLink(user.id); + } + + @Get('my') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.AGENT) + @ApiBearerAuth() + @ApiOperation({ summary: "Get agent's own testimonials" }) + @ApiQuery({ name: 'sort', enum: ['latest', 'oldest'], required: false }) + @ApiResponse({ status: 200, description: 'Testimonials retrieved successfully' }) + async getMyTestimonials( + @CurrentUser() user: { id: string }, + @Query('sort') sort?: 'latest' | 'oldest', + ) { + return this.testimonialsService.getMyTestimonials(user.id, sort); + } + + @Get('agent/:agentProfileId') + @Public() + @ApiOperation({ summary: 'Get published testimonials for an agent (public)' }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiResponse({ status: 200, description: 'Testimonials retrieved successfully' }) + async getAgentTestimonials( + @Param('agentProfileId') agentProfileId: string, + @Query('limit') limit?: string, + ) { + return this.testimonialsService.getAgentTestimonials( + agentProfileId, + limit ? parseInt(limit, 10) : undefined, + ); + } + + @Get('submit/:token') + @Public() + @ApiOperation({ summary: 'Get agent info for testimonial submission form' }) + @ApiResponse({ status: 200, description: 'Agent info retrieved' }) + @ApiResponse({ status: 404, description: 'Invalid token' }) + async getAgentByToken(@Param('token') token: string) { + return this.testimonialsService.getAgentByToken(token); + } + + @Post('submit/:token') + @Public() + @ApiOperation({ summary: 'Submit a testimonial for an agent' }) + @ApiResponse({ status: 201, description: 'Testimonial submitted successfully' }) + @ApiResponse({ status: 404, description: 'Invalid token' }) + async submitTestimonial( + @Param('token') token: string, + @Body() dto: SubmitTestimonialDto, + ) { + return this.testimonialsService.submitTestimonial(token, dto); + } +} diff --git a/src/testimonials/testimonials.module.ts b/src/testimonials/testimonials.module.ts new file mode 100644 index 0000000..d3e79ba --- /dev/null +++ b/src/testimonials/testimonials.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../prisma'; +import { TestimonialsController } from './testimonials.controller'; +import { TestimonialsService } from './testimonials.service'; + +@Module({ + imports: [PrismaModule], + controllers: [TestimonialsController], + providers: [TestimonialsService], + exports: [TestimonialsService], +}) +export class TestimonialsModule {} diff --git a/src/testimonials/testimonials.service.ts b/src/testimonials/testimonials.service.ts new file mode 100644 index 0000000..f017229 --- /dev/null +++ b/src/testimonials/testimonials.service.ts @@ -0,0 +1,108 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { SubmitTestimonialDto } from './dto'; +import { randomBytes } from 'crypto'; + +@Injectable() +export class TestimonialsService { + constructor(private readonly prisma: PrismaService) {} + + async generateLink(userId: string) { + const agentProfile = await this.prisma.agentProfile.findUnique({ + where: { userId }, + select: { id: true, testimonialToken: true }, + }); + + if (!agentProfile) { + throw new NotFoundException('Agent profile not found'); + } + + // Generate a URL-safe token + const token = randomBytes(8).toString('base64url'); + + await this.prisma.agentProfile.update({ + where: { id: agentProfile.id }, + data: { testimonialToken: token }, + }); + + return { token }; + } + + async getMyTestimonials(userId: string, sort: 'latest' | 'oldest' = 'latest') { + const agentProfile = await this.prisma.agentProfile.findUnique({ + where: { userId }, + select: { id: true }, + }); + + if (!agentProfile) { + throw new NotFoundException('Agent profile not found'); + } + + return this.prisma.testimonial.findMany({ + where: { agentProfileId: agentProfile.id }, + orderBy: { createdAt: sort === 'latest' ? 'desc' : 'asc' }, + }); + } + + async getAgentTestimonials(agentProfileId: string, limit?: number) { + return this.prisma.testimonial.findMany({ + where: { agentProfileId, isPublished: true }, + orderBy: { createdAt: 'desc' }, + ...(limit ? { take: limit } : {}), + }); + } + + async getAgentByToken(token: string) { + const agentProfile = await this.prisma.agentProfile.findUnique({ + where: { testimonialToken: token }, + select: { + id: true, + firstName: true, + lastName: true, + avatar: true, + agentType: { select: { name: true } }, + }, + }); + + if (!agentProfile) { + throw new NotFoundException('Invalid testimonial link'); + } + + return agentProfile; + } + + async submitTestimonial(token: string, dto: SubmitTestimonialDto) { + const agentProfile = await this.prisma.agentProfile.findUnique({ + where: { testimonialToken: token }, + select: { id: true, totalReviews: true, averageRating: true }, + }); + + if (!agentProfile) { + throw new NotFoundException('Invalid testimonial link'); + } + + // Create testimonial and update stats in a transaction + const [testimonial] = await this.prisma.$transaction([ + this.prisma.testimonial.create({ + data: { + agentProfileId: agentProfile.id, + rating: dto.rating, + text: dto.text, + authorName: dto.authorName, + authorRole: dto.authorRole, + }, + }), + this.prisma.agentProfile.update({ + where: { id: agentProfile.id }, + data: { + totalReviews: agentProfile.totalReviews + 1, + averageRating: + (agentProfile.averageRating * agentProfile.totalReviews + dto.rating) / + (agentProfile.totalReviews + 1), + }, + }), + ]); + + return testimonial; + } +}