Files
evening_detective_server/internal/repos/users_repo/repo.go
T
2026-08-19 02:15:57 +07:00

317 lines
7.8 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 users_repo
import (
"context"
"errors"
"evening_detective_server/internal/repos"
"evening_detective_server/internal/repos/user_agreements_repo"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrUserNotFound = errors.New("Пользователь не найден")
)
type UsersRepo struct {
pool *pgxpool.Pool
}
func NewUserRepo(pool *pgxpool.Pool) *UsersRepo {
return &UsersRepo{
pool: pool,
}
}
// AddUserWithAgreements создаёт пользователя и фиксирует акцепты документов
// (user_agreements) в одной транзакции: пользователь либо существует вместе с
// записями о согласиях, либо не существует вовсе (доказуемое согласие,
// ст. 9 152-ФЗ). Возвращает id созданного пользователя.
func (s *UsersRepo) AddUserWithAgreements(
ctx context.Context,
username string,
email string,
passwordHash string,
roles []string,
agreements []user_agreements_repo.Agreement,
) (int, error) {
// Инвариант «пользователь не существует без записей о согласиях»
// держится на том, что акцепты передаются всегда.
if len(agreements) == 0 {
return 0, errors.New("Не переданы записи об акцепте соглашений")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return 0, err
}
defer tx.Rollback(ctx) //nolint:errcheck
var id int
err = tx.QueryRow(
ctx,
`INSERT INTO users (username, email, password_hash, roles)
SELECT $1, $2, $3, $4
WHERE NOT EXISTS (
SELECT 1 FROM users
WHERE username = $5 OR email = $6
)
RETURNING id`,
username,
email,
passwordHash,
roles,
username,
email,
).Scan(&id)
if err != nil {
// Ничего не вставлено — занят username или email.
if errors.Is(err, pgx.ErrNoRows) {
return 0, errors.New("Username или email уже используется")
}
// Гонка: между проверкой NOT EXISTS и INSERT другой запрос успел
// создать пользователя с тем же username/email — нарушение
// уникальности. Возвращаем тот же понятный клиенту ответ.
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" { // unique_violation
return 0, errors.New("Username или email уже используется")
}
return 0, err
}
for _, agreement := range agreements {
if _, err = tx.Exec(
ctx,
`INSERT INTO user_agreements (user_id, agreement_type, version, ip, user_agent)
VALUES ($1, $2, $3, $4, $5)`,
id,
agreement.AgreementType,
agreement.Version,
agreement.IP,
agreement.UserAgent,
); err != nil {
return 0, err
}
}
if err = tx.Commit(ctx); err != nil {
return 0, err
}
return id, nil
}
func (s *UsersRepo) UpdateUserPassword(
ctx context.Context,
email string,
passwordHash string,
) error {
tag, err := s.pool.Exec(
ctx,
`UPDATE users
SET password_hash = $1
WHERE email = $2`,
passwordHash,
email,
)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrUserNotFound
}
return nil
}
func (s *UsersRepo) GetUserByEmail(
ctx context.Context,
email string,
) (*repos.User, error) {
user := &repos.User{}
row := s.pool.QueryRow(
ctx,
`SELECT id, username, email, password_hash, roles, is_active
FROM users
WHERE email = $1`,
email,
)
err := row.Scan(
&user.ID,
&user.Username,
&user.Email,
&user.PasswordHash,
&user.Roles,
&user.IsActive,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, err
}
return user, nil
}
func (s *UsersRepo) GetUserByID(
ctx context.Context,
id int,
) (*repos.User, error) {
user := &repos.User{}
row := s.pool.QueryRow(
ctx,
`SELECT id, username, email, password_hash, roles, is_active
FROM users
WHERE id = $1`,
id,
)
err := row.Scan(
&user.ID,
&user.Username,
&user.Email,
&user.PasswordHash,
&user.Roles,
&user.IsActive,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, err
}
return user, nil
}
func (s *UsersRepo) GetUsers(
ctx context.Context,
) ([]*repos.User, error) {
rows, err := s.pool.Query(
ctx,
`SELECT id, username, email, roles, is_active, created_at
FROM users
ORDER BY created_at DESC`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var users []*repos.User
for rows.Next() {
user := &repos.User{}
err := rows.Scan(
&user.ID,
&user.Username,
&user.Email,
&user.Roles,
&user.IsActive,
&user.CreatedAt,
)
if err != nil {
return nil, err
}
users = append(users, user)
}
if err := rows.Err(); err != nil {
return nil, err
}
return users, nil
}
func (s *UsersRepo) AddUserRole(
ctx context.Context,
userId int,
role string,
) error {
_, err := s.pool.Exec(
ctx,
`UPDATE users
SET roles = roles || $1::jsonb
WHERE id = $2 AND NOT roles @> $1::jsonb`,
fmt.Sprintf(`["%s"]`, role),
userId,
)
return err
}
func (s *UsersRepo) DeleteUserRole(
ctx context.Context,
userId int,
role string,
) error {
_, err := s.pool.Exec(
ctx,
`UPDATE users
SET roles = roles - $1::text
WHERE id = $2`,
role,
userId,
)
return err
}
// DeleteUser удаляет учётную запись и персональные данные пользователя
// (ст. 21 152-ФЗ — уничтожение ПДн при удалении аккаунта). Выполняется в одной
// транзакции. Публичный контент пользователя (сценарии, команды) сохраняется:
// FK-ограничения с ON DELETE SET NULL (миграция
// 20260818232725_keep_content_on_user_delete.sql) обнуляют привязку к автору —
// контент остаётся в обезличенном виде.
func (s *UsersRepo) DeleteUser(
ctx context.Context,
userId int,
) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx) //nolint:errcheck
// Refresh-токены (FK CASCADE, удаляем явно для предсказуемости).
if _, err = tx.Exec(
ctx,
`DELETE FROM refresh_tokens WHERE user_id = $1`,
userId,
); err != nil {
return err
}
// Зафиксированные акцепты соглашений НЕ удаляются: ст. 9 ч. 4 152-ФЗ
// возлагает на оператора бремя доказывания получения согласия, поэтому
// факт акцепта (документ, версия, время) сохраняется в обезличенном виде:
// ссылка на пользователя и персональные данные внутри записи (ip,
// user_agent) обнуляются. FK ON DELETE SET NULL (миграция
// 20260818231943_create_user_agreements_table.sql) гарантирует, что при
// удалении users строки акцептов остаются с user_id = NULL.
if _, err = tx.Exec(
ctx,
`UPDATE user_agreements
SET user_id = NULL, ip = NULL, user_agent = NULL
WHERE user_id = $1`,
userId,
); err != nil {
return err
}
// Пользователь. Связанные сценарии (author_id) и команды (creator_id)
// обнуляются автоматически через ON DELETE SET NULL.
tag, err := tx.Exec(
ctx,
`DELETE FROM users WHERE id = $1`,
userId,
)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrUserNotFound
}
return tx.Commit(ctx)
}