From d7739992969f90b60905dfc78c69071dd6526ad8 Mon Sep 17 00:00:00 2001 From: Ilya Date: Tue, 26 May 2026 00:45:49 +0300 Subject: [PATCH] =?UTF-8?q?feat(public-tokens):=20=D0=BE=D0=B4=D0=BD=D0=BE?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=BE=D0=B2=D1=8B=D0=B5=20=D1=81=D1=81=D1=8B?= =?UTF-8?q?=D0=BB=D0=BA=D0=B8=20=D0=B4=D0=BB=D1=8F=20=D0=BA=D0=B0=D0=BD?= =?UTF-8?q?=D0=B4=D0=B8=D0=B4=D0=B0=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PublicTokenRepository: - Create — генерирует 256-битный URL-safe токен через crypto/rand; intended_email нормализуется в lower-case; max_attempts<=0 → 1; - GetByToken — поиск по URL-токену для public-endpoint'ов; - ListByResource — все токены для теста/курса (HR-UI); - Revoke — soft-cancel (revoked_at = NOW()); - CheckUsable — валидирует токен: revoked/expired/exhausted → типизированные ошибки (ErrTokenInvalid/Expired/Exhausted/Email); - MatchEmail — case-insensitive сравнение; - MarkOpened / IncrementUsed — для аудита и счётчика попыток. PublicTokenHandler — два слоя: HR (/api, под service.learning.access + owner-проверка): - POST /public-tokens — Create; - GET /public-tokens?resource_type=...&resource_id=... — ListByResource; - DELETE /public-tokens/{id} — Revoke. Public (/public, без auth): - GET /public/learning/tokens/{token}/info — title + status ({valid|revoked|expired|exhausted}). IntendedEmail НЕ возвращаем, чтобы любой со ссылкой не узнал чей это email. - POST /public/learning/tokens/{token}/resolve {email} — сверяет email с intended (case-insensitive), создаёт attempt со public_token_id, помечает opened_at. IncrementUsed на submit'е (а не resolve'е), чтобы кандидат не сжёг попытку случайным открытием. - GET /public/learning/attempts/{id}?token=… — текущий attempt + questions (is_correct/explanation скрыты). - POST /public/learning/attempts/{id}/submit?token=… — сабмит + автогрейд + IncrementUsed. MVP поддерживает только resource_type='test'. Courses через public- ссылку — следующая итерация (нужен view-mode без логина). Co-Authored-By: Claude Opus 4.7 --- cmd/server/main.go | 24 +- internal/handler/public_token.go | 362 ++++++++++++++++++++++++++++ internal/repository/public_token.go | 209 ++++++++++++++++ 3 files changed, 588 insertions(+), 7 deletions(-) create mode 100644 internal/handler/public_token.go create mode 100644 internal/repository/public_token.go diff --git a/cmd/server/main.go b/cmd/server/main.go index c086ce4..150bd71 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -70,12 +70,14 @@ func main() { attemptRepo := repository.NewAttemptRepository(pool) courseRepo := repository.NewCourseRepository(pool) lessonRepo := repository.NewLessonRepository(pool) + publicTokenRepo := repository.NewPublicTokenRepository(pool) healthH := handler.NewHealthHandler(pool) testH := handler.NewTestHandler(testRepo) attemptH := handler.NewAttemptHandler(attemptRepo, testRepo) courseH := handler.NewCourseHandler(courseRepo) lessonH := handler.NewLessonHandler(lessonRepo, courseRepo, store) + publicTokenH := handler.NewPublicTokenHandler(publicTokenRepo, testRepo, courseRepo, attemptRepo) r := chi.NewRouter() r.Use(chimw.RequestID) @@ -136,17 +138,25 @@ func main() { r.Get("/lessons/{id}/video/stream", lessonH.StreamVideo) r.Delete("/lessons/{id}/video", lessonH.DeleteVideo) r.HandleFunc("/access/{resourceType}/{resourceId}", notImplemented) - r.HandleFunc("/public-tokens", notImplemented) - r.HandleFunc("/public-tokens/{id}", notImplemented) + + // Public tokens — HR-side: создать ссылку для кандидата, посмотреть + // список, отозвать. Сам прохождение тестa по токену — в /public ниже. + r.Post("/public-tokens", publicTokenH.Create) + r.Get("/public-tokens", publicTokenH.ListByResource) + r.Delete("/public-tokens/{id}", publicTokenH.Revoke) }) // Public endpoints — без InternalAuth (кандидаты ходят анонимно - // по token'у). Открываем минимум: проверка токена + старт attempt + - // сабмит. Содержательно гейтим через token-валидацию в самом handler'е. + // по token'у). Гейтят через сам token внутри handler'ов. r.Route("/public", func(r chi.Router) { - r.HandleFunc("/learning/resolve/{token}", notImplemented) - r.HandleFunc("/learning/attempts/{id}", notImplemented) - r.HandleFunc("/learning/attempts/{id}/submit", notImplemented) + // Info — лёгкий read для лэндинга (проверка валидности + title). + r.Get("/learning/tokens/{token}/info", publicTokenH.PublicInfo) + // Resolve — кандидат вводит email, бэк сверяет с intended_email + // и создаёт attempt. Возвращает attempt_id + первое чтение теста. + r.Post("/learning/tokens/{token}/resolve", publicTokenH.PublicResolve) + // Attempts — read/submit с обязательным ?token=… в query. + r.Get("/learning/attempts/{id}", publicTokenH.PublicGetAttempt) + r.Post("/learning/attempts/{id}/submit", publicTokenH.PublicSubmit) }) srv := &http.Server{ diff --git a/internal/handler/public_token.go b/internal/handler/public_token.go new file mode 100644 index 0000000..a1d2795 --- /dev/null +++ b/internal/handler/public_token.go @@ -0,0 +1,362 @@ +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) +} diff --git a/internal/repository/public_token.go b/internal/repository/public_token.go new file mode 100644 index 0000000..8179159 --- /dev/null +++ b/internal/repository/public_token.go @@ -0,0 +1,209 @@ +package repository + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "learning-service/internal/model" +) + +type PublicTokenRepository struct { + pool *pgxpool.Pool +} + +func NewPublicTokenRepository(pool *pgxpool.Pool) *PublicTokenRepository { + return &PublicTokenRepository{pool: pool} +} + +const tokenCols = ` + id, token, resource_type, resource_id, intended_email, candidate_id, + max_attempts, used_attempts, expires_at, opened_at, used_at, revoked_at, + created_by, created_at +` + +func scanToken(scan func(...any) error) (*model.PublicToken, error) { + var t model.PublicToken + if err := scan( + &t.ID, &t.Token, &t.ResourceType, &t.ResourceID, &t.IntendedEmail, &t.CandidateID, + &t.MaxAttempts, &t.UsedAttempts, &t.ExpiresAt, &t.OpenedAt, &t.UsedAt, &t.RevokedAt, + &t.CreatedBy, &t.CreatedAt, + ); err != nil { + return nil, err + } + return &t, nil +} + +// generateToken — длинный URL-safe random для токена в URL'е. Используем +// crypto/rand с 32 байтами → 43-символьная base64-строка, ~256 бит +// энтропии. Brute-force по такому пространству неосуществим. +func generateToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("rand: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// Create — создаёт токен. Если max_attempts <= 0, считаем 1 (одноразовая +// ссылка). intended_email нормализуется в lower-case (сравнение тоже +// case-insensitive — см. ResolveByEmail). +func (r *PublicTokenRepository) Create(ctx context.Context, createdBy uuid.UUID, req model.CreatePublicTokenRequest) (*model.PublicToken, error) { + if strings.TrimSpace(req.IntendedEmail) == "" { + return nil, fmt.Errorf("intended_email is required") + } + if req.ResourceType != "test" && req.ResourceType != "course" { + return nil, fmt.Errorf("resource_type must be test|course") + } + maxAttempts := req.MaxAttempts + if maxAttempts <= 0 { + maxAttempts = 1 + } + tok, err := generateToken() + if err != nil { + return nil, err + } + var id uuid.UUID + err = r.pool.QueryRow(ctx, ` + INSERT INTO public_tokens ( + token, resource_type, resource_id, intended_email, candidate_id, + max_attempts, expires_at, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id`, + tok, req.ResourceType, req.ResourceID, + strings.ToLower(strings.TrimSpace(req.IntendedEmail)), + req.CandidateID, maxAttempts, req.ExpiresAt, createdBy, + ).Scan(&id) + if err != nil { + return nil, err + } + return r.Get(ctx, id) +} + +func (r *PublicTokenRepository) Get(ctx context.Context, id uuid.UUID) (*model.PublicToken, error) { + t, err := scanToken(r.pool.QueryRow(ctx, + `SELECT `+tokenCols+` FROM public_tokens WHERE id = $1`, id).Scan) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return t, nil +} + +// GetByToken — поиск по URL-токену. Используется на public-endpoint'ах +// для resolve'а кандидата. +func (r *PublicTokenRepository) GetByToken(ctx context.Context, token string) (*model.PublicToken, error) { + t, err := scanToken(r.pool.QueryRow(ctx, + `SELECT `+tokenCols+` FROM public_tokens WHERE token = $1`, token).Scan) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return t, nil +} + +// ListByResource — все токены для конкретного теста/курса (для HR-UI). +// Сортировка — свежие сверху. +func (r *PublicTokenRepository) ListByResource(ctx context.Context, resourceType string, resourceID uuid.UUID) ([]model.PublicToken, error) { + rows, err := r.pool.Query(ctx, + `SELECT `+tokenCols+` FROM public_tokens + WHERE resource_type = $1 AND resource_id = $2 + ORDER BY created_at DESC LIMIT 200`, + resourceType, resourceID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []model.PublicToken{} + for rows.Next() { + t, err := scanToken(rows.Scan) + if err != nil { + return nil, err + } + out = append(out, *t) + } + return out, rows.Err() +} + +// Revoke — soft-cancel токена. Открыть нельзя, но история сохраняется. +func (r *PublicTokenRepository) Revoke(ctx context.Context, id uuid.UUID) error { + tag, err := r.pool.Exec(ctx, + `UPDATE public_tokens SET revoked_at = NOW() WHERE id = $1 AND revoked_at IS NULL`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// CheckUsable — валидирует токен на момент resolve'а: +// - revoked_at IS NULL +// - expires_at либо NULL либо > NOW() +// - used_attempts < max_attempts +// +// Возвращает ErrTokenInvalid / ErrTokenExpired / ErrTokenExhausted — +// handler маппит на специфические HTTP-коды (403/410/429). +var ( + ErrTokenInvalid = errors.New("token invalid or revoked") + ErrTokenExpired = errors.New("token expired") + ErrTokenExhausted = errors.New("token max attempts reached") + ErrTokenEmail = errors.New("email does not match") +) + +func (r *PublicTokenRepository) CheckUsable(t *model.PublicToken) error { + if t.RevokedAt != nil { + return ErrTokenInvalid + } + if t.ExpiresAt != nil && time.Now().After(*t.ExpiresAt) { + return ErrTokenExpired + } + if t.UsedAttempts >= t.MaxAttempts { + return ErrTokenExhausted + } + return nil +} + +// MatchEmail — case-insensitive сравнение. Возвращает ErrTokenEmail +// при несовпадении (handler → 403, чтобы кандидат понял что ссылка +// для другого получателя). +func (r *PublicTokenRepository) MatchEmail(t *model.PublicToken, candidateEmail string) error { + a := strings.ToLower(strings.TrimSpace(t.IntendedEmail)) + b := strings.ToLower(strings.TrimSpace(candidateEmail)) + if a != b { + return ErrTokenEmail + } + return nil +} + +// MarkOpened — первое открытие; если уже было, no-op. +func (r *PublicTokenRepository) MarkOpened(ctx context.Context, id uuid.UUID) error { + _, err := r.pool.Exec(ctx, + `UPDATE public_tokens SET opened_at = COALESCE(opened_at, NOW()) WHERE id = $1`, id) + return err +} + +// IncrementUsed — после успешного submit'а attempt'а. Атомарно растит +// used_attempts и ставит used_at (если это первое использование). +// max_attempts ENFORCE'ится отдельно через CheckUsable перед resolve'ом. +func (r *PublicTokenRepository) IncrementUsed(ctx context.Context, id uuid.UUID) error { + _, err := r.pool.Exec(ctx, ` + UPDATE public_tokens + SET used_attempts = used_attempts + 1, + used_at = COALESCE(used_at, NOW()) + WHERE id = $1`, id) + return err +}