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