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:
Tiago Yamamoto 2026-01-08 15:36:02 -03:00 committed by GitHub
commit 4fd54a9633
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 506 additions and 3 deletions

View file

@ -5,7 +5,7 @@
## Responsabilidade e limites ## Responsabilidade e limites
**Este serviço faz:** **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. - Orquestração de metadados com multi-tenancy por design.
- Auditoria e observabilidade para uso enterprise. - Auditoria e observabilidade para uso enterprise.
@ -29,6 +29,7 @@
- **Environment**: pertence a um projeto, tipos controlados. - **Environment**: pertence a um projeto, tipos controlados.
- **Repository**: metadados, provider enum, URL validada, branch obrigatória. - **Repository**: metadados, provider enum, URL validada, branch obrigatória.
- **Project Links**: somente IDs externos, meta JSON sem segredo. - **Project Links**: somente IDs externos, meta JSON sem segredo.
- **Tasks**: tarefas com status, prazo e vínculo por projeto.
## Exemplo de uso ## Exemplo de uso
@ -50,6 +51,15 @@ curl -X POST http://localhost:8080/api/v1/environments \
-d '{"project_id":"<project-id>","type":"production"}' -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 ## Escalabilidade com segurança
- Multi-tenancy explícito em todas as tabelas e queries. - Multi-tenancy explícito em todas as tabelas e queries.
@ -72,4 +82,3 @@ make lint
- Logs estruturados via `slog`. - Logs estruturados via `slog`.
- Endpoint `/api/v1/health`. - Endpoint `/api/v1/health`.
- Endpoint `/api/v1/metrics` (placeholder para instrumentação enterprise). - Endpoint `/api/v1/metrics` (placeholder para instrumentação enterprise).

View file

@ -32,8 +32,17 @@
- `url` - `url`
- `default_branch` - `default_branch`
## Task
- `id`
- `tenant_id`
- `project_id`
- `title`
- `description`
- `status` (open, in_progress, done, archived)
- `due_date`
## Project Links ## Project Links
- `infra`, `billing`, `security` - `infra`, `billing`, `security`
- apenas IDs externos e `meta` JSON sem segredo - apenas IDs externos e `meta` JSON sem segredo

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

View file

@ -44,6 +44,7 @@ func NewRouter(deps RouterDependencies) http.Handler {
r.Mount("/environments", handlers.NewEnvironmentsHandler(logger, deps.DB)) r.Mount("/environments", handlers.NewEnvironmentsHandler(logger, deps.DB))
r.Mount("/repositories", handlers.NewRepositoriesHandler(logger, deps.DB)) r.Mount("/repositories", handlers.NewRepositoriesHandler(logger, deps.DB))
r.Mount("/teams", handlers.NewTeamsHandler(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)) r.Mount("/project-links", handlers.NewProjectLinksHandler(logger, deps.DB))
}) })
}) })

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS project_tasks;

View file

@ -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);

View 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;

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

View file

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

View file

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