587 lines
23 KiB
Go
587 lines
23 KiB
Go
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
|
||
}
|