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)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
agentType AgentType? @relation(fields: [agentTypeId], references: [id])
|
agentType AgentType? @relation(fields: [agentTypeId], references: [id])
|
||||||
fieldValues AgentProfileFieldValue[]
|
fieldValues AgentProfileFieldValue[]
|
||||||
|
// Testimonial Link
|
||||||
|
testimonialToken String? @unique
|
||||||
|
|
||||||
connectionRequests ConnectionRequest[]
|
connectionRequests ConnectionRequest[]
|
||||||
conversations Conversation[]
|
conversations Conversation[]
|
||||||
|
testimonials Testimonial[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([agentTypeId])
|
@@index([agentTypeId])
|
||||||
@@ -547,3 +551,24 @@ model CmsContent {
|
|||||||
@@index([pageSlug])
|
@@index([pageSlug])
|
||||||
@@map("cms_contents")
|
@@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 { MessagesModule } from './messages';
|
||||||
import { CmsModule } from './cms';
|
import { CmsModule } from './cms';
|
||||||
import { NotificationsModule } from './notifications';
|
import { NotificationsModule } from './notifications';
|
||||||
|
import { TestimonialsModule } from './testimonials';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@ import { AppService } from './app.service';
|
|||||||
MessagesModule,
|
MessagesModule,
|
||||||
CmsModule,
|
CmsModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
|
TestimonialsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
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