This commit is contained in:
2026-10-01 16:30:32 +07:00
parent 9f081eeee5
commit 5638349623
7 changed files with 210 additions and 67 deletions
+14 -4
View File
@@ -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))
+2 -2
View File
@@ -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,
+96 -36
View File
@@ -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
@@ -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
}
@@ -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)
@@ -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: "Ночь &amp; тайна",
},
}
if !strings.Contains(message.HTML, "Ночь &amp; тайна") {
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)
}
})
}
}