113 lines
5.8 KiB
Go
113 lines
5.8 KiB
Go
package repository
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/jackc/pgx/v5/pgconn"
|
||
)
|
||
|
||
// Классы ошибок, которые понимают handlers.
|
||
//
|
||
// ErrNotFound / ErrConflict / ErrInvalid — ошибки предметной области,
|
||
// им можно отдавать клиенту код и сообщение.
|
||
// Всё остальное — инфраструктура (сеть, авторизация, падение БД):
|
||
// наружу уходит только «internal server error», детали пишутся в лог.
|
||
var (
|
||
// ErrNotFound — сущность не найдена → 404.
|
||
ErrNotFound = errors.New("not found")
|
||
|
||
// ErrConflict — нарушение уникальности или конфликт состояния → 409.
|
||
ErrConflict = errors.New("conflict")
|
||
|
||
// ErrInvalid — нарушение ограничений целостности (FK, CHECK, NOT NULL) → 422.
|
||
ErrInvalid = errors.New("invalid")
|
||
|
||
// Ниже — специализированные конфликты приглашений (миграция 0005).
|
||
// Каждый заворачивается в ErrConflict (→ 409), но хендлеру даёт
|
||
// точный текст для клиента: «уже состоит» и «ждём ответа» — разные
|
||
// ситуации, хотя обе это 409.
|
||
//
|
||
// ErrMembershipActive — мастер уже активно состоит в проекте.
|
||
ErrMembershipActive = errors.New("worker already active in project")
|
||
// ErrInvitationPending — приглашение отправлено и ещё не принято.
|
||
ErrInvitationPending = errors.New("invitation already pending")
|
||
// ErrInvitationExpired — срок ответа истёк, приглашение мертво.
|
||
ErrInvitationExpired = errors.New("invitation expired")
|
||
// ErrInvitationNotPending — строка есть, но ждать решения уже нечего
|
||
// (мастер ответил или строка устарела на момент операции).
|
||
ErrInvitationNotPending = errors.New("invitation is not pending")
|
||
|
||
// ErrResetToken — токен сброса пароля неизвестен, уже использован или
|
||
// истёк. Наружу уходит одним текстом (400 invalid token): различать
|
||
// причины — помощь переборщику.
|
||
ErrResetToken = errors.New("invalid reset token")
|
||
|
||
// ErrUserHasData — аккаунт нельзя удалить: на него ссылается история —
|
||
// заявки, проекты или участия (FK без каскада). Хендлер отвечает 409
|
||
// и предлагает отключить аккаунт вместо удаления — историю не стираем.
|
||
ErrUserHasData = errors.New("user has related records")
|
||
|
||
// ErrAlreadyConfirmed — подтверждать уже нечего: аккаунт подтверждён
|
||
// раньше (повторный POST /users/:id/confirm). Хендлер ветвится сам
|
||
// и отвечает 409 already confirmed.
|
||
ErrAlreadyConfirmed = errors.New("already confirmed")
|
||
|
||
// Ниже — специализированные конфликты фотоотчёта (миграция 0008).
|
||
// Оба заворачиваются в ErrConflict (→ 409), но хендлеру дают
|
||
// точный текст: «слишком много файлов» и «нельзя удалить» —
|
||
// разные ситуации, хотя обе это 409.
|
||
ErrTooManyPhotos = errors.New("too many photos")
|
||
// ErrPhotoNotRemovable — чужое фото или заявка уже закрыта.
|
||
ErrPhotoNotRemovable = errors.New("photo cannot be removed")
|
||
|
||
// Ниже — рубежи правила повторяющейся заявки: фоновый планировщик
|
||
// пропускает такое правило молча (создания заявки нет), а ручной
|
||
// запуск (POST /recurring/:id/run) получает конфликт с причиной.
|
||
// Каждый заворачивается в ErrConflict (→ 409).
|
||
ErrRuleNotActive = errors.New("rule is not active")
|
||
ErrRuleOpen = errors.New("open ticket exists")
|
||
ErrProjectNotActive = errors.New("project is not active")
|
||
ErrObjectNotActive = errors.New("object is not active")
|
||
)
|
||
|
||
// classify превращает ошибку pgx в ошибку одного из трёх классов.
|
||
// Инфраструктурные ошибки возвращаются как есть, чтобы handlers не
|
||
// приняли обрыв соединения за «запись не нашлась».
|
||
// Контекст вызывающего («ticket 12») сохраняется — он нужен для лога,
|
||
// наружу он не уходит: handlers отдают только фиксированные тексты.
|
||
func classify(err error) error {
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
|
||
var pgErr *pgconn.PgError
|
||
if errors.As(err, &pgErr) {
|
||
switch {
|
||
case pgErr.Code == pgUniqueViolation: // 23505
|
||
return fmt.Errorf("%w: %w", err, ErrConflict)
|
||
case strings.HasPrefix(pgErr.Code, "23"): // 23xxx — integrity constraint
|
||
return fmt.Errorf("%w: %w", err, ErrInvalid)
|
||
}
|
||
// Прочие коды (08xxx сеть, 40001 deadlock, 42xxx права) — инфраструктура.
|
||
return err
|
||
}
|
||
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return fmt.Errorf("%w: %w", err, ErrNotFound)
|
||
}
|
||
return err
|
||
}
|
||
|
||
const (
|
||
pgUniqueViolation = "23505"
|
||
pgFKViolation = "23503"
|
||
)
|
||
|
||
// notFound — обёртка, чтобы сообщение об ошибке было информативным в логе.
|
||
func notFound(format string, args ...any) error {
|
||
return fmt.Errorf("%w: %s", ErrNotFound, fmt.Sprintf(format, args...))
|
||
}
|