generated from VLADIMIR/template
add reviews
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"evening_detective_server/internal/modules/processor_jwt"
|
||||
"evening_detective_server/internal/modules/roles"
|
||||
"evening_detective_server/internal/repos/game_reviews_repo"
|
||||
"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)}
|
||||
}
|
||||
|
||||
func mapGameReviews(reviews []*game_reviews_repo.Review) []*proto.GameReview {
|
||||
result := make([]*proto.GameReview, 0, len(reviews))
|
||||
for _, review := range reviews {
|
||||
result = append(result, mapGameReview(review))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *server) CreateGameReview(ctx context.Context, req *proto.CreateGameReviewReq) (*proto.CreateGameReviewRsp, error) {
|
||||
review, err := s.reviewService.Create(ctx, int(req.GameId), int(req.TeamId), req.Password, req.Rating, req.Text)
|
||||
if err != nil {
|
||||
return &proto.CreateGameReviewRsp{Error: err.Error()}, nil
|
||||
}
|
||||
return &proto.CreateGameReviewRsp{Review: mapGameReview(review)}, nil
|
||||
}
|
||||
|
||||
func (s *server) GetGameReviews(ctx context.Context, req *proto.GetGameReviewsReq) (*proto.GetGameReviewsRsp, error) {
|
||||
reviews, average, count, err := s.reviewService.List(ctx, int(req.GameId), false)
|
||||
if err != nil {
|
||||
return &proto.GetGameReviewsRsp{Error: err.Error()}, nil
|
||||
}
|
||||
return &proto.GetGameReviewsRsp{AverageRating: average, ReviewsCount: count, Reviews: mapGameReviews(reviews)}, nil
|
||||
}
|
||||
|
||||
func (s *server) GetGameReviewsForModeration(ctx context.Context, req *proto.GetGameReviewsReq) (*proto.GetGameReviewsRsp, error) {
|
||||
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
|
||||
if !roles.HasRole(claims, roles.Admin) {
|
||||
return nil, status.Error(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
reviews, average, count, err := s.reviewService.List(ctx, int(req.GameId), true)
|
||||
if err != nil {
|
||||
return &proto.GetGameReviewsRsp{Error: err.Error()}, nil
|
||||
}
|
||||
return &proto.GetGameReviewsRsp{AverageRating: average, ReviewsCount: count, Reviews: mapGameReviews(reviews)}, nil
|
||||
}
|
||||
|
||||
func (s *server) ModerateGameReview(ctx context.Context, req *proto.ModerateGameReviewReq) (*proto.ModerateGameReviewRsp, error) {
|
||||
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
|
||||
if !roles.HasRole(claims, roles.Admin) {
|
||||
return nil, status.Error(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
review, err := s.reviewService.Moderate(ctx, int(req.Id), req.Status)
|
||||
if err != nil {
|
||||
return &proto.ModerateGameReviewRsp{Error: err.Error()}, nil
|
||||
}
|
||||
return &proto.ModerateGameReviewRsp{Review: mapGameReview(review)}, nil
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"evening_detective_server/internal/repos/screen_sessions_repo"
|
||||
"evening_detective_server/internal/services/answer_service"
|
||||
"evening_detective_server/internal/services/file_service"
|
||||
"evening_detective_server/internal/services/game_review_service"
|
||||
"evening_detective_server/internal/services/game_service"
|
||||
"evening_detective_server/internal/services/gameplay_service"
|
||||
"evening_detective_server/internal/services/participation_service"
|
||||
@@ -25,6 +26,7 @@ type server struct {
|
||||
gameService *game_service.GameService
|
||||
gameplayService *gameplay_service.GameplayService
|
||||
answerService *answer_service.AnswerService
|
||||
reviewService *game_review_service.Service
|
||||
teamService *team_service.TeamService
|
||||
participationService *participation_service.ParticipationService
|
||||
screenRepo *screen_sessions_repo.ScreenSessionsRepo
|
||||
@@ -39,6 +41,7 @@ func NewServer(
|
||||
gameService *game_service.GameService,
|
||||
gameplayService *gameplay_service.GameplayService,
|
||||
answerService *answer_service.AnswerService,
|
||||
reviewService *game_review_service.Service,
|
||||
teamService *team_service.TeamService,
|
||||
participationService *participation_service.ParticipationService,
|
||||
screenRepo *screen_sessions_repo.ScreenSessionsRepo,
|
||||
@@ -52,6 +55,7 @@ func NewServer(
|
||||
gameService: gameService,
|
||||
gameplayService: gameplayService,
|
||||
answerService: answerService,
|
||||
reviewService: reviewService,
|
||||
teamService: teamService,
|
||||
participationService: participationService,
|
||||
screenRepo: screenRepo,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package game_reviews_repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrReviewNotFound = errors.New("Отзыв не найден")
|
||||
ErrReviewExists = errors.New("Отзыв от команды уже оставлен")
|
||||
)
|
||||
|
||||
type Review struct {
|
||||
ID int
|
||||
GameID int
|
||||
TeamID int
|
||||
TeamName string
|
||||
Rating int32
|
||||
Text string
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Repo struct{ pool *pgxpool.Pool }
|
||||
|
||||
func New(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, `
|
||||
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
|
||||
ON CONFLICT (game_id, team_id) DO NOTHING
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
if exists {
|
||||
return nil, ErrReviewExists
|
||||
}
|
||||
return nil, errors.New("Команда не найдена")
|
||||
}
|
||||
return review, err
|
||||
}
|
||||
|
||||
func (r *Repo) GetByID(ctx context.Context, id int) (*Review, error) {
|
||||
return r.queryOne(ctx, `WHERE r.id=$1`, 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,r.rating,r.text,r.status,r.created_at
|
||||
FROM game_reviews r JOIN teams t ON t.id=r.team_id WHERE `+where+` ORDER BY r.created_at DESC`, gameID)
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var reviews []*Review
|
||||
for rows.Next() {
|
||||
review := &Review{}
|
||||
if err := rows.Scan(&review.ID, &review.GameID, &review.TeamID, &review.TeamName, &review.Rating, &review.Text, &review.Status, &review.CreatedAt); err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
reviews = append(reviews, review)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
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 {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
return reviews, average, int32(count), nil
|
||||
}
|
||||
|
||||
func (r *Repo) Moderate(ctx context.Context, id int, status string) (*Review, error) {
|
||||
review, err := r.queryOne(ctx, `WHERE r.id=$1`, id)
|
||||
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("Недопустимый статус модерации")
|
||||
}
|
||||
|
||||
func (r *Repo) queryOne(ctx context.Context, condition string, id int) (*Review, error) {
|
||||
review := &Review{}
|
||||
err := r.pool.QueryRow(ctx, `SELECT r.id,r.game_id,r.team_id,t.name,r.rating,r.text,r.status,r.created_at
|
||||
FROM game_reviews r JOIN teams t ON t.id=r.team_id `+condition, id).Scan(
|
||||
&review.ID, &review.GameID, &review.TeamID, &review.TeamName, &review.Rating, &review.Text, &review.Status, &review.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrReviewNotFound
|
||||
}
|
||||
return review, err
|
||||
}
|
||||
@@ -16,6 +16,8 @@ var ErrParticipationFinal = errors.New("Решение уже принято и
|
||||
|
||||
const maxParticipationEmailAttempts = 8
|
||||
|
||||
const maxReviewReminderAttempts = 8
|
||||
|
||||
type Participation struct {
|
||||
TeamID int
|
||||
TeamName string
|
||||
@@ -28,6 +30,16 @@ type Participation struct {
|
||||
Attempts int
|
||||
}
|
||||
|
||||
type ReviewReminder struct {
|
||||
TeamID int
|
||||
TeamName string
|
||||
GameID int
|
||||
GameName string
|
||||
Email string
|
||||
Password string
|
||||
Attempts int
|
||||
}
|
||||
|
||||
func tokenHash(token string) ([]byte, error) {
|
||||
if len(token) != 64 {
|
||||
return nil, ErrParticipationLink
|
||||
@@ -134,3 +146,33 @@ func (r *TeamsRepo) FinishParticipationEmail(ctx context.Context, id, attempt in
|
||||
WHERE id=$1 AND participation_attempts=$2`, id, attempt, message)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *TeamsRepo) ClaimReviewReminder(ctx context.Context) (*ReviewReminder, error) {
|
||||
job := &ReviewReminder{}
|
||||
err := r.pool.QueryRow(ctx, `WITH candidate AS (
|
||||
SELECT t.id FROM teams t JOIN games g ON g.id=t.game_id
|
||||
WHERE t.captain_email<>'' AND t.review_reminder_sent_at IS NULL
|
||||
AND t.review_reminder_attempts < $1
|
||||
AND (t.review_reminder_next_attempt_at IS NULL OR t.review_reminder_next_attempt_at <= NOW())
|
||||
AND g.status='finish' AND g.started_at <= NOW() - INTERVAL '16 hours'
|
||||
AND NOT EXISTS (SELECT 1 FROM game_reviews r WHERE r.game_id=t.game_id AND r.team_id=t.id)
|
||||
ORDER BY g.started_at, t.id
|
||||
FOR UPDATE OF t SKIP LOCKED LIMIT 1
|
||||
) UPDATE teams t SET review_reminder_next_attempt_at=NOW()+INTERVAL '5 minutes',
|
||||
review_reminder_attempts=t.review_reminder_attempts+1
|
||||
FROM candidate c, games g WHERE t.id=c.id AND g.id=t.game_id
|
||||
RETURNING t.id,t.name,t.game_id,g.name,t.captain_email,t.password,t.review_reminder_attempts`, maxReviewReminderAttempts).Scan(
|
||||
&job.TeamID, &job.TeamName, &job.GameID, &job.GameName, &job.Email, &job.Password, &job.Attempts)
|
||||
return job, err
|
||||
}
|
||||
|
||||
func (r *TeamsRepo) FinishReviewReminder(ctx context.Context, id, attempt int, sendErr error) error {
|
||||
message := ""
|
||||
if sendErr != nil {
|
||||
message = sendErr.Error()
|
||||
}
|
||||
_, err := r.pool.Exec(ctx, `UPDATE teams SET review_reminder_error=$3,
|
||||
review_reminder_sent_at=CASE WHEN $3='' THEN NOW() ELSE review_reminder_sent_at END
|
||||
WHERE id=$1 AND review_reminder_attempts=$2`, id, attempt, message)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package game_review_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"log"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evening_detective_server/internal/modules/email_sender"
|
||||
"evening_detective_server/internal/repos/game_reviews_repo"
|
||||
"evening_detective_server/internal/repos/games_repo"
|
||||
"evening_detective_server/internal/repos/teams_repo"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrGameNotFinished = errors.New("Оценить можно только завершённую игру")
|
||||
ErrInvalidReview = errors.New("Оценка должна быть от 1 до 5, текст обязателен")
|
||||
)
|
||||
|
||||
type Service 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}
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, gameID, teamID int, password string, rating int32, text string) (*game_reviews_repo.Review, error) {
|
||||
if rating < 1 || rating > 5 || strings.TrimSpace(text) == "" {
|
||||
return nil, ErrInvalidReview
|
||||
}
|
||||
team, err := s.teams.GetTeamByID(ctx, teamID)
|
||||
if err != nil || team.GameId != gameID || team.Password != password || team.ParticipationStatus == "cancelled" {
|
||||
return nil, teams_repo.ErrTeamNotFound
|
||||
}
|
||||
game, err := s.games.GetGameByID(ctx, gameID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if game.Status != "finish" {
|
||||
return nil, ErrGameNotFinished
|
||||
}
|
||||
return s.reviews.Create(ctx, gameID, teamID, rating, strings.TrimSpace(text))
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, gameID int, all bool) ([]*game_reviews_repo.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)
|
||||
}
|
||||
|
||||
func (s *Service) Moderate(ctx context.Context, id int, status string) (*game_reviews_repo.Review, error) {
|
||||
return s.reviews.Moderate(ctx, id, status)
|
||||
}
|
||||
|
||||
func reviewReminderEmail(job *teams_repo.ReviewReminder, baseURL string) email_sender.Message {
|
||||
link := strings.TrimRight(baseURL, "/") + "/team-story/" + fmt.Sprint(job.TeamID) + "?" + url.Values{"password": {job.Password}, "review": {"1"}}.Encode()
|
||||
body := fmt.Sprintf("Команда «%s», поделитесь впечатлениями об игре «%s».\n\nОценка и отзыв займут пару минут: %s\n\nОтзыв можно оставить один раз от команды.", job.TeamName, job.GameName, link)
|
||||
content := "<p class=\"email-text\"><strong>Как вам игра?</strong></p><p class=\"email-text\">Команда «" + html.EscapeString(job.TeamName) + "» — «" + html.EscapeString(job.GameName) + "»</p>"
|
||||
content += email_sender.ButtonHTML(link, "Оставить отзыв")
|
||||
content += "<p class=\"email-text\">Оценка и отзыв займут пару минут. Отзыв можно оставить один раз от команды.</p>"
|
||||
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) {
|
||||
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")
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if err := s.sendReviewReminderEmails(ctx, sender); err != nil && ctx.Err() == nil {
|
||||
log.Printf("review reminder email worker: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) 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)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
cancel()
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
err = sender.Send(jobCtx, reviewReminderEmail(job, s.appBaseURL))
|
||||
cancel()
|
||||
saveCtx, saveCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
saveErr := s.teams.FinishReviewReminder(saveCtx, job.TeamID, job.Attempts, err)
|
||||
saveCancel()
|
||||
if saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package game_review_service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"evening_detective_server/internal/repos/teams_repo"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
if !strings.Contains(message.HTML, "Ночь & тайна") {
|
||||
t.Fatalf("game name is not escaped in HTML: %s", message.HTML)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user