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
This commit is contained in:
436
internal/service/task.go
Normal file
436
internal/service/task.go
Normal file
@@ -0,0 +1,436 @@
|
||||
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),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user