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