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

587 lines
23 KiB
Go
Raw Permalink 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"
"time"
"github.com/jackc/pgx/v5"
"building-mgmt/internal/models"
)
// ProjectRepo — проекты клиентов, объекты внутри проектов и мастера,
// добавленные в проект. Схема — миграция 0004.
//
// Проверка владельца встроена в запросы: OwnedProject/OwnedObject принимают
// clientID, и чужой проект неотличим от несуществующего (404, не 403).
type ProjectRepo struct {
db Querier
}
// NewProjectRepo создаёт репозиторий поверх Querier (пул или транзакция).
func NewProjectRepo(db Querier) *ProjectRepo {
return &ProjectRepo{db: db}
}
// projectColumns и projectFrom — одна проекция для всех выборок проекта.
const projectColumns = `
p.id, p.client_id, p.name, p.is_active, p.created_at,
c.name AS client_name`
const projectFrom = `
FROM projects p
JOIN profiles c ON c.id = p.client_id`
// scanProject читает строку в модель; порядок Scan = projectColumns.
// extra — доп. назначения в конце SELECT (COUNT(*) OVER() у списка).
func scanProject(s scanner, extra ...any) (models.Project, error) {
var p models.Project
dest := []any{&p.ID, &p.ClientID, &p.Name, &p.IsActive, &p.CreatedAt, &p.ClientName}
dest = append(dest, extra...)
err := s.Scan(dest...)
return p, classify(err)
}
// CreateProject — новый проект. Одинаковое имя у одного клиента → ErrConflict.
func (r *ProjectRepo) CreateProject(ctx context.Context, p *models.Project) error {
err := r.db.QueryRow(ctx,
`INSERT INTO projects (client_id, name, is_active)
VALUES ($1, $2, $3)
RETURNING id, created_at`,
p.ClientID, p.Name, p.IsActive,
).Scan(&p.ID, &p.CreatedAt)
return classify(err)
}
// OwnedProject — проект по id; clientID != "" ограничивает выборку
// проектами этого клиента.
func (r *ProjectRepo) OwnedProject(ctx context.Context, id int64, clientID string) (*models.Project, error) {
query := `SELECT ` + projectColumns + projectFrom + ` WHERE p.id = $1`
args := []any{id}
if clientID != "" {
query += ` AND p.client_id = $2`
args = append(args, clientID)
}
p, err := scanProject(r.db.QueryRow(ctx, query, args...))
if err != nil {
return nil, fmt.Errorf("project %d: %w", id, err)
}
return &p, nil
}
// ListProjects — страница проектов: все (clientID пуст, для админа)
// или только свои. Возвращает страницу и total (сколько подходило
// без обрезки) — по нему фронт считает число страниц.
func (r *ProjectRepo) ListProjects(ctx context.Context, clientID string, page Page) ([]models.Project, int, error) {
page = page.normalize()
order, err := orderBy(page, ProjectSorts, "created_at", "p.id DESC")
if err != nil {
return nil, 0, err
}
where := `1=1`
var whereArgs []any
if clientID != "" {
where += ` AND p.client_id = $1`
whereArgs = append(whereArgs, clientID)
}
query := `SELECT ` + projectColumns + `, COUNT(*) OVER() AS total` + projectFrom + `
WHERE ` + where + order +
` LIMIT $` + itoa(len(whereArgs)+1) + ` OFFSET $` + itoa(len(whereArgs)+2)
args := append(append([]any{}, whereArgs...), page.Limit, page.Offset)
rows, err := r.db.Query(ctx, query, args...)
if err != nil {
return nil, 0, classify(err)
}
defer rows.Close()
var projects []models.Project
total := 0
for rows.Next() {
p, err := scanProject(rows, &total)
if err != nil {
return nil, 0, err
}
projects = append(projects, p)
}
if err := rows.Err(); err != nil {
return nil, 0, classify(err)
}
// Пустое окно: window не вернул total — досчитываем тем же WHERE.
if len(projects) == 0 {
if err := r.db.QueryRow(ctx,
`SELECT COUNT(*)`+projectFrom+` WHERE `+where, whereArgs...).Scan(&total); err != nil {
return nil, 0, classify(err)
}
}
return projects, total, nil
}
// UpdateProject — переименование и/или переключение is_active.
// nil-аргумент означает «поле не трогаем» (COALESCE в самом UPDATE).
func (r *ProjectRepo) UpdateProject(ctx context.Context, id int64, name *string, active *bool) error {
tag, err := r.db.Exec(ctx,
`UPDATE projects
SET name = COALESCE($2, name),
is_active = COALESCE($3, is_active)
WHERE id = $1`,
id, name, active)
if err != nil {
return classify(err)
}
if tag.RowsAffected() == 0 {
return notFound("project %d", id)
}
return nil
}
// DeleteProject — удалить проект целиком (только админ, хендлер).
// Три шага в одной транзакции (Beginer, как в DeleteWorkerForClient):
// 1. снять object_key фото заявок проекта — после каскадов строк не будет;
// 2. удалить заявки проекта: события журнала и фото уходят каскадом
// (0006/0008), явный DELETE делает порядок читаемым, а каскады 0013
// остаются страховкой на уровне БД;
// 3. удалить проект: объекты и участия — каскад 0004.
//
// Проекта нет (повторное удаление) → ErrNotFound. Возвращает ключи
// объектов фото — их вызывающая сторона удаляет из хранилища после
// коммита, сбой — в лог (порядок «сначала строка, потом объект»,
// как в TicketRepo.DeleteExpired).
func (r *ProjectRepo) DeleteProject(ctx context.Context, id int64) ([]string, error) {
b, ok := r.db.(Beginer)
if !ok {
return nil, errors.New("repository: querier cannot begin a transaction")
}
tx, err := b.Begin(ctx)
if err != nil {
return nil, classify(err)
}
defer func() { _ = tx.Rollback(ctx) }() // после Commit — безопасный no-op
rows, err := tx.Query(ctx,
`SELECT ph.object_key
FROM ticket_photos ph
JOIN tickets t ON t.id = ph.ticket_id
WHERE t.project_id = $1`, id)
if err != nil {
return nil, classify(err)
}
var keys []string
for rows.Next() {
var key string
if err := rows.Scan(&key); err != nil {
rows.Close()
return nil, classify(err)
}
keys = append(keys, key)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, classify(err)
}
if _, err := tx.Exec(ctx, `DELETE FROM tickets WHERE project_id = $1`, id); err != nil {
return nil, classify(err)
}
tag, err := tx.Exec(ctx, `DELETE FROM projects WHERE id = $1`, id)
if err != nil {
return nil, classify(err)
}
if tag.RowsAffected() == 0 {
return nil, notFound("project %d", id)
}
if err := tx.Commit(ctx); err != nil {
return nil, classify(err)
}
return keys, nil
}
// ============ объекты ============
const objectColumns = `
o.id, o.project_id, o.name, o.address, o.is_active, o.created_at,
p.name AS project_name, p.is_active AS project_active`
const objectFrom = `
FROM project_objects o
JOIN projects p ON p.id = o.project_id`
func scanObject(s scanner, extra ...any) (models.Object, error) {
var o models.Object
dest := []any{&o.ID, &o.ProjectID, &o.Name, &o.Address, &o.IsActive, &o.CreatedAt,
&o.ProjectName, &o.ProjectActive}
dest = append(dest, extra...)
err := s.Scan(dest...)
return o, classify(err)
}
// CreateObject — новый объект в проекте. Дубликат имени в проекте → ErrConflict.
// Вызывающий обязан уже проверить, что проект принадлежит ему (OwnedProject).
func (r *ProjectRepo) CreateObject(ctx context.Context, o *models.Object) error {
err := r.db.QueryRow(ctx,
`INSERT INTO project_objects (project_id, name, address, is_active)
VALUES ($1, $2, $3, $4)
RETURNING id, created_at`,
o.ProjectID, o.Name, o.Address, o.IsActive,
).Scan(&o.ID, &o.CreatedAt)
return classify(err)
}
// OwnedObject — объект по id вместе с его проектом; clientID != "" ограничивает
// объектами проектов этого клиента. Ключевой метод для создания заявки:
// он же даёт адрес и принадлежность одним запросом.
func (r *ProjectRepo) OwnedObject(ctx context.Context, id int64, clientID string) (*models.Object, error) {
query := `SELECT ` + objectColumns + objectFrom + ` WHERE o.id = $1`
args := []any{id}
if clientID != "" {
query += ` AND p.client_id = $2`
args = append(args, clientID)
}
o, err := scanObject(r.db.QueryRow(ctx, query, args...))
if err != nil {
return nil, fmt.Errorf("object %d: %w", id, err)
}
return &o, nil
}
// ListObjects — страница объектов проекта (по умолчанию по имени —
// так и раньше выглядел список, только теперь он ещё и пагинируется).
func (r *ProjectRepo) ListObjects(ctx context.Context, projectID int64, page Page) ([]models.Object, int, error) {
page = page.normalize()
order, err := orderBy(page, ObjectSorts, "name", "o.id ASC")
if err != nil {
return nil, 0, err
}
rows, err := r.db.Query(ctx,
`SELECT `+objectColumns+`, COUNT(*) OVER() AS total`+objectFrom+`
WHERE o.project_id = $1`+order+`
LIMIT $2 OFFSET $3`,
projectID, page.Limit, page.Offset)
if err != nil {
return nil, 0, classify(err)
}
defer rows.Close()
var objects []models.Object
total := 0
for rows.Next() {
o, err := scanObject(rows, &total)
if err != nil {
return nil, 0, err
}
objects = append(objects, o)
}
if err := rows.Err(); err != nil {
return nil, 0, classify(err)
}
// Пустое окно: window не вернул total — досчитываем тем же WHERE.
if len(objects) == 0 {
if err := r.db.QueryRow(ctx,
`SELECT COUNT(*)`+objectFrom+` WHERE o.project_id = $1`, projectID).Scan(&total); err != nil {
return nil, 0, classify(err)
}
}
return objects, total, nil
}
// UpdateObject — правка объекта; nil-аргумент = «поле не трогаем».
// Адрес правим только у объекта: у уже созданных заявок остаётся адрес
// на момент создания — это осознанно.
func (r *ProjectRepo) UpdateObject(ctx context.Context, id int64, name, address *string, active *bool) error {
tag, err := r.db.Exec(ctx,
`UPDATE project_objects
SET name = COALESCE($2, name),
address = COALESCE($3, address),
is_active = COALESCE($4, is_active)
WHERE id = $1`,
id, name, address, active)
if err != nil {
return classify(err)
}
if tag.RowsAffected() == 0 {
return notFound("object %d", id)
}
return nil
}
// ============ мастера проекта (приглашения, миграция 0005) ============
// InvitationTTL — сколько живёт приглашение. Единственный источник:
// и в SQL (expires_at), и в текстах Swagger/README. По истечении
// мастер принять его уже не может (409 invitation expired), а клиент
// может отозвать строку обычным DELETE.
const InvitationTTL = 7 * 24 * time.Hour
// ListWorkers — страница состава проекта: профили мастеров плюс состояние
// членства (invited ещё не работает по заявкам). Сортировка — ProfileSorts,
// алиасы u/pw как в запросе.
func (r *ProjectRepo) ListWorkers(ctx context.Context, projectID int64, page Page) ([]models.ProjectWorker, int, error) {
page = page.normalize()
order, err := orderBy(page, ProfileSorts, "name", "u.id ASC")
if err != nil {
return nil, 0, err
}
rows, err := r.db.Query(ctx,
`SELECT u.id, u.email, '', u.name, COALESCE(u.phone, ''), u.role, u.is_active, u.created_at,
pw.status, pw.responded_at, pw.expires_at,
COUNT(*) OVER() AS total
FROM project_workers pw
JOIN profiles u ON u.id = pw.worker_id
WHERE pw.project_id = $1`+order+`
LIMIT $2 OFFSET $3`,
projectID, page.Limit, page.Offset)
if err != nil {
return nil, 0, classify(err)
}
defer rows.Close()
var workers []models.ProjectWorker
total := 0
for rows.Next() {
var w models.ProjectWorker
if err := rows.Scan(&w.ID, &w.Email, &w.PasswordHash, &w.Name, &w.Phone,
&w.Role, &w.IsActive, &w.CreatedAt,
&w.Status, &w.RespondedAt, &w.ExpiresAt, &total); err != nil {
return nil, 0, classify(err)
}
workers = append(workers, w)
}
if err := rows.Err(); err != nil {
return nil, 0, classify(err)
}
// Пустое окно: window не вернул total — досчитываем тем же WHERE.
if len(workers) == 0 {
if err := r.db.QueryRow(ctx,
`SELECT COUNT(*) FROM project_workers pw WHERE pw.project_id = $1`,
projectID).Scan(&total); err != nil {
return nil, 0, classify(err)
}
}
return workers, total, nil
}
// InviteWorker — пригласить мастера в проект: единственный путь членства.
// Возвращает срок ответа (expires_at), чтобы клиент знал дедлайн.
//
// Дубликаты не теряются: активное членство и уже висящее приглашение —
// это ErrConflict с уточнением (ErrMembershipActive / ErrInvitationPending),
// а отклонённое приглашение перезапускается (строка одна, история — та же).
// Пригласить можно только подтверждённого мастера (миграция 0010):
// SELECT-форма INSERT не вставит строку для неподтверждённого —
// неподтверждённый и несуществующий неразличимы (404).
func (r *ProjectRepo) InviteWorker(ctx context.Context, projectID int64, workerID, invitedBy string) (time.Time, error) {
var expires time.Time
err := r.db.QueryRow(ctx,
`INSERT INTO project_workers (worker_id, project_id, invited_by, status, invited_at, expires_at)
SELECT $1, $2, $3, 'invited', now(), now() + make_interval(secs => $4)
FROM profiles w
WHERE w.id = $1 AND w.role = 'worker' AND w.confirmed_at IS NOT NULL
ON CONFLICT (worker_id, project_id) DO UPDATE
SET status = 'invited',
invited_by = EXCLUDED.invited_by,
invited_at = EXCLUDED.invited_at,
responded_at = NULL,
expires_at = EXCLUDED.expires_at
WHERE project_workers.status = 'declined'
RETURNING expires_at`,
workerID, projectID, invitedBy, InvitationTTL.Seconds(),
).Scan(&expires)
if err == nil {
return expires, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return time.Time{}, classify(err)
}
// Строку не тронули — выясняем, что уже стоит, чтобы ответ был точным.
var status string
if err := r.db.QueryRow(ctx,
`SELECT status FROM project_workers WHERE worker_id = $1 AND project_id = $2`,
workerID, projectID).Scan(&status); err != nil {
return time.Time{}, classify(err)
}
if status == "invited" {
return time.Time{}, fmt.Errorf("%w: %w", ErrConflict, ErrInvitationPending)
}
if status == "active" {
return time.Time{}, fmt.Errorf("%w: %w", ErrConflict, ErrMembershipActive)
}
return time.Time{}, fmt.Errorf("%w: membership status %q", ErrConflict, status)
}
// AcceptInvitation — мастер принял приглашение: invited → active.
// Просроченное принять нельзя — строка устарела, нужен новый вызов
// InviteWorker от клиента.
func (r *ProjectRepo) AcceptInvitation(ctx context.Context, projectID int64, workerID string) error {
tag, err := r.db.Exec(ctx,
`UPDATE project_workers
SET status = 'active', responded_at = now()
WHERE worker_id = $1 AND project_id = $2
AND status = 'invited' AND expires_at > now()`,
workerID, projectID)
if err != nil {
return classify(err)
}
if tag.RowsAffected() > 0 {
return nil
}
return r.invitationStateError(ctx, projectID, workerID, true)
}
// DeclineInvitation — мастер отказал: invited → declined. Отказ разрешён
// и по просроченному приглашению — устаревшая строка всё равно мертва,
// а клиент увидит в списке состава, что ждать уже нечего.
func (r *ProjectRepo) DeclineInvitation(ctx context.Context, projectID int64, workerID string) error {
tag, err := r.db.Exec(ctx,
`UPDATE project_workers
SET status = 'declined', responded_at = now()
WHERE worker_id = $1 AND project_id = $2
AND status = 'invited'`,
workerID, projectID)
if err != nil {
return classify(err)
}
if tag.RowsAffected() > 0 {
return nil
}
return r.invitationStateError(ctx, projectID, workerID, false)
}
// invitationStateError — почему accept/decline не нашёл строки: её нет (404)
// или ждать решения уже нечего (409 с точной причиной). checkExpiry —
// считать ли просрочку ошибкой: для отказа — нет (см. DeclineInvitation).
func (r *ProjectRepo) invitationStateError(ctx context.Context, projectID int64, workerID string, checkExpiry bool) error {
var status string
var expires *time.Time
err := r.db.QueryRow(ctx,
`SELECT status, expires_at FROM project_workers
WHERE worker_id = $1 AND project_id = $2`,
workerID, projectID).Scan(&status, &expires)
if errors.Is(err, pgx.ErrNoRows) {
return notFound("invitation for worker %s in project %d", workerID, projectID)
}
if err != nil {
return classify(err)
}
if checkExpiry && status == "invited" && expires != nil && !expires.After(time.Now()) {
return fmt.Errorf("%w: %w", ErrConflict, ErrInvitationExpired)
}
return fmt.Errorf("%w: %w", ErrConflict, ErrInvitationNotPending)
}
// ListInvitations — приглашения, ожидающие решения этого мастера.
// Просроченные не отдаются: принять их нельзя, а для мастера они — шум.
func (r *ProjectRepo) ListInvitations(ctx context.Context, workerID string, page Page) ([]models.ProjectInvitation, int, error) {
page = page.normalize()
order, err := orderBy(page, InvitationSorts, "invited_at", "pw.project_id ASC")
if err != nil {
return nil, 0, err
}
const active = `pw.worker_id = $1 AND pw.status = 'invited' AND pw.expires_at > now()`
rows, err := r.db.Query(ctx,
`SELECT pw.project_id, p.name, c.name, pw.invited_by, COALESCE(inv.name, ''),
pw.invited_at, pw.expires_at,
COUNT(*) OVER() AS total
FROM project_workers pw
JOIN projects p ON p.id = pw.project_id
JOIN profiles c ON c.id = p.client_id
LEFT JOIN profiles inv ON inv.id = pw.invited_by
WHERE `+active+order+`
LIMIT $2 OFFSET $3`,
workerID, page.Limit, page.Offset)
if err != nil {
return nil, 0, classify(err)
}
defer rows.Close()
var invites []models.ProjectInvitation
total := 0
for rows.Next() {
var v models.ProjectInvitation
if err := rows.Scan(&v.ProjectID, &v.ProjectName, &v.ClientName,
&v.InvitedBy, &v.InvitedName, &v.InvitedAt, &v.ExpiresAt, &total); err != nil {
return nil, 0, classify(err)
}
invites = append(invites, v)
}
if err := rows.Err(); err != nil {
return nil, 0, classify(err)
}
// Пустое окно: window не вернул total — досчитываем тем же WHERE.
if len(invites) == 0 {
if err := r.db.QueryRow(ctx,
`SELECT COUNT(*) FROM project_workers pw WHERE `+active,
workerID).Scan(&total); err != nil {
return nil, 0, classify(err)
}
}
return invites, total, nil
}
// RemoveWorker — убрать мастера из проекта.
//
// Условие «нет незакрытых заявок, назначенных ему» стоит прямо в DELETE:
// так проверка и сам отзыв атомарны, гонка между ними исключена.
// Неназначенная заявка (worker_id IS NULL) мастера не удерживает —
// исполнитель у неё и так не он. Если строк не ушло —
// различаем «не состоит в проекте» (404) и «есть открытые заявки» (409).
func (r *ProjectRepo) RemoveWorker(ctx context.Context, projectID int64, workerID string) error {
tag, err := r.db.Exec(ctx,
`DELETE FROM project_workers
WHERE worker_id = $1 AND project_id = $2
AND NOT EXISTS (
SELECT 1 FROM tickets
WHERE worker_id = $1 AND project_id = $2
AND status <> 'closed')`,
workerID, projectID)
if err != nil {
return classify(err)
}
if tag.RowsAffected() > 0 {
return nil
}
// Ни одной строки не ушло — выясняем причину, чтобы ответ был честным.
var busy bool
if err := r.db.QueryRow(ctx,
`SELECT EXISTS (
SELECT 1 FROM tickets
WHERE worker_id = $1 AND project_id = $2
AND status <> 'closed')`, workerID, projectID).Scan(&busy); err != nil {
return classify(err)
}
if busy {
return fmt.Errorf("%w: worker %s still has open tickets in project %d",
ErrConflict, workerID, projectID)
}
return notFound("worker %s in project %d", workerID, projectID)
}
// MembershipStatus — состояние членства мастера в проекте: "" — строки
// нет, иначе invited/active/declined. Нужно назначению: «ждём ответа»
// и «вообще не состоит» — разные ошибки для клиента, а решающим рубежом
// в самом UPDATE остаётся требование status='active'.
func (r *ProjectRepo) MembershipStatus(ctx context.Context, workerID string, projectID int64) (string, error) {
var status string
err := r.db.QueryRow(ctx,
`SELECT status FROM project_workers
WHERE worker_id = $1 AND project_id = $2`,
workerID, projectID).Scan(&status)
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
if err != nil {
return "", classify(err)
}
return status, nil
}