axe/internal/repository/photo.go
2026-10-18 22:07:25 +03:00

258 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}