- Config: try ENV_FILE, .env, ../.env for loading; trim trailing slash from BaseURL - Log BASE_URL at server startup for verification - .env.example: document BASE_URL - Tasks, projects, tags, migrations and related API/handlers Made-with: Cursor
437 lines
11 KiB
Go
437 lines
11 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/calendarapi/internal/models"
|
|
"github.com/calendarapi/internal/repository"
|
|
"github.com/calendarapi/internal/utils"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
type TaskService struct {
|
|
queries *repository.Queries
|
|
audit *AuditService
|
|
webhook *TaskWebhookService
|
|
}
|
|
|
|
func NewTaskService(queries *repository.Queries, audit *AuditService, webhook *TaskWebhookService) *TaskService {
|
|
return &TaskService{queries: queries, audit: audit, webhook: webhook}
|
|
}
|
|
|
|
type CreateTaskRequest struct {
|
|
Title string
|
|
Description *string
|
|
Status *string
|
|
Priority *string
|
|
DueDate *time.Time
|
|
ProjectID *uuid.UUID
|
|
ParentID *uuid.UUID
|
|
SortOrder *int
|
|
RecurrenceRule *string
|
|
}
|
|
|
|
type UpdateTaskRequest struct {
|
|
Title *string
|
|
Description *string
|
|
Status *string
|
|
Priority *string
|
|
DueDate *time.Time
|
|
ProjectID *uuid.UUID
|
|
SortOrder *int
|
|
RecurrenceRule *string
|
|
}
|
|
|
|
type ListTasksParams struct {
|
|
Status *string
|
|
Priority *string
|
|
DueFrom *time.Time
|
|
DueTo *time.Time
|
|
ProjectID *uuid.UUID
|
|
TagIDs []uuid.UUID
|
|
Sort string // created_at, due_date, priority
|
|
Order string // asc, desc
|
|
Limit int
|
|
Cursor string
|
|
}
|
|
|
|
func (s *TaskService) Create(ctx context.Context, userID uuid.UUID, req CreateTaskRequest) (*models.Task, error) {
|
|
if err := utils.ValidateTaskTitle(req.Title); err != nil {
|
|
return nil, err
|
|
}
|
|
status := "todo"
|
|
if req.Status != nil {
|
|
if err := utils.ValidateTaskStatus(*req.Status); err != nil {
|
|
return nil, err
|
|
}
|
|
status = *req.Status
|
|
}
|
|
priority := "medium"
|
|
if req.Priority != nil {
|
|
if err := utils.ValidateTaskPriority(*req.Priority); err != nil {
|
|
return nil, err
|
|
}
|
|
priority = *req.Priority
|
|
}
|
|
sortOrder := int32(0)
|
|
if req.SortOrder != nil {
|
|
sortOrder = int32(*req.SortOrder)
|
|
}
|
|
|
|
id := uuid.New()
|
|
t, err := s.queries.CreateTask(ctx, repository.CreateTaskParams{
|
|
ID: utils.ToPgUUID(id),
|
|
OwnerID: utils.ToPgUUID(userID),
|
|
Title: req.Title,
|
|
Description: utils.ToPgTextPtr(req.Description),
|
|
Status: status,
|
|
Priority: priority,
|
|
DueDate: utils.ToPgTimestamptzPtr(req.DueDate),
|
|
ProjectID: utils.ToPgUUIDPtr(req.ProjectID),
|
|
ParentID: utils.ToPgUUIDPtr(req.ParentID),
|
|
SortOrder: sortOrder,
|
|
RecurrenceRule: utils.ToPgTextPtr(req.RecurrenceRule),
|
|
})
|
|
if err != nil {
|
|
return nil, models.ErrInternal
|
|
}
|
|
|
|
s.audit.Log(ctx, "task", id, "CREATE_TASK", userID)
|
|
task := taskFromDB(t)
|
|
|
|
if s.webhook != nil {
|
|
go s.webhook.Deliver(ctx, userID, "created", task)
|
|
}
|
|
|
|
return task, nil
|
|
}
|
|
|
|
func (s *TaskService) Get(ctx context.Context, userID uuid.UUID, taskID uuid.UUID) (*models.Task, error) {
|
|
t, err := s.queries.GetTaskByID(ctx, repository.GetTaskByIDParams{
|
|
ID: utils.ToPgUUID(taskID),
|
|
OwnerID: utils.ToPgUUID(userID),
|
|
})
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return nil, models.ErrNotFound
|
|
}
|
|
return nil, models.ErrInternal
|
|
}
|
|
task := taskFromDB(t)
|
|
|
|
tags, _ := s.queries.ListTagsByTask(ctx, utils.ToPgUUID(taskID))
|
|
task.Tags = make([]models.Tag, 0, len(tags))
|
|
for _, tag := range tags {
|
|
task.Tags = append(task.Tags, tagFromDB(tag))
|
|
}
|
|
|
|
if count, err := s.queries.CountSubtasksByStatus(ctx, utils.ToPgUUID(taskID)); err == nil && count.TotalCount > 0 {
|
|
pct := int(100 * count.DoneCount / count.TotalCount)
|
|
task.CompletionPercentage = &pct
|
|
}
|
|
|
|
return task, nil
|
|
}
|
|
|
|
func (s *TaskService) List(ctx context.Context, userID uuid.UUID, params ListTasksParams) ([]models.Task, *string, error) {
|
|
lim := utils.ClampLimit(params.Limit)
|
|
if lim <= 0 {
|
|
lim = 50
|
|
}
|
|
|
|
var status, priority pgtype.Text
|
|
if params.Status != nil && *params.Status != "" {
|
|
status = utils.ToPgText(*params.Status)
|
|
}
|
|
if params.Priority != nil && *params.Priority != "" {
|
|
priority = utils.ToPgText(*params.Priority)
|
|
}
|
|
projectID := utils.ToPgUUIDPtr(params.ProjectID)
|
|
dueFrom := utils.ToPgTimestamptzPtr(params.DueFrom)
|
|
dueTo := utils.ToPgTimestamptzPtr(params.DueTo)
|
|
|
|
var cursorTime *time.Time
|
|
var cursorID *uuid.UUID
|
|
if params.Cursor != "" {
|
|
ct, cid, err := utils.ParseCursor(params.Cursor)
|
|
if err != nil {
|
|
return nil, nil, models.NewValidationError("invalid cursor")
|
|
}
|
|
cursorTime = ct
|
|
cursorID = cid
|
|
}
|
|
|
|
listParams := repository.ListTasksParams{
|
|
OwnerID: utils.ToPgUUID(userID),
|
|
Status: status,
|
|
Priority: priority,
|
|
ProjectID: projectID,
|
|
DueFrom: dueFrom,
|
|
DueTo: dueTo,
|
|
Lim: lim + 1,
|
|
}
|
|
if cursorTime != nil && cursorID != nil {
|
|
listParams.CursorTime = utils.ToPgTimestamptz(*cursorTime)
|
|
listParams.CursorID = utils.ToPgUUID(*cursorID)
|
|
}
|
|
|
|
rows, err := s.queries.ListTasks(ctx, listParams)
|
|
if err != nil {
|
|
return nil, nil, models.ErrInternal
|
|
}
|
|
|
|
tasks := make([]models.Task, 0, len(rows))
|
|
for _, r := range rows {
|
|
tasks = append(tasks, *taskFromDB(r))
|
|
}
|
|
|
|
var nextCursor *string
|
|
if len(tasks) > int(lim) {
|
|
tasks = tasks[:lim]
|
|
last := tasks[len(tasks)-1]
|
|
c := utils.EncodeCursor(last.CreatedAt, last.ID)
|
|
nextCursor = &c
|
|
}
|
|
|
|
return tasks, nextCursor, nil
|
|
}
|
|
|
|
func (s *TaskService) Update(ctx context.Context, userID uuid.UUID, taskID uuid.UUID, req UpdateTaskRequest) (*models.Task, error) {
|
|
if req.Title != nil {
|
|
if err := utils.ValidateTaskTitle(*req.Title); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if req.Status != nil {
|
|
if err := utils.ValidateTaskStatus(*req.Status); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if req.Priority != nil {
|
|
if err := utils.ValidateTaskPriority(*req.Priority); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
prev, err := s.queries.GetTaskByID(ctx, repository.GetTaskByIDParams{
|
|
ID: utils.ToPgUUID(taskID),
|
|
OwnerID: utils.ToPgUUID(userID),
|
|
})
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return nil, models.ErrNotFound
|
|
}
|
|
return nil, models.ErrInternal
|
|
}
|
|
|
|
updateParams := repository.UpdateTaskParams{
|
|
ID: utils.ToPgUUID(taskID),
|
|
OwnerID: utils.ToPgUUID(userID),
|
|
}
|
|
if req.Title != nil {
|
|
updateParams.Title = utils.ToPgTextPtr(req.Title)
|
|
}
|
|
if req.Description != nil {
|
|
updateParams.Description = utils.ToPgTextPtr(req.Description)
|
|
}
|
|
if req.Status != nil {
|
|
updateParams.Status = utils.ToPgTextPtr(req.Status)
|
|
}
|
|
if req.Priority != nil {
|
|
updateParams.Priority = utils.ToPgTextPtr(req.Priority)
|
|
}
|
|
if req.DueDate != nil {
|
|
updateParams.DueDate = utils.ToPgTimestamptzPtr(req.DueDate)
|
|
}
|
|
if req.ProjectID != nil {
|
|
updateParams.ProjectID = utils.ToPgUUIDPtr(req.ProjectID)
|
|
}
|
|
if req.SortOrder != nil {
|
|
updateParams.SortOrder = utils.ToPgInt4Ptr(req.SortOrder)
|
|
}
|
|
if req.RecurrenceRule != nil {
|
|
updateParams.RecurrenceRule = utils.ToPgTextPtr(req.RecurrenceRule)
|
|
}
|
|
|
|
t, err := s.queries.UpdateTask(ctx, updateParams)
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return nil, models.ErrNotFound
|
|
}
|
|
return nil, models.ErrInternal
|
|
}
|
|
|
|
task := taskFromDB(t)
|
|
s.audit.Log(ctx, "task", taskID, "UPDATE_TASK", userID)
|
|
|
|
if s.webhook != nil && req.Status != nil && *req.Status != prev.Status {
|
|
go s.webhook.Deliver(ctx, userID, "status_change", task)
|
|
if *req.Status == "done" {
|
|
go s.webhook.Deliver(ctx, userID, "completion", task)
|
|
}
|
|
}
|
|
|
|
if prev.ParentID.Valid {
|
|
s.maybeAutoCompleteParent(ctx, userID, utils.FromPgUUID(prev.ParentID))
|
|
}
|
|
|
|
return task, nil
|
|
}
|
|
|
|
func (s *TaskService) Delete(ctx context.Context, userID uuid.UUID, taskID uuid.UUID, permanent bool) error {
|
|
if permanent {
|
|
err := s.queries.HardDeleteTask(ctx, repository.HardDeleteTaskParams{
|
|
ID: utils.ToPgUUID(taskID),
|
|
OwnerID: utils.ToPgUUID(userID),
|
|
})
|
|
if err != nil {
|
|
return models.ErrInternal
|
|
}
|
|
} else {
|
|
err := s.queries.SoftDeleteTask(ctx, repository.SoftDeleteTaskParams{
|
|
ID: utils.ToPgUUID(taskID),
|
|
OwnerID: utils.ToPgUUID(userID),
|
|
})
|
|
if err != nil {
|
|
return models.ErrInternal
|
|
}
|
|
}
|
|
s.audit.Log(ctx, "task", taskID, "DELETE_TASK", userID)
|
|
return nil
|
|
}
|
|
|
|
func (s *TaskService) MarkComplete(ctx context.Context, userID uuid.UUID, taskID uuid.UUID) (*models.Task, error) {
|
|
blockers, _ := s.queries.ListTaskBlockers(ctx, utils.ToPgUUID(taskID))
|
|
for _, b := range blockers {
|
|
if b.Status != "done" {
|
|
return nil, models.NewConflictError("cannot complete: blocked by incomplete task")
|
|
}
|
|
}
|
|
|
|
t, err := s.queries.MarkTaskComplete(ctx, repository.MarkTaskCompleteParams{
|
|
ID: utils.ToPgUUID(taskID),
|
|
OwnerID: utils.ToPgUUID(userID),
|
|
})
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return nil, models.ErrNotFound
|
|
}
|
|
return nil, models.ErrInternal
|
|
}
|
|
|
|
task := taskFromDB(t)
|
|
s.audit.Log(ctx, "task", taskID, "COMPLETE_TASK", userID)
|
|
if s.webhook != nil {
|
|
go s.webhook.Deliver(ctx, userID, "completion", task)
|
|
}
|
|
|
|
if t.ParentID.Valid {
|
|
s.maybeAutoCompleteParent(ctx, userID, utils.FromPgUUID(t.ParentID))
|
|
}
|
|
|
|
return task, nil
|
|
}
|
|
|
|
func (s *TaskService) MarkUncomplete(ctx context.Context, userID uuid.UUID, taskID uuid.UUID, newStatus *string) (*models.Task, error) {
|
|
status := "todo"
|
|
if newStatus != nil {
|
|
if err := utils.ValidateTaskStatus(*newStatus); err != nil {
|
|
return nil, err
|
|
}
|
|
status = *newStatus
|
|
}
|
|
|
|
t, err := s.queries.MarkTaskUncomplete(ctx, repository.MarkTaskUncompleteParams{
|
|
ID: utils.ToPgUUID(taskID),
|
|
OwnerID: utils.ToPgUUID(userID),
|
|
NewStatus: utils.ToPgTextPtr(&status),
|
|
})
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return nil, models.ErrNotFound
|
|
}
|
|
return nil, models.ErrInternal
|
|
}
|
|
|
|
task := taskFromDB(t)
|
|
s.audit.Log(ctx, "task", taskID, "UNCOMPLETE_TASK", userID)
|
|
return task, nil
|
|
}
|
|
|
|
func (s *TaskService) ListSubtasks(ctx context.Context, userID uuid.UUID, taskID uuid.UUID) ([]models.Task, error) {
|
|
rows, err := s.queries.ListSubtasks(ctx, repository.ListSubtasksParams{
|
|
ParentID: utils.ToPgUUID(taskID),
|
|
OwnerID: utils.ToPgUUID(userID),
|
|
})
|
|
if err != nil {
|
|
return nil, models.ErrInternal
|
|
}
|
|
|
|
tasks := make([]models.Task, 0, len(rows))
|
|
for _, r := range rows {
|
|
tasks = append(tasks, *taskFromDB(r))
|
|
}
|
|
return tasks, nil
|
|
}
|
|
|
|
func (s *TaskService) maybeAutoCompleteParent(ctx context.Context, userID uuid.UUID, parentID uuid.UUID) {
|
|
count, err := s.queries.CountSubtasksByStatus(ctx, utils.ToPgUUID(parentID))
|
|
if err != nil || count.TotalCount == 0 {
|
|
return
|
|
}
|
|
if count.DoneCount == count.TotalCount {
|
|
_, _ = s.queries.MarkTaskComplete(ctx, repository.MarkTaskCompleteParams{
|
|
ID: utils.ToPgUUID(parentID),
|
|
OwnerID: utils.ToPgUUID(userID),
|
|
})
|
|
s.audit.Log(ctx, "task", parentID, "AUTO_COMPLETE_PARENT", userID)
|
|
}
|
|
}
|
|
|
|
func taskFromDB(t repository.Task) *models.Task {
|
|
task := &models.Task{
|
|
ID: utils.FromPgUUID(t.ID),
|
|
OwnerID: utils.FromPgUUID(t.OwnerID),
|
|
Title: t.Title,
|
|
Status: t.Status,
|
|
Priority: t.Priority,
|
|
CreatedAt: utils.FromPgTimestamptz(t.CreatedAt),
|
|
UpdatedAt: utils.FromPgTimestamptz(t.UpdatedAt),
|
|
SortOrder: int(t.SortOrder),
|
|
}
|
|
if t.Description.Valid {
|
|
task.Description = &t.Description.String
|
|
}
|
|
if t.DueDate.Valid {
|
|
task.DueDate = &t.DueDate.Time
|
|
}
|
|
if t.CompletedAt.Valid {
|
|
task.CompletedAt = &t.CompletedAt.Time
|
|
}
|
|
if t.ProjectID.Valid {
|
|
id := utils.FromPgUUID(t.ProjectID)
|
|
task.ProjectID = &id
|
|
}
|
|
if t.ParentID.Valid {
|
|
id := utils.FromPgUUID(t.ParentID)
|
|
task.ParentID = &id
|
|
}
|
|
if t.RecurrenceRule.Valid {
|
|
task.RecurrenceRule = &t.RecurrenceRule.String
|
|
}
|
|
return task
|
|
}
|
|
|
|
func tagFromDB(t repository.Tag) models.Tag {
|
|
return models.Tag{
|
|
ID: utils.FromPgUUID(t.ID),
|
|
OwnerID: utils.FromPgUUID(t.OwnerID),
|
|
Name: t.Name,
|
|
Color: t.Color,
|
|
CreatedAt: utils.FromPgTimestamptz(t.CreatedAt),
|
|
}
|
|
}
|