package repository import "fmt" // Пагинация списков — общий контракт для всех GET-списков // (/tickets, /projects, объекты, мастера, пользователи). // // Хендлер разбирает query и валидирует его против тех же словарей, // что и здесь (400 с человеческим текстом), а репозиторий не доверяет // даже ему: limit подрезается, а в SQL уходит только колонка // из словаря — пользовательская строка никогда не конкатенируется. const ( // DefaultLimit — строк на странице, если ?limit= не задан. DefaultLimit = 20 // MaxLimit — потолок: столько максимум попадает в ответ. MaxLimit = 100 ) // Page — параметры одной страницы. type Page struct { Limit int // строк на странице (нормализуется в normalize) Offset int // сдвиг от начала выборки Sort string // ключ из словаря сортировок; "" — сортировка по умолчанию Order string // "asc" | "desc"; "" — направление по умолчанию ключа } // SortSpec — колонка сортировки и её направление по умолчанию. // Col — готовое выражение ORDER BY, собирается только здесь. type SortSpec struct { Col string Desc bool } // Словари сортировок. Единственный источник: по ним хендлер отвечает // 400 на неизвестный ?sort=, а orderBy берёт колонку для SQL. var ( // TicketSorts: priority — «сначала важные», поэтому колонка // это CASE, а не текст (по алфавиту high оказался бы в середине). TicketSorts = map[string]SortSpec{ "created_at": {Col: "t.created_at", Desc: true}, "priority": {Col: "CASE t.priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END", Desc: false}, "id": {Col: "t.id", Desc: true}, } ProjectSorts = map[string]SortSpec{ "created_at": {Col: "p.created_at", Desc: true}, "name": {Col: "p.name", Desc: false}, "id": {Col: "p.id", Desc: true}, } ObjectSorts = map[string]SortSpec{ "name": {Col: "o.name", Desc: false}, "created_at": {Col: "o.created_at", Desc: true}, "id": {Col: "o.id", Desc: true}, } // ProfileSorts общий для /users и мастеров проекта: оба запроса // используют алиас u (см. ListByRole/ListWorkersForClient/ListWorkers). ProfileSorts = map[string]SortSpec{ "name": {Col: "u.name", Desc: false}, "created_at": {Col: "u.created_at", Desc: true}, "email": {Col: "u.email", Desc: false}, "id": {Col: "u.id", Desc: true}, } // InvitationSorts — приглашения мастера (GET /invitations): по // умолчанию сначала свежие; для алиасов см. ListInvitations. InvitationSorts = map[string]SortSpec{ "invited_at": {Col: "pw.invited_at", Desc: true}, "expires_at": {Col: "pw.expires_at", Desc: false}, "project_name": {Col: "p.name", Desc: false}, } // EventSorts — журнал заявки: хронология (id ASC) по умолчанию, // алиас e — из ListEvents. EventSorts = map[string]SortSpec{ "id": {Col: "e.id", Desc: false}, "created_at": {Col: "e.created_at", Desc: false}, } // PhotoSorts — список фото заявки: тоже хронология (id ASC), // алиас ph — из ListPhotos. PhotoSorts = map[string]SortSpec{ "id": {Col: "ph.id", Desc: false}, "created_at": {Col: "ph.created_at", Desc: false}, } // RecurringSorts — правила повторяющихся заявок (алиас r из // ListRules): по умолчанию сначала свежие; «date» — по точке // отсчёта расписания (ближайший первый запуск вперёд). RecurringSorts = map[string]SortSpec{ "created_at": {Col: "r.created_at", Desc: true}, "id": {Col: "r.id", Desc: true}, "date": {Col: "r.anchor_at", Desc: false}, } // RecurringRunSorts — история срабатываний правила (алиас rr из // ListRuns): по умолчанию сначала свежие запуски. RecurringRunSorts = map[string]SortSpec{ "run_at": {Col: "rr.run_at", Desc: true}, "id": {Col: "rr.id", Desc: true}, } ) // normalize подрезает страницу в допустимые границы — второй рубеж // после валидации в хендлере: лишних строк в ответ не уйдёт в любом случае. func (p Page) normalize() Page { if p.Limit <= 0 || p.Limit > MaxLimit { p.Limit = DefaultLimit } if p.Offset < 0 { p.Offset = 0 } if p.Order != "asc" && p.Order != "desc" { p.Order = "" } return p } // orderBy — фрагмент ORDER BY: ключ из словаря (или сортировка по // умолчанию списка), направление из запроса или дефолт ключа. // tiebreak — добавляемая последней колонка (обычно id): без неё // одинаковые значения колонки «перетасовывались» бы между страницами. func orderBy(p Page, sorts map[string]SortSpec, defaultKey, tiebreak string) (string, error) { key := p.Sort if key == "" { key = defaultKey } spec, ok := sorts[key] if !ok { return "", fmt.Errorf("sort %q: %w", key, ErrInvalid) } desc := spec.Desc switch p.Order { case "": case "asc": desc = false case "desc": desc = true default: return "", fmt.Errorf("order %q: %w", p.Order, ErrInvalid) } dir := "ASC" if desc { dir = "DESC" } return " ORDER BY " + spec.Col + " " + dir + ", " + tiebreak, nil }