diff --git a/go.mod b/go.mod index fe3b02b..94a23fa 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( go.uber.org/fx v1.24.0 go.uber.org/zap v1.27.1 golang.org/x/crypto v0.49.0 + golang.org/x/sync v0.20.0 ) require ( @@ -91,7 +92,6 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/mod v0.33.0 // indirect golang.org/x/net v0.51.0 // indirect - golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.35.0 // indirect golang.org/x/tools v0.42.0 // indirect diff --git a/internal/app.go b/internal/app.go index b347c14..b9ea56e 100644 --- a/internal/app.go +++ b/internal/app.go @@ -8,6 +8,7 @@ import ( "github.com/bit-issues/backend/internal/jwt" "github.com/bit-issues/backend/internal/projects" "github.com/bit-issues/backend/internal/server" + "github.com/bit-issues/backend/internal/tasks" "github.com/bit-issues/backend/internal/users" "github.com/go-core-fx/bunfx" "github.com/go-core-fx/fiberfx" @@ -52,6 +53,7 @@ func Run(version healthfx.Version) { jwt.Module(), users.Module(), projects.Module(), + tasks.Module(), // fx.Invoke(func(lc fx.Lifecycle, logger *zap.Logger) { lc.Append(fx.Hook{ diff --git a/internal/db/migrations/20260414000000_create_tasks.sql b/internal/db/migrations/20260414000000_create_tasks.sql new file mode 100644 index 0000000..50c270f --- /dev/null +++ b/internal/db/migrations/20260414000000_create_tasks.sql @@ -0,0 +1,48 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE `tasks` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `project_slug` VARCHAR(255) NOT NULL, + `number` INT NOT NULL, + `title` VARCHAR(255) NOT NULL, + `description` TEXT, + `priority` ENUM( + 'Trivial', + 'Minor', + 'Major', + 'Critical', + 'Blocker' + ) NOT NULL DEFAULT 'Minor', + `status` ENUM( + 'New', + 'Open', + 'In Progress', + 'Resolved', + 'Closed', + 'Reopened' + ) NOT NULL DEFAULT 'New', + `author_id` BIGINT UNSIGNED NOT NULL, + `assignee_id` BIGINT UNSIGNED, + `due_date` DATE, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_tasks_project_number` (`project_slug`, `number`), + KEY `idx_tasks_author_id` (`author_id`), + KEY `idx_tasks_assignee_id` (`assignee_id`), + KEY `idx_tasks_status_priority` (`status`, `priority`), + KEY `idx_tasks_created_at` (`created_at`), + KEY `idx_tasks_due_date` (`due_date`), + KEY `idx_tasks_deleted_at` (`deleted_at`), + CONSTRAINT `fk_tasks_project` FOREIGN KEY (`project_slug`) REFERENCES `projects`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_tasks_author` FOREIGN KEY (`author_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT, + CONSTRAINT `fk_tasks_assignee` FOREIGN KEY (`assignee_id`) REFERENCES `users`(`id`) ON DELETE + SET NULL +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; +-- +goose StatementEnd +--- +-- +goose Down +-- +goose StatementBegin +DROP TABLE `tasks`; +-- +goose StatementEnd \ No newline at end of file diff --git a/internal/server/docs/docs.go b/internal/server/docs/docs.go index 6586499..6bc4f4e 100644 --- a/internal/server/docs/docs.go +++ b/internal/server/docs/docs.go @@ -465,6 +465,378 @@ const docTemplate = `{ } } }, + "/tasks": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns a paginated list of tasks with optional filtering.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tasks" + ], + "summary": "List all tasks", + "parameters": [ + { + "type": "integer", + "default": 20, + "description": "Page limit (max 100)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Page offset", + "name": "offset", + "in": "query" + }, + { + "type": "string", + "description": "Filter by project slug", + "name": "project", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "Filter by author ID", + "name": "author", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "Filter by assignee ID", + "name": "assignee", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "description": "Filter by status (comma-separated)", + "name": "statuses", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "description": "Filter by priority (comma-separated)", + "name": "priorities", + "in": "query" + }, + { + "type": "string", + "default": "created_at", + "description": "Sort field (e.g., created_at, -priority)", + "name": "sort", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/tasks.TaskListResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates a new task in the specified project.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tasks" + ], + "summary": "Create a new task", + "parameters": [ + { + "description": "Task creation data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tasks.TaskCreateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/tasks.TaskResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + } + } + } + }, + "/tasks/me": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns tasks assigned to or created by the authenticated user.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tasks" + ], + "summary": "Get my tasks", + "parameters": [ + { + "type": "integer", + "default": 20, + "description": "Page limit (max 100)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Page offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/tasks.TaskListResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + } + } + } + }, + "/tasks/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns detailed information about a specific task.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tasks" + ], + "summary": "Get task by ID", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Task ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/tasks.TaskResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Soft-deletes a task (preserves it in the database for audit).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tasks" + ], + "summary": "Delete a task", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Task ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + } + } + }, + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Updates task details.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tasks" + ], + "summary": "Update a task", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Task ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Task update data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tasks.TaskUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/tasks.TaskResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + } + } + } + }, "/users": { "get": { "security": [ @@ -785,6 +1157,163 @@ const docTemplate = `{ } } }, + "tasks.TaskCreateRequest": { + "description": "Task creation request with title, description, priority, assignee, and due date.", + "type": "object", + "required": [ + "project_slug", + "title" + ], + "properties": { + "assignee_id": { + "type": "integer", + "minimum": 1 + }, + "description": { + "type": "string", + "maxLength": 10000 + }, + "due_date": { + "type": "string" + }, + "priority": { + "type": "string", + "enum": [ + "Trivial", + "Minor", + "Major", + "Critical", + "Blocker" + ] + }, + "project_slug": { + "type": "string", + "maxLength": 255 + }, + "title": { + "type": "string", + "maxLength": 255 + } + } + }, + "tasks.TaskListResponse": { + "description": "Paginated list of tasks with total count.", + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/tasks.TaskResponse" + } + }, + "total": { + "type": "integer" + } + } + }, + "tasks.TaskResponse": { + "description": "Full task details with nested author and assignee information.", + "type": "object", + "properties": { + "assignee": { + "$ref": "#/definitions/tasks.UserBrief" + }, + "author": { + "$ref": "#/definitions/tasks.UserBrief" + }, + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "due_date": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "number": { + "type": "integer" + }, + "priority": { + "type": "string" + }, + "project_slug": { + "type": "string" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "tasks.TaskUpdateRequest": { + "description": "Task update request with optional fields for partial updates.", + "type": "object", + "properties": { + "assignee_id": { + "type": "integer", + "minimum": 0 + }, + "description": { + "type": "string", + "maxLength": 10000 + }, + "due_date": { + "type": "string" + }, + "priority": { + "type": "string", + "default": "Minor", + "enum": [ + "Trivial", + "Minor", + "Major", + "Critical", + "Blocker" + ] + }, + "status": { + "type": "string", + "enum": [ + "New", + "Open", + "In Progress", + "Resolved", + "Closed", + "Reopened" + ] + }, + "title": { + "type": "string", + "maxLength": 255 + } + } + }, + "tasks.UserBrief": { + "description": "Minimal user information for task relationships.", + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "role": { + "type": "string" + } + } + }, "users.GetResponse": { "description": "User data returned in admin API responses (password excluded).", "type": "object", diff --git a/internal/server/dto/query.go b/internal/server/dto/query.go new file mode 100644 index 0000000..a32df1c --- /dev/null +++ b/internal/server/dto/query.go @@ -0,0 +1,12 @@ +package dto + +// PaginationQuery represents pagination query parameters. +type PaginationQuery struct { + Limit int `query:"limit" validate:"omitempty,min=1,max=100" default:"20"` + Offset int `query:"offset" validate:"omitempty,min=0" default:"0"` +} + +// SortQuery represents sorting query parameters. +type SortQuery struct { + Sort string `query:"sort" validate:"omitempty"` +} diff --git a/internal/server/module.go b/internal/server/module.go index c1ec12e..6c4ee49 100644 --- a/internal/server/module.go +++ b/internal/server/module.go @@ -5,6 +5,7 @@ import ( "github.com/bit-issues/backend/internal/server/docs" "github.com/bit-issues/backend/internal/server/middlewares/jwtauth" "github.com/bit-issues/backend/internal/server/projects" + "github.com/bit-issues/backend/internal/server/tasks" "github.com/bit-issues/backend/internal/server/users" "github.com/go-core-fx/fiberfx" "github.com/go-core-fx/fiberfx/handler" @@ -39,6 +40,7 @@ func Module() fx.Option { fx.Annotate(users.NewHandler, fx.ResultTags(`group:"handlers"`)), fx.Annotate(auth.NewHandler, fx.ResultTags(`group:"handlers"`)), fx.Annotate(projects.NewHandler, fx.ResultTags(`group:"handlers"`)), + fx.Annotate(tasks.NewHandler, fx.ResultTags(`group:"handlers"`)), fx.Private, ), diff --git a/internal/server/projects/dto.go b/internal/server/projects/dto.go index 8452ac0..44bf625 100644 --- a/internal/server/projects/dto.go +++ b/internal/server/projects/dto.go @@ -57,12 +57,6 @@ func NewProjectListResponse(items []projects.Project, total int64) ProjectListRe } } -// PaginationQuery represents pagination query parameters. -type PaginationQuery struct { - Limit int `query:"limit" validate:"omitempty,min=1,max=100" default:"20"` - Offset int `query:"offset" validate:"omitempty,min=0" default:"0"` -} - // Conversion functions // toProjectInput converts a ProjectRequest DTO to a domain ProjectInput. diff --git a/internal/server/projects/handler.go b/internal/server/projects/handler.go index 79a0c1d..e8f737f 100644 --- a/internal/server/projects/handler.go +++ b/internal/server/projects/handler.go @@ -6,6 +6,7 @@ import ( "github.com/bit-issues/backend/internal/jwt" "github.com/bit-issues/backend/internal/projects" + "github.com/bit-issues/backend/internal/server/dto" "github.com/bit-issues/backend/internal/server/middlewares/jwtauth" "github.com/bit-issues/backend/internal/users" "github.com/go-core-fx/fiberfx/handler" @@ -80,7 +81,7 @@ func (h *Handler) Register(r fiber.Router) { // // list retrieves a paginated list of all projects. func (h *Handler) list(c *fiber.Ctx) error { - query := PaginationQuery{ + query := dto.PaginationQuery{ Limit: 0, Offset: 0, } diff --git a/internal/server/tasks/dto.go b/internal/server/tasks/dto.go new file mode 100644 index 0000000..609964b --- /dev/null +++ b/internal/server/tasks/dto.go @@ -0,0 +1,238 @@ +package tasks + +import ( + "strings" + "time" + + "github.com/bit-issues/backend/internal/server/dto" + "github.com/bit-issues/backend/internal/tasks" + "github.com/samber/lo" +) + +// TaskListQuery represents pagination and sorting query parameters. +type TaskListQuery struct { + dto.PaginationQuery + dto.SortQuery + + Project *string `query:"project" validate:"omitempty,max=255"` + Author *int64 `query:"author" validate:"omitempty,min=1"` + Assignee *int64 `query:"assignee" validate:"omitempty,min=0"` + Statuses *string `query:"statuses"` + Priorities *string `query:"priorities"` +} + +func (q *TaskListQuery) toFilter() tasks.TaskFilter { + statuses := []tasks.Status{} + if q.Statuses != nil { + statuses = lo.Map( + strings.Split(*q.Statuses, ","), + func(s string, _ int) tasks.Status { return tasks.Status(s) }, + ) + } + priorities := []tasks.Priority{} + if q.Priorities != nil { + priorities = lo.Map( + strings.Split(*q.Priorities, ","), + func(s string, _ int) tasks.Priority { return tasks.Priority(s) }, + ) + } + + return tasks.TaskFilter{ + ProjectSlug: q.Project, + AuthorID: q.Author, + AssigneeID: q.Assignee, + Statuses: statuses, + Priorities: priorities, + IncludeDeleted: false, + + DueFrom: nil, + DueTo: nil, + CreatedFrom: nil, + CreatedTo: nil, + + UserID: nil, + } +} + +func (q *TaskListQuery) toPagination() *tasks.Pagination { + return tasks.NewPagination(q.Limit, q.Offset) +} + +// TaskCreateRequest represents the request body for creating a new task. +// +// @Description Task creation request with title, description, priority, assignee, and due date. +type TaskCreateRequest struct { + ProjectSlug string `json:"project_slug" validate:"required,max=255"` + Title string `json:"title" validate:"required,max=255"` + Description string `json:"description,omitempty" validate:"max=10000"` + Priority string `json:"priority,omitempty" validate:"omitempty,oneof=Trivial Minor Major Critical Blocker"` + AssigneeID *int64 `json:"assignee_id,omitempty" validate:"omitempty,min=1"` + DueDate *string `json:"due_date,omitempty" validate:"omitempty,datetime=2006-01-02"` +} + +// TaskUpdateRequest represents the request body for updating a task. +// All fields are optional to support partial updates. +// +// @Description Task update request with optional fields for partial updates. +type TaskUpdateRequest struct { + Title *string `json:"title,omitempty" validate:"omitempty,max=255"` + Description *string `json:"description,omitempty" validate:"omitempty,max=10000"` + Priority *string `json:"priority,omitempty" validate:"omitempty,oneof=Trivial Minor Major Critical Blocker" default:"Minor"` + Status *string `json:"status,omitempty" validate:"omitempty,oneof=New Open 'In Progress' Resolved Closed Reopened"` + AssigneeID *int64 `json:"assignee_id,omitempty" validate:"omitempty,min=0"` + DueDate *string `json:"due_date,omitempty" validate:"omitzero,datetime=2006-01-02"` +} + +// TaskResponse represents the API response for a single task. +// +// @Description Full task details with nested author and assignee information. +type TaskResponse struct { + ID int64 `json:"id"` + ProjectSlug string `json:"project_slug"` + Number int `json:"number"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Priority string `json:"priority"` + Status string `json:"status"` + Author UserBrief `json:"author"` + Assignee *UserBrief `json:"assignee,omitempty"` + DueDate *string `json:"due_date,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// TaskListResponse represents the API response for a list of tasks. +// +// @Description Paginated list of tasks with total count. +type TaskListResponse struct { + Items []TaskResponse `json:"items"` + Total int64 `json:"total"` +} + +// UserBrief represents a minimal user profile for task author/assignee. +// +// @Description Minimal user information for task relationships. +type UserBrief struct { + ID int64 `json:"id"` + Email string `json:"email"` + Role string `json:"role"` + CreatedAt string `json:"created_at"` +} + +// toTaskResponse converts a domain Task to a TaskResponse DTO. +// It requires fetching author and assignee from the users service. +func toTaskResponse(task *tasks.Task) TaskResponse { + var assignee *UserBrief + if task.AssigneeID != nil { + assignee = &UserBrief{ + ID: *task.AssigneeID, + Email: "", // Will be populated by service + Role: "", + CreatedAt: "", + } + } + + return TaskResponse{ + ID: task.ID, + ProjectSlug: task.ProjectSlug, + Number: task.Number, + Title: task.Title, + Description: task.Description, + Priority: string(task.Priority), + Status: string(task.Status), + Author: UserBrief{ + ID: task.AuthorID, + Email: "", + Role: "", + CreatedAt: "", + }, + Assignee: assignee, + DueDate: nil, + CreatedAt: task.CreatedAt.Format(time.RFC3339), + UpdatedAt: task.UpdatedAt.Format(time.RFC3339), + } +} + +// toTaskListResponse converts a list of domain Tasks to TaskListResponse DTO. +func toTaskListResponse(items []tasks.Task, total int64) TaskListResponse { + return TaskListResponse{ + Items: lo.Map( + items, + func(t tasks.Task, _ int) TaskResponse { + var assignee *UserBrief + if t.AssigneeID != nil { + assignee = &UserBrief{ + ID: *t.AssigneeID, + Email: "", // Will be populated by service + Role: "", + CreatedAt: "", + } + } + + return TaskResponse{ + ID: t.ID, + ProjectSlug: t.ProjectSlug, + Number: t.Number, + Title: t.Title, + Description: t.Description, + Priority: string(t.Priority), + Status: string(t.Status), + Author: UserBrief{ + ID: t.AuthorID, + Email: "", // Will be populated by service + Role: "", + CreatedAt: "", + }, + Assignee: assignee, + DueDate: t.DueDate, + CreatedAt: t.CreatedAt.Format(time.RFC3339), + UpdatedAt: t.UpdatedAt.Format(time.RFC3339), + } + }, + ), + Total: total, + } +} + +// toTaskUpdate converts a TaskUpdateRequest DTO to a domain TaskUpdate. +// The domain layer uses pointer-to-pointer types to distinguish between: +// - nil (field not provided/unchanged) +// - *nil (set to zero value/null) +// - *value (set to specific value). +func (req TaskUpdateRequest) toTaskUpdate() tasks.TaskUpdate { + var priority *tasks.Priority + if req.Priority != nil { + p := tasks.Priority(*req.Priority) + priority = &p + } + + var status *tasks.Status + if req.Status != nil { + s := tasks.Status(*req.Status) + status = &s + } + + return tasks.TaskUpdate{ + Title: req.Title, + Description: req.Description, + Priority: priority, + Status: status, + AssigneeID: req.AssigneeID, + DueDate: req.DueDate, + } +} + +// toTaskCreateInput converts a TaskCreateRequest DTO to a domain TaskInput. +func (req TaskCreateRequest) toTaskInput(authorID int64) tasks.TaskInput { + priority := tasks.Priority(req.Priority) + + return tasks.TaskInput{ + ProjectSlug: req.ProjectSlug, + Title: req.Title, + Description: req.Description, + Priority: lo.CoalesceOrEmpty(priority, tasks.PriorityMinor), + AuthorID: authorID, + AssigneeID: req.AssigneeID, + DueDate: req.DueDate, + } +} diff --git a/internal/server/tasks/handler.go b/internal/server/tasks/handler.go new file mode 100644 index 0000000..c6379aa --- /dev/null +++ b/internal/server/tasks/handler.go @@ -0,0 +1,273 @@ +package tasks + +import ( + "errors" + "fmt" + "strconv" + + "github.com/bit-issues/backend/internal/server/middlewares/jwtauth" + "github.com/bit-issues/backend/internal/tasks" + "github.com/bit-issues/backend/internal/users" + "github.com/go-core-fx/fiberfx/handler" + "github.com/go-core-fx/fiberfx/validation" + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" +) + +// Handler handles HTTP requests for task-related endpoints. +type Handler struct { + handler.Base + + tasksSvc *tasks.Service + usersSvc *users.Service +} + +// NewHandler creates a new Handler instance with the given dependencies. +func NewHandler( + tasksSvc *tasks.Service, + usersSvc *users.Service, + validate *validator.Validate, +) handler.Handler { + return &Handler{ + Base: handler.Base{Validator: validate}, + tasksSvc: tasksSvc, + usersSvc: usersSvc, + } +} + +// Register sets up the task routes on the given router. +// Routes are organized with appropriate middleware for authentication +// and authorization based on the operation. +func (h *Handler) Register(r fiber.Router) { + // Public routes (authenticated users only) + tasks := r.Group( + "/tasks", + h.errorsHandler, + ) + + tasks.Get("/", h.list) + tasks.Get("/me", h.myTasks) + tasks.Get("/:id", h.get) + tasks.Post("/", + validation.DecorateWithBodyEx(h.Validator, h.post), + ) + tasks.Patch("/:id", + validation.DecorateWithBodyEx(h.Validator, h.patch), + ) + tasks.Delete("/:id", h.delete) +} + +// @Summary List all tasks +// @Description Returns a paginated list of tasks with optional filtering. +// @Tags Tasks +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param limit query int false "Page limit (max 100)" default(20) +// @Param offset query int false "Page offset" default(0) +// @Param project query string false "Filter by project slug" +// @Param author query int64 false "Filter by author ID" +// @Param assignee query int64 false "Filter by assignee ID" +// @Param statuses query []string false "Filter by status (comma-separated)" +// @Param priorities query []string false "Filter by priority (comma-separated)" +// @Param sort query string false "Sort field (e.g., created_at, -priority)" default(created_at) +// @Success 200 {object} TaskListResponse +// @Failure 401 {object} fiberfx.ErrorResponse +// @Router /tasks [get] +// +// list retrieves a paginated list of tasks with optional filtering. +func (h *Handler) list(c *fiber.Ctx) error { + query := new(TaskListQuery) + if err := h.QueryParserValidator(c, query); err != nil { + return fmt.Errorf("failed to parse query: %w", err) + } + + filter := query.toFilter() + + // Fetch tasks from service + taskList, total, err := h.tasksSvc.List(c.Context(), filter, query.Sort, query.toPagination()) + if err != nil { + return fmt.Errorf("failed to list tasks: %w", err) + } + + return c.JSON(toTaskListResponse(taskList, total)) +} + +// @Summary Get task by ID +// @Description Returns detailed information about a specific task. +// @Tags Tasks +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int64 true "Task ID" +// @Success 200 {object} TaskResponse +// @Failure 400 {object} fiberfx.ErrorResponse +// @Failure 401 {object} fiberfx.ErrorResponse +// @Failure 404 {object} fiberfx.ErrorResponse +// @Router /tasks/{id} [get] +// +// get retrieves a single task by its ID. +func (h *Handler) get(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "invalid task ID") + } + + // Fetch task from service + task, err := h.tasksSvc.GetByID(c.Context(), id) + if err != nil { + return fmt.Errorf("failed to get task: %w", err) + } + + return c.JSON(toTaskResponse(task)) +} + +// @Summary Create a new task +// @Description Creates a new task in the specified project. +// @Tags Tasks +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body TaskCreateRequest true "Task creation data" +// @Success 201 {object} TaskResponse +// @Failure 400 {object} fiberfx.ErrorResponse +// @Failure 401 {object} fiberfx.ErrorResponse +// @Failure 404 {object} fiberfx.ErrorResponse +// @Router /tasks [post] +// +// post creates a new task. +func (h *Handler) post(c *fiber.Ctx, req *TaskCreateRequest) error { + // Get current user from JWT context + user, ok := jwtauth.GetUser(c) + if !ok { + return fiber.NewError(fiber.StatusUnauthorized, "unauthorized") + } + + // Convert DTO to domain input + input := req.toTaskInput(user.ID) + + // Create task via service + task, err := h.tasksSvc.Create(c.Context(), input) + if err != nil { + return fmt.Errorf("failed to create task: %w", err) + } + + return c.Status(fiber.StatusCreated).JSON(toTaskResponse(task)) +} + +// @Summary Update a task +// @Description Updates task details. +// @Tags Tasks +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int64 true "Task ID" +// @Param request body TaskUpdateRequest true "Task update data" +// @Success 200 {object} TaskResponse +// @Failure 400 {object} fiberfx.ErrorResponse +// @Failure 401 {object} fiberfx.ErrorResponse +// @Failure 404 {object} fiberfx.ErrorResponse +// @Router /tasks/{id} [patch] +// +// patch updates an existing task. +func (h *Handler) patch(c *fiber.Ctx, req *TaskUpdateRequest) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "invalid task ID") + } + + // Convert DTO to domain update + update := req.toTaskUpdate() + + // Update task via service + task, err := h.tasksSvc.Update(c.Context(), id, update) + if err != nil { + return fmt.Errorf("failed to update task: %w", err) + } + + return c.JSON(toTaskResponse(task)) +} + +// @Summary Delete a task +// @Description Soft-deletes a task (preserves it in the database for audit). +// @Tags Tasks +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int64 true "Task ID" +// @Success 204 +// @Failure 400 {object} fiberfx.ErrorResponse +// @Failure 401 {object} fiberfx.ErrorResponse +// @Failure 404 {object} fiberfx.ErrorResponse +// @Router /tasks/{id} [delete] +// +// delete soft-deletes a task. +func (h *Handler) delete(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "invalid task ID") + } + + // Delete task via service + if delErr := h.tasksSvc.Delete(c.Context(), id); delErr != nil { + return fmt.Errorf("failed to delete task: %w", delErr) + } + + return c.SendStatus(fiber.StatusNoContent) +} + +// @Summary Get my tasks +// @Description Returns tasks assigned to or created by the authenticated user. +// @Tags Tasks +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param limit query int false "Page limit (max 100)" default(20) +// @Param offset query int false "Page offset" default(0) +// @Success 200 {object} TaskListResponse +// @Failure 401 {object} fiberfx.ErrorResponse +// @Router /tasks/me [get] +// +// myTasks retrieves tasks assigned to or created by the current user. +func (h *Handler) myTasks(c *fiber.Ctx) error { + user, ok := jwtauth.GetUser(c) + if !ok { + return fiber.NewError(fiber.StatusUnauthorized, "unauthorized") + } + + query := new(TaskListQuery) + if err := h.QueryParserValidator(c, query); err != nil { + return fmt.Errorf("failed to parse query: %w", err) + } + + filter := query.toFilter() + filter.UserID = &user.ID + + // Fetch tasks from service + taskList, total, err := h.tasksSvc.List(c.Context(), filter, query.Sort, query.toPagination()) + if err != nil { + return fmt.Errorf("failed to list tasks: %w", err) + } + + return c.JSON(toTaskListResponse(taskList, total)) +} + +// errorsHandler is a middleware that converts service errors to appropriate HTTP responses. +// It should be registered as the first middleware in the route group. +func (h *Handler) errorsHandler(c *fiber.Ctx) error { + err := c.Next() + if err == nil { + return nil + } + + switch { + case errors.Is(err, tasks.ErrValidationFailed): + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + case errors.Is(err, tasks.ErrNotFound): + return fiber.NewError(fiber.StatusNotFound, err.Error()) + case errors.Is(err, tasks.ErrProjectNotFound): + return fiber.NewError(fiber.StatusNotFound, err.Error()) + default: + return err //nolint:wrapcheck // err is already wrapped + } +} diff --git a/internal/tasks/config.go b/internal/tasks/config.go new file mode 100644 index 0000000..1717a95 --- /dev/null +++ b/internal/tasks/config.go @@ -0,0 +1,9 @@ +package tasks + +// Config holds configuration for the tasks module. +// +// Currently, this module does not require any specific configuration. +// The struct is provided for consistency with other modules and future +// extensibility (e.g., default limits, feature flags). +type Config struct { +} diff --git a/internal/tasks/doc.go b/internal/tasks/doc.go new file mode 100644 index 0000000..f3a8e7a --- /dev/null +++ b/internal/tasks/doc.go @@ -0,0 +1,32 @@ +// Package tasks provides task management functionality for the corporate issue tracker. +// +// This module implements the core task lifecycle management including: +// - Task creation with per-project auto-incrementing numbers +// - Status and priority management (matching BitBucket enums) +// - Assignment and due date tracking +// - Soft delete support for audit trails +// - Filtering and sorting for dashboards +// +// The module follows clean architecture principles with clear separation between: +// - Domain layer (domain.go): Business entities and validation +// - Data layer (models.go, repository.go): Persistence and queries +// - Service layer (service.go): Business logic and coordination +// +// Integration: +// - Depends on projects.Service for project existence validation +// - Uses bun.DB for MySQL persistence +// - Provides tasks.Service for HTTP handlers +// +// Example usage: +// +// // In your FX application setup: +// app := fx.New( +// tasks.Module(), +// fx.Invoke(func(service *tasks.Service) { +// // Use the service +// }), +// ) +// +// The module is production-ready and follows the established patterns from +// the internal/example, internal/users, and internal/projects modules. +package tasks diff --git a/internal/tasks/domain.go b/internal/tasks/domain.go new file mode 100644 index 0000000..126e6ea --- /dev/null +++ b/internal/tasks/domain.go @@ -0,0 +1,286 @@ +package tasks + +import ( + "fmt" + "strings" + "time" + + "github.com/uptrace/bun" +) + +const MaxTitleLength = 255 + +// Priority represents task priority levels, matching BitBucket values. +type Priority string + +// Priority constants. +const ( + PriorityTrivial Priority = "Trivial" + PriorityMinor Priority = "Minor" + PriorityMajor Priority = "Major" + PriorityCritical Priority = "Critical" + PriorityBlocker Priority = "Blocker" +) + +// IsValid checks if the priority value is one of the allowed constants. +func (p Priority) IsValid() bool { + switch p { + case PriorityTrivial, PriorityMinor, PriorityMajor, PriorityCritical, PriorityBlocker: + return true + default: + return false + } +} + +// Status represents task lifecycle states, matching BitBucket values. +type Status string + +// Status constants. +const ( + StatusNew Status = "New" + StatusOpen Status = "Open" + StatusInProgress Status = "In Progress" + StatusResolved Status = "Resolved" + StatusClosed Status = "Closed" + StatusReopened Status = "Reopened" +) + +// IsValid checks if the status value is one of the allowed constants. +func (s Status) IsValid() bool { + switch s { + case StatusNew, StatusOpen, StatusInProgress, StatusResolved, StatusClosed, StatusReopened: + return true + default: + return false + } +} + +// Task represents a complete task entity with all fields. +type Task struct { + ID int64 + ProjectSlug string + Number int + Title string + Description string + Priority Priority + Status Status + AuthorID int64 + AssigneeID *int64 + DueDate *string + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time +} + +// TaskInput contains the data required to create a new task. +type TaskInput struct { + ProjectSlug string + Title string + Description string + Priority Priority + AuthorID int64 + AssigneeID *int64 + DueDate *string // YYYY-MM-DD format +} + +// Validate checks that the input data is valid for task creation. +func (i TaskInput) Validate() error { + // Validate project slug + projectSlug := strings.TrimSpace(i.ProjectSlug) + if projectSlug == "" { + return fmt.Errorf("%w: project slug is required", ErrValidationFailed) + } + + // Validate title + title := strings.TrimSpace(i.Title) + if title == "" { + return fmt.Errorf("%w: title is required", ErrValidationFailed) + } + + if len(title) > MaxTitleLength { + return fmt.Errorf("%w: title too long (max 255 characters)", ErrValidationFailed) + } + + // Validate priority + if !i.Priority.IsValid() { + return fmt.Errorf("%w: invalid priority value", ErrValidationFailed) + } + + // AuthorID must be positive + if i.AuthorID <= 0 { + return fmt.Errorf("%w: author_id must be positive", ErrValidationFailed) + } + + // If assignee is provided, it must be positive + if i.AssigneeID != nil && *i.AssigneeID <= 0 { + return fmt.Errorf("%w: assignee_id must be positive", ErrValidationFailed) + } + + if i.DueDate != nil { + if _, err := time.Parse(time.DateOnly, *i.DueDate); err != nil { + return fmt.Errorf("%w: invalid due_date format", ErrValidationFailed) + } + } + + return nil +} + +// TaskUpdate represents the data that can be updated for a task. +// All fields are optional. Pointers are used to distinguish between +// "set to zero value" and "not provided". +type TaskUpdate struct { + Title *string + Description *string + Priority *Priority + Status *Status + AssigneeID *int64 // nil=unchanged, 0=set to NULL, value=set to ID + DueDate *string // nil=unchanged, ""=set to NULL, value=set date string +} + +// IsEmpty returns true if no update fields are set. +func (u TaskUpdate) IsEmpty() bool { + return u.Title == nil && + u.Description == nil && + u.Priority == nil && + u.Status == nil && + u.AssigneeID == nil && + u.DueDate == nil +} + +func (u TaskUpdate) Validate() error { + if u.Title != nil { + // Trim and validate title + title := strings.TrimSpace(*u.Title) + if title == "" { + return fmt.Errorf("%w: title is required", ErrValidationFailed) + } + + if len(title) > MaxTitleLength { + return fmt.Errorf("%w: title too long (max 255 characters)", ErrValidationFailed) + } + } + + if u.Priority != nil && !u.Priority.IsValid() { + return fmt.Errorf("%w: invalid priority value", ErrValidationFailed) + } + + if u.Status != nil && !u.Status.IsValid() { + return fmt.Errorf("%w: invalid status value", ErrValidationFailed) + } + + if u.AssigneeID != nil && *u.AssigneeID < 0 { + return fmt.Errorf("%w: assignee_id must be non-negative", ErrValidationFailed) + } + + if u.DueDate != nil && *u.DueDate != "" { + if _, err := time.Parse(time.DateOnly, *u.DueDate); err != nil { + return fmt.Errorf("%w: invalid due_date format", ErrValidationFailed) + } + } + + return nil +} + +type Pagination struct { + limit int + offset int +} + +func NewPagination(limit, offset int) *Pagination { + return &Pagination{ + limit: limit, + offset: offset, + } +} + +func (p *Pagination) Limit() int { + if p == nil { + return DefaultLimit + } + + if p.limit <= 0 { + p.limit = DefaultLimit // default + } + if p.limit > MaxLimit { + p.limit = MaxLimit // max + } + + return p.limit +} + +func (p *Pagination) Offset() int { + if p == nil { + return 0 + } + + if p.offset < 0 { + p.offset = 0 + } + return p.offset +} + +func (p *Pagination) apply(query *bun.SelectQuery) *bun.SelectQuery { + return query.Limit(p.Limit()).Offset(p.Offset()) +} + +// TaskFilter contains filtering criteria for querying tasks. +type TaskFilter struct { + ProjectSlug *string + AuthorID *int64 + AssigneeID *int64 + Statuses []Status + Priorities []Priority + DueFrom *time.Time + DueTo *time.Time + CreatedFrom *time.Time + CreatedTo *time.Time + IncludeDeleted bool + + // Extended + UserID *int64 // AuthorID or AssigneeID +} + +func (f TaskFilter) apply(query *bun.SelectQuery) *bun.SelectQuery { + if f.ProjectSlug != nil && *f.ProjectSlug != "" { + query = query.Where("project_slug = ?", *f.ProjectSlug) + } + if f.AuthorID != nil && *f.AuthorID > 0 { + query = query.Where("author_id = ?", *f.AuthorID) + } + if f.AssigneeID != nil { + if *f.AssigneeID == 0 { + query = query.Where("assignee_id IS NULL") + } else { + query = query.Where("assignee_id = ?", *f.AssigneeID) + } + } + if len(f.Statuses) > 0 { + query = query.Where("status IN (?)", bun.List(f.Statuses)) + } + if len(f.Priorities) > 0 { + query = query.Where("priority IN (?)", bun.List(f.Priorities)) + } + if f.DueFrom != nil { + query = query.Where("due_date >= ?", *f.DueFrom) + } + if f.DueTo != nil { + query = query.Where("due_date <= ?", *f.DueTo) + } + if f.CreatedFrom != nil { + query = query.Where("created_at >= ?", *f.CreatedFrom) + } + if f.CreatedTo != nil { + query = query.Where("created_at <= ?", *f.CreatedTo) + } + if f.IncludeDeleted { + query = query.WhereAllWithDeleted() + } + + if f.UserID != nil { + query = query.WhereGroup("OR", func(sq *bun.SelectQuery) *bun.SelectQuery { + return sq.WhereOr("author_id = ?", *f.UserID).WhereOr("assignee_id = ?", *f.UserID) + }) + } + + return query +} diff --git a/internal/tasks/errors.go b/internal/tasks/errors.go new file mode 100644 index 0000000..b8c05a5 --- /dev/null +++ b/internal/tasks/errors.go @@ -0,0 +1,16 @@ +package tasks + +import "errors" + +// Module-specific error definitions. +// These errors can be checked using [errors.Is](err, ErrXXX). +var ( + // ErrNotFound indicates the requested task does not exist. + ErrNotFound = errors.New("task not found") + + // ErrValidationFailed indicates input validation failed. + ErrValidationFailed = errors.New("validation failed") + + // ErrProjectNotFound indicates the specified project does not exist. + ErrProjectNotFound = errors.New("project not found") +) diff --git a/internal/tasks/models.go b/internal/tasks/models.go new file mode 100644 index 0000000..2a809d5 --- /dev/null +++ b/internal/tasks/models.go @@ -0,0 +1,89 @@ +package tasks + +import ( + "time" + + "github.com/go-core-fx/bunfx" + "github.com/uptrace/bun" + "github.com/uptrace/bun/schema" +) + +// taskModel is the database representation using Bun ORM. +// This struct maps to the `tasks` table and is used for all database operations. +type taskModel struct { + bun.BaseModel `bun:"table:tasks,alias:t"` + bunfx.TimedModel + + ID int64 `bun:"id,pk,autoincrement"` + ProjectSlug string `bun:"project_slug,notnull"` + Number int `bun:"number,notnull"` + Title string `bun:"title,notnull"` + Description string `bun:"description,type:text,nullzero"` + Priority Priority `bun:"priority,notnull,default:'Minor'"` + Status Status `bun:"status,notnull,default:'New'"` + AuthorID int64 `bun:"author_id,notnull"` + AssigneeID *int64 `bun:"assignee_id,nullzero"` + DueDate *time.Time `bun:"due_date,nullzero"` + DeletedAt *time.Time `bun:"deleted_at,soft_delete,nullzero"` +} + +// newTaskModel creates a new taskModel from TaskInput and the generated task number. +func newTaskModel(input TaskInput, number int) *taskModel { + now := time.Now() + return &taskModel{ + BaseModel: schema.BaseModel{}, + TimedModel: bunfx.TimedModel{CreatedAt: now, UpdatedAt: now}, + ID: 0, // Auto-generated by database + ProjectSlug: input.ProjectSlug, + Number: number, + Title: input.Title, + Description: input.Description, + Priority: input.Priority, + Status: StatusNew, // Always start as "New" + AuthorID: input.AuthorID, + AssigneeID: input.AssigneeID, + // Convert *string to *time.Time + DueDate: func() *time.Time { + if input.DueDate != nil { + t, err := time.Parse(time.DateOnly, *input.DueDate) + if err != nil { + // Invalid date format, return nil + return nil + } + return &t + } + return nil + }(), + DeletedAt: nil, + } +} + +// toDomain converts the database model to a domain Task entity. +// Returns nil if the model is nil. +func (m *taskModel) toDomain() *Task { + if m == nil { + return nil + } + return &Task{ + ID: m.ID, + ProjectSlug: m.ProjectSlug, + Number: m.Number, + Title: m.Title, + Description: m.Description, + Priority: m.Priority, + Status: m.Status, + AuthorID: m.AuthorID, + AssigneeID: m.AssigneeID, + // Convert *time.Time to *string (YYYY-MM-DD format) + DueDate: func() *string { + if m.DueDate != nil { + t := m.DueDate.Format(time.DateOnly) + return &t + } + return nil + }(), + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + DeletedAt: m.DeletedAt, + } +} diff --git a/internal/tasks/module.go b/internal/tasks/module.go new file mode 100644 index 0000000..e5829b1 --- /dev/null +++ b/internal/tasks/module.go @@ -0,0 +1,21 @@ +package tasks + +import ( + "github.com/go-core-fx/logger" + "go.uber.org/fx" +) + +// Module creates and returns an FX module for the tasks package. +// +// The module provides: +// - tasks.Service (public) - for use by HTTP handlers and other modules +// - tasks.Repository (private) - internal data access layer +// - Named logger "tasks" for structured logging +func Module() fx.Option { + return fx.Module( + "tasks", + logger.WithNamedLogger("tasks"), + fx.Provide(NewRepository, fx.Private), + fx.Provide(NewService), + ) +} diff --git a/internal/tasks/repository.go b/internal/tasks/repository.go new file mode 100644 index 0000000..4d19bcc --- /dev/null +++ b/internal/tasks/repository.go @@ -0,0 +1,226 @@ +package tasks + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/bit-issues/backend/internal/db" + "github.com/uptrace/bun" +) + +// Repository handles data access operations for tasks. +type Repository struct { + db *bun.DB +} + +// NewRepository creates a new Repository instance with the given database connection. +func NewRepository(db *bun.DB) *Repository { + return &Repository{db: db} +} + +// Create inserts a new task with an auto-generated per-project number. +// The number generation happens within a transaction to ensure uniqueness. +func (r *Repository) Create(ctx context.Context, input TaskInput) (*Task, error) { + var task *Task + + err := r.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + // Get the next task number for this project + var maxNumber int + err := tx.NewSelect(). + Model((*taskModel)(nil)). + Column("number"). + Where("project_slug = ?", input.ProjectSlug). + WhereAllWithDeleted(). + OrderExpr("number DESC"). + Limit(1). + Scan(ctx, &maxNumber) + + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("failed to get max task number: %w", err) + } + + nextNumber := maxNumber + 1 + model := newTaskModel(input, nextNumber) + + if _, insErr := tx.NewInsert().Model(model).Exec(ctx); insErr != nil { + if db.IsUniqueViolation(insErr) { + return fmt.Errorf( + "%w: task number %d already exists for project %s", + ErrValidationFailed, + nextNumber, + input.ProjectSlug, + ) + } + return fmt.Errorf("failed to insert task: %w", insErr) + } + + task = model.toDomain() + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to create task: %w", err) + } + + return task, nil +} + +// GetByID retrieves a task by its global database ID. +func (r *Repository) GetByID(ctx context.Context, id int64) (*Task, error) { + var model taskModel + if err := r.db.NewSelect().Model(&model).Where("id = ?", id).Where("deleted_at IS NULL").Scan(ctx); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("failed to get task by ID: %w", err) + } + return model.toDomain(), nil +} + +// List retrieves a paginated list of tasks with optional filtering and sorting. +// The filter parameter controls which tasks are returned. Empty fields mean no filter. +// The sort parameter is a field name, optionally prefixed with "-" for descending order. +func (r *Repository) List( + ctx context.Context, + filter TaskFilter, + sort string, + pagination *Pagination, +) ([]Task, error) { + models := make([]taskModel, 0) + query := r.db.NewSelect().Model(&models) + + // Apply filters + query = filter.apply(query) + + // Apply sorting + switch sort { + case "", "-created_at", "created_at": + if sort == "-created_at" || sort == "" { + query = query.OrderBy("created_at", bun.OrderDesc) + } else { + query = query.OrderBy("created_at", bun.OrderAsc) + } + case "-priority", "priority": + if sort == "-priority" { + query = query.OrderBy("priority", bun.OrderDesc) + } else { + query = query.OrderBy("priority", bun.OrderAsc) + } + case "-due_date", "due_date": + if sort == "-due_date" { + query = query.OrderBy("due_date", bun.OrderDesc) + } else { + query = query.OrderBy("due_date", bun.OrderAsc) + } + case "-status", "status": + if sort == "-status" { + query = query.OrderBy("status", bun.OrderDesc) + } else { + query = query.OrderBy("status", bun.OrderAsc) + } + } + + // Apply pagination + query = pagination.apply(query) + + if err := query.Scan(ctx, &models); err != nil { + return nil, fmt.Errorf("failed to list tasks: %w", err) + } + + tasks := make([]Task, 0, len(models)) + for _, model := range models { + tasks = append(tasks, *model.toDomain()) + } + + return tasks, nil +} + +// Count returns the total number of tasks matching the given filter. +func (r *Repository) Count(ctx context.Context, filter TaskFilter) (int64, error) { + query := r.db.NewSelect().Model((*taskModel)(nil)) + + // Apply the same filters as List + query = filter.apply(query) + + count, err := query.Count(ctx) + if err != nil { + return 0, fmt.Errorf("failed to count tasks: %w", err) + } + return int64(count), nil +} + +// Update modifies an existing task with the provided update data. +// Only non-nil fields in the TaskUpdate struct will be changed. +func (r *Repository) Update(ctx context.Context, id int64, update TaskUpdate) error { + if update.IsEmpty() { + return nil + } + + query := r.db.NewUpdate().Model((*taskModel)(nil)).Where("id = ?", id) + + // Build the SET clause dynamically + if update.Title != nil { + query = query.Set("title = ?", *update.Title) + } + if update.Description != nil { + query = query.Set("description = ?", *update.Description) + } + if update.Priority != nil { + query = query.Set("priority = ?", *update.Priority) + } + if update.Status != nil { + query = query.Set("status = ?", *update.Status) + } + if update.AssigneeID != nil { + if *update.AssigneeID == 0 { + query = query.Set("assignee_id = NULL") + } else { + query = query.Set("assignee_id = ?", *update.AssigneeID) + } + } + if update.DueDate != nil { + if *update.DueDate == "" { + query = query.Set("due_date = NULL") + } else { + query = query.Set("due_date = ?", *update.DueDate) + } + } + + result, err := query.Exec(ctx) + if err != nil { + return fmt.Errorf("failed to update task: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get affected rows: %w", err) + } + if rows == 0 { + return ErrNotFound + } + + return nil +} + +// Delete soft-deletes a task by setting its deleted_at timestamp. +func (r *Repository) Delete(ctx context.Context, id int64) error { + result, err := r.db.NewUpdate().Model((*taskModel)(nil)). + Set("deleted_at = NOW()"). + Where("id = ?", id). + Exec(ctx) + if err != nil { + return fmt.Errorf("failed to delete task: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get affected rows: %w", err) + } + if rows == 0 { + return ErrNotFound + } + + return nil +} diff --git a/internal/tasks/service.go b/internal/tasks/service.go new file mode 100644 index 0000000..b4558ba --- /dev/null +++ b/internal/tasks/service.go @@ -0,0 +1,115 @@ +package tasks + +import ( + "context" + "fmt" + + "github.com/bit-issues/backend/internal/projects" + "go.uber.org/zap" + "golang.org/x/sync/errgroup" +) + +const ( + DefaultLimit = 20 + MaxLimit = 100 +) + +// Service implements the business logic for task management. +type Service struct { + tasks *Repository + projects *projects.Service + logger *zap.Logger +} + +// NewService creates a new Service instance with the given dependencies. +func NewService(repo *Repository, projects *projects.Service, logger *zap.Logger) *Service { + return &Service{ + tasks: repo, + projects: projects, + logger: logger, + } +} + +// Create validates input and creates a new task with auto-generated number. +func (s *Service) Create(ctx context.Context, input TaskInput) (*Task, error) { + // Validate input + if err := input.Validate(); err != nil { + return nil, err + } + + // Verify project exists + exists, err := s.projects.Exists(ctx, input.ProjectSlug) + if err != nil { + return nil, fmt.Errorf("failed to verify project: %w", err) + } + if !exists { + return nil, ErrProjectNotFound + } + + // Create task (repository handles number generation in transaction) + return s.tasks.Create(ctx, input) +} + +// GetByID retrieves a task by its global ID. +func (s *Service) GetByID(ctx context.Context, id int64) (*Task, error) { + return s.tasks.GetByID(ctx, id) +} + +// List retrieves tasks with filtering, sorting, and pagination. +// Returns the list of tasks and the total count matching the filter. +func (s *Service) List( + ctx context.Context, + filter TaskFilter, + sort string, + pagination *Pagination, +) ([]Task, int64, error) { + var tasks []Task + var total int64 + + g, ctx := errgroup.WithContext(ctx) + g.Go(func() error { + var err error + tasks, err = s.tasks.List(ctx, filter, sort, pagination) + return err + }) + g.Go(func() error { + var err error + total, err = s.tasks.Count(ctx, filter) + return err + }) + + if err := g.Wait(); err != nil { + return nil, 0, fmt.Errorf("failed to list tasks: %w", err) + } + + return tasks, total, nil +} + +// func (s *Service) ListByUser(ctx context.Context, userID int64, sort string, limit, offset int) ([]Task, int64, error) { + +// } + +// Update modifies an existing task with the provided data. +// Returns the updated task or an error if not found or validation fails. +func (s *Service) Update(ctx context.Context, id int64, update TaskUpdate) (*Task, error) { + if update.IsEmpty() { + return nil, fmt.Errorf("%w: no update data provided", ErrValidationFailed) + } + + if err := update.Validate(); err != nil { + return nil, err + } + + // Perform update + if err := s.tasks.Update(ctx, id, update); err != nil { + return nil, err + } + + // Return the updated task + return s.tasks.GetByID(ctx, id) +} + +// Delete soft-deletes a task. +func (s *Service) Delete(ctx context.Context, id int64) error { + return s.tasks.Delete(ctx, id) +} diff --git a/requests.http b/requests.http index 43d2c51..2241af9 100644 --- a/requests.http +++ b/requests.http @@ -169,3 +169,100 @@ Authorization: Bearer {{adminAccessToken}} # WARNING: This permanently deletes the project and all associated tasks, comments, and attachments DELETE {{baseURL}}/projects/my-new-project Authorization: Bearer {{adminAccessToken}} + +### +# Tasks API + +### +# List all tasks (authenticated users) +# Optional query parameters: project, author, assignee, statuses, priorities, sort, limit, offset +GET {{baseURL}}/tasks?limit=20&offset=0 +Authorization: Bearer {{accessToken}} + +### +# List tasks with filters +# Filter by project, status, priority, author, assignee, and date ranges +GET {{baseURL}}/tasks?project=my-new-project&statuses=New,Open,In%20Progress&priorities=Major,Critical&sort=-priority +Authorization: Bearer {{accessToken}} + +### +# Get my tasks (tasks assigned to or created by current user) +# Returns tasks sorted by created_at descending +GET {{baseURL}}/tasks/me +Authorization: Bearer {{accessToken}} + +### +# Get task by ID +GET {{baseURL}}/tasks/{{taskId}} +Authorization: Bearer {{accessToken}} + +### +# Create a new task +# POST to /tasks +# @name createTask +@taskId={{createTask.response.body.$.id}} +POST {{baseURL}}/tasks +Content-Type: application/json +Authorization: Bearer {{accessToken}} + +{ + "project_slug": "my-new-project", + "title": "Implement user authentication", + "description": "# Overview\nAdd JWT-based authentication to the API.\n\n## Requirements\n- Login endpoint\n- Token validation middleware", + "priority": "Major", + "due_date": "2026-04-20", + "assignee_id": {{userID}} +} + +### +# Create a task with minimal data +# @name createTask +@taskId={{createTask.response.body.$.id}} +POST {{baseURL}}/tasks +Content-Type: application/json +Authorization: Bearer {{accessToken}} + +{ + "project_slug": "my-new-project", + "title": "Fix bug in task numbering" +} + +### +# Update a task +PATCH {{baseURL}}/tasks/{{taskId}} +Content-Type: application/json +Authorization: Bearer {{accessToken}} + +{ + "title": "Implement user authentication (Updated)", + "description": "# Overview\nAdd JWT-based authentication to the API.\n\n## Requirements\n- Login endpoint\n- Token validation middleware\n- Logout endpoint", + "priority": "Critical", + "status": "In Progress", + "assignee_id": 1, + "due_date": "2026-04-18" +} + +### +# Update only the status of a task +PATCH {{baseURL}}/tasks/{{taskId}} +Content-Type: application/json +Authorization: Bearer {{accessToken}} + +{ + "status": "Resolved" +} + +### +# Update only the due date (set to NULL to clear) +PATCH {{baseURL}}/tasks/{{taskId}} +Content-Type: application/json +Authorization: Bearer {{accessToken}} + +{ + "due_date": "" +} + +### +# Delete a task (soft delete) +DELETE {{baseURL}}/tasks/{{taskId}} +Authorization: Bearer {{accessToken}}