660 lines
27 KiB
Go
660 lines
27 KiB
Go
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)
|
||
}
|