258 lines
11 KiB
Go
258 lines
11 KiB
Go
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)
|
||
}
|