generated from VLADIMIR/template
317 lines
7.8 KiB
Go
317 lines
7.8 KiB
Go
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)
|
||
}
|