axe/internal/repository/project.go

587 lines
23 KiB
Go
Raw Permalink Normal View History

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