// Package tasks_repo persists developer tasks. package tasks_repo import ( "context" "errors" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" ) var ( ErrInvalidTask = errors.New("некорректные поля задачи") ErrNotFound = errors.New("задача не найдена") ErrConflict = errors.New("задача изменена другим пользователем; обновите данные") ErrInvalidAssignee = errors.New("ответственный должен иметь роль разработчика") ) // Task is a persisted developer task. type Task struct { ID int Title string Description string Status string AssigneeID *int AssigneeName string AuthorID *int DueDate *time.Time CreatedAt time.Time UpdatedAt time.Time Version int } // Assignee contains only the public identity needed for task assignment. type Assignee struct { ID int Name string } // TasksRepo persists the shared task board. type TasksRepo struct{ pool *pgxpool.Pool } func NewTasksRepo(pool *pgxpool.Pool) *TasksRepo { return &TasksRepo{pool: pool} } const taskColumns = ` id, title, description, status, assignee_id, author_id, due_date, created_at, updated_at, version, COALESCE(( SELECT username FROM users WHERE users.id = developer_tasks.assignee_id ), '')` func scanTask(row pgx.Row) (*Task, error) { var task Task err := row.Scan(&task.ID, &task.Title, &task.Description, &task.Status, &task.AssigneeID, &task.AuthorID, &task.DueDate, &task.CreatedAt, &task.UpdatedAt, &task.Version, &task.AssigneeName) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } return &task, err } func (r *TasksRepo) Get(ctx context.Context, id int) (*Task, error) { return scanTask(r.pool.QueryRow( ctx, `SELECT `+taskColumns+` FROM developer_tasks WHERE id = $1`, id, )) } func (r *TasksRepo) List(ctx context.Context) ([]*Task, error) { rows, err := r.pool.Query( ctx, `SELECT t.id, t.title, t.description, t.status, t.assignee_id, t.author_id, t.due_date, t.created_at, t.updated_at, t.version, COALESCE(u.username, '') FROM developer_tasks t LEFT JOIN users u ON u.id = t.assignee_id ORDER BY t.created_at DESC, t.id DESC`, ) if err != nil { return nil, err } defer rows.Close() tasks := make([]*Task, 0) for rows.Next() { var task Task if err := rows.Scan(&task.ID, &task.Title, &task.Description, &task.Status, &task.AssigneeID, &task.AuthorID, &task.DueDate, &task.CreatedAt, &task.UpdatedAt, &task.Version, &task.AssigneeName); err != nil { return nil, err } tasks = append(tasks, &task) } return tasks, rows.Err() } func (r *TasksRepo) ListAssignees(ctx context.Context) ([]Assignee, error) { rows, err := r.pool.Query( ctx, `SELECT id, username FROM users WHERE roles @> '["developer"]'::jsonb ORDER BY username, id`, ) if err != nil { return nil, err } defer rows.Close() result := make([]Assignee, 0) for rows.Next() { var user Assignee if err := rows.Scan(&user.ID, &user.Name); err != nil { return nil, err } result = append(result, user) } return result, rows.Err() } func (r *TasksRepo) Create(ctx context.Context, task Task) (*Task, error) { result, err := scanTask(r.pool.QueryRow( ctx, `INSERT INTO developer_tasks (title, description, status, assignee_id, author_id, due_date) SELECT $1, $2, $3, $4::integer, $5::integer, $6::date WHERE $4::integer IS NULL OR EXISTS ( SELECT 1 FROM users WHERE id = $4 AND roles @> '["developer"]'::jsonb ) RETURNING `+taskColumns, task.Title, task.Description, task.Status, task.AssigneeID, task.AuthorID, task.DueDate, )) if errors.Is(err, ErrNotFound) { return nil, ErrInvalidAssignee } return result, writeError(err) } func (r *TasksRepo) Update(ctx context.Context, task Task) (*Task, error) { result, err := scanTask(r.pool.QueryRow( ctx, `UPDATE developer_tasks SET title = $1, description = $2, status = $3, assignee_id = $4, due_date = $5, updated_at = NOW(), version = version + 1 WHERE id = $6 AND version = $7 AND ( $4::integer IS NULL OR assignee_id = $4 OR EXISTS ( SELECT 1 FROM users WHERE id = $4 AND roles @> '["developer"]'::jsonb ) ) RETURNING `+taskColumns, task.Title, task.Description, task.Status, task.AssigneeID, task.DueDate, task.ID, task.Version, )) if errors.Is(err, ErrNotFound) { current, getErr := r.Get(ctx, task.ID) if getErr != nil { return nil, getErr } if current.Version != task.Version { return nil, ErrConflict } return nil, ErrInvalidAssignee } return result, writeError(err) } func (r *TasksRepo) SetStatus(ctx context.Context, id, version int, status string) (*Task, error) { result, err := scanTask(r.pool.QueryRow( ctx, `UPDATE developer_tasks SET status = $1, updated_at = NOW(), version = version + 1 WHERE id = $2 AND version = $3 RETURNING `+taskColumns, status, id, version, )) if errors.Is(err, ErrNotFound) { return nil, r.missingWrite(ctx, id) } return result, err } func (r *TasksRepo) Delete(ctx context.Context, id, version int) error { tag, err := r.pool.Exec( ctx, `DELETE FROM developer_tasks WHERE id = $1 AND version = $2`, id, version, ) if err != nil { return err } if tag.RowsAffected() == 0 { return r.missingWrite(ctx, id) } return nil } func (r *TasksRepo) missingWrite(ctx context.Context, id int) error { if _, err := r.Get(ctx, id); err != nil { return err } return ErrConflict } func writeError(err error) error { var pgErr *pgconn.PgError if errors.As(err, &pgErr) { switch pgErr.Code { case "23503": return ErrInvalidAssignee case "23514", "22001": return ErrInvalidTask } } return err }