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 }