package handler import ( "errors" "net/http" "github.com/go-chi/chi/v5" "github.com/google/uuid" "learning-service/internal/model" "learning-service/internal/repository" ) // PublicTokenHandler — два слоя: // // 1. HR-методы под /api (защищены X-User-Id через portal-gateway): // Create / List / Revoke. Только владелец ресурса может создавать // ссылки на свой тест/курс. // // 2. Public-методы под /public (без auth, гейтят через сам token): // Resolve (email-check + create attempt), GetAttempt, SubmitAttempt. // Используются на лэндинге кандидата. type PublicTokenHandler struct { repo *repository.PublicTokenRepository testRepo *repository.TestRepository courseRepo *repository.CourseRepository attemptRepo *repository.AttemptRepository } func NewPublicTokenHandler( repo *repository.PublicTokenRepository, testRepo *repository.TestRepository, courseRepo *repository.CourseRepository, attemptRepo *repository.AttemptRepository, ) *PublicTokenHandler { return &PublicTokenHandler{ repo: repo, testRepo: testRepo, courseRepo: courseRepo, attemptRepo: attemptRepo, } } // ============================================================ // HR-side (/api) // ============================================================ // authorizeResourceOwner — проверяет что X-User-Id владеет ресурсом // (тест или курс). Если нет — 403, если ресурс не найден — 404. func (h *PublicTokenHandler) authorizeResourceOwner(w http.ResponseWriter, r *http.Request, resourceType string, resourceID uuid.UUID) (uuid.UUID, bool) { uid, ok := userIDFromHeader(r) if !ok { writeError(w, http.StatusUnauthorized, "unauthorized") return uuid.Nil, false } var owner uuid.UUID switch resourceType { case "test": t, err := h.testRepo.Get(r.Context(), resourceID) if err != nil { writeRepoError(w, r, err, "get test") return uuid.Nil, false } owner = t.OwnerUserID case "course": c, err := h.courseRepo.Get(r.Context(), resourceID) if err != nil { writeRepoError(w, r, err, "get course") return uuid.Nil, false } owner = c.OwnerUserID default: writeError(w, http.StatusBadRequest, "resource_type must be test|course") return uuid.Nil, false } if owner != uid { writeError(w, http.StatusForbidden, "only owner can issue tokens") return uuid.Nil, false } return uid, true } // Create — POST /public-tokens. Body: CreatePublicTokenRequest. func (h *PublicTokenHandler) Create(w http.ResponseWriter, r *http.Request) { var req model.CreatePublicTokenRequest if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid body") return } uid, ok := h.authorizeResourceOwner(w, r, req.ResourceType, req.ResourceID) if !ok { return } t, err := h.repo.Create(r.Context(), uid, req) if err != nil { writeRepoError(w, r, err, "create public token") return } writeJSON(w, http.StatusCreated, t) } // ListByResource — GET /public-tokens?resource_type=test&resource_id=uuid. // Только владелец ресурса. func (h *PublicTokenHandler) ListByResource(w http.ResponseWriter, r *http.Request) { resourceType := r.URL.Query().Get("resource_type") rawID := r.URL.Query().Get("resource_id") if resourceType == "" || rawID == "" { writeError(w, http.StatusBadRequest, "resource_type and resource_id are required") return } resourceID, err := parseUUID(rawID) if err != nil { writeError(w, http.StatusBadRequest, "invalid resource_id") return } if _, ok := h.authorizeResourceOwner(w, r, resourceType, resourceID); !ok { return } items, err := h.repo.ListByResource(r.Context(), resourceType, resourceID) if err != nil { writeRepoError(w, r, err, "list public tokens") return } writeJSON(w, http.StatusOK, map[string]any{"items": items}) } // Revoke — DELETE /public-tokens/{id}. Только владелец ресурса. func (h *PublicTokenHandler) Revoke(w http.ResponseWriter, r *http.Request) { id, err := parseUUID(chi.URLParam(r, "id")) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } t, err := h.repo.Get(r.Context(), id) if err != nil { writeRepoError(w, r, err, "get token") return } if _, ok := h.authorizeResourceOwner(w, r, t.ResourceType, t.ResourceID); !ok { return } if err := h.repo.Revoke(r.Context(), id); err != nil { writeRepoError(w, r, err, "revoke token") return } w.WriteHeader(http.StatusNoContent) } // ============================================================ // Public-side (/public — без auth) // ============================================================ // publicTokenStatus — что отдаём кандидату при resolve'е/info-запросе. // Безопасные поля: тип ресурса, заголовок (для preview), shows valid'ность. // IntendedEmail не возвращаем — иначе любой со ссылкой узнает на чей email // она выпущена. type publicTokenStatus struct { Token string `json:"token"` ResourceType string `json:"resource_type"` ResourceID string `json:"resource_id"` ResourceTitle string `json:"resource_title"` Status string `json:"status"` // valid | expired | revoked | exhausted } // Info — GET /public/learning/tokens/{token}/info. // Возвращает «безопасные» данные для лэндинга (без intended_email) и // статус (для отображения «ссылка отозвана» / «истекла» / etc.). func (h *PublicTokenHandler) PublicInfo(w http.ResponseWriter, r *http.Request) { token := chi.URLParam(r, "token") t, err := h.repo.GetByToken(r.Context(), token) if err != nil { writeRepoError(w, r, err, "get token") return } out := publicTokenStatus{ Token: t.Token, ResourceType: t.ResourceType, ResourceID: t.ResourceID.String(), Status: "valid", } if err := h.repo.CheckUsable(t); err != nil { switch { case errors.Is(err, repository.ErrTokenInvalid): out.Status = "revoked" case errors.Is(err, repository.ErrTokenExpired): out.Status = "expired" case errors.Is(err, repository.ErrTokenExhausted): out.Status = "exhausted" } } // Резолвим title ресурса (тест — title теста; курс — title курса) // чтобы кандидат на лэндинге увидел «Вы открываете тест: Базовая…» // и понял что собирается проходить. switch t.ResourceType { case "test": test, err := h.testRepo.Get(r.Context(), t.ResourceID) if err == nil { out.ResourceTitle = test.Title } case "course": c, err := h.courseRepo.Get(r.Context(), t.ResourceID) if err == nil { out.ResourceTitle = c.Title } } writeJSON(w, http.StatusOK, out) } // Resolve — POST /public/learning/tokens/{token}/resolve {email}. // Полный flow «кандидат жмёт ссылку из письма»: // // 1. Найти токен; CheckUsable (не revoked/expired/exhausted). // 2. Сравнить email с intended_email (case-insensitive). // 3. Создать attempt в БД, привязать к public_token_id. // 4. Пометить opened_at; вернуть attempt_id + начальные данные. // // IncrementUsed НЕ дёргаем здесь — это происходит на submit'е, чтобы // не «съесть» попытку если кандидат открыл и закрыл вкладку. func (h *PublicTokenHandler) PublicResolve(w http.ResponseWriter, r *http.Request) { token := chi.URLParam(r, "token") t, err := h.repo.GetByToken(r.Context(), token) if err != nil { writeRepoError(w, r, err, "get token") return } if err := h.repo.CheckUsable(t); err != nil { switch { case errors.Is(err, repository.ErrTokenInvalid): writeError(w, http.StatusForbidden, "link is revoked") case errors.Is(err, repository.ErrTokenExpired): writeError(w, http.StatusGone, "link expired") case errors.Is(err, repository.ErrTokenExhausted): writeError(w, http.StatusTooManyRequests, "link already used") default: writeError(w, http.StatusForbidden, "link unavailable") } return } var req model.PublicTokenResolveRequest if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid body") return } if err := h.repo.MatchEmail(t, req.Email); err != nil { writeError(w, http.StatusForbidden, "email does not match the recipient") return } // На этой итерации поддерживаем только resource_type='test'. Курсы // через public-ссылку — следующая итерация (нужен view-mode для // urls'а без логина). if t.ResourceType != "test" { writeError(w, http.StatusNotImplemented, "only tests are supported via public links in this version") return } att, err := h.attemptRepo.Start(r.Context(), repository.StartParams{ TestID: t.ResourceID, PublicTokenID: &t.ID, CandidateID: t.CandidateID, RespondentEmail: req.Email, IP: r.RemoteAddr, UserAgent: r.UserAgent(), }) if err != nil { writeRepoError(w, r, err, "start attempt from public token") return } if err := h.repo.MarkOpened(r.Context(), t.ID); err != nil { // Не критично — не блокируем кандидата. // (логирование на стороне slog'а repo'а не делаем — это hot-path). _ = err } writeJSON(w, http.StatusOK, map[string]any{ "attempt": att, "token": token, "test_id": t.ResourceID, }) } // PublicGetAttempt — GET /public/learning/attempts/{id}?token=… // Возвращает текущий attempt + вопросы (без is_correct/explanation). // Проверка: attempt.public_token_id == token и токен ещё валиден. func (h *PublicTokenHandler) PublicGetAttempt(w http.ResponseWriter, r *http.Request) { id, err := parseUUID(chi.URLParam(r, "id")) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } token := r.URL.Query().Get("token") if token == "" { writeError(w, http.StatusBadRequest, "token query param is required") return } att, err := h.attemptRepo.Get(r.Context(), id) if err != nil { writeRepoError(w, r, err, "get attempt") return } t, err := h.repo.GetByToken(r.Context(), token) if err != nil || att.PublicTokenID == nil || *att.PublicTokenID != t.ID { writeError(w, http.StatusForbidden, "token does not match attempt") return } // Сами вопросы — через TestRepository, скрываем is_correct/explanation. qs, err := h.testRepo.ListQuestions(r.Context(), att.TestID) if err != nil { writeRepoError(w, r, err, "list questions") return } for i := range qs { for j := range qs[i].Answers { qs[i].Answers[j].IsCorrect = false } qs[i].Explanation = "" } test, _ := h.testRepo.Get(r.Context(), att.TestID) writeJSON(w, http.StatusOK, map[string]any{ "attempt": att, "test": test, "questions": qs, }) } // PublicSubmit — POST /public/learning/attempts/{id}/submit?token=… // Сабмит ответов + автогрейд. После успешного submit'а // IncrementUsed увеличивает used_attempts токена. func (h *PublicTokenHandler) PublicSubmit(w http.ResponseWriter, r *http.Request) { id, err := parseUUID(chi.URLParam(r, "id")) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } token := r.URL.Query().Get("token") if token == "" { writeError(w, http.StatusBadRequest, "token query param is required") return } att, err := h.attemptRepo.Get(r.Context(), id) if err != nil { writeRepoError(w, r, err, "get attempt") return } t, err := h.repo.GetByToken(r.Context(), token) if err != nil || att.PublicTokenID == nil || *att.PublicTokenID != t.ID { writeError(w, http.StatusForbidden, "token does not match attempt") return } if att.UserID != nil { writeError(w, http.StatusForbidden, "attempt is not from public token") return } var req model.SubmitAttemptRequest if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid body") return } updated, err := h.attemptRepo.SubmitAndGrade(r.Context(), id, req) if err != nil { writeRepoError(w, r, err, "submit attempt") return } // IncrementUsed best-effort. Если упадёт — попытка уже засчитана, // токен не отметится как «использован», ребро-кейс редкий. _ = h.repo.IncrementUsed(r.Context(), t.ID) writeJSON(w, http.StatusOK, updated) }