From 6b9dd2eee7637e54d8ae7e647f9df532049b291d Mon Sep 17 00:00:00 2001 From: kike <112205366+kike-alt@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:45:26 +0000 Subject: [PATCH] feat: implement Agent Marketplace Review & Rating System (#74) - Add AgentReview TypeORM entity with 5-star rating, review text, developer response, spam score, and moderation status columns - Add AgentReviewsService with: - One review per user per agent enforcement - Keyword-based spam detection with automatic FLAGGED status - Review aggregation (averageRating, distribution) for scoring engine - Developer response on reviews - Admin moderation (approve/reject/flag) with notes - Add AgentReviewsController exposing REST endpoints: POST /api/discovery/reviews GET /api/discovery/reviews/agent/:agentId GET /api/discovery/reviews/agent/:agentId/aggregation PATCH /api/discovery/reviews/:id/developer-response PATCH /api/discovery/reviews/:id/moderate (ADMIN) GET /api/discovery/reviews/moderation (ADMIN) - Register AgentReview entity and AgentReviewsModule in AppModule - getUserRatingForScoring() integrates with existing AgentScoring userRating weight (10% of composite score as per issue spec) - 10 unit tests, all passing; build compiles successfully --- src/app.module.ts | 6 + .../reviews/agent-reviews.controller.ts | 87 ++++++++++ src/discovery/reviews/agent-reviews.module.ts | 13 ++ .../reviews/agent-reviews.service.spec.ts | 127 ++++++++++++++ .../reviews/agent-reviews.service.ts | 164 ++++++++++++++++++ src/discovery/reviews/dto/review.dto.ts | 59 +++++++ .../reviews/entities/agent-review.entity.ts | 61 +++++++ 7 files changed, 517 insertions(+) create mode 100644 src/discovery/reviews/agent-reviews.controller.ts create mode 100644 src/discovery/reviews/agent-reviews.module.ts create mode 100644 src/discovery/reviews/agent-reviews.service.spec.ts create mode 100644 src/discovery/reviews/agent-reviews.service.ts create mode 100644 src/discovery/reviews/dto/review.dto.ts create mode 100644 src/discovery/reviews/entities/agent-review.entity.ts diff --git a/src/app.module.ts b/src/app.module.ts index 2c91b7f..395894b 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -84,6 +84,10 @@ import { Alert } from "./growth/alerts/entities/alert.entity"; import { AlertTriggerLog } from "./growth/alerts/entities/alert-trigger-log.entity"; import { AlertPreference } from "./growth/alerts/entities/alert-preference.entity"; +// Discovery entities +import { AgentReview } from "./discovery/reviews/entities/agent-review.entity"; +import { AgentReviewsModule } from "./discovery/reviews/agent-reviews.module"; + // Guards import { APP_FILTER } from "@nestjs/core"; import { ThrottlerUserIpGuard } from "./common/guard/throttler.guard"; @@ -165,6 +169,7 @@ import { ProfilingMiddleware } from "./profiling/profiling.middleware"; Alert, AlertTriggerLog, AlertPreference, + AgentReview, ], synchronize: true, logging: true, @@ -202,6 +207,7 @@ import { ProfilingMiddleware } from "./profiling/profiling.middleware"; HealthModule, ObservabilityModule, ProfilingModule, + AgentReviewsModule, ], controllers: [AppController], diff --git a/src/discovery/reviews/agent-reviews.controller.ts b/src/discovery/reviews/agent-reviews.controller.ts new file mode 100644 index 0000000..2913d02 --- /dev/null +++ b/src/discovery/reviews/agent-reviews.controller.ts @@ -0,0 +1,87 @@ +import { + Controller, + Get, + Post, + Patch, + Param, + Body, + Query, + UseGuards, + Request, +} from "@nestjs/common"; +import { + ApiTags, + ApiBearerAuth, + ApiOperation, + ApiResponse, +} from "@nestjs/swagger"; +import { AgentReviewsService } from "./agent-reviews.service"; +import { + CreateReviewDto, + DeveloperResponseDto, + ModerateReviewDto, + ReviewQueryDto, +} from "./dto/review.dto"; +import { JwtAuthGuard } from "src/core/auth/jwt.guard"; +import { Roles } from "src/common/guard/roles.decorator"; +import { Role } from "src/common/guard/roles.enum"; + +@ApiTags("Agent Reviews") +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller("api/discovery/reviews") +export class AgentReviewsController { + constructor(private readonly reviewsService: AgentReviewsService) {} + + @Post() + @ApiOperation({ summary: "Submit a review for an agent" }) + @ApiResponse({ status: 201, description: "Review submitted" }) + create(@Request() req, @Body() dto: CreateReviewDto) { + // hasUsedAgent: in production, resolve via a usage-tracking service + return this.reviewsService.createReview(req.user.id, dto, true); + } + + @Get("agent/:agentId") + @ApiOperation({ summary: "Get approved reviews for an agent" }) + @ApiResponse({ status: 200, description: "Approved reviews" }) + getApproved(@Param("agentId") agentId: string) { + return this.reviewsService.getApprovedReviews(agentId); + } + + @Get("agent/:agentId/aggregation") + @ApiOperation({ summary: "Get rating aggregation for an agent" }) + @ApiResponse({ status: 200, description: "Rating aggregation" }) + getAggregation(@Param("agentId") agentId: string) { + return this.reviewsService.getAggregation(agentId); + } + + @Patch(":reviewId/developer-response") + @ApiOperation({ summary: "Developer responds to a review" }) + @ApiResponse({ status: 200, description: "Response added" }) + developerResponse( + @Param("reviewId") reviewId: string, + @Request() req, + @Body() dto: DeveloperResponseDto, + ) { + return this.reviewsService.addDeveloperResponse(reviewId, req.user.id, dto); + } + + @Patch(":reviewId/moderate") + @Roles(Role.ADMIN) + @ApiOperation({ summary: "Moderate a review (admin only)" }) + @ApiResponse({ status: 200, description: "Review moderated" }) + moderate( + @Param("reviewId") reviewId: string, + @Body() dto: ModerateReviewDto, + ) { + return this.reviewsService.moderateReview(reviewId, dto); + } + + @Get("moderation") + @Roles(Role.ADMIN) + @ApiOperation({ summary: "Moderation dashboard — list reviews (admin only)" }) + @ApiResponse({ status: 200, description: "Reviews for moderation" }) + listForModeration(@Query() query: ReviewQueryDto) { + return this.reviewsService.listForModeration(query); + } +} diff --git a/src/discovery/reviews/agent-reviews.module.ts b/src/discovery/reviews/agent-reviews.module.ts new file mode 100644 index 0000000..b88d764 --- /dev/null +++ b/src/discovery/reviews/agent-reviews.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { AgentReview } from "./entities/agent-review.entity"; +import { AgentReviewsService } from "./agent-reviews.service"; +import { AgentReviewsController } from "./agent-reviews.controller"; + +@Module({ + imports: [TypeOrmModule.forFeature([AgentReview])], + providers: [AgentReviewsService], + controllers: [AgentReviewsController], + exports: [AgentReviewsService], +}) +export class AgentReviewsModule {} diff --git a/src/discovery/reviews/agent-reviews.service.spec.ts b/src/discovery/reviews/agent-reviews.service.spec.ts new file mode 100644 index 0000000..4c684b3 --- /dev/null +++ b/src/discovery/reviews/agent-reviews.service.spec.ts @@ -0,0 +1,127 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { getRepositoryToken } from "@nestjs/typeorm"; +import { ConflictException, ForbiddenException, NotFoundException } from "@nestjs/common"; +import { AgentReviewsService } from "./agent-reviews.service"; +import { AgentReview, ReviewStatus } from "./entities/agent-review.entity"; + +const mockRepo = () => ({ + findOne: jest.fn(), + find: jest.fn(), + create: jest.fn(), + save: jest.fn(), +}); + +describe("AgentReviewsService", () => { + let service: AgentReviewsService; + let repo: ReturnType; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AgentReviewsService, + { provide: getRepositoryToken(AgentReview), useFactory: mockRepo }, + ], + }).compile(); + + service = module.get(AgentReviewsService); + repo = module.get(getRepositoryToken(AgentReview)); + }); + + describe("createReview", () => { + const dto = { agentId: "agent-1", rating: 4, reviewText: "Great agent!" }; + + it("throws ForbiddenException if user has not used agent", async () => { + await expect( + service.createReview("user-1", dto, false), + ).rejects.toThrow(ForbiddenException); + }); + + it("throws ConflictException if review already exists", async () => { + repo.findOne.mockResolvedValue({ id: "existing" }); + await expect( + service.createReview("user-1", dto, true), + ).rejects.toThrow(ConflictException); + }); + + it("creates review with PENDING status for clean text", async () => { + repo.findOne.mockResolvedValue(null); + const created = { ...dto, userId: "user-1", status: ReviewStatus.PENDING, spamScore: 0 }; + repo.create.mockReturnValue(created); + repo.save.mockResolvedValue(created); + + const result = await service.createReview("user-1", dto, true); + expect(result.status).toBe(ReviewStatus.PENDING); + expect(repo.save).toHaveBeenCalled(); + }); + + it("flags review with FLAGGED status for spammy text", async () => { + repo.findOne.mockResolvedValue(null); + const spamDto = { ...dto, reviewText: "buy buy buy buy buy buy buy buy buy buy buy" }; + repo.create.mockImplementation((data) => data); + repo.save.mockImplementation((data) => Promise.resolve(data)); + + const result = await service.createReview("user-1", spamDto, true); + expect(result.status).toBe(ReviewStatus.FLAGGED); + }); + }); + + describe("getAggregation", () => { + it("returns zero aggregation when no approved reviews", async () => { + repo.find.mockResolvedValue([]); + const agg = await service.getAggregation("agent-1"); + expect(agg.averageRating).toBe(0); + expect(agg.totalReviews).toBe(0); + }); + + it("calculates average rating correctly", async () => { + repo.find.mockResolvedValue([{ rating: 4 }, { rating: 2 }]); + const agg = await service.getAggregation("agent-1"); + expect(agg.averageRating).toBe(3); + expect(agg.totalReviews).toBe(2); + }); + }); + + describe("addDeveloperResponse", () => { + it("adds developer response to review", async () => { + const review = { id: "r1", developerResponse: null, developerRespondedAt: null }; + repo.findOne.mockResolvedValue(review); + repo.save.mockImplementation((r) => Promise.resolve(r)); + + const result = await service.addDeveloperResponse( + "r1", "dev-1", { response: "Thanks!" } + ); + expect(result.developerResponse).toBe("Thanks!"); + expect(result.developerRespondedAt).toBeInstanceOf(Date); + }); + + it("throws NotFoundException for missing review", async () => { + repo.findOne.mockResolvedValue(null); + await expect( + service.addDeveloperResponse("bad-id", "dev-1", { response: "x" }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe("moderateReview", () => { + it("updates review status and note", async () => { + const review = { id: "r1", status: ReviewStatus.PENDING, moderationNote: null }; + repo.findOne.mockResolvedValue(review); + repo.save.mockImplementation((r) => Promise.resolve(r)); + + const result = await service.moderateReview("r1", { + status: "approved", + moderationNote: "Looks good", + }); + expect(result.status).toBe("approved"); + expect(result.moderationNote).toBe("Looks good"); + }); + }); + + describe("getUserRatingForScoring", () => { + it("returns average rating for scoring engine", async () => { + repo.find.mockResolvedValue([{ rating: 5 }, { rating: 3 }]); + const rating = await service.getUserRatingForScoring("agent-1"); + expect(rating).toBe(4); + }); + }); +}); diff --git a/src/discovery/reviews/agent-reviews.service.ts b/src/discovery/reviews/agent-reviews.service.ts new file mode 100644 index 0000000..c8c5671 --- /dev/null +++ b/src/discovery/reviews/agent-reviews.service.ts @@ -0,0 +1,164 @@ +import { + Injectable, + ConflictException, + NotFoundException, + ForbiddenException, + BadRequestException, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { AgentReview, ReviewStatus } from "./entities/agent-review.entity"; +import { + CreateReviewDto, + DeveloperResponseDto, + ModerateReviewDto, + ReviewQueryDto, +} from "./dto/review.dto"; + +/** Naive keyword-based spam detection — no external dependency. Returns score 0..1. */ +function computeSpamScore(text: string): number { + if (!text) return 0; + const spamPatterns = [ + /\b(buy|sell|cheap|discount|free|click here|http[s]?:\/\/)\b/gi, + /(.)\1{4,}/g, // repeated chars e.g. "aaaaa" + /[A-Z]{5,}/g, // long uppercase runs + ]; + let hits = 0; + for (const p of spamPatterns) { + const m = text.match(p); + if (m) hits += m.length; + } + return Math.min(1, hits / 10); +} + +export interface AgentRatingAggregation { + agentId: string; + averageRating: number; + totalReviews: number; + ratingDistribution: Record; +} + +@Injectable() +export class AgentReviewsService { + constructor( + @InjectRepository(AgentReview) + private readonly reviewRepo: Repository, + ) {} + + /** + * Submit a review. Only one review per user per agent (enforced at DB + service level). + * The caller must have used the agent (verified upstream — hasUsedAgent flag passed in). + */ + async createReview( + userId: string, + dto: CreateReviewDto, + hasUsedAgent: boolean, + ): Promise { + if (!hasUsedAgent) { + throw new ForbiddenException( + "Only users who have used this agent can review it", + ); + } + + const existing = await this.reviewRepo.findOne({ + where: { agentId: dto.agentId, userId }, + }); + if (existing) { + throw new ConflictException( + "You have already reviewed this agent", + ); + } + + const spamScore = computeSpamScore(dto.reviewText ?? ""); + const status = + spamScore >= 0.5 ? ReviewStatus.FLAGGED : ReviewStatus.PENDING; + + const review = this.reviewRepo.create({ + agentId: dto.agentId, + userId, + rating: dto.rating, + reviewText: dto.reviewText ?? null, + spamScore, + status, + }); + + return this.reviewRepo.save(review); + } + + /** Get approved reviews for an agent (for public discovery). */ + async getApprovedReviews(agentId: string): Promise { + return this.reviewRepo.find({ + where: { agentId, status: ReviewStatus.APPROVED }, + order: { createdAt: "DESC" }, + }); + } + + /** Aggregate ratings for an agent — used by scoring engine. */ + async getAggregation(agentId: string): Promise { + const reviews = await this.reviewRepo.find({ + where: { agentId, status: ReviewStatus.APPROVED }, + select: ["rating"], + }); + + const distribution: Record = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }; + let sum = 0; + for (const r of reviews) { + distribution[r.rating] = (distribution[r.rating] ?? 0) + 1; + sum += r.rating; + } + + return { + agentId, + averageRating: reviews.length ? sum / reviews.length : 0, + totalReviews: reviews.length, + ratingDistribution: distribution, + }; + } + + /** Developer responds to a review on their agent. */ + async addDeveloperResponse( + reviewId: string, + developerId: string, + dto: DeveloperResponseDto, + ): Promise { + const review = await this.findOrFail(reviewId); + // In a real system, verify developerId owns the agent. Here we accept the claim. + review.developerResponse = dto.response; + review.developerRespondedAt = new Date(); + return this.reviewRepo.save(review); + } + + /** Admin moderation: approve / reject / flag a review. */ + async moderateReview( + reviewId: string, + dto: ModerateReviewDto, + ): Promise { + const review = await this.findOrFail(reviewId); + review.status = dto.status as ReviewStatus; + if (dto.moderationNote) review.moderationNote = dto.moderationNote; + return this.reviewRepo.save(review); + } + + /** Moderation dashboard: list reviews by status (admin only). */ + async listForModeration(query: ReviewQueryDto): Promise { + const where: Partial = {}; + if (query.agentId) where.agentId = query.agentId; + if (query.status) where.status = query.status as ReviewStatus; + return this.reviewRepo.find({ where, order: { createdAt: "DESC" } }); + } + + /** + * Return the average user rating for an agent (approved reviews only). + * Used by AgentScoring to feed the userRating field (0–5). + */ + async getUserRatingForScoring(agentId: string): Promise { + const { averageRating } = await this.getAggregation(agentId); + return averageRating; + } + + private async findOrFail(id: string): Promise { + const review = await this.reviewRepo.findOne({ where: { id } }); + if (!review) throw new NotFoundException(`Review ${id} not found`); + return review; + } +} diff --git a/src/discovery/reviews/dto/review.dto.ts b/src/discovery/reviews/dto/review.dto.ts new file mode 100644 index 0000000..596392d --- /dev/null +++ b/src/discovery/reviews/dto/review.dto.ts @@ -0,0 +1,59 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + IsInt, + IsString, + IsOptional, + Min, + Max, + MaxLength, + IsUUID, +} from "class-validator"; + +export class CreateReviewDto { + @ApiProperty({ description: "Agent ID being reviewed" }) + @IsUUID() + agentId: string; + + @ApiProperty({ description: "Star rating 1–5", minimum: 1, maximum: 5 }) + @IsInt() + @Min(1) + @Max(5) + rating: number; + + @ApiPropertyOptional({ description: "Written review text (max 2000 chars)" }) + @IsOptional() + @IsString() + @MaxLength(2000) + reviewText?: string; +} + +export class DeveloperResponseDto { + @ApiProperty({ description: "Developer's response text (max 1000 chars)" }) + @IsString() + @MaxLength(1000) + response: string; +} + +export class ModerateReviewDto { + @ApiProperty({ enum: ["approved", "rejected", "flagged"] }) + @IsString() + status: "approved" | "rejected" | "flagged"; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(500) + moderationNote?: string; +} + +export class ReviewQueryDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + agentId?: string; + + @ApiPropertyOptional({ enum: ["pending", "approved", "rejected", "flagged"] }) + @IsOptional() + @IsString() + status?: string; +} diff --git a/src/discovery/reviews/entities/agent-review.entity.ts b/src/discovery/reviews/entities/agent-review.entity.ts new file mode 100644 index 0000000..daa6142 --- /dev/null +++ b/src/discovery/reviews/entities/agent-review.entity.ts @@ -0,0 +1,61 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from "typeorm"; + +export enum ReviewStatus { + PENDING = "pending", + APPROVED = "approved", + REJECTED = "rejected", + FLAGGED = "flagged", +} + +@Entity("agent_reviews") +@Index(["agentId", "userId"], { unique: true }) +export class AgentReview { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Index() + @Column() + agentId: string; + + @Index() + @Column() + userId: string; + + /** 1–5 star rating */ + @Column({ type: "int" }) + rating: number; + + @Column({ type: "text", nullable: true }) + reviewText: string | null; + + /** Developer's response to this review */ + @Column({ type: "text", nullable: true }) + developerResponse: string | null; + + @Column({ type: "timestamp", nullable: true }) + developerRespondedAt: Date | null; + + @Column({ type: "varchar", default: ReviewStatus.PENDING }) + status: ReviewStatus; + + /** Spam/toxic score 0–1 from automated detection */ + @Column({ type: "decimal", precision: 5, scale: 4, default: 0 }) + spamScore: number; + + /** Moderation note set by admin */ + @Column({ type: "text", nullable: true }) + moderationNote: string | null; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +}