Merge pull request #20 from rede5/codex/find-backend-project-management-solution
Add project tasks API and persistence to platform-projects-core
This commit is contained in:
commit
4fd54a9633
14 changed files with 506 additions and 3 deletions
|
|
@ -5,7 +5,7 @@
|
|||
## Responsabilidade e limites
|
||||
|
||||
**Este serviço faz:**
|
||||
- Governança de projetos, ambientes, repositórios e vínculos externos.
|
||||
- Governança de projetos, ambientes, repositórios, tarefas e vínculos externos.
|
||||
- Orquestração de metadados com multi-tenancy por design.
|
||||
- Auditoria e observabilidade para uso enterprise.
|
||||
|
||||
|
|
@ -29,6 +29,7 @@
|
|||
- **Environment**: pertence a um projeto, tipos controlados.
|
||||
- **Repository**: metadados, provider enum, URL validada, branch obrigatória.
|
||||
- **Project Links**: somente IDs externos, meta JSON sem segredo.
|
||||
- **Tasks**: tarefas com status, prazo e vínculo por projeto.
|
||||
|
||||
## Exemplo de uso
|
||||
|
||||
|
|
@ -50,6 +51,15 @@ curl -X POST http://localhost:8080/api/v1/environments \
|
|||
-d '{"project_id":"<project-id>","type":"production"}'
|
||||
```
|
||||
|
||||
Criar tarefa:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/tasks \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"project_id":"<project-id>","title":"Kickoff","description":"Reunião inicial","due_date":"2024-09-01T12:00:00Z"}'
|
||||
```
|
||||
|
||||
## Escalabilidade com segurança
|
||||
|
||||
- Multi-tenancy explícito em todas as tabelas e queries.
|
||||
|
|
@ -72,4 +82,3 @@ make lint
|
|||
- Logs estruturados via `slog`.
|
||||
- Endpoint `/api/v1/health`.
|
||||
- Endpoint `/api/v1/metrics` (placeholder para instrumentação enterprise).
|
||||
|
||||
|
|
|
|||
|
|
@ -32,8 +32,17 @@
|
|||
- `url`
|
||||
- `default_branch`
|
||||
|
||||
## Task
|
||||
|
||||
- `id`
|
||||
- `tenant_id`
|
||||
- `project_id`
|
||||
- `title`
|
||||
- `description`
|
||||
- `status` (open, in_progress, done, archived)
|
||||
- `due_date`
|
||||
|
||||
## Project Links
|
||||
|
||||
- `infra`, `billing`, `security`
|
||||
- apenas IDs externos e `meta` JSON sem segredo
|
||||
|
||||
|
|
|
|||
162
platform-projects-core/internal/api/handlers/tasks_handler.go
Normal file
162
platform-projects-core/internal/api/handlers/tasks_handler.go
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"platform-projects-core/internal/api/transport"
|
||||
"platform-projects-core/internal/application/tasks"
|
||||
"platform-projects-core/internal/infrastructure/repositories"
|
||||
)
|
||||
|
||||
type TasksHandler struct {
|
||||
logger *slog.Logger
|
||||
usecases struct {
|
||||
create tasks.CreateTaskUseCase
|
||||
update tasks.UpdateTaskUseCase
|
||||
archive tasks.ArchiveTaskUseCase
|
||||
list tasks.ListTasksUseCase
|
||||
}
|
||||
}
|
||||
|
||||
func NewTasksHandler(logger *slog.Logger, pool *pgxpool.Pool) http.Handler {
|
||||
repo := repositories.NewTasksRepository(pool)
|
||||
h := &TasksHandler{logger: logger}
|
||||
h.usecases.create = tasks.CreateTaskUseCase{Repo: repo}
|
||||
h.usecases.update = tasks.UpdateTaskUseCase{Repo: repo}
|
||||
h.usecases.archive = tasks.ArchiveTaskUseCase{Repo: repo}
|
||||
h.usecases.list = tasks.ListTasksUseCase{Repo: repo}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/", h.create)
|
||||
r.Get("/", h.list)
|
||||
r.Put("/{taskId}", h.update)
|
||||
r.Delete("/{taskId}", h.archive)
|
||||
return r
|
||||
}
|
||||
|
||||
type createTaskRequest struct {
|
||||
ProjectID string `json:"project_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
DueDate *string `json:"due_date"`
|
||||
}
|
||||
|
||||
func (h *TasksHandler) create(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID, ok := transport.TenantFromContext(r.Context())
|
||||
if !ok {
|
||||
transport.WriteError(w, http.StatusUnauthorized, "unauthorized", "tenant missing")
|
||||
return
|
||||
}
|
||||
var req createTaskRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
transport.WriteError(w, http.StatusBadRequest, "invalid_payload", "invalid JSON payload")
|
||||
return
|
||||
}
|
||||
dueDate, err := parseDueDate(req.DueDate)
|
||||
if err != nil {
|
||||
transport.WriteError(w, http.StatusBadRequest, "invalid_payload", "invalid due_date")
|
||||
return
|
||||
}
|
||||
task, err := h.usecases.create.Execute(r.Context(), tasks.CreateTaskInput{
|
||||
TenantID: tenantID,
|
||||
ProjectID: req.ProjectID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
DueDate: dueDate,
|
||||
})
|
||||
if err != nil {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
transport.WriteJSON(w, http.StatusCreated, transport.Envelope{"task": task})
|
||||
}
|
||||
|
||||
type updateTaskRequest struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
DueDate *string `json:"due_date"`
|
||||
}
|
||||
|
||||
func (h *TasksHandler) update(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID, ok := transport.TenantFromContext(r.Context())
|
||||
if !ok {
|
||||
transport.WriteError(w, http.StatusUnauthorized, "unauthorized", "tenant missing")
|
||||
return
|
||||
}
|
||||
taskID := chi.URLParam(r, "taskId")
|
||||
var req updateTaskRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
transport.WriteError(w, http.StatusBadRequest, "invalid_payload", "invalid JSON payload")
|
||||
return
|
||||
}
|
||||
dueDate, err := parseDueDate(req.DueDate)
|
||||
if err != nil {
|
||||
transport.WriteError(w, http.StatusBadRequest, "invalid_payload", "invalid due_date")
|
||||
return
|
||||
}
|
||||
task, err := h.usecases.update.Execute(r.Context(), tasks.UpdateTaskInput{
|
||||
TenantID: tenantID,
|
||||
TaskID: taskID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
Status: req.Status,
|
||||
DueDate: dueDate,
|
||||
})
|
||||
if err != nil {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
transport.WriteJSON(w, http.StatusOK, transport.Envelope{"task": task})
|
||||
}
|
||||
|
||||
func (h *TasksHandler) archive(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID, ok := transport.TenantFromContext(r.Context())
|
||||
if !ok {
|
||||
transport.WriteError(w, http.StatusUnauthorized, "unauthorized", "tenant missing")
|
||||
return
|
||||
}
|
||||
taskID := chi.URLParam(r, "taskId")
|
||||
if err := h.usecases.archive.Execute(r.Context(), tasks.ArchiveTaskInput{
|
||||
TenantID: tenantID,
|
||||
TaskID: taskID,
|
||||
}); err != nil {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
transport.WriteJSON(w, http.StatusOK, transport.Envelope{"status": "archived"})
|
||||
}
|
||||
|
||||
func (h *TasksHandler) list(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID, ok := transport.TenantFromContext(r.Context())
|
||||
if !ok {
|
||||
transport.WriteError(w, http.StatusUnauthorized, "unauthorized", "tenant missing")
|
||||
return
|
||||
}
|
||||
projectID := r.URL.Query().Get("project_id")
|
||||
tasksList, err := h.usecases.list.Execute(r.Context(), tasks.ListTasksInput{
|
||||
TenantID: tenantID,
|
||||
ProjectID: projectID,
|
||||
})
|
||||
if err != nil {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
transport.WriteJSON(w, http.StatusOK, transport.Envelope{"tasks": tasksList})
|
||||
}
|
||||
|
||||
func parseDueDate(value *string) (*time.Time, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, *value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &parsed, nil
|
||||
}
|
||||
|
|
@ -44,6 +44,7 @@ func NewRouter(deps RouterDependencies) http.Handler {
|
|||
r.Mount("/environments", handlers.NewEnvironmentsHandler(logger, deps.DB))
|
||||
r.Mount("/repositories", handlers.NewRepositoriesHandler(logger, deps.DB))
|
||||
r.Mount("/teams", handlers.NewTeamsHandler(logger, deps.DB))
|
||||
r.Mount("/tasks", handlers.NewTasksHandler(logger, deps.DB))
|
||||
r.Mount("/project-links", handlers.NewProjectLinksHandler(logger, deps.DB))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
package tasks
|
||||
|
||||
import "context"
|
||||
|
||||
type ArchiveTaskUseCase struct {
|
||||
Repo TaskRepository
|
||||
}
|
||||
|
||||
type ArchiveTaskInput struct {
|
||||
TenantID string
|
||||
TaskID string
|
||||
}
|
||||
|
||||
func (uc ArchiveTaskUseCase) Execute(ctx context.Context, input ArchiveTaskInput) error {
|
||||
return uc.Repo.Archive(ctx, input.TenantID, input.TaskID)
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"platform-projects-core/internal/domain/projects"
|
||||
)
|
||||
|
||||
type TaskRepository interface {
|
||||
Create(ctx context.Context, task projects.Task) (projects.Task, error)
|
||||
Update(ctx context.Context, task projects.Task) (projects.Task, error)
|
||||
Archive(ctx context.Context, tenantID, taskID string) error
|
||||
List(ctx context.Context, tenantID, projectID string) ([]projects.Task, error)
|
||||
Find(ctx context.Context, tenantID, taskID string) (projects.Task, error)
|
||||
}
|
||||
|
||||
type CreateTaskUseCase struct {
|
||||
Repo TaskRepository
|
||||
}
|
||||
|
||||
type CreateTaskInput struct {
|
||||
TenantID string
|
||||
ProjectID string
|
||||
Title string
|
||||
Description string
|
||||
DueDate *time.Time
|
||||
}
|
||||
|
||||
func (uc CreateTaskUseCase) Execute(ctx context.Context, input CreateTaskInput) (projects.Task, error) {
|
||||
task, err := projects.NewTask(input.TenantID, input.ProjectID, input.Title, input.Description, input.DueDate)
|
||||
if err != nil {
|
||||
return projects.Task{}, err
|
||||
}
|
||||
return uc.Repo.Create(ctx, task)
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"platform-projects-core/internal/domain/projects"
|
||||
)
|
||||
|
||||
type ListTasksUseCase struct {
|
||||
Repo TaskRepository
|
||||
}
|
||||
|
||||
type ListTasksInput struct {
|
||||
TenantID string
|
||||
ProjectID string
|
||||
}
|
||||
|
||||
func (uc ListTasksUseCase) Execute(ctx context.Context, input ListTasksInput) ([]projects.Task, error) {
|
||||
return uc.Repo.List(ctx, input.TenantID, input.ProjectID)
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"platform-projects-core/internal/domain/projects"
|
||||
)
|
||||
|
||||
type UpdateTaskUseCase struct {
|
||||
Repo TaskRepository
|
||||
}
|
||||
|
||||
type UpdateTaskInput struct {
|
||||
TenantID string
|
||||
TaskID string
|
||||
Title string
|
||||
Description string
|
||||
Status string
|
||||
DueDate *time.Time
|
||||
}
|
||||
|
||||
func (uc UpdateTaskUseCase) Execute(ctx context.Context, input UpdateTaskInput) (projects.Task, error) {
|
||||
existing, err := uc.Repo.Find(ctx, input.TenantID, input.TaskID)
|
||||
if err != nil {
|
||||
return projects.Task{}, err
|
||||
}
|
||||
status, err := projects.ParseTaskStatus(input.Status)
|
||||
if err != nil {
|
||||
return projects.Task{}, err
|
||||
}
|
||||
updated, err := existing.WithUpdates(input.Title, input.Description, status, input.DueDate)
|
||||
if err != nil {
|
||||
return projects.Task{}, err
|
||||
}
|
||||
return uc.Repo.Update(ctx, updated)
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS project_tasks;
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
CREATE TABLE IF NOT EXISTS project_tasks (
|
||||
id UUID PRIMARY KEY,
|
||||
tenant_id UUID NOT NULL,
|
||||
project_id UUID NOT NULL REFERENCES projects(id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL,
|
||||
due_date TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS project_tasks_tenant_idx ON project_tasks (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS project_tasks_project_idx ON project_tasks (project_id);
|
||||
26
platform-projects-core/internal/db/queries/tasks.sql
Normal file
26
platform-projects-core/internal/db/queries/tasks.sql
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
-- name: CreateTask :exec
|
||||
INSERT INTO project_tasks (id, tenant_id, project_id, title, description, status, due_date, created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9);
|
||||
|
||||
-- name: UpdateTask :exec
|
||||
UPDATE project_tasks
|
||||
SET title = $1,
|
||||
description = $2,
|
||||
status = $3,
|
||||
due_date = $4,
|
||||
updated_at = $5
|
||||
WHERE tenant_id = $6
|
||||
AND id = $7;
|
||||
|
||||
-- name: ArchiveTask :exec
|
||||
UPDATE project_tasks
|
||||
SET status = 'archived',
|
||||
updated_at = $1
|
||||
WHERE tenant_id = $2
|
||||
AND id = $3;
|
||||
|
||||
-- name: ListTasks :many
|
||||
SELECT id, tenant_id, project_id, title, description, status, due_date, created_at, updated_at
|
||||
FROM project_tasks
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY created_at DESC;
|
||||
51
platform-projects-core/internal/domain/projects/task.go
Normal file
51
platform-projects-core/internal/domain/projects/task.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package projects
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"platform-projects-core/internal/domain/common"
|
||||
)
|
||||
|
||||
type Task struct {
|
||||
ID string
|
||||
TenantID string
|
||||
ProjectID string
|
||||
Title string
|
||||
Description string
|
||||
Status TaskStatus
|
||||
DueDate *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func NewTask(tenantID, projectID, title, description string, dueDate *time.Time) (Task, error) {
|
||||
title = strings.TrimSpace(title)
|
||||
if tenantID == "" || projectID == "" || title == "" {
|
||||
return Task{}, common.ErrInvalidInput
|
||||
}
|
||||
return Task{
|
||||
ID: common.NewID(),
|
||||
TenantID: tenantID,
|
||||
ProjectID: projectID,
|
||||
Title: title,
|
||||
Description: strings.TrimSpace(description),
|
||||
Status: TaskStatusOpen,
|
||||
DueDate: dueDate,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t Task) WithUpdates(title, description string, status TaskStatus, dueDate *time.Time) (Task, error) {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
return Task{}, common.ErrInvalidInput
|
||||
}
|
||||
t.Title = title
|
||||
t.Description = strings.TrimSpace(description)
|
||||
t.Status = status
|
||||
t.DueDate = dueDate
|
||||
t.UpdatedAt = time.Now().UTC()
|
||||
return t, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package projects
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"platform-projects-core/internal/domain/common"
|
||||
)
|
||||
|
||||
type TaskStatus string
|
||||
|
||||
const (
|
||||
TaskStatusOpen TaskStatus = "open"
|
||||
TaskStatusInProgress TaskStatus = "in_progress"
|
||||
TaskStatusDone TaskStatus = "done"
|
||||
TaskStatusArchived TaskStatus = "archived"
|
||||
)
|
||||
|
||||
func ParseTaskStatus(value string) (TaskStatus, error) {
|
||||
switch strings.TrimSpace(value) {
|
||||
case string(TaskStatusOpen):
|
||||
return TaskStatusOpen, nil
|
||||
case string(TaskStatusInProgress):
|
||||
return TaskStatusInProgress, nil
|
||||
case string(TaskStatusDone):
|
||||
return TaskStatusDone, nil
|
||||
case string(TaskStatusArchived):
|
||||
return TaskStatusArchived, nil
|
||||
default:
|
||||
return "", common.ErrInvalidInput
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"platform-projects-core/internal/domain/common"
|
||||
"platform-projects-core/internal/domain/projects"
|
||||
)
|
||||
|
||||
type TasksRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewTasksRepository(pool *pgxpool.Pool) *TasksRepository {
|
||||
return &TasksRepository{pool: pool}
|
||||
}
|
||||
|
||||
func (r *TasksRepository) Create(ctx context.Context, task projects.Task) (projects.Task, error) {
|
||||
query := `INSERT INTO project_tasks (id, tenant_id, project_id, title, description, status, due_date, created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`
|
||||
_, err := r.pool.Exec(ctx, query, task.ID, task.TenantID, task.ProjectID, task.Title, task.Description, task.Status, task.DueDate, task.CreatedAt, task.UpdatedAt)
|
||||
if err != nil {
|
||||
return projects.Task{}, err
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (r *TasksRepository) Update(ctx context.Context, task projects.Task) (projects.Task, error) {
|
||||
query := `UPDATE project_tasks SET title=$1, description=$2, status=$3, due_date=$4, updated_at=$5 WHERE tenant_id=$6 AND id=$7`
|
||||
cmd, err := r.pool.Exec(ctx, query, task.Title, task.Description, task.Status, task.DueDate, time.Now().UTC(), task.TenantID, task.ID)
|
||||
if err != nil {
|
||||
return projects.Task{}, err
|
||||
}
|
||||
if cmd.RowsAffected() == 0 {
|
||||
return projects.Task{}, common.ErrNotFound
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (r *TasksRepository) Archive(ctx context.Context, tenantID, taskID string) error {
|
||||
query := `UPDATE project_tasks SET status='archived', updated_at=$1 WHERE tenant_id=$2 AND id=$3`
|
||||
cmd, err := r.pool.Exec(ctx, query, time.Now().UTC(), tenantID, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cmd.RowsAffected() == 0 {
|
||||
return common.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *TasksRepository) List(ctx context.Context, tenantID, projectID string) ([]projects.Task, error) {
|
||||
query := `SELECT id, tenant_id, project_id, title, description, status, due_date, created_at, updated_at FROM project_tasks WHERE tenant_id=$1`
|
||||
args := []any{tenantID}
|
||||
if projectID != "" {
|
||||
query += " AND project_id=$2"
|
||||
args = append(args, projectID)
|
||||
}
|
||||
query += " ORDER BY created_at DESC"
|
||||
rows, err := r.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []projects.Task
|
||||
for rows.Next() {
|
||||
var t projects.Task
|
||||
if err := rows.Scan(&t.ID, &t.TenantID, &t.ProjectID, &t.Title, &t.Description, &t.Status, &t.DueDate, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, t)
|
||||
}
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
func (r *TasksRepository) Find(ctx context.Context, tenantID, taskID string) (projects.Task, error) {
|
||||
query := `SELECT id, tenant_id, project_id, title, description, status, due_date, created_at, updated_at FROM project_tasks WHERE tenant_id=$1 AND id=$2`
|
||||
var t projects.Task
|
||||
if err := r.pool.QueryRow(ctx, query, tenantID, taskID).Scan(&t.ID, &t.TenantID, &t.ProjectID, &t.Title, &t.Description, &t.Status, &t.DueDate, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return projects.Task{}, common.ErrNotFound
|
||||
}
|
||||
return projects.Task{}, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
Loading…
Reference in a new issue