diff --git a/cmd/evening_detective_server/main.go b/cmd/evening_detective_server/main.go index c450618..1508a83 100644 --- a/cmd/evening_detective_server/main.go +++ b/cmd/evening_detective_server/main.go @@ -141,7 +141,7 @@ func main() { screenRepo := screen_sessions_repo.NewScreenSessionsRepo(dbpool) screenService := screen_service.NewScreenService(screenRepo) mapService := map_service.NewMapService(game_map_repo.NewGameMapRepo(dbpool)) - reviewsRepo := game_reviews_repo.New(dbpool) + reviewsRepo := game_reviews_repo.NewGameReviewsRepo(dbpool) gameService := game_service.NewGameService( gameRepo, teamRepo, @@ -155,7 +155,7 @@ func main() { gameplayService := gameplay_service.NewGameplayService(gameRepo, teamRepo, scenarioService, storyteller, actionsRepo, applicationsRepo, cleaner, os.Getenv("FILE_PREFIX_DOMAIN")) answerService := answer_service.NewAnswerService(gameRepo, teamRepo, scenarioService, answersRepo, actionsRepo) - reviewService := game_review_service.New(reviewsRepo, gameRepo, teamRepo, os.Getenv("APP_BASE_URL")) + reviewService := game_review_service.NewGameReviewService(reviewsRepo, gameRepo, teamRepo, os.Getenv("APP_BASE_URL")) teamService := team_service.NewTeamService(gameRepo, teamRepo, passwordGenerator, os.Getenv("APP_BASE_URL"), emailSender) diff --git a/internal/app/game_reviews.go b/internal/app/game_reviews.go index 191760f..e77c034 100644 --- a/internal/app/game_reviews.go +++ b/internal/app/game_reviews.go @@ -4,18 +4,28 @@ import ( "context" "evening_detective_server/internal/modules/roles" - "evening_detective_server/internal/repos/game_reviews_repo" + "evening_detective_server/internal/services/game_review_service" "evening_detective_server/proto" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" ) -func mapGameReview(r *game_reviews_repo.Review) *proto.GameReview { - return &proto.GameReview{ID: int32(r.ID), GameID: int32(r.GameID), TeamID: int32(r.TeamID), TeamName: r.TeamName, Rating: r.Rating, Text: r.Text, Status: r.Status, CreatedAt: timestamppb.New(r.CreatedAt), GameName: r.GameName} +func mapGameReview(r *game_review_service.Review) *proto.GameReview { + return &proto.GameReview{ + ID: int32(r.ID), + GameID: int32(r.GameID), + TeamID: int32(r.TeamID), + TeamName: r.TeamName, + Rating: r.Rating, + Text: r.Text, + Status: r.Status, + CreatedAt: timestamppb.New(r.CreatedAt), + GameName: r.GameName, + } } -func mapGameReviews(reviews []*game_reviews_repo.Review) []*proto.GameReview { +func mapGameReviews(reviews []*game_review_service.Review) []*proto.GameReview { result := make([]*proto.GameReview, 0, len(reviews)) for _, review := range reviews { result = append(result, mapGameReview(review)) diff --git a/internal/app/server.go b/internal/app/server.go index 5de3a78..799720b 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -27,7 +27,7 @@ type server struct { gameService *game_service.GameService gameplayService *gameplay_service.GameplayService answerService *answer_service.AnswerService - reviewService *game_review_service.Service + reviewService *game_review_service.GameReviewService teamService *team_service.TeamService ticketService *ticket_service.Service participationService *participation_service.ParticipationService @@ -43,7 +43,7 @@ func NewServer( gameService *game_service.GameService, gameplayService *gameplay_service.GameplayService, answerService *answer_service.AnswerService, - reviewService *game_review_service.Service, + reviewService *game_review_service.GameReviewService, teamService *team_service.TeamService, ticketService *ticket_service.Service, participationService *participation_service.ParticipationService, diff --git a/internal/repos/game_reviews_repo/repo.go b/internal/repos/game_reviews_repo/repo.go index 06170ce..0addc8d 100644 --- a/internal/repos/game_reviews_repo/repo.go +++ b/internal/repos/game_reviews_repo/repo.go @@ -12,6 +12,8 @@ import ( var ( ErrReviewNotFound = errors.New("Отзыв не найден") ErrReviewExists = errors.New("Отзыв от команды уже оставлен") + ErrTeamNotFound = errors.New("Команда не найдена") + ErrInvalidStatus = errors.New("Недопустимый статус модерации") ) type Review struct { @@ -28,40 +30,63 @@ type Review struct { type Repo struct{ pool *pgxpool.Pool } -func New(pool *pgxpool.Pool) *Repo { return &Repo{pool: pool} } +func NewGameReviewsRepo(pool *pgxpool.Pool) *Repo { return &Repo{pool: pool} } func (r *Repo) Create(ctx context.Context, gameID, teamID int, rating int32, text string) (*Review, error) { review := &Review{} - err := r.pool.QueryRow(ctx, ` + err := r.pool.QueryRow( + ctx, + ` INSERT INTO game_reviews (game_id, team_id, rating, text) - SELECT $1, t.id, $3, $4 FROM teams t WHERE t.id = $2 AND t.game_id = $1 + SELECT $1, t.id, $3, $4 + FROM teams t + WHERE t.id = $2 AND t.game_id = $1 ON CONFLICT (game_id, team_id) DO NOTHING - RETURNING id, game_id, team_id, rating, text, status, created_at`, gameID, teamID, rating, text).Scan( + RETURNING id, game_id, team_id, rating, text, status, created_at`, + gameID, + teamID, + rating, + text, + ).Scan( &review.ID, &review.GameID, &review.TeamID, &review.Rating, &review.Text, &review.Status, &review.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { var exists bool - if err := r.pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM game_reviews WHERE game_id=$1 AND team_id=$2)`, gameID, teamID).Scan(&exists); err != nil { + if err := r.pool.QueryRow( + ctx, + `SELECT EXISTS ( + SELECT 1 + FROM game_reviews + WHERE game_id = $1 AND team_id = $2 + )`, + gameID, + teamID, + ).Scan(&exists); err != nil { return nil, err } if exists { return nil, ErrReviewExists } - return nil, errors.New("Команда не найдена") + return nil, ErrTeamNotFound } return review, err } func (r *Repo) GetByID(ctx context.Context, id int) (*Review, error) { - return r.queryOne(ctx, `WHERE r.id=$1`, id) + return r.queryOne(ctx, id) } func (r *Repo) List(ctx context.Context, gameID int, all bool) ([]*Review, float64, int32, error) { - where := "r.game_id=$1 AND r.status='approved'" - if all { - where = "r.game_id=$1" - } - rows, err := r.pool.Query(ctx, `SELECT r.id,r.game_id,r.team_id,t.name,g.name,r.rating,r.text,r.status,r.created_at - FROM game_reviews r JOIN teams t ON t.id=r.team_id JOIN games g ON g.id=r.game_id WHERE `+where+` ORDER BY r.created_at DESC`, gameID) + rows, err := r.pool.Query( + ctx, + `SELECT r.id, r.game_id, r.team_id, t.name, g.name, r.rating, r.text, r.status, r.created_at + FROM game_reviews r + JOIN teams t ON t.id = r.team_id + JOIN games g ON g.id = r.game_id + WHERE r.game_id = $1 AND ($2 OR r.status = 'approved') + ORDER BY r.created_at DESC`, + gameID, + all, + ) if err != nil { return nil, 0, 0, err } @@ -79,16 +104,27 @@ func (r *Repo) List(ctx context.Context, gameID int, all bool) ([]*Review, float } var average float64 var count int64 - if err := r.pool.QueryRow(ctx, `SELECT COALESCE(AVG(rating),0), COUNT(*) FROM game_reviews WHERE game_id=$1 AND status='approved'`, gameID).Scan(&average, &count); err != nil { + if err := r.pool.QueryRow( + ctx, + `SELECT COALESCE(AVG(rating), 0), COUNT(*) + FROM game_reviews + WHERE game_id = $1 AND status = 'approved'`, + gameID, + ).Scan(&average, &count); err != nil { return nil, 0, 0, err } return reviews, average, int32(count), nil } func (r *Repo) ListForModeration(ctx context.Context) ([]*Review, error) { - rows, err := r.pool.Query(ctx, `SELECT r.id,r.game_id,r.team_id,t.name,g.name,r.rating,r.text,r.status,r.created_at - FROM game_reviews r JOIN teams t ON t.id=r.team_id JOIN games g ON g.id=r.game_id - ORDER BY CASE WHEN r.status='pending' THEN 0 ELSE 1 END, r.created_at ASC`) + rows, err := r.pool.Query( + ctx, + `SELECT r.id, r.game_id, r.team_id, t.name, g.name, r.rating, r.text, r.status, r.created_at + FROM game_reviews r + JOIN teams t ON t.id = r.team_id + JOIN games g ON g.id = r.game_id + ORDER BY CASE WHEN r.status = 'pending' THEN 0 ELSE 1 END, r.created_at ASC`, + ) if err != nil { return nil, err } @@ -105,34 +141,51 @@ func (r *Repo) ListForModeration(ctx context.Context) ([]*Review, error) { } func (r *Repo) Moderate(ctx context.Context, id int, status string) (*Review, error) { - review, err := r.queryOne(ctx, `WHERE r.id=$1`, id) + if status != "approved" && status != "rejected" && status != "hidden" { + return nil, ErrInvalidStatus + } + if _, err := r.queryOne(ctx, id); err != nil { + return nil, err + } + _, err := r.pool.Exec( + ctx, + `UPDATE game_reviews + SET status = $2, moderated_at = NOW() + WHERE id = $1`, + id, + status, + ) if err != nil { return nil, err } - if status == "approved" || status == "rejected" || status == "hidden" { - _, err = r.pool.Exec(ctx, `UPDATE game_reviews SET status=$2, moderated_at=NOW() WHERE id=$1`, id, status) - if err != nil { - return nil, err - } - return r.queryOne(ctx, `WHERE r.id=$1`, id) - } - return review, errors.New("Недопустимый статус модерации") + return r.queryOne(ctx, id) } func (r *Repo) ScenarioRating(ctx context.Context, scenarioID int) (float64, int32, error) { var average float64 var count int64 - err := r.pool.QueryRow(ctx, `SELECT COALESCE(AVG(r.rating),0), COUNT(*) - FROM game_reviews r JOIN games g ON g.id=r.game_id - WHERE g.scenario_id=$1 AND g.status='finish' AND r.status='approved'`, scenarioID).Scan(&average, &count) + err := r.pool.QueryRow( + ctx, + `SELECT COALESCE(AVG(r.rating), 0), COUNT(*) + FROM game_reviews r + JOIN games g ON g.id = r.game_id + WHERE g.scenario_id = $1 AND g.status = 'finish' AND r.status = 'approved'`, + scenarioID, + ).Scan(&average, &count) return average, int32(count), err } func (r *Repo) ListByScenario(ctx context.Context, scenarioID int) ([]*Review, float64, int32, error) { - rows, err := r.pool.Query(ctx, `SELECT r.id,r.game_id,r.team_id,t.name,g.name,r.rating,r.text,r.status,r.created_at - FROM game_reviews r JOIN teams t ON t.id=r.team_id JOIN games g ON g.id=r.game_id - WHERE g.scenario_id=$1 AND g.status='finish' AND r.status='approved' - ORDER BY r.created_at DESC`, scenarioID) + rows, err := r.pool.Query( + ctx, + `SELECT r.id, r.game_id, r.team_id, t.name, g.name, r.rating, r.text, r.status, r.created_at + FROM game_reviews r + JOIN teams t ON t.id = r.team_id + JOIN games g ON g.id = r.game_id + WHERE g.scenario_id = $1 AND g.status = 'finish' AND r.status = 'approved' + ORDER BY r.created_at DESC`, + scenarioID, + ) if err != nil { return nil, 0, 0, err } @@ -152,10 +205,17 @@ func (r *Repo) ListByScenario(ctx context.Context, scenarioID int) ([]*Review, f return reviews, average, count, err } -func (r *Repo) queryOne(ctx context.Context, condition string, id int) (*Review, error) { +func (r *Repo) queryOne(ctx context.Context, id int) (*Review, error) { review := &Review{} - err := r.pool.QueryRow(ctx, `SELECT r.id,r.game_id,r.team_id,t.name,g.name,r.rating,r.text,r.status,r.created_at - FROM game_reviews r JOIN teams t ON t.id=r.team_id JOIN games g ON g.id=r.game_id `+condition, id).Scan( + err := r.pool.QueryRow( + ctx, + `SELECT r.id, r.game_id, r.team_id, t.name, g.name, r.rating, r.text, r.status, r.created_at + FROM game_reviews r + JOIN teams t ON t.id = r.team_id + JOIN games g ON g.id = r.game_id + WHERE r.id = $1`, + id, + ).Scan( &review.ID, &review.GameID, &review.TeamID, &review.TeamName, &review.GameName, &review.Rating, &review.Text, &review.Status, &review.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrReviewNotFound diff --git a/internal/services/game_review_service/mapper.go b/internal/services/game_review_service/mapper.go new file mode 100644 index 0000000..d64c397 --- /dev/null +++ b/internal/services/game_review_service/mapper.go @@ -0,0 +1,45 @@ +package game_review_service + +import ( + "time" + + "evening_detective_server/internal/repos/game_reviews_repo" +) + +// Review is the application model returned by game review use cases. +type Review struct { + ID int + GameID int + GameName string + TeamID int + TeamName string + Rating int32 + Text string + Status string + CreatedAt time.Time +} + +func mapReview(review *game_reviews_repo.Review) *Review { + if review == nil { + return nil + } + return &Review{ + ID: review.ID, + GameID: review.GameID, + GameName: review.GameName, + TeamID: review.TeamID, + TeamName: review.TeamName, + Rating: review.Rating, + Text: review.Text, + Status: review.Status, + CreatedAt: review.CreatedAt, + } +} + +func mapReviews(reviews []*game_reviews_repo.Review) []*Review { + result := make([]*Review, 0, len(reviews)) + for _, review := range reviews { + result = append(result, mapReview(review)) + } + return result +} diff --git a/internal/services/game_review_service/service.go b/internal/services/game_review_service/service.go index a4d7e31..9268b12 100644 --- a/internal/services/game_review_service/service.go +++ b/internal/services/game_review_service/service.go @@ -22,18 +22,20 @@ var ( ErrInvalidReview = errors.New("Оценка должна быть от 1 до 5, текст обязателен") ) -type Service struct { +// GameReviewService handles game review use cases and reminder emails. +type GameReviewService struct { reviews *game_reviews_repo.Repo games *games_repo.GamesRepo teams *teams_repo.TeamsRepo appBaseURL string } -func New(reviews *game_reviews_repo.Repo, games *games_repo.GamesRepo, teams *teams_repo.TeamsRepo, appBaseURL string) *Service { - return &Service{reviews: reviews, games: games, teams: teams, appBaseURL: appBaseURL} +// NewGameReviewService creates a game review service. +func NewGameReviewService(reviews *game_reviews_repo.Repo, games *games_repo.GamesRepo, teams *teams_repo.TeamsRepo, appBaseURL string) *GameReviewService { + return &GameReviewService{reviews: reviews, games: games, teams: teams, appBaseURL: appBaseURL} } -func (s *Service) Create(ctx context.Context, gameID, teamID int, password string, rating int32, text string) (*game_reviews_repo.Review, error) { +func (s *GameReviewService) Create(ctx context.Context, gameID, teamID int, password string, rating int32, text string) (*Review, error) { if rating < 1 || rating > 5 || strings.TrimSpace(text) == "" { return nil, ErrInvalidReview } @@ -48,30 +50,41 @@ func (s *Service) Create(ctx context.Context, gameID, teamID int, password strin if game.Status != "finish" { return nil, ErrGameNotFinished } - return s.reviews.Create(ctx, gameID, teamID, rating, strings.TrimSpace(text)) + review, err := s.reviews.Create(ctx, gameID, teamID, rating, strings.TrimSpace(text)) + if err != nil { + return nil, err + } + return mapReview(review), nil } -func (s *Service) List(ctx context.Context, gameID int, all bool) ([]*game_reviews_repo.Review, float64, int32, error) { +func (s *GameReviewService) List(ctx context.Context, gameID int, all bool) ([]*Review, float64, int32, error) { if _, err := s.games.GetGameByID(ctx, gameID); err != nil { return nil, 0, 0, err } - return s.reviews.List(ctx, gameID, all) + reviews, average, count, err := s.reviews.List(ctx, gameID, all) + return mapReviews(reviews), average, count, err } -func (s *Service) ListForModeration(ctx context.Context) ([]*game_reviews_repo.Review, error) { - return s.reviews.ListForModeration(ctx) +func (s *GameReviewService) ListForModeration(ctx context.Context) ([]*Review, error) { + reviews, err := s.reviews.ListForModeration(ctx) + return mapReviews(reviews), err } -func (s *Service) Moderate(ctx context.Context, id int, status string) (*game_reviews_repo.Review, error) { - return s.reviews.Moderate(ctx, id, status) +func (s *GameReviewService) Moderate(ctx context.Context, id int, status string) (*Review, error) { + review, err := s.reviews.Moderate(ctx, id, status) + if err != nil { + return nil, err + } + return mapReview(review), nil } -func (s *Service) ScenarioRating(ctx context.Context, scenarioID int) (float64, int32, error) { +func (s *GameReviewService) ScenarioRating(ctx context.Context, scenarioID int) (float64, int32, error) { return s.reviews.ScenarioRating(ctx, scenarioID) } -func (s *Service) ListByScenario(ctx context.Context, scenarioID int) ([]*game_reviews_repo.Review, float64, int32, error) { - return s.reviews.ListByScenario(ctx, scenarioID) +func (s *GameReviewService) ListByScenario(ctx context.Context, scenarioID int) ([]*Review, float64, int32, error) { + reviews, average, count, err := s.reviews.ListByScenario(ctx, scenarioID) + return mapReviews(reviews), average, count, err } func reviewReminderEmail(job *teams_repo.ReviewReminder, baseURL string) email_sender.Message { @@ -83,7 +96,7 @@ func reviewReminderEmail(job *teams_repo.ReviewReminder, baseURL string) email_s return email_sender.Message{To: job.Email, Subject: "Поделитесь впечатлениями об игре", Body: body, HTML: email_sender.WrapHTML(content, strings.TrimRight(baseURL, "/"))} } -func (s *Service) RunReviewReminderEmails(ctx context.Context, sender email_sender.IEmailSender) { +func (s *GameReviewService) RunReviewReminderEmails(ctx context.Context, sender email_sender.IEmailSender) { base, err := url.Parse(s.appBaseURL) if err != nil || base.Host == "" || (base.Scheme != "https" && base.Scheme != "http") || base.User != nil || base.RawQuery != "" || base.Fragment != "" { log.Print("review reminder email worker disabled: configure APP_BASE_URL") @@ -103,7 +116,7 @@ func (s *Service) RunReviewReminderEmails(ctx context.Context, sender email_send } } -func (s *Service) sendReviewReminderEmails(ctx context.Context, sender email_sender.IEmailSender) error { +func (s *GameReviewService) sendReviewReminderEmails(ctx context.Context, sender email_sender.IEmailSender) error { for ctx.Err() == nil { jobCtx, cancel := context.WithTimeout(ctx, time.Minute) job, err := s.teams.ClaimReviewReminder(jobCtx) diff --git a/internal/services/game_review_service/service_test.go b/internal/services/game_review_service/service_test.go index 4cbf6c4..3ae8b05 100644 --- a/internal/services/game_review_service/service_test.go +++ b/internal/services/game_review_service/service_test.go @@ -8,13 +8,28 @@ import ( ) func TestReviewReminderEmail(t *testing.T) { - message := reviewReminderEmail(&teams_repo.ReviewReminder{ - TeamID: 7, TeamName: "Шерлоки", GameName: "Ночь & тайна", Email: "captain@example.org", Password: "secret", - }, "https://app.example") - if message.To != "captain@example.org" || !strings.Contains(message.Body, "/team-story/7?password=secret&review=1") { - t.Fatalf("unexpected reminder email: %+v", message) + tests := []struct { + name string + job *teams_repo.ReviewReminder + want string + }{ + { + name: "escapes game name and builds review link", + job: &teams_repo.ReviewReminder{ + TeamID: 7, TeamName: "Шерлоки", GameName: "Ночь & тайна", Email: "captain@example.org", Password: "secret", + }, + want: "Ночь & тайна", + }, } - if !strings.Contains(message.HTML, "Ночь & тайна") { - t.Fatalf("game name is not escaped in HTML: %s", message.HTML) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + message := reviewReminderEmail(tt.job, "https://app.example") + if message.To != "captain@example.org" || !strings.Contains(message.Body, "/team-story/7?password=secret&review=1") { + t.Fatalf("unexpected reminder email: %+v", message) + } + if !strings.Contains(message.HTML, tt.want) { + t.Fatalf("game name is not escaped in HTML: %s", message.HTML) + } + }) } }