feat: Implement testimonials module with submission, link generation, and viewing functionalities.
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
1
src/testimonials/dto/index.ts
Normal file
1
src/testimonials/dto/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './submit-testimonial.dto';
|
||||
28
src/testimonials/dto/submit-testimonial.dto.ts
Normal file
28
src/testimonials/dto/submit-testimonial.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
3
src/testimonials/index.ts
Normal file
3
src/testimonials/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './testimonials.module';
|
||||
export * from './testimonials.service';
|
||||
export * from './testimonials.controller';
|
||||
84
src/testimonials/testimonials.controller.ts
Normal file
84
src/testimonials/testimonials.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
12
src/testimonials/testimonials.module.ts
Normal file
12
src/testimonials/testimonials.module.ts
Normal file
@@ -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 {}
|
||||
108
src/testimonials/testimonials.service.ts
Normal file
108
src/testimonials/testimonials.service.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user