generated from VLADIMIR/template
196 lines
7.6 KiB
Go
196 lines
7.6 KiB
Go
// Package teams_repo persists teams and participation data.
|
|
package teams_repo
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// ErrParticipationLink indicates that a participation link is invalid.
|
|
var ErrParticipationLink = errors.New("Ссылка недействительна")
|
|
|
|
// ErrParticipationClosed indicates that participation answers are closed.
|
|
var ErrParticipationClosed = errors.New("Приём ответов закрыт")
|
|
|
|
// ErrParticipationFinal indicates that a final answer cannot be changed.
|
|
var ErrParticipationFinal = errors.New("Решение уже принято и не может быть изменено")
|
|
|
|
const maxParticipationEmailAttempts = 8
|
|
|
|
const maxReviewReminderAttempts = 8
|
|
|
|
// Participation describes a team's participation state.
|
|
type Participation struct {
|
|
TeamID int
|
|
TeamName string
|
|
GameName string
|
|
StartAt time.Time
|
|
ParticipantsCount int32
|
|
Status string
|
|
Closed bool
|
|
Email string
|
|
Attempts int
|
|
}
|
|
|
|
// ReviewReminder describes a pending review reminder.
|
|
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
|
|
}
|
|
raw, err := hex.DecodeString(token)
|
|
if err != nil || len(raw) != 32 {
|
|
return nil, ErrParticipationLink
|
|
}
|
|
hash := sha256.Sum256([]byte(token))
|
|
return hash[:], nil
|
|
}
|
|
|
|
// GetParticipation returns participation data by token.
|
|
func (r *TeamsRepo) GetParticipation(ctx context.Context, token string) (*Participation, error) {
|
|
hash, err := tokenHash(token)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p := &Participation{}
|
|
err = r.pool.QueryRow(ctx, `SELECT t.id, t.name, g.name, g.start_at, t.participants_count, t.participation_status,
|
|
g.status <> 'draft' OR g.start_at <= (NOW() AT TIME ZONE 'UTC')
|
|
FROM teams t JOIN games g ON g.id=t.game_id WHERE t.participation_token_hash=$1`, hash).Scan(
|
|
&p.TeamID, &p.TeamName, &p.GameName, &p.StartAt, &p.ParticipantsCount, &p.Status, &p.Closed)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrParticipationLink
|
|
}
|
|
return p, err
|
|
}
|
|
|
|
// AnswerParticipation stores a team's final answer.
|
|
func (r *TeamsRepo) AnswerParticipation(ctx context.Context, token, answer string) (*Participation, error) {
|
|
if answer != "confirmed" && answer != "cancelled" {
|
|
return nil, errors.New("Выберите подтверждение или отмену участия")
|
|
}
|
|
hash, err := tokenHash(token)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Условие проверяется повторно после снятия блокировки строки конкурентным ответом.
|
|
_, err = r.pool.Exec(ctx, `WITH eligible_game AS (SELECT g.id FROM games g JOIN teams t ON t.game_id=g.id
|
|
WHERE t.participation_token_hash=$1 AND g.status='draft' AND g.start_at > (clock_timestamp() AT TIME ZONE 'UTC') FOR SHARE OF g)
|
|
UPDATE teams t SET participation_status=$2,
|
|
participation_source='captain', participation_answered_at=NOW()
|
|
FROM eligible_game g WHERE g.id=t.game_id AND t.participation_token_hash=$1
|
|
AND t.participation_status='pending'`, hash, answer)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p, err := r.GetParticipation(ctx, token)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if p.Status == answer {
|
|
return p, nil
|
|
}
|
|
if p.Status != "pending" {
|
|
return p, ErrParticipationFinal
|
|
}
|
|
return p, ErrParticipationClosed
|
|
}
|
|
|
|
// Пятиминутная аренда переживает перезапуски и не позволяет рабочим процессам
|
|
// отправить одну задачу повторно.
|
|
// ClaimParticipationEmail claims the next participation email job.
|
|
func (r *TeamsRepo) ClaimParticipationEmail(ctx context.Context) (*Participation, error) {
|
|
p := &Participation{}
|
|
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.participation_status='pending' AND t.captain_email<>'' AND t.participation_sent_at IS NULL
|
|
AND t.participation_attempts < $1
|
|
AND (t.participation_next_attempt_at IS NULL OR t.participation_next_attempt_at <= NOW())
|
|
AND g.status='draft' AND g.start_at > (NOW() AT TIME ZONE 'UTC')
|
|
AND g.start_at <= (NOW() AT TIME ZONE 'UTC') + INTERVAL '24 hours'
|
|
ORDER BY t.participation_next_attempt_at NULLS FIRST, t.id
|
|
FOR UPDATE OF t SKIP LOCKED LIMIT 1
|
|
) UPDATE teams t SET participation_next_attempt_at=NOW()+INTERVAL '5 minutes',
|
|
participation_attempts=t.participation_attempts+1, participation_email_error=''
|
|
FROM candidate c, games g WHERE t.id=c.id AND g.id=t.game_id
|
|
|
|
RETURNING t.id,t.name,t.captain_email,g.name,g.start_at,t.participants_count,t.participation_attempts`, maxParticipationEmailAttempts).Scan(
|
|
&p.TeamID, &p.TeamName, &p.Email, &p.GameName, &p.StartAt, &p.ParticipantsCount, &p.Attempts)
|
|
return p, err
|
|
}
|
|
|
|
// SetParticipationToken stores a participation token.
|
|
func (r *TeamsRepo) SetParticipationToken(ctx context.Context, id int, token string) error {
|
|
hash, err := tokenHash(token)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = r.pool.Exec(ctx, `UPDATE teams SET participation_token_hash=$2 WHERE id=$1`, id, hash)
|
|
return err
|
|
}
|
|
|
|
// ConfirmParticipationByFirstAction confirms participation after the first action.
|
|
func (r *TeamsRepo) ConfirmParticipationByFirstAction(ctx context.Context, teamID int) error {
|
|
_, err := r.pool.Exec(ctx, `UPDATE teams SET participation_status='confirmed',
|
|
participation_source='first_action', participation_answered_at=NOW()
|
|
WHERE id=$1 AND participation_status='pending'`, teamID)
|
|
return err
|
|
}
|
|
|
|
// FinishParticipationEmail completes a participation email job.
|
|
func (r *TeamsRepo) FinishParticipationEmail(ctx context.Context, id, attempt int, sendErr error) error {
|
|
message := ""
|
|
if sendErr != nil {
|
|
message = sendErr.Error()
|
|
}
|
|
_, err := r.pool.Exec(ctx, `UPDATE teams SET participation_email_error=$3,
|
|
participation_sent_at=CASE WHEN $3='' THEN NOW() ELSE participation_sent_at END
|
|
WHERE id=$1 AND participation_attempts=$2`, id, attempt, message)
|
|
return err
|
|
}
|
|
|
|
// ClaimReviewReminder claims the next review reminder job.
|
|
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
|
|
}
|
|
|
|
// FinishReviewReminder completes a review reminder job.
|
|
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
|
|
}
|