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
|
||
}
|