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