generated from VLADIMIR/template
add docs
This commit is contained in:
@@ -75,17 +75,17 @@ func (r *ScenariosRepo) GetScenariosByAuthorID(
|
||||
|
||||
var scenarios []*repos.Scenario
|
||||
for rows.Next() {
|
||||
scenario := &repos.Scenario{
|
||||
Author: &repos.User{},
|
||||
}
|
||||
scenario := &repos.Scenario{}
|
||||
var authorID *int
|
||||
var authorUsername *string
|
||||
err := rows.Scan(
|
||||
&scenario.ID,
|
||||
&scenario.Name,
|
||||
&scenario.Description,
|
||||
&scenario.Image,
|
||||
&scenario.Scenario,
|
||||
&scenario.Author.ID,
|
||||
&scenario.Author.Username,
|
||||
&authorID,
|
||||
&authorUsername,
|
||||
&scenario.Status,
|
||||
&scenario.UpdatedAt,
|
||||
&scenario.CreatedAt,
|
||||
@@ -94,6 +94,9 @@ func (r *ScenariosRepo) GetScenariosByAuthorID(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if authorID != nil && authorUsername != nil {
|
||||
scenario.Author = &repos.User{ID: *authorID, Username: *authorUsername}
|
||||
}
|
||||
scenarios = append(scenarios, scenario)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -134,17 +137,17 @@ func (r *ScenariosRepo) GetScenariosByStatus(
|
||||
|
||||
var scenarios []*repos.Scenario
|
||||
for rows.Next() {
|
||||
scenario := &repos.Scenario{
|
||||
Author: &repos.User{},
|
||||
}
|
||||
scenario := &repos.Scenario{}
|
||||
var authorID *int
|
||||
var authorUsername *string
|
||||
err := rows.Scan(
|
||||
&scenario.ID,
|
||||
&scenario.Name,
|
||||
&scenario.Description,
|
||||
&scenario.Image,
|
||||
&scenario.Scenario,
|
||||
&scenario.Author.ID,
|
||||
&scenario.Author.Username,
|
||||
&authorID,
|
||||
&authorUsername,
|
||||
&scenario.Status,
|
||||
&scenario.UpdatedAt,
|
||||
&scenario.CreatedAt,
|
||||
@@ -153,6 +156,9 @@ func (r *ScenariosRepo) GetScenariosByStatus(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if authorID != nil && authorUsername != nil {
|
||||
scenario.Author = &repos.User{ID: *authorID, Username: *authorUsername}
|
||||
}
|
||||
scenarios = append(scenarios, scenario)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -166,9 +172,9 @@ func (r *ScenariosRepo) GetScenarioByID(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
) (*repos.Scenario, error) {
|
||||
scenario := &repos.Scenario{
|
||||
Author: &repos.User{},
|
||||
}
|
||||
scenario := &repos.Scenario{}
|
||||
var authorID *int
|
||||
var authorUsername *string
|
||||
row := r.pool.QueryRow(
|
||||
ctx,
|
||||
`SELECT
|
||||
@@ -195,8 +201,8 @@ func (r *ScenariosRepo) GetScenarioByID(
|
||||
&scenario.Description,
|
||||
&scenario.Image,
|
||||
&scenario.Scenario,
|
||||
&scenario.Author.ID,
|
||||
&scenario.Author.Username,
|
||||
&authorID,
|
||||
&authorUsername,
|
||||
&scenario.Status,
|
||||
&scenario.UpdatedAt,
|
||||
&scenario.CreatedAt,
|
||||
@@ -210,9 +216,15 @@ func (r *ScenariosRepo) GetScenarioByID(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Автор может отсутствовать (обезличен после удаления аккаунта).
|
||||
if authorID != nil && authorUsername != nil {
|
||||
scenario.Author = &repos.User{ID: *authorID, Username: *authorUsername}
|
||||
}
|
||||
|
||||
return scenario, nil
|
||||
}
|
||||
|
||||
// UpdateScenarioByID меняет основные поля сценария.
|
||||
func (r *ScenariosRepo) UpdateScenarioByID(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
@@ -243,50 +255,61 @@ func (r *ScenariosRepo) UpdateScenarioByID(
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateScenarioStatusByID меняет статус сценария. allowPublished=true
|
||||
// разрешает менять статус опубликованного сценария (только для админа).
|
||||
// Возвращает false, если строка не обновлена (например, не-админ пытается
|
||||
// снять с публикации опубликованный сценарий). published_at фиксируется
|
||||
// только при ПЕРВОЙ публикации: повторная публикация не двигает «момент
|
||||
// отчуждения» исключительного права (п. 7.2 Пользовательского соглашения).
|
||||
func (r *ScenariosRepo) UpdateScenarioStatusByID(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
status string,
|
||||
) error {
|
||||
allowPublished bool,
|
||||
) (bool, error) {
|
||||
tag, err := r.pool.Exec(
|
||||
ctx,
|
||||
`UPDATE scenarios
|
||||
SET
|
||||
status = $1,
|
||||
published_at = NOW(),
|
||||
published_at = CASE
|
||||
WHEN $1 = 'public' AND published_at IS NULL THEN NOW()
|
||||
ELSE published_at
|
||||
END,
|
||||
updated_at = NOW()
|
||||
WHERE id = $2`,
|
||||
WHERE id = $2 AND ($3 OR status <> 'public')`,
|
||||
status,
|
||||
id,
|
||||
allowPublished,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrScenarioNotFound
|
||||
}
|
||||
return nil
|
||||
return tag.RowsAffected() > 0, nil
|
||||
}
|
||||
|
||||
// DeleteScenarioByID помечает сценарий удалённым (soft delete).
|
||||
// allowPublished=true разрешает удаление опубликованного сценария
|
||||
// (только для админа). Возвращает false, если строка не обновлена
|
||||
// (например, не-админ пытается удалить опубликованный сценарий).
|
||||
func (r *ScenariosRepo) DeleteScenarioByID(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
) error {
|
||||
allowPublished bool,
|
||||
) (bool, error) {
|
||||
tag, err := r.pool.Exec(
|
||||
ctx,
|
||||
`UPDATE scenarios
|
||||
SET
|
||||
is_deleted = TRUE
|
||||
WHERE id = $1`,
|
||||
WHERE id = $1 AND ($2 OR status <> 'public')`,
|
||||
id,
|
||||
allowPublished,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrScenarioNotFound
|
||||
}
|
||||
return nil
|
||||
return tag.RowsAffected() > 0, nil
|
||||
}
|
||||
|
||||
func (r *ScenariosRepo) GetStoryByScenarioID(
|
||||
@@ -315,6 +338,7 @@ func (r *ScenariosRepo) GetStoryByScenarioID(
|
||||
return story, nil
|
||||
}
|
||||
|
||||
// UpdateStoryByScenarioID обновляет содержимое (историю) сценария.
|
||||
func (r *ScenariosRepo) UpdateStoryByScenarioID(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package user_agreements_repo
|
||||
|
||||
// AgreementType — типы документов, акцептуемых пользователем.
|
||||
const (
|
||||
AgreementTypeTerms = "terms" // Пользовательское соглашение
|
||||
AgreementTypePrivacy = "privacy" // Политика конфиденциальности
|
||||
)
|
||||
|
||||
// Agreement — фиксируемый факт акцепта документа (ст. 9 152-ФЗ — согласие в
|
||||
// форме, позволяющей подтвердить факт его получения). Version должна
|
||||
// соответствовать опубликованной редакции документа; при изменении текста
|
||||
// документа версия обязана увеличиваться.
|
||||
type Agreement struct {
|
||||
AgreementType string
|
||||
Version string
|
||||
IP string
|
||||
UserAgent string
|
||||
}
|
||||
@@ -4,9 +4,11 @@ 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"
|
||||
)
|
||||
|
||||
@@ -24,21 +26,40 @@ func NewUserRepo(pool *pgxpool.Pool) *UsersRepo {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *UsersRepo) AddUser(
|
||||
// AddUserWithAgreements создаёт пользователя и фиксирует акцепты документов
|
||||
// (user_agreements) в одной транзакции: пользователь либо существует вместе с
|
||||
// записями о согласиях, либо не существует вовсе (доказуемое согласие,
|
||||
// ст. 9 152-ФЗ). Возвращает id созданного пользователя.
|
||||
func (s *UsersRepo) AddUserWithAgreements(
|
||||
ctx context.Context,
|
||||
username string,
|
||||
email string,
|
||||
passwordHash string,
|
||||
roles []string,
|
||||
) error {
|
||||
tag, err := s.pool.Exec(
|
||||
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,
|
||||
@@ -46,14 +67,41 @@ func (s *UsersRepo) AddUser(
|
||||
|
||||
username,
|
||||
email,
|
||||
)
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return err
|
||||
// Ничего не вставлено — занят 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
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return errors.New("username или email уже используется")
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *UsersRepo) UpdateUserPassword(
|
||||
@@ -207,3 +255,62 @@ func (s *UsersRepo) DeleteUserRole(
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user