package repository import ( "context" "errors" "fmt" "github.com/jackc/pgx/v5" "building-mgmt/internal/models" ) // PhotoRepo — фотоотчёт заявки (миграция 0008): метаданные в ticket_photos, // байты — в объектном хранилище (см. package storage). Как и переходы // статусов, все правила живут в самом SQL: статус заявки, членство // в проекте, лимит файлов и событие в журнале проверяются и пишутся // одной операцией — «фото записано, а журнал потерялся» невозможно. type PhotoRepo struct { db Querier } // NewPhotoRepo создаёт репозиторий поверх Querier (пул или транзакция). func NewPhotoRepo(db Querier) *PhotoRepo { return &PhotoRepo{db: db} } // PhotoMeta — то, что хендлер уже проверил до обращения к БД: тип файла // (белый список по сигнатурам) и размер. Права, статус и лимит здесь — // в SQL: им нельзя доверять хендлеру, их ловит только БД. type PhotoMeta struct { TicketID int64 UploaderID string ObjectKey string ContentType string SizeBytes int64 OriginalName string // MaxPerTicket — потолок файлов на заявку (PHOTO_MAX_PER_TICKET): // счётчик считается в той же CTE, что и вставка, поэтому гонку // «двое залезли в последнее свободное место» ловит SQL. MaxPerTicket int } // photoColumns — проекция строки фото. Один источник для AddPhoto // и ListPhotos: порядок обязан совпадать со сканом. const photoColumns = ` ph.id, ph.ticket_id, COALESCE(ph.uploaded_by::text, ''), COALESCE(p.name, ''), ph.object_key, ph.content_type, ph.size_bytes, ph.original_name, ph.created_at` // scanPhoto читает строку в модель; extra — доп. назначения в конце // (например COUNT(*) OVER() у списка). func scanPhoto(s scanner, extra ...any) (models.TicketPhoto, error) { var ph models.TicketPhoto dest := []any{ &ph.ID, &ph.TicketID, &ph.UploaderID, &ph.UploaderName, &ph.ObjectKey, &ph.ContentType, &ph.SizeBytes, &ph.OriginalName, &ph.CreatedAt, } dest = append(dest, extra...) err := s.Scan(dest...) return ph, classify(err) } // AddPhoto — вставить фото и событие photo_added одной операцией. // // Вставка разрешена, только если всё вместе истинно (см. WHERE): // - заявка существует и в статусе in_progress: после «Работа // выполнена» (done) отчёт мастера заморожен — и на доработке // (rejected) он снова откроется вместе со стартом; // - загрузивший — админ, либо активный мастер проекта заявки // (тот же приём, что Assign в 0004/0005); // - файлов на заявке меньше лимита. // // role — роль из токена: админу членство в проекте не нужно, но // статус заявки обязан быть рабочим и у него. func (r *PhotoRepo) AddPhoto(ctx context.Context, m PhotoMeta, role string) (*models.TicketPhoto, error) { ph, err := scanPhoto(r.db.QueryRow(ctx, `WITH ins AS ( INSERT INTO ticket_photos (ticket_id, uploaded_by, object_key, content_type, size_bytes, original_name) SELECT t.id, $2, $3, $4, $5, $6 FROM tickets t WHERE t.id = $1 AND t.status = 'in_progress' AND ( $7::text = 'admin' OR EXISTS ( SELECT 1 FROM project_workers pw WHERE pw.worker_id = $2 AND pw.project_id = t.project_id AND pw.status = 'active') ) AND (SELECT COUNT(*) FROM ticket_photos ph WHERE ph.ticket_id = t.id) < $8 RETURNING id, ticket_id, uploaded_by, object_key, content_type, size_bytes, original_name, created_at ), ev AS ( -- Событие той же операцией, что и вставка (как в 0006): -- статус не менялся, поэтому from_status = to_status = текущий. INSERT INTO ticket_events (ticket_id, actor_id, action, from_status, to_status, comment) SELECT i.ticket_id, $2, 'photo_added', t.status, t.status, i.original_name FROM ins i JOIN tickets t ON t.id = i.ticket_id ) SELECT i.id, i.ticket_id, COALESCE(i.uploaded_by::text, ''), COALESCE(p.name, ''), i.object_key, i.content_type, i.size_bytes, i.original_name, i.created_at FROM ins i LEFT JOIN profiles p ON p.id = i.uploaded_by`, m.TicketID, m.UploaderID, m.ObjectKey, m.ContentType, m.SizeBytes, m.OriginalName, role, m.MaxPerTicket, )) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, r.addPhotoError(ctx, m) } return nil, classify(err) } return &ph, nil } // addPhotoError — вставка не применилась: разделяем «заявки нет» (404), // «лимит исчерпан» (409 too many photos) и «не тот статус/членство» (409), // не выдавая наружу текстов ошибок БД. func (r *PhotoRepo) addPhotoError(ctx context.Context, m PhotoMeta) error { var status string var count int err := r.db.QueryRow(ctx, `SELECT t.status, (SELECT COUNT(*) FROM ticket_photos ph WHERE ph.ticket_id = t.id) FROM tickets t WHERE t.id = $1`, m.TicketID, ).Scan(&status, &count) if errors.Is(err, pgx.ErrNoRows) { return notFound("ticket %d", m.TicketID) } if err != nil { return classify(err) } if count >= m.MaxPerTicket { return fmt.Errorf("%w: %w: ticket %d already has %d photos", ErrConflict, ErrTooManyPhotos, m.TicketID, count) } return fmt.Errorf("%w: ticket %d must be in 'in_progress' and the uploader must be an active member of the project (status=%s)", ErrConflict, m.TicketID, status) } // CountPhotos — сколько файлов уже на заявке. Дешёвый precheck хендлера // до заливки в S3: спорная гонка всё равно решается в AddPhoto. func (r *PhotoRepo) CountPhotos(ctx context.Context, ticketID int64) (int, error) { var n int if err := r.db.QueryRow(ctx, `SELECT COUNT(*) FROM ticket_photos WHERE ticket_id = $1`, ticketID, ).Scan(&n); err != nil { return 0, classify(err) } return n, nil } // ListPhotos — страница фото заявки в хронологии (id ASC по умолчанию). // Имя загрузившего — LEFT JOIN: аккаунт могли удалить, файл останется. func (r *PhotoRepo) ListPhotos(ctx context.Context, ticketID int64, page Page) ([]models.TicketPhoto, int, error) { page = page.normalize() order, err := orderBy(page, PhotoSorts, "id", "ph.id ASC") if err != nil { return nil, 0, err } rows, err := r.db.Query(ctx, `SELECT `+photoColumns+`, COUNT(*) OVER() AS total FROM ticket_photos ph LEFT JOIN profiles p ON p.id = ph.uploaded_by WHERE ph.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 photos []models.TicketPhoto total := 0 for rows.Next() { ph, err := scanPhoto(rows, &total) if err != nil { return nil, 0, err } photos = append(photos, ph) } if err := rows.Err(); err != nil { return nil, 0, classify(err) } // Пустое окно: window не вернул total — досчитываем тем же WHERE. if len(photos) == 0 { if err := r.db.QueryRow(ctx, `SELECT COUNT(*) FROM ticket_photos WHERE ticket_id = $1`, ticketID).Scan(&total); err != nil { return nil, 0, classify(err) } } return photos, total, nil } // DeletePhoto — убрать фото: своё и только пока заявка в работе // (in_progress — отчёт мастера замораживается в done), либо любое // у админа (роль приходит из токена). Событие photo_removed // пишется той же операцией. Возвращает object_key удалённой строки — // вызывающий ещё удалит сам объект из хранилища. // // ticket_id в условиях обязателен: :photo_id из чужой заявки должен // быть неотличим от несуществующего, даже если дошли до этого роута. func (r *PhotoRepo) DeletePhoto(ctx context.Context, ticketID, photoID int64, requesterID, role string) (string, error) { var key string err := r.db.QueryRow(ctx, `WITH del AS ( DELETE FROM ticket_photos ph WHERE ph.ticket_id = $1 AND ph.id = $2 AND ( $4::text = 'admin' OR (ph.uploaded_by = $3 AND EXISTS (SELECT 1 FROM tickets t WHERE t.id = ph.ticket_id AND t.status = 'in_progress')) ) RETURNING ph.ticket_id, ph.object_key, ph.original_name ), ev AS ( INSERT INTO ticket_events (ticket_id, actor_id, action, from_status, to_status, comment) SELECT d.ticket_id, $3, 'photo_removed', t.status, t.status, d.original_name FROM del d JOIN tickets t ON t.id = d.ticket_id ) SELECT object_key FROM del`, ticketID, photoID, requesterID, role, ).Scan(&key) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return "", r.deletePhotoError(ctx, ticketID, photoID) } return "", classify(err) } return key, nil } // deletePhotoError — DELETE не применился: «фото нет» (404) или // «есть, но трогать нельзя» (409, свой текст для хендлера). func (r *PhotoRepo) deletePhotoError(ctx context.Context, ticketID, photoID int64) error { var ok bool if err := r.db.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM ticket_photos WHERE ticket_id = $1 AND id = $2)`, ticketID, photoID).Scan(&ok); err != nil { return classify(err) } if !ok { return notFound("photo %d", photoID) } return fmt.Errorf("%w: %w: ticket %d is not in progress or the photo is not yours", ErrConflict, ErrPhotoNotRemovable, ticketID) }