Skip to content

Commit 628dc93

Browse files
authored
Merge pull request #183 from Lakes41/lakes1
Puzzle Rating and Review System
2 parents a7ebbc3 + 209e112 commit 628dc93

19 files changed

Lines changed: 1111 additions & 608 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Controller, Post, Body, Param, UseGuards, Request, Get } from '@nestjs/common';
2+
import { ThrottlerGuard } from '@nestjs/throttler';
3+
import { PuzzleRatingService } from '../services/puzzle-rating.service';
4+
import { CreateRatingDto } from '../dto/create-rating.dto';
5+
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
6+
import { PuzzleRating } from '../entities/puzzle-rating.entity';
7+
import { PuzzleRatingAggregate } from '../entities/puzzle-rating-aggregate.entity';
8+
9+
@Controller('api/puzzles')
10+
export class PuzzleRatingController {
11+
constructor(private readonly ratingService: PuzzleRatingService) {}
12+
13+
@Post(':id/ratings')
14+
@UseGuards(JwtAuthGuard, ThrottlerGuard)
15+
async submitRating(
16+
@Param('id') puzzleId: string,
17+
@Body() createRatingDto: CreateRatingDto,
18+
@Request() req,
19+
): Promise<PuzzleRating> {
20+
return this.ratingService.submitRating(req.user.id, puzzleId, createRatingDto);
21+
}
22+
23+
@Get(':id/ratings/aggregate')
24+
async getAggregate(@Param('id') puzzleId: string): Promise<PuzzleRatingAggregate> {
25+
return this.ratingService.getPuzzleAggregate(puzzleId);
26+
}
27+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { Controller, Post, Put, Delete, Body, Param, UseGuards, Request, Get, Query } from '@nestjs/common';
2+
import { ThrottlerGuard } from '@nestjs/throttler';
3+
import { PuzzleReviewService } from '../services/puzzle-review.service';
4+
import { CreateReviewDto } from '../dto/create-review.dto';
5+
import { UpdateReviewDto } from '../dto/update-review.dto';
6+
import { VoteReviewDto } from '../dto/vote-review.dto';
7+
import { FlagReviewDto } from '../dto/flag-review.dto';
8+
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
9+
import { PuzzleReview } from '../entities/puzzle-review.entity';
10+
11+
@Controller('api')
12+
export class PuzzleReviewController {
13+
constructor(private readonly reviewService: PuzzleReviewService) {}
14+
15+
@Post('puzzles/:id/reviews')
16+
@UseGuards(JwtAuthGuard)
17+
async submitReview(
18+
@Param('id') puzzleId: string,
19+
@Body() createReviewDto: CreateReviewDto,
20+
@Request() req,
21+
): Promise<PuzzleReview> {
22+
return this.reviewService.submitReview(req.user.id, puzzleId, createReviewDto);
23+
}
24+
25+
@Put('reviews/:id')
26+
@UseGuards(JwtAuthGuard)
27+
async updateReview(
28+
@Param('id') reviewId: string,
29+
@Body() updateReviewDto: UpdateReviewDto,
30+
@Request() req,
31+
): Promise<PuzzleReview> {
32+
return this.reviewService.updateReview(req.user.id, reviewId, updateReviewDto);
33+
}
34+
35+
@Delete('reviews/:id')
36+
@UseGuards(JwtAuthGuard)
37+
async deleteReview(@Param('id') reviewId: string, @Request() req): Promise<void> {
38+
return this.reviewService.deleteReview(req.user.id, reviewId);
39+
}
40+
41+
@Post('reviews/:id/vote')
42+
@UseGuards(JwtAuthGuard, ThrottlerGuard)
43+
async voteReview(
44+
@Param('id') reviewId: string,
45+
@Body() voteDto: VoteReviewDto,
46+
@Request() req,
47+
): Promise<void> {
48+
return this.reviewService.voteReview(req.user.id, reviewId, voteDto);
49+
}
50+
51+
@Post('reviews/:id/flag')
52+
@UseGuards(JwtAuthGuard, ThrottlerGuard)
53+
async flagReview(
54+
@Param('id') reviewId: string,
55+
@Body() flagDto: FlagReviewDto,
56+
@Request() req,
57+
): Promise<void> {
58+
return this.reviewService.flagReview(req.user.id, reviewId, flagDto);
59+
}
60+
61+
@Get('puzzles/:id/reviews')
62+
async getReviews(
63+
@Param('id') puzzleId: string,
64+
@Query('page') page: number = 1,
65+
@Query('limit') limit: number = 20,
66+
@Query('sort') sort: 'recency' | 'helpful' = 'recency',
67+
): Promise<{ reviews: PuzzleReview[], total: number }> {
68+
return this.reviewService.getPuzzleReviews(puzzleId, page, limit, sort);
69+
}
70+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { IsInt, IsNotEmpty, Min, Max, IsOptional, IsString } from 'class-validator';
2+
3+
export class CreateRatingDto {
4+
@IsInt()
5+
@Min(1)
6+
@Max(5)
7+
@IsNotEmpty()
8+
rating: number;
9+
10+
@IsOptional()
11+
@IsString()
12+
difficultyVote?: 'easy' | 'medium' | 'hard' | 'expert';
13+
14+
@IsOptional()
15+
@IsString({ each: true })
16+
tags?: string[];
17+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { IsString, IsNotEmpty, Length } from 'class-validator';
2+
3+
export class CreateReviewDto {
4+
@IsString()
5+
@IsNotEmpty()
6+
@Length(50, 1000)
7+
reviewText: string;
8+
}

src/puzzles/dto/flag-review.dto.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { IsString, IsNotEmpty } from 'class-validator';
2+
3+
export class FlagReviewDto {
4+
@IsString()
5+
@IsNotEmpty()
6+
reason: string;
7+
}

src/puzzles/dto/search-puzzle.dto.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export enum SortBy {
77
TITLE = 'title',
88
DIFFICULTY = 'difficulty',
99
RATING = 'rating',
10+
REVIEWS = 'reviews',
1011
PLAYS = 'totalPlays',
1112
COMPLETION_RATE = 'completionRate'
1213
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { IsString, IsNotEmpty, Length } from 'class-validator';
2+
3+
export class UpdateReviewDto {
4+
@IsString()
5+
@IsNotEmpty()
6+
@Length(50, 1000)
7+
reviewText: string;
8+
}

src/puzzles/dto/vote-review.dto.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { IsEnum, IsNotEmpty } from 'class-validator';
2+
3+
export enum VoteType {
4+
HELPFUL = 'helpful',
5+
UNHELPFUL = 'unhelpful',
6+
}
7+
8+
export class VoteReviewDto {
9+
@IsEnum(VoteType)
10+
@IsNotEmpty()
11+
voteType: VoteType;
12+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import {
2+
Entity,
3+
PrimaryGeneratedColumn,
4+
Column,
5+
UpdateDateColumn,
6+
OneToOne,
7+
JoinColumn,
8+
Index,
9+
} from 'typeorm';
10+
import { Puzzle } from './puzzle.entity';
11+
12+
@Entity('puzzle_rating_aggregates')
13+
export class PuzzleRatingAggregate {
14+
@PrimaryGeneratedColumn('uuid')
15+
id: string;
16+
17+
@Column({ type: 'uuid' })
18+
@Index({ unique: true })
19+
puzzleId: string;
20+
21+
@Column({ type: 'decimal', precision: 3, scale: 2, default: 0 })
22+
averageRating: number;
23+
24+
@Column({ type: 'int', default: 0 })
25+
totalRatings: number;
26+
27+
@Column({ type: 'int', default: 0 })
28+
totalReviews: number;
29+
30+
// Rating distribution for histogram
31+
@Column({ type: 'jsonb', default: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 } })
32+
ratingDistribution: {
33+
1: number;
34+
2: number;
35+
3: number;
36+
4: number;
37+
5: number;
38+
};
39+
40+
@UpdateDateColumn()
41+
updatedAt: Date;
42+
43+
@OneToOne(() => Puzzle, { onDelete: 'CASCADE' })
44+
@JoinColumn({ name: 'puzzleId' })
45+
puzzle: Puzzle;
46+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import {
2+
Entity,
3+
PrimaryGeneratedColumn,
4+
Column,
5+
CreateDateColumn,
6+
UpdateDateColumn,
7+
DeleteDateColumn,
8+
ManyToOne,
9+
OneToMany,
10+
Index,
11+
JoinColumn,
12+
} from 'typeorm';
13+
import { User } from '../../users/entities/user.entity';
14+
import { Puzzle } from './puzzle.entity';
15+
import { ReviewVote } from './review-vote.entity';
16+
17+
@Entity('puzzle_reviews')
18+
@Index(['userId', 'puzzleId'], { unique: true, where: '"deletedAt" IS NULL' })
19+
export class PuzzleReview {
20+
@PrimaryGeneratedColumn('uuid')
21+
id: string;
22+
23+
@Column({ type: 'uuid' })
24+
@Index()
25+
userId: string;
26+
27+
@Column({ type: 'uuid' })
28+
@Index()
29+
puzzleId: string;
30+
31+
@Column({ type: 'text' })
32+
reviewText: string;
33+
34+
@Column({ type: 'enum', enum: ['pending', 'approved', 'rejected', 'flagged'], default: 'pending' })
35+
@Index()
36+
moderationStatus: 'pending' | 'approved' | 'rejected' | 'flagged';
37+
38+
@Column({ type: 'int', default: 0 })
39+
helpfulVotes: number;
40+
41+
@Column({ type: 'int', default: 0 })
42+
unhelpfulVotes: number;
43+
44+
@Column({ type: 'boolean', default: false })
45+
isFlagged: boolean;
46+
47+
@Column({ type: 'text', nullable: true })
48+
flagReason: string;
49+
50+
@CreateDateColumn()
51+
createdAt: Date;
52+
53+
@UpdateDateColumn()
54+
updatedAt: Date;
55+
56+
@DeleteDateColumn()
57+
deletedAt: Date;
58+
59+
@ManyToOne(() => User, { onDelete: 'CASCADE' })
60+
@JoinColumn({ name: 'userId' })
61+
user: User;
62+
63+
@ManyToOne(() => Puzzle, { onDelete: 'CASCADE' })
64+
@JoinColumn({ name: 'puzzleId' })
65+
puzzle: Puzzle;
66+
67+
@OneToMany(() => ReviewVote, (vote) => vote.review)
68+
votes: ReviewVote[];
69+
}

0 commit comments

Comments
 (0)