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