tasks.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package lunatask
  6
  7import (
  8	"bytes"
  9	"context"
 10	"encoding/json"
 11	"errors"
 12	"fmt"
 13	"io"
 14	"net/http"
 15	"strings"
 16
 17	"github.com/go-playground/validator/v10"
 18)
 19
 20// Client handles communication with the Lunatask API
 21type Client struct {
 22	AccessToken string
 23	BaseURL     string
 24	HTTPClient  *http.Client
 25}
 26
 27// NewClient creates a new Lunatask API client
 28func NewClient(accessToken string) *Client {
 29	return &Client{
 30		AccessToken: accessToken,
 31		BaseURL:     "https://api.lunatask.app/v1",
 32		HTTPClient:  &http.Client{},
 33	}
 34}
 35
 36// CreateTaskRequest represents the request to create a task in Lunatask
 37type CreateTaskRequest struct {
 38	AreaID      string `json:"area_id"`
 39	GoalID      string `json:"goal_id,omitempty" validate:"omitempty"`
 40	Name        string `json:"name" validate:"max=100"`
 41	Note        string `json:"note,omitempty" validate:"omitempty"`
 42	Status      string `json:"status,omitempty" validate:"omitempty,oneof=later next started waiting completed"`
 43	Motivation  string `json:"motivation,omitempty" validate:"omitempty,oneof=must should want unknown"`
 44	Estimate    int    `json:"estimate,omitempty" validate:"omitempty,min=0,max=720"`
 45	Priority    int    `json:"priority,omitempty" validate:"omitempty,min=-2,max=2"`
 46	ScheduledOn string `json:"scheduled_on,omitempty" validate:"omitempty"`
 47	CompletedAt string `json:"completed_at,omitempty" validate:"omitempty"`
 48	Source      string `json:"source,omitempty" validate:"omitempty"`
 49}
 50
 51// CreateTaskResponse represents the response from Lunatask API when creating a task
 52type CreateTaskResponse struct {
 53	Task struct {
 54		ID string `json:"id"`
 55	} `json:"task"`
 56}
 57
 58// ValidationError represents errors returned by the validator
 59type ValidationError struct {
 60	Field   string
 61	Tag     string
 62	Message string
 63}
 64
 65// Error implements the error interface for ValidationError
 66func (e ValidationError) Error() string {
 67	return e.Message
 68}
 69
 70// ValidateTask validates the create task request
 71func ValidateTask(task *CreateTaskRequest) error {
 72	validate := validator.New()
 73	if err := validate.Struct(task); err != nil {
 74		var invalidValidationError *validator.InvalidValidationError
 75		if errors.As(err, &invalidValidationError) {
 76			return fmt.Errorf("invalid validation error: %w", err)
 77		}
 78
 79		var validationErrs validator.ValidationErrors
 80		if errors.As(err, &validationErrs) {
 81			var msgBuilder strings.Builder
 82			msgBuilder.WriteString("task validation failed:")
 83			for _, e := range validationErrs {
 84				fmt.Fprintf(&msgBuilder, " field '%s' failed '%s' validation (was: %v);", e.Field(), e.Tag(), e.Value())
 85			}
 86			return errors.New(msgBuilder.String())
 87		}
 88		return fmt.Errorf("validation error: %w", err)
 89	}
 90	return nil
 91}
 92
 93// CreateTask creates a new task in Lunatask
 94func (c *Client) CreateTask(ctx context.Context, task *CreateTaskRequest) (*CreateTaskResponse, error) {
 95	// Validate the task
 96	if err := ValidateTask(task); err != nil {
 97		return nil, err
 98	}
 99
100	// Marshal the task to JSON
101	payloadBytes, err := json.Marshal(task)
102	if err != nil {
103		return nil, fmt.Errorf("failed to marshal payload: %w", err)
104	}
105
106	// Create the request
107	req, err := http.NewRequestWithContext(
108		ctx,
109		"POST",
110		fmt.Sprintf("%s/tasks", c.BaseURL),
111		bytes.NewBuffer(payloadBytes),
112	)
113	if err != nil {
114		return nil, fmt.Errorf("failed to create HTTP request: %w", err)
115	}
116
117	// Set headers
118	req.Header.Set("Content-Type", "application/json")
119	req.Header.Set("Authorization", "bearer "+c.AccessToken)
120
121	// Send the request
122	resp, err := c.HTTPClient.Do(req)
123	if err != nil {
124		return nil, fmt.Errorf("failed to send HTTP request: %w", err)
125	}
126	defer func() {
127		if resp.Body != nil {
128			if err := resp.Body.Close(); err != nil {
129				// We're in a defer, so we can only log the error
130				fmt.Printf("Error closing response body: %v\n", err)
131			}
132		}
133	}()
134
135	// Handle already exists (no content) case
136	if resp.StatusCode == http.StatusNoContent {
137		return nil, nil // Task already exists (not an error)
138	}
139
140	// Handle error status codes
141	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
142		respBody, _ := io.ReadAll(resp.Body)
143		return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody))
144	}
145
146	// Read and parse the response
147	respBody, err := io.ReadAll(resp.Body)
148	if err != nil {
149		return nil, fmt.Errorf("failed to read response body: %w", err)
150	}
151
152	var response CreateTaskResponse
153	err = json.Unmarshal(respBody, &response)
154	if err != nil {
155		return nil, fmt.Errorf("failed to parse response: %w", err)
156	}
157
158	return &response, nil
159}