package repository import ( "context" "encoding/json" "errors" "fmt" "sort" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "learning-service/internal/model" ) // AttemptRepository — попытки прохождения теста + автогрейд. // // Сценарии: // 1. Сотрудник из портала: Start с userID, без public_token_id. // 2. Кандидат по public-ссылке: Start с publicTokenID и respondentName/email. // // SubmitAndGrade — один transactional проход: пишет ответы, считает score // по правильным test_answers, маркирует passed по test.passing_score. // Для text-вопросов correct=NULL и score=NULL — ждут ручной оценки. type AttemptRepository struct { pool *pgxpool.Pool } func NewAttemptRepository(pool *pgxpool.Pool) *AttemptRepository { return &AttemptRepository{pool: pool} } const attemptCols = ` id, test_id, user_id, public_token_id, candidate_id, status, score, max_score, passed, respondent_name, respondent_email, started_at, submitted_at, graded_at, ip, user_agent ` func scanAttempt(scan func(...any) error) (*model.Attempt, error) { var a model.Attempt if err := scan( &a.ID, &a.TestID, &a.UserID, &a.PublicTokenID, &a.CandidateID, &a.Status, &a.Score, &a.MaxScore, &a.Passed, &a.RespondentName, &a.RespondentEmail, &a.StartedAt, &a.SubmittedAt, &a.GradedAt, &a.IP, &a.UserAgent, ); err != nil { return nil, err } return &a, nil } // StartParams — параметры старта попытки. Один из userID / publicTokenID // обязателен (CHECK на уровне БД). type StartParams struct { TestID uuid.UUID UserID *uuid.UUID PublicTokenID *uuid.UUID CandidateID *int64 RespondentName string RespondentEmail string IP string UserAgent string } // Start — создаёт новую попытку. Проверяет max_attempts: считает существующие // попытки этого test_id × user_id (или × public_token_id для guest'ов) и // возвращает ошибку, если лимит достигнут. func (r *AttemptRepository) Start(ctx context.Context, p StartParams) (*model.Attempt, error) { // max_attempts проверяем заранее — иначе UI узнает только после INSERT'а. var maxAttempts int if err := r.pool.QueryRow(ctx, `SELECT max_attempts FROM tests WHERE id = $1`, p.TestID).Scan(&maxAttempts); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } return nil, err } if maxAttempts > 0 { var used int var err error switch { case p.UserID != nil: err = r.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM test_attempts WHERE test_id = $1 AND user_id = $2 AND status <> 'expired'`, p.TestID, *p.UserID).Scan(&used) case p.PublicTokenID != nil: err = r.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM test_attempts WHERE test_id = $1 AND public_token_id = $2 AND status <> 'expired'`, p.TestID, *p.PublicTokenID).Scan(&used) } if err != nil { return nil, err } if used >= maxAttempts { return nil, fmt.Errorf("max attempts reached (%d)", maxAttempts) } } var id uuid.UUID err := r.pool.QueryRow(ctx, ` INSERT INTO test_attempts ( test_id, user_id, public_token_id, candidate_id, status, respondent_name, respondent_email, ip, user_agent ) VALUES ($1, $2, $3, $4, 'in_progress', $5, $6, $7, $8) RETURNING id`, p.TestID, p.UserID, p.PublicTokenID, p.CandidateID, p.RespondentName, p.RespondentEmail, p.IP, p.UserAgent, ).Scan(&id) if err != nil { return nil, err } return r.Get(ctx, id) } func (r *AttemptRepository) Get(ctx context.Context, id uuid.UUID) (*model.Attempt, error) { a, err := scanAttempt(r.pool.QueryRow(ctx, `SELECT `+attemptCols+` FROM test_attempts WHERE id = $1`, id).Scan) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } return nil, err } return a, nil } // ListByTest — попытки для теста (для HR-просмотра «кто проходил»). // Сортировка свежие сверху; пагинации в MVP нет (lim 200). func (r *AttemptRepository) ListByTest(ctx context.Context, testID uuid.UUID) ([]model.Attempt, error) { rows, err := r.pool.Query(ctx, `SELECT `+attemptCols+` FROM test_attempts WHERE test_id = $1 ORDER BY started_at DESC LIMIT 200`, testID) if err != nil { return nil, err } defer rows.Close() out := []model.Attempt{} for rows.Next() { a, err := scanAttempt(rows.Scan) if err != nil { return nil, err } out = append(out, *a) } return out, rows.Err() } // ListByUser — мои попытки (для страницы «История прохождений»). func (r *AttemptRepository) ListByUser(ctx context.Context, userID uuid.UUID) ([]model.Attempt, error) { rows, err := r.pool.Query(ctx, `SELECT `+attemptCols+` FROM test_attempts WHERE user_id = $1 ORDER BY started_at DESC LIMIT 200`, userID) if err != nil { return nil, err } defer rows.Close() out := []model.Attempt{} for rows.Next() { a, err := scanAttempt(rows.Scan) if err != nil { return nil, err } out = append(out, *a) } return out, rows.Err() } // SubmitAndGrade — атомарно: сохраняет ответы пользователя, считает score // по test_answers (для single/multi), маркирует passed/status. // // Логика автогрейда: // single: 1 правильный ответ — points за вопрос; иначе 0. // multi: выбранные answer_ids == множеству is_correct=TRUE — points; // иначе 0 (частичные баллы не делаем в MVP). // text: correct=NULL, score=NULL — ждут ручной оценки HR'ом. // max_score такого вопроса всё равно учитывается в общем max. // // passed: NULL если у теста нет passing_score; иначе сравниваем // score/max_score × 100 с порогом. func (r *AttemptRepository) SubmitAndGrade(ctx context.Context, attemptID uuid.UUID, req model.SubmitAttemptRequest) (*model.Attempt, error) { tx, err := r.pool.Begin(ctx) if err != nil { return nil, err } defer func() { _ = tx.Rollback(ctx) }() // Загрузить попытку + test (для passing_score). var a model.Attempt err = tx.QueryRow(ctx, `SELECT `+attemptCols+` FROM test_attempts WHERE id = $1 FOR UPDATE`, attemptID).Scan( &a.ID, &a.TestID, &a.UserID, &a.PublicTokenID, &a.CandidateID, &a.Status, &a.Score, &a.MaxScore, &a.Passed, &a.RespondentName, &a.RespondentEmail, &a.StartedAt, &a.SubmittedAt, &a.GradedAt, &a.IP, &a.UserAgent, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } return nil, err } if a.Status != "in_progress" { return nil, fmt.Errorf("attempt is %s, cannot submit", a.Status) } var passingScore *int if err := tx.QueryRow(ctx, `SELECT passing_score FROM tests WHERE id = $1`, a.TestID).Scan(&passingScore); err != nil { return nil, err } // Загрузить вопросы теста + ответы — для каждого вопроса нужно знать // правильные answer_ids и points. type qDef struct { Kind string Points int // CorrectIDs — отсортированный список правильных ответов // (для multi сравниваем как множества). CorrectIDs []string } defs := map[uuid.UUID]*qDef{} qRows, err := tx.Query(ctx, `SELECT id, kind, points FROM test_questions WHERE test_id = $1`, a.TestID) if err != nil { return nil, err } for qRows.Next() { var qid uuid.UUID d := &qDef{} if err := qRows.Scan(&qid, &d.Kind, &d.Points); err != nil { qRows.Close() return nil, err } defs[qid] = d } qRows.Close() if len(defs) == 0 { return nil, fmt.Errorf("test has no questions") } // Все правильные answer_ids одним батчем. qIDs := make([]uuid.UUID, 0, len(defs)) for id := range defs { qIDs = append(qIDs, id) } aRows, err := tx.Query(ctx, `SELECT question_id, id FROM test_answers WHERE question_id = ANY($1) AND is_correct = TRUE`, qIDs) if err != nil { return nil, err } for aRows.Next() { var qid, aid uuid.UUID if err := aRows.Scan(&qid, &aid); err != nil { aRows.Close() return nil, err } if d := defs[qid]; d != nil { d.CorrectIDs = append(d.CorrectIDs, aid.String()) } } aRows.Close() for _, d := range defs { sort.Strings(d.CorrectIDs) } // Полный max_score — сумма points по ВСЕМ вопросам теста, не только // отвеченным. Иначе пропуск вопроса завышал бы процент. maxScore := 0 for _, d := range defs { maxScore += d.Points } // Пройтись по каждому ответу пользователя, посчитать correct + score, // записать в test_attempt_answers (UPSERT). totalScore := 0 for _, ans := range req.Answers { d, ok := defs[ans.QuestionID] if !ok { // Вопрос не из этого теста — игнорируем. Альтернатива — ошибку // возвращать; в MVP толерантно, чтобы фронт мог слать «лишнее». continue } var correctPtr *bool var scorePtr *int payload, _ := json.Marshal(map[string]any{ "answer_id": ans.AnswerID, "answer_ids": ans.AnswerIDs, "text": ans.Text, }) switch d.Kind { case "single": isCorrect := ans.AnswerID != nil && len(d.CorrectIDs) == 1 && ans.AnswerID.String() == d.CorrectIDs[0] correctPtr = &isCorrect s := 0 if isCorrect { s = d.Points } scorePtr = &s totalScore += s case "multi": picked := make([]string, 0, len(ans.AnswerIDs)) for _, id := range ans.AnswerIDs { picked = append(picked, id.String()) } sort.Strings(picked) eq := len(picked) == len(d.CorrectIDs) if eq { for i := range picked { if picked[i] != d.CorrectIDs[i] { eq = false break } } } correctPtr = &eq s := 0 if eq { s = d.Points } scorePtr = &s totalScore += s case "text": // correctPtr=nil, scorePtr=nil — ждёт ручной оценки. } if _, err := tx.Exec(ctx, ` INSERT INTO test_attempt_answers (attempt_id, question_id, payload, correct, score) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (attempt_id, question_id) DO UPDATE SET payload = EXCLUDED.payload, correct = EXCLUDED.correct, score = EXCLUDED.score, answered_at = NOW()`, attemptID, ans.QuestionID, payload, correctPtr, scorePtr); err != nil { return nil, err } } // Определяем passed по passing_score. Если у теста есть text-вопросы // без ручной оценки, passed считаем по auto-graded части — это // «предварительная оценка», HR может пересчитать позже. var passed *bool if passingScore != nil && maxScore > 0 { pct := int(float64(totalScore) * 100 / float64(maxScore)) v := pct >= *passingScore passed = &v } now := time.Now().UTC() // status: submitted (есть text без оценки) или graded (всё auto). hasText := false for _, d := range defs { if d.Kind == "text" { hasText = true break } } finalStatus := "graded" gradedAt := &now if hasText { finalStatus = "submitted" gradedAt = nil } if _, err := tx.Exec(ctx, ` UPDATE test_attempts SET status = $2, score = $3, max_score = $4, passed = $5, submitted_at = $6, graded_at = $7 WHERE id = $1`, attemptID, finalStatus, totalScore, maxScore, passed, now, gradedAt); err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } return r.Get(ctx, attemptID) } // ListAnswers — ответы попытки. Для UI «мои результаты»: подсветка // правильно/неправильно для auto-graded вопросов, raw payload для text. func (r *AttemptRepository) ListAnswers(ctx context.Context, attemptID uuid.UUID) (map[uuid.UUID]struct { Payload []byte Correct *bool Score *int }, error) { rows, err := r.pool.Query(ctx, ` SELECT question_id, payload, correct, score FROM test_attempt_answers WHERE attempt_id = $1`, attemptID) if err != nil { return nil, err } defer rows.Close() out := map[uuid.UUID]struct { Payload []byte Correct *bool Score *int }{} for rows.Next() { var qid uuid.UUID var raw []byte var correct *bool var score *int if err := rows.Scan(&qid, &raw, &correct, &score); err != nil { return nil, err } out[qid] = struct { Payload []byte Correct *bool Score *int }{raw, correct, score} } return out, rows.Err() }