axe/internal/repository/profile.go
2026-10-18 22:07:25 +03:00

473 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}