Files
evening_detective_server/internal/repos/tasks_repo/repo.go
T
2026-10-07 23:09:17 +07:00

257 lines
5.8 KiB
Go

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