axe/internal/repository/profile.go

473 lines
22 KiB
Go
Raw Permalink Normal View History

2026-10-18 19:07:25 +00:00
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
}