473 lines
22 KiB
Go
473 lines
22 KiB
Go
|
|
package repository
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"errors"
|
|||
|
|
"fmt"
|
|||
|
|
"strings"
|
|||
|
|
|
|||
|
|
"github.com/jackc/pgx/v5"
|
|||
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|||
|
|
|
|||
|
|
"building-mgmt/internal/models"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// ProfileRepo — CRUD профилей: регистрация, вход, выборки по роли,
|
|||
|
|
// включение/выключение аккаунтов, удаление «чистых» профилей.
|
|||
|
|
type ProfileRepo struct {
|
|||
|
|
db Querier
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// NewProfileRepo создаёт репозиторий поверх Querier (пул или транзакция).
|
|||
|
|
func NewProfileRepo(db Querier) *ProfileRepo {
|
|||
|
|
return &ProfileRepo{db: db}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// normalizeEmail — email храним канонически: без обрамляющих пробелов
|
|||
|
|
// и в нижнем регистре. Admin@x.ru и admin@x.ru — один адрес:
|
|||
|
|
// уникальность держит индекс profiles_email_lower_key (миграция 0003),
|
|||
|
|
// поэтому вставляется уже нормализованное значение.
|
|||
|
|
func normalizeEmail(email string) string {
|
|||
|
|
return strings.ToLower(strings.TrimSpace(email))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Create вставляет профиль без создателя (created_by NULL): email
|
|||
|
|
// нормализуется, роль и id задаёт вызывающий, аккаунт остаётся
|
|||
|
|
// неподтверждённым — до POST /users/:id/confirm войти нельзя.
|
|||
|
|
// HTTP-пути за методом нет (его давно не было, кроме снятой
|
|||
|
|
// саморегистрации): так заводят демо-данные seed и тесты.
|
|||
|
|
// Дубликат адреса (в любом регистре) → ErrConflict.
|
|||
|
|
func (r *ProfileRepo) Create(ctx context.Context, p *models.Profile) error {
|
|||
|
|
return r.CreateBy(ctx, p, "")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// CreateBy — создание существующим пользователем (POST /users):
|
|||
|
|
// created_by = актор, а правило «создатель и есть подтверждающий»
|
|||
|
|
// исполняется тем же INSERT: админ при создании сразу подтверждает
|
|||
|
|
// аккаунт (confirmed_at/confirmed_by), у остальных создателей поля
|
|||
|
|
// остаются NULL — клиент ждёт подтверждения своего мастера.
|
|||
|
|
// Пустой actorID — вставка без актора (см. Create): CTE actor пуст
|
|||
|
|
// и даёт NULL-ветки.
|
|||
|
|
// RETURNING created_at, confirmed_at — иначе в ответе уйдёт нулевое
|
|||
|
|
// время (0001-01-01T00:00:00Z) и неверный статус подтверждения.
|
|||
|
|
func (r *ProfileRepo) CreateBy(ctx context.Context, p *models.Profile, actorID string) error {
|
|||
|
|
p.Email = normalizeEmail(p.Email) // правим и структуру: ответ покажет сохранённый вид
|
|||
|
|
err := r.db.QueryRow(ctx,
|
|||
|
|
`WITH actor AS (
|
|||
|
|
SELECT id, role FROM profiles WHERE id = NULLIF($8, '')::uuid
|
|||
|
|
), ins AS (
|
|||
|
|
INSERT INTO profiles (id, email, password_hash, name, phone, role, is_active,
|
|||
|
|
created_by, confirmed_at, confirmed_by)
|
|||
|
|
SELECT $1, $2, $3, $4, $5, $6, $7,
|
|||
|
|
NULLIF($8, '')::uuid,
|
|||
|
|
CASE WHEN a.role = 'admin' THEN now() END,
|
|||
|
|
CASE WHEN a.role = 'admin' THEN a.id END
|
|||
|
|
FROM (SELECT 1) one LEFT JOIN actor a ON true
|
|||
|
|
RETURNING created_at, confirmed_at
|
|||
|
|
)
|
|||
|
|
SELECT created_at, confirmed_at FROM ins`,
|
|||
|
|
p.ID, p.Email, p.PasswordHash, p.Name, p.Phone, p.Role, p.IsActive, actorID,
|
|||
|
|
).Scan(&p.CreatedAt, &p.ConfirmedAt)
|
|||
|
|
return classify(err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetByEmail ищет пользователя по адресу без учёта регистра —
|
|||
|
|
// через индекс по lower(email); не нашлось → ErrNotFound.
|
|||
|
|
func (r *ProfileRepo) GetByEmail(ctx context.Context, email string) (*models.Profile, error) {
|
|||
|
|
var p models.Profile
|
|||
|
|
// lower() на обеих сторонах: поймает и ненормализованный ввод,
|
|||
|
|
// и старые строки с верхним регистром, и использует индекс по lower(email).
|
|||
|
|
err := r.db.QueryRow(ctx,
|
|||
|
|
`SELECT id, email, password_hash, name, COALESCE(phone, ''), role, is_active, confirmed_at, created_at
|
|||
|
|
FROM profiles WHERE lower(email) = lower($1)`, email,
|
|||
|
|
).Scan(&p.ID, &p.Email, &p.PasswordHash, &p.Name, &p.Phone, &p.Role, &p.IsActive, &p.ConfirmedAt, &p.CreatedAt)
|
|||
|
|
if err != nil {
|
|||
|
|
// Не раскрываем, что именно не нашлось: наружу уйдёт общий текст.
|
|||
|
|
return nil, classify(fmt.Errorf("profile by email: %w", err))
|
|||
|
|
}
|
|||
|
|
return &p, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetByID возвращает профиль по uuid; не нашлось → ErrNotFound.
|
|||
|
|
func (r *ProfileRepo) GetByID(ctx context.Context, id string) (*models.Profile, error) {
|
|||
|
|
var p models.Profile
|
|||
|
|
err := r.db.QueryRow(ctx,
|
|||
|
|
`SELECT id, email, password_hash, name, COALESCE(phone, ''), role, is_active, confirmed_at, created_at
|
|||
|
|
FROM profiles WHERE id = $1`, id,
|
|||
|
|
).Scan(&p.ID, &p.Email, &p.PasswordHash, &p.Name, &p.Phone, &p.Role, &p.IsActive, &p.ConfirmedAt, &p.CreatedAt)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, classify(fmt.Errorf("profile by id: %w", err))
|
|||
|
|
}
|
|||
|
|
return &p, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// scanProfileRows читает строки в модели; порядок Scan обязан совпадать
|
|||
|
|
// с SELECT во всех List* этого файла (project.go ListWorkers сканирует
|
|||
|
|
// сам — у него дополнительные колонки членства).
|
|||
|
|
// Все выборки-списки заканчиваются COUNT(*) OVER() — последним он и
|
|||
|
|
// читается, поэтому функция возвращает и total (0 у пустой страницы,
|
|||
|
|
// если total не досчитан отдельным запросом).
|
|||
|
|
func scanProfileRows(rows pgx.Rows) ([]models.Profile, int, error) {
|
|||
|
|
var profiles []models.Profile
|
|||
|
|
total := 0
|
|||
|
|
for rows.Next() {
|
|||
|
|
var p models.Profile
|
|||
|
|
if err := rows.Scan(&p.ID, &p.Email, &p.PasswordHash, &p.Name, &p.Phone,
|
|||
|
|
&p.Role, &p.IsActive, &p.ConfirmedAt, &p.CreatedAt, &total); err != nil {
|
|||
|
|
return nil, 0, classify(err)
|
|||
|
|
}
|
|||
|
|
profiles = append(profiles, p)
|
|||
|
|
}
|
|||
|
|
return profiles, total, classify(rows.Err())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ListByRole — страница пользователей одной роли (для выпадающих списков:
|
|||
|
|
// кого назначить мастером). По умолчанию только активные — так отключённые
|
|||
|
|
// не мешают приглашениям; includeInactive=true показывает и выключенных
|
|||
|
|
// (админские списки «Клиенты»/«Мастера»: кнопка «Включить» не должна
|
|||
|
|
// быть тупиком). Алиас u — общий со всеми выборками профилей
|
|||
|
|
// (см. ProfileSorts в page.go).
|
|||
|
|
func (r *ProfileRepo) ListByRole(ctx context.Context, role string, includeInactive bool, page Page) ([]models.Profile, int, error) {
|
|||
|
|
page = page.normalize()
|
|||
|
|
order, err := orderBy(page, ProfileSorts, "name", "u.id ASC")
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, 0, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const where = `u.role = $1 AND ($2 OR u.is_active = TRUE)`
|
|||
|
|
rows, err := r.db.Query(ctx,
|
|||
|
|
`SELECT u.id, u.email, u.password_hash, u.name, COALESCE(u.phone, ''), u.role, u.is_active, u.confirmed_at, u.created_at,
|
|||
|
|
COUNT(*) OVER() AS total
|
|||
|
|
FROM profiles u
|
|||
|
|
WHERE `+where+order+`
|
|||
|
|
LIMIT $3 OFFSET $4`,
|
|||
|
|
role, includeInactive, page.Limit, page.Offset,
|
|||
|
|
)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, 0, classify(err)
|
|||
|
|
}
|
|||
|
|
defer rows.Close()
|
|||
|
|
|
|||
|
|
profiles, total, err := scanProfileRows(rows)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, 0, err
|
|||
|
|
}
|
|||
|
|
// Пустое окно: window не вернул total — досчитываем тем же WHERE.
|
|||
|
|
if len(profiles) == 0 {
|
|||
|
|
if err := r.db.QueryRow(ctx,
|
|||
|
|
`SELECT COUNT(*) FROM profiles u WHERE `+where,
|
|||
|
|
role, includeInactive).Scan(&total); err != nil {
|
|||
|
|
return nil, 0, classify(err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return profiles, total, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ListWorkersForClient — страница «свои мастера» клиента: активный
|
|||
|
|
// участник хотя бы одного его проекта (членство status='active':
|
|||
|
|
// приглашённый, но не ответивший ещё не «свой мастер») либо созданный
|
|||
|
|
// им самим — только что заведённый мастер иначе не виден нигде,
|
|||
|
|
// и подтверждать было бы не из чего. Чужие организации через этот
|
|||
|
|
// запрос не подсмотреть.
|
|||
|
|
// include_inactive — как у админа: отключённые свои мастера остаются
|
|||
|
|
// в списке (иначе «Включить» после отключения оказался бы тупиком);
|
|||
|
|
// поиск q у клиента в хендлере всегда идёт только по активным.
|
|||
|
|
func (r *ProfileRepo) ListWorkersForClient(ctx context.Context, clientID string, includeInactive bool, page Page) ([]models.Profile, int, error) {
|
|||
|
|
page = page.normalize()
|
|||
|
|
order, err := orderBy(page, ProfileSorts, "name", "u.id ASC")
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, 0, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const ownWorker = `u.role = 'worker'
|
|||
|
|
AND ($4::bool OR u.is_active)
|
|||
|
|
AND (
|
|||
|
|
u.created_by = $1
|
|||
|
|
OR EXISTS (
|
|||
|
|
SELECT 1
|
|||
|
|
FROM project_workers pw
|
|||
|
|
JOIN projects p ON p.id = pw.project_id
|
|||
|
|
WHERE pw.worker_id = u.id
|
|||
|
|
AND p.client_id = $1
|
|||
|
|
AND pw.status = 'active'
|
|||
|
|
)
|
|||
|
|
)`
|
|||
|
|
rows, err := r.db.Query(ctx,
|
|||
|
|
`SELECT u.id, u.email, u.password_hash, u.name, COALESCE(u.phone, ''), u.role, u.is_active, u.confirmed_at, u.created_at,
|
|||
|
|
COUNT(*) OVER() AS total
|
|||
|
|
FROM profiles u
|
|||
|
|
WHERE `+ownWorker+order+`
|
|||
|
|
LIMIT $2 OFFSET $3`,
|
|||
|
|
clientID, page.Limit, page.Offset, includeInactive,
|
|||
|
|
)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, 0, classify(err)
|
|||
|
|
}
|
|||
|
|
defer rows.Close()
|
|||
|
|
|
|||
|
|
profiles, total, err := scanProfileRows(rows)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, 0, err
|
|||
|
|
}
|
|||
|
|
// Пустое окно: window не вернул total — досчитываем тем же WHERE.
|
|||
|
|
if len(profiles) == 0 {
|
|||
|
|
if err := r.db.QueryRow(ctx,
|
|||
|
|
`SELECT COUNT(*) FROM profiles u WHERE `+ownWorker,
|
|||
|
|
clientID, includeInactive).Scan(&total); err != nil {
|
|||
|
|
return nil, 0, classify(err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return profiles, total, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// likePattern — шаблон подстрочного поиска: экранируем %, _ и \,
|
|||
|
|
// чтобы введённый пользователем «100%» искал буквально, а не раздувал
|
|||
|
|
// выборку. В SQL экранирование — стандартное для ILIKE (бэкслэш).
|
|||
|
|
func likePattern(q string) string {
|
|||
|
|
repl := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
|
|||
|
|
return "%" + repl.Replace(q) + "%"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// SearchByRole — поиск пользователей роли по email/имени (поле «найти
|
|||
|
|
// мастера» при приглашении в проект): подстрока без учёта регистра.
|
|||
|
|
// По умолчанию только активные; includeInactive=true — для админского
|
|||
|
|
// списка, где нужно найти и отключённого (и включить обратно).
|
|||
|
|
// Клиенту список без запроса не отдаётся (см. ListWorkersForClient) —
|
|||
|
|
// через поиск он находит только того, чей адрес или имя ввёл сам.
|
|||
|
|
func (r *ProfileRepo) SearchByRole(ctx context.Context, role, q string, includeInactive bool, page Page) ([]models.Profile, int, error) {
|
|||
|
|
page = page.normalize()
|
|||
|
|
order, err := orderBy(page, ProfileSorts, "name", "u.id ASC")
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, 0, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pattern := likePattern(q)
|
|||
|
|
const where = `u.role = $1 AND ($3 OR u.is_active = TRUE)
|
|||
|
|
AND (u.email ILIKE $2 OR u.name ILIKE $2)`
|
|||
|
|
rows, err := r.db.Query(ctx,
|
|||
|
|
`SELECT u.id, u.email, u.password_hash, u.name, COALESCE(u.phone, ''), u.role, u.is_active, u.confirmed_at, u.created_at,
|
|||
|
|
COUNT(*) OVER() AS total
|
|||
|
|
FROM profiles u
|
|||
|
|
WHERE `+where+order+`
|
|||
|
|
LIMIT $4 OFFSET $5`,
|
|||
|
|
role, pattern, includeInactive, page.Limit, page.Offset,
|
|||
|
|
)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, 0, classify(err)
|
|||
|
|
}
|
|||
|
|
defer rows.Close()
|
|||
|
|
|
|||
|
|
profiles, total, err := scanProfileRows(rows)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, 0, err
|
|||
|
|
}
|
|||
|
|
// Пустое окно: window не вернул total — досчитываем тем же WHERE.
|
|||
|
|
if len(profiles) == 0 {
|
|||
|
|
if err := r.db.QueryRow(ctx,
|
|||
|
|
`SELECT COUNT(*) FROM profiles u WHERE `+where,
|
|||
|
|
role, pattern, includeInactive).Scan(&total); err != nil {
|
|||
|
|
return nil, 0, classify(err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return profiles, total, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// SetActive — включить/выключить аккаунт (админ, любой пользователь).
|
|||
|
|
// Выключенный не может войти (Login проверяет is_active) и не сможет
|
|||
|
|
// получить новую сессию через refresh.
|
|||
|
|
func (r *ProfileRepo) SetActive(ctx context.Context, id string, active bool) error {
|
|||
|
|
tag, err := r.db.Exec(ctx,
|
|||
|
|
`UPDATE profiles SET is_active = $2 WHERE id = $1`, id, active)
|
|||
|
|
if err != nil {
|
|||
|
|
return classify(err)
|
|||
|
|
}
|
|||
|
|
if tag.RowsAffected() == 0 {
|
|||
|
|
return notFound("profile %s", id)
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// SetActiveWorkerForClient — клиент включает/выключает своего мастера:
|
|||
|
|
// активный участник хотя бы одного его проекта либо созданный им самим
|
|||
|
|
// («свой» ровно как в списке ListWorkersForClient — созданный, но ещё не
|
|||
|
|
// приглашённый, иначе кнопка в его строке оказалась бы тупиком).
|
|||
|
|
// Правило целиком в SQL: роль, членство/создание и владелец проекта
|
|||
|
|
// проверяются в WHERE — хендлер ничего не решает. Чужой мастер, другой
|
|||
|
|
// клиент и несуществующий id неразличимы: все три дают «строку не
|
|||
|
|
// найдено» (404), не раскрывая чужие данные.
|
|||
|
|
// Побочный эффект (осознанный): is_active общий на все проекты мастера —
|
|||
|
|
// выключенный уходит со всех работ и теряет сессии.
|
|||
|
|
func (r *ProfileRepo) SetActiveWorkerForClient(ctx context.Context, id, clientID string, active bool) error {
|
|||
|
|
tag, err := r.db.Exec(ctx,
|
|||
|
|
`UPDATE profiles p SET is_active = $3
|
|||
|
|
WHERE p.id = $1
|
|||
|
|
AND p.role = 'worker'
|
|||
|
|
AND (
|
|||
|
|
p.created_by = $2
|
|||
|
|
OR EXISTS (
|
|||
|
|
SELECT 1
|
|||
|
|
FROM projects pr
|
|||
|
|
JOIN project_workers pw ON pw.project_id = pr.id
|
|||
|
|
WHERE pr.client_id = $2
|
|||
|
|
AND pw.worker_id = p.id
|
|||
|
|
AND pw.status = 'active')
|
|||
|
|
)`,
|
|||
|
|
id, clientID, active)
|
|||
|
|
if err != nil {
|
|||
|
|
return classify(err)
|
|||
|
|
}
|
|||
|
|
if tag.RowsAffected() == 0 {
|
|||
|
|
return notFound("profile %s", id)
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ConfirmUser — подтвердить аккаунт (POST /users/:id/confirm).
|
|||
|
|
// Правило целиком в WHERE самого UPDATE: подтверждает админ — любой
|
|||
|
|
// неподтверждённый аккаунт; клиент — только созданного им мастера.
|
|||
|
|
// Нулевой RowsAffected различается отдельным SELECT уже после попытки
|
|||
|
|
// (чисто для корректной ошибки, не для решения): право есть — значит,
|
|||
|
|
// подтверждать уже нечего → ErrAlreadyConfirmed (409), иначе чужой или
|
|||
|
|
// несуществующий → ErrNotFound (404): чужие аккаунты не подсматриваются.
|
|||
|
|
// Гонка «два подтверждения одновременно» безопасна: WHERE confirmed_at
|
|||
|
|
// IS NULL пропускает ровно одну операцию.
|
|||
|
|
func (r *ProfileRepo) ConfirmUser(ctx context.Context, targetID, actorID string) error {
|
|||
|
|
tag, err := r.db.Exec(ctx,
|
|||
|
|
`UPDATE profiles
|
|||
|
|
SET confirmed_at = now(), confirmed_by = $2
|
|||
|
|
WHERE id = $1
|
|||
|
|
AND confirmed_at IS NULL
|
|||
|
|
AND (
|
|||
|
|
EXISTS (SELECT 1 FROM profiles a
|
|||
|
|
WHERE a.id = $2 AND a.role = 'admin')
|
|||
|
|
OR EXISTS (SELECT 1 FROM profiles t
|
|||
|
|
WHERE t.id = $1
|
|||
|
|
AND t.role = 'worker'
|
|||
|
|
AND t.created_by = $2)
|
|||
|
|
)`,
|
|||
|
|
targetID, actorID)
|
|||
|
|
if err != nil {
|
|||
|
|
return classify(err)
|
|||
|
|
}
|
|||
|
|
if tag.RowsAffected() == 0 {
|
|||
|
|
return r.confirmDenyReason(ctx, targetID, actorID)
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// confirmDenyReason — почему UPDATE не тронул строку. Вызывается только
|
|||
|
|
// при RowsAffected()==0 и ничего не решает: классификация нужна лишь
|
|||
|
|
// для ответа клиенту.
|
|||
|
|
func (r *ProfileRepo) confirmDenyReason(ctx context.Context, targetID, actorID string) error {
|
|||
|
|
var canConfirm bool
|
|||
|
|
err := r.db.QueryRow(ctx,
|
|||
|
|
`SELECT EXISTS (
|
|||
|
|
SELECT 1 FROM profiles t
|
|||
|
|
WHERE t.id = $1
|
|||
|
|
AND (EXISTS (SELECT 1 FROM profiles a
|
|||
|
|
WHERE a.id = $2 AND a.role = 'admin')
|
|||
|
|
OR (t.role = 'worker' AND t.created_by = $2))
|
|||
|
|
)`,
|
|||
|
|
targetID, actorID).Scan(&canConfirm)
|
|||
|
|
if err != nil {
|
|||
|
|
return classify(err)
|
|||
|
|
}
|
|||
|
|
if canConfirm {
|
|||
|
|
return ErrAlreadyConfirmed
|
|||
|
|
}
|
|||
|
|
return notFound("profile %s", targetID)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// DeleteProfile — удалить профиль. Решение принимает БД: refresh-сессии
|
|||
|
|
// и токены сброса уходят каскадом, но заявки, проекты и участия ссылаются
|
|||
|
|
// без каскада — их наличие даёт FK-отказ 23503, который здесь
|
|||
|
|
// превращается в ErrUserHasData (409: велеть отключить аккаунт, а не
|
|||
|
|
// стирать историю). Нет строки — ErrNotFound (404).
|
|||
|
|
func (r *ProfileRepo) DeleteProfile(ctx context.Context, id string) error {
|
|||
|
|
tag, err := r.db.Exec(ctx, `DELETE FROM profiles WHERE id = $1`, id)
|
|||
|
|
if err != nil {
|
|||
|
|
var pgErr *pgconn.PgError
|
|||
|
|
if errors.As(err, &pgErr) && pgErr.Code == pgFKViolation {
|
|||
|
|
return fmt.Errorf("%w: %w", err, ErrUserHasData)
|
|||
|
|
}
|
|||
|
|
return classify(err)
|
|||
|
|
}
|
|||
|
|
if tag.RowsAffected() == 0 {
|
|||
|
|
return notFound("profile %s", id)
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// DeleteWorkerForClient — клиент удаляет своего мастера: состоит в одном
|
|||
|
|
// из его проектов (любой статус членства) либо создан им самим — гвард
|
|||
|
|
// ровно тот же, что в списке и включение/выключение («свой мастер»).
|
|||
|
|
// Три шага в одной транзакции:
|
|||
|
|
// 1. guard: цель — мастер и мой (участие или создание); иначе ErrNotFound —
|
|||
|
|
// чужой мастер и несуществующий id неразличимы (как в
|
|||
|
|
// SetActiveWorkerForClient);
|
|||
|
|
// 2. членства удаляются только в моих проектах — чужие не трогаем
|
|||
|
|
// (у созданного, но не приглашённого, членств нет — шаг пустой);
|
|||
|
|
// 3. DELETE profiles: если на мастера ссылаются заявки либо чужие
|
|||
|
|
// проекты — FK-отказ 23503 → ErrUserHasData (409 «отключите вместо
|
|||
|
|
// удаления»), и вся транзакция откатывается: членства из шага 2
|
|||
|
|
// остаются на месте, аккаунт — тоже.
|
|||
|
|
func (r *ProfileRepo) DeleteWorkerForClient(ctx context.Context, id, clientID string) error {
|
|||
|
|
b, ok := r.db.(Beginer)
|
|||
|
|
if !ok {
|
|||
|
|
return errors.New("repository: querier cannot begin a transaction")
|
|||
|
|
}
|
|||
|
|
tx, err := b.Begin(ctx)
|
|||
|
|
if err != nil {
|
|||
|
|
return classify(err)
|
|||
|
|
}
|
|||
|
|
defer func() { _ = tx.Rollback(ctx) }() // после Commit — безопасный no-op
|
|||
|
|
|
|||
|
|
var mine bool
|
|||
|
|
if err := tx.QueryRow(ctx,
|
|||
|
|
`SELECT EXISTS (
|
|||
|
|
SELECT 1
|
|||
|
|
FROM profiles p
|
|||
|
|
WHERE p.id = $1
|
|||
|
|
AND p.role = 'worker'
|
|||
|
|
AND (
|
|||
|
|
p.created_by = $2
|
|||
|
|
OR EXISTS (
|
|||
|
|
SELECT 1
|
|||
|
|
FROM projects pr
|
|||
|
|
JOIN project_workers pw ON pw.project_id = pr.id
|
|||
|
|
WHERE pr.client_id = $2 AND pw.worker_id = p.id
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
)`, id, clientID).Scan(&mine); err != nil {
|
|||
|
|
return classify(err)
|
|||
|
|
}
|
|||
|
|
if !mine {
|
|||
|
|
return notFound("profile %s", id)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if _, err := tx.Exec(ctx,
|
|||
|
|
`DELETE FROM project_workers
|
|||
|
|
WHERE worker_id = $1
|
|||
|
|
AND project_id IN (SELECT id FROM projects WHERE client_id = $2)`,
|
|||
|
|
id, clientID); err != nil {
|
|||
|
|
return classify(err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
tag, err := tx.Exec(ctx, `DELETE FROM profiles WHERE id = $1`, id)
|
|||
|
|
if err != nil {
|
|||
|
|
var pgErr *pgconn.PgError
|
|||
|
|
if errors.As(err, &pgErr) && pgErr.Code == pgFKViolation {
|
|||
|
|
return fmt.Errorf("%w: %w", err, ErrUserHasData)
|
|||
|
|
}
|
|||
|
|
return classify(err)
|
|||
|
|
}
|
|||
|
|
if tag.RowsAffected() == 0 {
|
|||
|
|
return notFound("profile %s", id)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err := tx.Commit(ctx); err != nil {
|
|||
|
|
return classify(err)
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
}
|