axe/internal/repository/photo.go

258 lines
11 KiB
Go
Raw Permalink Normal View History

2026-10-18 19:07:25 +00:00
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)
}