Files
CalendarApi/internal/service/task.go
Michilis bd24545b7b Fix BASE_URL config loading, add tasks/projects; robust .env path resolution
- 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
2026-03-09 18:57:51 +00:00

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