diff --git a/cmd/server/main.go b/cmd/server/main.go index 37e18d7..ea504ac 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -44,9 +44,11 @@ func main() { } testRepo := repository.NewTestRepository(pool) + attemptRepo := repository.NewAttemptRepository(pool) healthH := handler.NewHealthHandler(pool) testH := handler.NewTestHandler(testRepo) + attemptH := handler.NewAttemptHandler(attemptRepo, testRepo) r := chi.NewRouter() r.Use(chimw.RequestID) @@ -71,11 +73,15 @@ func main() { r.Post("/tests/{id}/questions", testH.CreateQuestion) r.Delete("/tests/{id}/questions/{questionId}", testH.DeleteQuestion) - // Заглушки на следующие итерации. Возвращают 501 — фронт может - // рендерить «функция в разработке», если случайно дёрнет. - r.HandleFunc("/tests/{id}/attempts", notImplemented) - r.HandleFunc("/attempts/{id}", notImplemented) - r.HandleFunc("/attempts/{id}/submit", notImplemented) + // Attempts: старт + получение + сабмит. Списки — отдельно + // (мои попытки vs все попытки по тесту для HR). + r.Post("/tests/{id}/attempts", attemptH.Start) + r.Get("/tests/{id}/attempts", attemptH.ListByTest) + r.Get("/attempts", attemptH.ListMine) + r.Get("/attempts/{id}", attemptH.Get) + r.Post("/attempts/{id}/submit", attemptH.Submit) + + // Заглушки на следующие итерации. r.HandleFunc("/courses", notImplemented) r.HandleFunc("/courses/{id}", notImplemented) r.HandleFunc("/courses/{id}/lessons", notImplemented) diff --git a/internal/handler/attempt.go b/internal/handler/attempt.go new file mode 100644 index 0000000..aa66a21 --- /dev/null +++ b/internal/handler/attempt.go @@ -0,0 +1,167 @@ +package handler + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + + "learning-service/internal/model" + "learning-service/internal/repository" +) + +type AttemptHandler struct { + repo *repository.AttemptRepository + testRepo *repository.TestRepository +} + +func NewAttemptHandler(repo *repository.AttemptRepository, testRepo *repository.TestRepository) *AttemptHandler { + return &AttemptHandler{repo: repo, testRepo: testRepo} +} + +// Start — POST /tests/{id}/attempts. Создаёт попытку прохождения для +// текущего юзера. Респондент берётся из X-User-Id (заголовок ставит +// portal-gateway). Для public-tokens — отдельный handler в public.go. +func (h *AttemptHandler) Start(w http.ResponseWriter, r *http.Request) { + uid, ok := userIDFromHeader(r) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + testID, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid test id") + return + } + // Проверим, что тест опубликован — иначе только владелец может стартовать + // (для preview). MVP: для не-владельца требуем is_published. + t, err := h.testRepo.Get(r.Context(), testID) + if err != nil { + writeRepoError(w, r, err, "get test") + return + } + if !t.IsPublished && t.OwnerUserID != uid { + writeError(w, http.StatusForbidden, "test is not published") + return + } + att, err := h.repo.Start(r.Context(), repository.StartParams{ + TestID: testID, + UserID: &uid, + IP: r.RemoteAddr, + UserAgent: r.UserAgent(), + }) + if err != nil { + writeRepoError(w, r, err, "start attempt") + return + } + writeJSON(w, http.StatusCreated, att) +} + +// Get — GET /attempts/{id}. Возвращает попытку. Доступно: владелец попытки +// (user_id), владелец теста (HR-просмотр), или admin (через bypass на портале). +func (h *AttemptHandler) Get(w http.ResponseWriter, r *http.Request) { + uid, ok := userIDFromHeader(r) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + id, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + a, err := h.repo.Get(r.Context(), id) + if err != nil { + writeRepoError(w, r, err, "get attempt") + return + } + // Доступ: я респондент или я владелец теста. + if a.UserID == nil || *a.UserID != uid { + t, err := h.testRepo.Get(r.Context(), a.TestID) + if err != nil || t.OwnerUserID != uid { + writeError(w, http.StatusForbidden, "forbidden") + return + } + } + writeJSON(w, http.StatusOK, a) +} + +// Submit — POST /attempts/{id}/submit. Применяет ответы и считает score. +// После submit'а попытка переходит в submitted (если есть text-вопросы) +// или сразу в graded. Идемпотентно НЕ делаем — повторный submit вернёт +// ошибку «уже submitted». +func (h *AttemptHandler) Submit(w http.ResponseWriter, r *http.Request) { + uid, ok := userIDFromHeader(r) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + id, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + existing, err := h.repo.Get(r.Context(), id) + if err != nil { + writeRepoError(w, r, err, "get attempt") + return + } + if existing.UserID == nil || *existing.UserID != uid { + writeError(w, http.StatusForbidden, "not your attempt") + return + } + var req model.SubmitAttemptRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid body") + return + } + updated, err := h.repo.SubmitAndGrade(r.Context(), id, req) + if err != nil { + writeRepoError(w, r, err, "submit attempt") + return + } + writeJSON(w, http.StatusOK, updated) +} + +// ListMine — GET /attempts?mine=true. История моих попыток. +func (h *AttemptHandler) ListMine(w http.ResponseWriter, r *http.Request) { + uid, ok := userIDFromHeader(r) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + items, err := h.repo.ListByUser(r.Context(), uid) + if err != nil { + writeRepoError(w, r, err, "list my attempts") + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": items}) +} + +// ListByTest — GET /tests/{id}/attempts. Список попыток для HR (владелец теста). +func (h *AttemptHandler) ListByTest(w http.ResponseWriter, r *http.Request) { + uid, ok := userIDFromHeader(r) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + testID, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid test id") + return + } + t, err := h.testRepo.Get(r.Context(), testID) + if err != nil { + writeRepoError(w, r, err, "get test") + return + } + if t.OwnerUserID != uid { + writeError(w, http.StatusForbidden, "only owner can view attempts") + return + } + items, err := h.repo.ListByTest(r.Context(), testID) + if err != nil { + writeRepoError(w, r, err, "list test attempts") + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": items}) +} diff --git a/internal/repository/attempt.go b/internal/repository/attempt.go new file mode 100644 index 0000000..456854e --- /dev/null +++ b/internal/repository/attempt.go @@ -0,0 +1,418 @@ +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() +}