package repository import ( "context" "errors" "fmt" "time" "github.com/jackc/pgx/v5" "building-mgmt/internal/models" ) // TicketRepo — заявки и переходы статусной машины. Каждый переход // описан в самом UPDATE: `WHERE` проверяет и статус, и принадлежность, // поэтому запрещённая операция не доходит до логики вообще. type TicketRepo struct { db Querier } // NewTicketRepo создаёт репозиторий поверх Querier (пул или транзакция). func NewTicketRepo(db Querier) *TicketRepo { return &TicketRepo{db: db} } // Create — создать заявку. Принадлежность (объект → проект → клиент) // проверяют составные FK в БД, а не эта функция: сюда приходит уже // проверенный хендлером объект. Событие 'created' в журнале пишется // той же операцией — заявка без первой записи истории невозможна. func (r *TicketRepo) Create(ctx context.Context, t *models.Ticket) error { err := r.db.QueryRow(ctx, `WITH ins AS ( INSERT INTO tickets (client_id, project_id, object_id, address, floor, category, description, priority) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, status, created_at ), ev AS ( INSERT INTO ticket_events (ticket_id, actor_id, action, from_status, to_status) SELECT id, $1, 'created', NULL, status FROM ins ) SELECT id, status, created_at FROM ins`, t.ClientID, t.ProjectID, t.ObjectID, t.Address, t.Floor, t.Category, t.Description, t.Priority, ).Scan(&t.ID, &t.Status, &t.CreatedAt) return classify(err) } // Schedule — расписание правила повторения: точка отсчёта (дата-время // первого запуска) плюс период в днях, см. models.RecurringRule. type Schedule struct { AnchorAt time.Time PeriodDays int } // CreateWithRule — заявка + правило повторения одной операцией // (чекбокс «Повторяющаяся заявка» в форме новой заявки): шаблон // правила — поля самой заявки, расписание — из Schedule. Связь // «правило ↔ первая заявка» пишется в recurring_runs тем же прогоном: // - блок на странице заявки зацепился за правило; // - незакрытая первая заявка блокирует следующий автозапуск // (тот же EXISTS, что и для заявок, созданных планировщиком); // - история срабатываний показывает «семенную» заявку. // // createdBy — кто поставил галочку (для журнала правила). Возвращает // id созданного правила. func (r *TicketRepo) CreateWithRule(ctx context.Context, t *models.Ticket, s Schedule, createdBy string) (int64, error) { var ruleID int64 err := r.db.QueryRow(ctx, `WITH ins AS ( INSERT INTO tickets (client_id, project_id, object_id, address, floor, category, description, priority) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, status, created_at ), ev AS ( INSERT INTO ticket_events (ticket_id, actor_id, action, from_status, to_status) SELECT id, $1, 'created', NULL, status FROM ins ), rule AS ( INSERT INTO recurring_rules (client_id, project_id, object_id, category, description, priority, floor, anchor_at, period_days, created_by) VALUES ($1, $2, $3, $6, $7, $8, NULLIF($5, ''), $9, $10, NULLIF($11, '')::uuid) RETURNING id ), link AS ( INSERT INTO recurring_runs (rule_id, ticket_id) SELECT rule.id, ins.id FROM rule, ins RETURNING rule_id ) SELECT ins.id, ins.status, ins.created_at, link.rule_id FROM ins, link`, t.ClientID, t.ProjectID, t.ObjectID, t.Address, t.Floor, t.Category, t.Description, t.Priority, s.AnchorAt, s.PeriodDays, createdBy, ).Scan(&t.ID, &t.Status, &t.CreatedAt, &ruleID) return ruleID, classify(err) } // ticketColumns — проекция заявки. Один источник для GetByID и List, // чтобы новое поле не приходилось дописывать в двух запросах. const ticketColumns = ` t.id, t.client_id, t.worker_id, t.assigned_by, t.project_id, t.object_id, t.address, COALESCE(t.floor, ''), t.category, t.description, t.priority, t.status, t.created_at, t.assigned_at, t.started_at, t.done_at, t.confirmed_at, t.closed_at, COALESCE(t.reject_reason, ''), COALESCE(t.cancel_reason, ''), t.rejected_at, t.closed_by, t.rework_count, c.name AS client_name, COALESCE(w.name, '') AS worker_name, p.name AS project_name, o.name AS object_name` // ticketFrom — общий FROM с JOIN'ами, подтягивающими имена. const ticketFrom = ` FROM tickets t JOIN profiles c ON c.id = t.client_id JOIN projects p ON p.id = t.project_id JOIN project_objects o ON o.id = t.object_id LEFT JOIN profiles w ON w.id = t.worker_id` // scanner — общий интерфейс для pgx.Row и pgx.Rows. type scanner interface { Scan(dest ...any) error } // scanTicket читает строку в модель. Порядок Scan обязан совпадать // с ticketColumns. extra — доп. назначения в конце SELECT (например // COUNT(*) OVER() у списка): значение читается в вызывающий код. func scanTicket(s scanner, extra ...any) (models.Ticket, error) { var t models.Ticket dest := []any{ &t.ID, &t.ClientID, &t.WorkerID, &t.AssignedBy, &t.ProjectID, &t.ObjectID, &t.Address, &t.Floor, &t.Category, &t.Description, &t.Priority, &t.Status, &t.CreatedAt, &t.AssignedAt, &t.StartedAt, &t.DoneAt, &t.ConfirmedAt, &t.ClosedAt, &t.RejectReason, &t.CancelReason, &t.RejectedAt, &t.ClosedBy, &t.ReworkCount, &t.ClientName, &t.WorkerName, &t.ProjectName, &t.ObjectName, } dest = append(dest, extra...) err := s.Scan(dest...) return t, classify(err) } // GetByID — одна заявка с именами клиента и мастера; если заявка // связана с правилом повторения (создана вместе с ним или планировщиком), // подтягивается и оно — блок «Повторяющаяся заявка» на странице. func (r *TicketRepo) GetByID(ctx context.Context, id int64) (*models.Ticket, error) { t, err := scanTicket(r.db.QueryRow(ctx, `SELECT `+ticketColumns+ticketFrom+` WHERE t.id = $1`, id)) if err != nil { return nil, fmt.Errorf("ticket %d: %w", id, err) } var rec models.TicketRecurring var ref time.Time err = r.db.QueryRow(ctx, `SELECT r.id, r.anchor_at, r.period_days, r.is_active, COALESCE(r.last_run_at, r.created_at) FROM recurring_runs rr JOIN recurring_rules r ON r.id = rr.rule_id WHERE rr.ticket_id = $1 ORDER BY rr.id LIMIT 1`, id). Scan(&rec.RuleID, &rec.AnchorAt, &rec.PeriodDays, &rec.IsActive, &ref) switch { case errors.Is(err, pgx.ErrNoRows): // Заявка не связана с правилом или правило удалили (каскад) — // блок на странице не показываем. case err != nil: return nil, classify(err) default: // Ближайший запуск — та же чистая функция, что у списка правил // (nextDue от последнего срабатывания или создания). next := nextDue(rec.AnchorAt, rec.PeriodDays, ref) rec.NextRunAt = &next t.Recurring = &rec } return &t, nil } // ListFilter — какие заявки запрашивать. Все поля опциональны: // незаданный фильтр ничего не режет, в комбинации с выборкой по роли. type ListFilter struct { // Если задан — только заявки этого клиента. ClientID string // Если задан — заявки этого мастера (назначенные ему). WorkerID string // Если задан — видимость мастера: назначенные ему плюс свободные // (new) заявки проектов, где он активный участник. Свои плюс // «можно взять самому» — ровно тот набор, что ему нужно. FreeForWorker string // Фильтры по полям заявки. Status string Category string Priority string // Если >0 — заявки этого проекта/объекта (структура вместо людей). ProjectID int64 ObjectID int64 // Диапазон created_at: DateFrom включительно, DateTo исключительно. // Хендлер кладёт в DateTo границу «следующего дня», чтобы date_to // включал в себя весь свой день. DateFrom *time.Time DateTo *time.Time // Пагинация и сортировка (см. page.go). Page Page } // List — страница заявок с фильтрами плюс total: сколько строк подходило // без LIMIT/OFFSET (COUNT(*) OVER() считает до обрезки — одним запросом). func (r *TicketRepo) List(ctx context.Context, f ListFilter) ([]models.Ticket, int, error) { page := f.Page.normalize() order, err := orderBy(page, TicketSorts, "created_at", "t.id DESC") if err != nil { return nil, 0, err } where, whereArgs := ticketWhere(f) args := append([]interface{}{}, whereArgs...) // копия: у страницы свои аргументы query := `SELECT ` + ticketColumns + `, COUNT(*) OVER() AS total` + ticketFrom + ` WHERE ` + where + order + ` LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2) args = append(args, page.Limit, page.Offset) rows, err := r.db.Query(ctx, query, args...) if err != nil { return nil, 0, classify(err) } defer rows.Close() var tickets []models.Ticket total := 0 for rows.Next() { t, err := scanTicket(rows, &total) if err != nil { return nil, 0, err } tickets = append(tickets, t) } if err := rows.Err(); err != nil { return nil, 0, classify(err) } if len(tickets) == 0 { // Пустое окно (offset за концом): window-функция возвращает // значение только вместе со строками, а страница без данных // обязана всё равно сообщить полное число — досчитываем. var count int if err := r.db.QueryRow(ctx, `SELECT COUNT(*)`+ticketFrom+` WHERE `+where, whereArgs...).Scan(&count); err != nil { return nil, 0, classify(err) } total = count } return tickets, total, nil } // ticketWhere — WHERE из фильтров ListFilter. Один источник и для // страницы, и для подсчёта total: два запроса обязаны считать // один и тот же набор строк. Операторы — только литералы из этого // файла, значения всегда уходят плейсхолдерами. func ticketWhere(f ListFilter) (string, []interface{}) { where := `1=1` args := []interface{}{} idx := 1 appendCond := func(column, op string, value interface{}) { where += ` AND ` + column + ` ` + op + ` $` + itoa(idx) args = append(args, value) idx++ } if f.ClientID != "" { appendCond("t.client_id", "=", f.ClientID) } if f.WorkerID != "" { appendCond("t.worker_id", "=", f.WorkerID) } if f.FreeForWorker != "" { // Свободная заявка — status='new' и активное членство // (тот же приём, что в Assign): чужие проекты не подглядеть. where += ` AND (t.worker_id = $` + itoa(idx) + ` OR (t.status = 'new' AND EXISTS ( SELECT 1 FROM project_workers pw WHERE pw.worker_id = $` + itoa(idx) + ` AND pw.project_id = t.project_id AND pw.status = 'active')))` args = append(args, f.FreeForWorker) idx++ } if f.Status != "" { appendCond("t.status", "=", f.Status) } if f.Category != "" { appendCond("t.category", "=", f.Category) } if f.Priority != "" { appendCond("t.priority", "=", f.Priority) } if f.ProjectID > 0 { appendCond("t.project_id", "=", f.ProjectID) } if f.ObjectID > 0 { appendCond("t.object_id", "=", f.ObjectID) } if f.DateFrom != nil { appendCond("t.created_at", ">=", *f.DateFrom) } if f.DateTo != nil { appendCond("t.created_at", "<", *f.DateTo) } return where, args } // itoa — маленький хелпер, чтобы не тащить strconv ради одной цифры. func itoa(n int) string { if n < 10 { return string(rune('0' + n)) } return itoa(n/10) + string(rune('0'+n%10)) } // Assign — назначить мастера. Только для заявок в статусе new. // // Членство мастера в проекте проверяется здесь же, в WHERE: это последний // рубеж на уровне БД (см. шапку миграции 0004) — «чужой» мастер не // назначается даже при обходе хендлера. С 0005 членство обязано быть // активным: приглашённый, но не ответивший заявок не получает. // С 0010 мастер обязан быть подтверждённым — хотя приглашение и не // пускает неподтверждённых, это защита на случай ручных правок в БД. func (r *TicketRepo) Assign(ctx context.Context, ticketID int64, workerID, assignedBy string) error { tag, err := r.db.Exec(ctx, `WITH upd AS ( UPDATE tickets SET worker_id = $1, assigned_by = $2, status = 'assigned', assigned_at = now() WHERE id = $3 AND status = 'new' AND EXISTS ( SELECT 1 FROM project_workers pw JOIN profiles w ON w.id = pw.worker_id WHERE pw.worker_id = $1 AND pw.project_id = tickets.project_id AND pw.status = 'active' AND w.confirmed_at IS NOT NULL) RETURNING id ), ev AS ( INSERT INTO ticket_events (ticket_id, actor_id, action, from_status, to_status) SELECT id, $2, 'assigned', 'new', 'assigned' FROM upd ) SELECT id FROM upd`, workerID, assignedBy, ticketID, ) if err != nil { return classify(err) } if tag.RowsAffected() == 0 { return r.stateError(ctx, ticketID, "must be in 'new' status and the worker must be a member of the project") } return nil } // Start — взять в работу (или перезапустить после доработки). // Только мастер, которому назначена. started_at не затирается: // у второго круга остаётся время первого старта. // prev читает статус ДО апдейта (один снапшот на всю операцию), // чтобы в журнале стоял настоящий from_status: assigned или rejected. func (r *TicketRepo) Start(ctx context.Context, ticketID int64, workerID string) error { tag, err := r.db.Exec(ctx, `WITH prev AS ( SELECT status FROM tickets WHERE id = $1 ), upd AS ( UPDATE tickets SET status = 'in_progress', started_at = COALESCE(started_at, now()) WHERE id = $1 AND worker_id = $2 AND status IN ('assigned','in_progress','rejected') RETURNING id, status ), ev AS ( INSERT INTO ticket_events (ticket_id, actor_id, action, from_status, to_status) SELECT u.id, $2, 'started', p.status, u.status FROM upd u JOIN prev p ON true ) SELECT id FROM upd`, ticketID, workerID, ) if err != nil { return classify(err) } if tag.RowsAffected() == 0 { return r.stateError(ctx, ticketID, "must be assigned to you and not finished") } return nil } // Done — завершить. Только мастер, взявший в работу. func (r *TicketRepo) Done(ctx context.Context, ticketID int64, workerID string) error { tag, err := r.db.Exec(ctx, `WITH upd AS ( UPDATE tickets SET status = 'done', done_at = now() WHERE id = $1 AND worker_id = $2 AND status = 'in_progress' RETURNING id ), ev AS ( INSERT INTO ticket_events (ticket_id, actor_id, action, from_status, to_status) SELECT id, $2, 'done', 'in_progress', 'done' FROM upd ) SELECT id FROM upd`, ticketID, workerID, ) if err != nil { return classify(err) } if tag.RowsAffected() == 0 { return r.stateError(ctx, ticketID, "must be in progress by you") } return nil } // Confirm — клиент принял работу: финальное закрытие заявки. // Фиксируем и факт принятия (confirmed_at), и само закрытие (closed_at). func (r *TicketRepo) Confirm(ctx context.Context, ticketID int64, clientID string) error { tag, err := r.db.Exec(ctx, `WITH upd AS ( UPDATE tickets SET status = 'closed', confirmed_at = now(), closed_at = now() WHERE id = $1 AND client_id = $2 AND status = 'done' RETURNING id ), ev AS ( INSERT INTO ticket_events (ticket_id, actor_id, action, from_status, to_status) SELECT id, $2, 'confirmed', 'done', 'closed' FROM upd ) SELECT id FROM upd`, ticketID, clientID, ) if err != nil { return classify(err) } if tag.RowsAffected() == 0 { return r.stateError(ctx, ticketID, "must be done and belong to the client") } return nil } // Reject — клиент отклонил выполненную работу: заявка уходит на доработку. // Это не финал: статус 'rejected' открывает второй круг, причина обязательна // (нарушение проверит констрейнт tickets_rejected_reason) и попадает // в журнал как comment. func (r *TicketRepo) Reject(ctx context.Context, ticketID int64, clientID, reason string) error { tag, err := r.db.Exec(ctx, `WITH upd AS ( UPDATE tickets SET status = 'rejected', rejected_at = now(), reject_reason = $3, rework_count = rework_count + 1 WHERE id = $1 AND client_id = $2 AND status = 'done' RETURNING id ), ev AS ( INSERT INTO ticket_events (ticket_id, actor_id, action, from_status, to_status, comment) SELECT id, $2, 'rejected', 'done', 'rejected', $3 FROM upd ) SELECT id FROM upd`, ticketID, clientID, reason, ) if err != nil { return classify(err) } if tag.RowsAffected() == 0 { return r.stateError(ctx, ticketID, "must be done and belong to the client") } return nil } // Close — окончательный отказ/отмена. В отличие от Reject это финал: // заявка уходит в 'closed' с cancel_reason и закрывается тем, кто отменил. // from — из каких статусов разрешено (клиенту нельзя трогать чужую работу // в процессе, админу можно закрыть застрявшую). prev даёт журналу // настоящий from_status, а не один из вариантов списка. func (r *TicketRepo) Close(ctx context.Context, ticketID int64, actorID, reason string, from []string) error { tag, err := r.db.Exec(ctx, `WITH prev AS ( SELECT status FROM tickets WHERE id = $1 ), upd AS ( UPDATE tickets SET status = 'closed', cancel_reason = $2, closed_by = $3, closed_at = now() WHERE id = $1 AND status = ANY($4) RETURNING id, status ), ev AS ( INSERT INTO ticket_events (ticket_id, actor_id, action, from_status, to_status, comment) SELECT u.id, $3, 'canceled', p.status, u.status, $2 FROM upd u JOIN prev p ON true ) SELECT id FROM upd`, ticketID, reason, actorID, from, ) if err != nil { return classify(err) } if tag.RowsAffected() == 0 { return r.stateError(ctx, ticketID, "cannot be closed in its current status") } return nil } // ListEvents — страница журнала заявки: хронология событий с именами // авторов (LEFT JOIN — автор мог быть удалён, событие останется). // Сортировка по умолчанию — id ASC, то есть от создания вперёд. func (r *TicketRepo) ListEvents(ctx context.Context, ticketID int64, page Page) ([]models.TicketEvent, int, error) { page = page.normalize() order, err := orderBy(page, EventSorts, "id", "e.id ASC") if err != nil { return nil, 0, err } rows, err := r.db.Query(ctx, `SELECT e.id, e.ticket_id, COALESCE(a.id::text, ''), COALESCE(a.name, ''), e.action, COALESCE(e.from_status, ''), e.to_status, COALESCE(e.comment, ''), e.created_at, COUNT(*) OVER() AS total FROM ticket_events e LEFT JOIN profiles a ON a.id = e.actor_id WHERE e.ticket_id = $1`+order+` LIMIT $2 OFFSET $3`, ticketID, page.Limit, page.Offset) if err != nil { return nil, 0, classify(err) } defer rows.Close() var events []models.TicketEvent total := 0 for rows.Next() { var ev models.TicketEvent if err := rows.Scan(&ev.ID, &ev.TicketID, &ev.ActorID, &ev.ActorName, &ev.Action, &ev.FromStatus, &ev.ToStatus, &ev.Comment, &ev.CreatedAt, &total); err != nil { return nil, 0, classify(err) } events = append(events, ev) } if err := rows.Err(); err != nil { return nil, 0, classify(err) } // Пустое окно: window не вернул total — досчитываем тем же WHERE. if len(events) == 0 { if err := r.db.QueryRow(ctx, `SELECT COUNT(*) FROM ticket_events WHERE ticket_id = $1`, ticketID).Scan(&total); err != nil { return nil, 0, classify(err) } } return events, total, nil } // DeleteExpired — автоочистка: удалить закрытые заявки, закрытые раньше // before. Сначала снимаются ключи объектов фото — после каскада строк // на месте уже не будет, — затем один DELETE: события журнала и строки // фото уходят каскадом (ON DELETE CASCADE, миграции 0006/0008), а // сироты истории повторяющихся заявок (recurring_runs без ticket_id) — // вторым DELETE в конце метода. // // Порядок «сначала строка, потом объект» — тот же, что в чистке фото: // сбой удаления объекта остаётся в логе вызывающей стороны, строка в БД // уже удалена и разъезда с журналом нет. Транзакция не нужна: closed — // терминальный статус, фото в закрытую заявку не добавить (409), набор // кандидатов между двумя шагами не меняется. // // Возвращает id удалённых заявок и ключи их объектов в хранилище // (пустые срезы, если удалять нечего). func (r *TicketRepo) DeleteExpired(ctx context.Context, before time.Time) ([]int64, []string, error) { photoRows, err := r.db.Query(ctx, `SELECT ph.object_key FROM ticket_photos ph JOIN tickets t ON t.id = ph.ticket_id WHERE t.status = 'closed' AND t.closed_at < $1`, before) if err != nil { return nil, nil, classify(err) } defer photoRows.Close() var keys []string for photoRows.Next() { var key string if err := photoRows.Scan(&key); err != nil { return nil, nil, classify(err) } keys = append(keys, key) } if err := photoRows.Err(); err != nil { return nil, nil, classify(err) } delRows, err := r.db.Query(ctx, `DELETE FROM tickets WHERE status = 'closed' AND closed_at < $1 RETURNING id`, before) if err != nil { return nil, nil, classify(err) } defer delRows.Close() var ids []int64 for delRows.Next() { var id int64 if err := delRows.Scan(&id); err != nil { return nil, nil, classify(err) } ids = append(ids, id) } if err := delRows.Err(); err != nil { return nil, nil, classify(err) } // Сироты истории повторяющихся заявок: у удалённой заявки строка // recurring_runs не удаляется (ticket_id → SET NULL, миграция 0014), // а заявки удаляются только здесь (ручного DELETE нет, каскады // проектов уносят историю вместе с правилами). Жить без заявки // строке не на что — уходит тем же прогоном автоочистки. if _, err := r.db.Exec(ctx, `DELETE FROM recurring_runs WHERE ticket_id IS NULL`); err != nil { return nil, nil, classify(err) } return ids, keys, nil } // exists — есть ли заявка с таким id. func (r *TicketRepo) exists(ctx context.Context, id int64) (bool, error) { var ok bool if err := r.db.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM tickets WHERE id = $1)`, id).Scan(&ok); err != nil { return false, classify(err) } return ok, nil } // stateError — UPDATE не применился. Разделяем «заявки нет» (404) // и «заявка в другом статусе / назначена не тебе» (409), // не выдавая наружу текстов ошибок БД. func (r *TicketRepo) stateError(ctx context.Context, id int64, expectation string) error { ok, err := r.exists(ctx, id) if err != nil { return err } if !ok { return notFound("ticket %d", id) } return fmt.Errorf("%w: ticket %d %s", ErrConflict, id, expectation) }