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 }