diff --git a/platform-projects-core/README.md b/platform-projects-core/README.md index a4475a8..8883b8d 100644 --- a/platform-projects-core/README.md +++ b/platform-projects-core/README.md @@ -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":"","type":"production"}' ``` +Criar tarefa: + +```bash +curl -X POST http://localhost:8080/api/v1/tasks \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"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). - diff --git a/platform-projects-core/docs/domain-model.md b/platform-projects-core/docs/domain-model.md index 3f76605..cd24495 100644 --- a/platform-projects-core/docs/domain-model.md +++ b/platform-projects-core/docs/domain-model.md @@ -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 - diff --git a/platform-projects-core/internal/api/handlers/tasks_handler.go b/platform-projects-core/internal/api/handlers/tasks_handler.go new file mode 100644 index 0000000..4366c1b --- /dev/null +++ b/platform-projects-core/internal/api/handlers/tasks_handler.go @@ -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 +} diff --git a/platform-projects-core/internal/api/http/router.go b/platform-projects-core/internal/api/http/router.go index 19094b4..6d09f04 100644 --- a/platform-projects-core/internal/api/http/router.go +++ b/platform-projects-core/internal/api/http/router.go @@ -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)) }) }) diff --git a/platform-projects-core/internal/application/tasks/archive_task.go b/platform-projects-core/internal/application/tasks/archive_task.go new file mode 100644 index 0000000..abf4d40 --- /dev/null +++ b/platform-projects-core/internal/application/tasks/archive_task.go @@ -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) +} diff --git a/platform-projects-core/internal/application/tasks/create_task.go b/platform-projects-core/internal/application/tasks/create_task.go new file mode 100644 index 0000000..115847c --- /dev/null +++ b/platform-projects-core/internal/application/tasks/create_task.go @@ -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) +} diff --git a/platform-projects-core/internal/application/tasks/list_tasks.go b/platform-projects-core/internal/application/tasks/list_tasks.go new file mode 100644 index 0000000..181159f --- /dev/null +++ b/platform-projects-core/internal/application/tasks/list_tasks.go @@ -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) +} diff --git a/platform-projects-core/internal/application/tasks/update_task.go b/platform-projects-core/internal/application/tasks/update_task.go new file mode 100644 index 0000000..c832634 --- /dev/null +++ b/platform-projects-core/internal/application/tasks/update_task.go @@ -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) +} diff --git a/platform-projects-core/internal/db/migrations/0002_project_tasks.down.sql b/platform-projects-core/internal/db/migrations/0002_project_tasks.down.sql new file mode 100644 index 0000000..35f267d --- /dev/null +++ b/platform-projects-core/internal/db/migrations/0002_project_tasks.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS project_tasks; diff --git a/platform-projects-core/internal/db/migrations/0002_project_tasks.up.sql b/platform-projects-core/internal/db/migrations/0002_project_tasks.up.sql new file mode 100644 index 0000000..64f3297 --- /dev/null +++ b/platform-projects-core/internal/db/migrations/0002_project_tasks.up.sql @@ -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); diff --git a/platform-projects-core/internal/db/queries/tasks.sql b/platform-projects-core/internal/db/queries/tasks.sql new file mode 100644 index 0000000..0c9703e --- /dev/null +++ b/platform-projects-core/internal/db/queries/tasks.sql @@ -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; diff --git a/platform-projects-core/internal/domain/projects/task.go b/platform-projects-core/internal/domain/projects/task.go new file mode 100644 index 0000000..2e58536 --- /dev/null +++ b/platform-projects-core/internal/domain/projects/task.go @@ -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 +} diff --git a/platform-projects-core/internal/domain/projects/task_status.go b/platform-projects-core/internal/domain/projects/task_status.go new file mode 100644 index 0000000..5447eb2 --- /dev/null +++ b/platform-projects-core/internal/domain/projects/task_status.go @@ -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 + } +} diff --git a/platform-projects-core/internal/infrastructure/repositories/tasks_repo_sqlc.go b/platform-projects-core/internal/infrastructure/repositories/tasks_repo_sqlc.go new file mode 100644 index 0000000..2c417c6 --- /dev/null +++ b/platform-projects-core/internal/infrastructure/repositories/tasks_repo_sqlc.go @@ -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 +}