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// Source represents a task source like GitHub or other integrations
 37type Source struct {
 38	Source   string `json:"source"`
 39	SourceID string `json:"source_id"`
 40}
 41
 42// Task is only ever used in responses
 43type Task struct {
 44	ID             string   `json:"id,omitempty"`
 45	AreaID         string   `json:"area_id,omitempty"`
 46	GoalID         string   `json:"goal_id,omitempty"`
 47	Status         string   `json:"status,omitempty"`
 48	PreviousStatus string   `json:"previous_status,omitempty"`
 49	Estimate       int      `json:"estimate,omitempty"`
 50	Priority       int      `json:"priority,omitempty"`
 51	Progress       int      `json:"progress,omitempty"`
 52	Motivation     string   `json:"motivation,omitempty"`
 53	Eisenhower     int      `json:"eisenhower,omitempty"`
 54	Sources        []Source `json:"sources,omitempty"`
 55	ScheduledOn    string   `json:"scheduled_on,omitempty"`
 56	CompletedAt    string   `json:"completed_at,omitempty"`
 57	CreatedAt      string   `json:"created_at,omitempty"`
 58	UpdatedAt      string   `json:"updated_at,omitempty"`
 59}
 60
 61// CreateTaskRequest represents the request to create a task in Lunatask
 62type CreateTaskRequest struct {
 63	AreaID      string `json:"area_id" validate:"omitempty,uuid4"`
 64	GoalID      string `json:"goal_id,omitempty" validate:"omitempty,uuid4"`
 65	Name        string `json:"name" validate:"max=100"`
 66	Note        string `json:"note,omitempty" validate:"omitempty"`
 67	Status      string `json:"status,omitempty" validate:"omitempty,oneof=later next started waiting completed"`
 68	Motivation  string `json:"motivation,omitempty" validate:"omitempty,oneof=must should want unknown"`
 69	Estimate    int    `json:"estimate,omitempty" validate:"omitempty,min=0,max=720"`
 70	Priority    int    `json:"priority,omitempty" validate:"omitempty,min=-2,max=2"`
 71	ScheduledOn string `json:"scheduled_on,omitempty" validate:"omitempty"`
 72	CompletedAt string `json:"completed_at,omitempty" validate:"omitempty"`
 73	Source      string `json:"source,omitempty" validate:"omitempty"`
 74}
 75
 76// CreateTaskResponse represents the response from Lunatask API when creating a task
 77type CreateTaskResponse struct {
 78	Task struct {
 79		ID string `json:"id"`
 80	} `json:"task"`
 81}
 82
 83// ValidationError represents errors returned by the validator
 84type ValidationError struct {
 85	Field   string
 86	Tag     string
 87	Message string
 88}
 89
 90// Error implements the error interface for ValidationError
 91func (e ValidationError) Error() string {
 92	return e.Message
 93}
 94
 95// ValidateTask validates the create task request
 96func ValidateTask(task *CreateTaskRequest) error {
 97	validate := validator.New()
 98	if err := validate.Struct(task); err != nil {
 99		var invalidValidationError *validator.InvalidValidationError
100		if errors.As(err, &invalidValidationError) {
101			return fmt.Errorf("invalid validation error: %w", err)
102		}
103
104		var validationErrs validator.ValidationErrors
105		if errors.As(err, &validationErrs) {
106			var msgBuilder strings.Builder
107			msgBuilder.WriteString("task validation failed:")
108			for _, e := range validationErrs {
109				fmt.Fprintf(&msgBuilder, " field '%s' failed '%s' validation (was: %v);", e.Field(), e.Tag(), e.Value())
110			}
111			return errors.New(msgBuilder.String())
112		}
113		return fmt.Errorf("validation error: %w", err)
114	}
115	return nil
116}
117
118// CreateTask creates a new task in Lunatask
119func (c *Client) CreateTask(ctx context.Context, task *CreateTaskRequest) (*CreateTaskResponse, error) {
120	// Validate the task
121	if err := ValidateTask(task); err != nil {
122		return nil, err
123	}
124
125	// Marshal the task to JSON
126	payloadBytes, err := json.Marshal(task)
127	if err != nil {
128		return nil, fmt.Errorf("failed to marshal payload: %w", err)
129	}
130
131	// Create the request
132	req, err := http.NewRequestWithContext(
133		ctx,
134		"POST",
135		fmt.Sprintf("%s/tasks", c.BaseURL),
136		bytes.NewBuffer(payloadBytes),
137	)
138	if err != nil {
139		return nil, fmt.Errorf("failed to create HTTP request: %w", err)
140	}
141
142	// Set headers
143	req.Header.Set("Content-Type", "application/json")
144	req.Header.Set("Authorization", "bearer "+c.AccessToken)
145
146	// Send the request
147	resp, err := c.HTTPClient.Do(req)
148	if err != nil {
149		return nil, fmt.Errorf("failed to send HTTP request: %w", err)
150	}
151	defer func() {
152		if resp.Body != nil {
153			if err := resp.Body.Close(); err != nil {
154				// We're in a defer, so we can only log the error
155				fmt.Printf("Error closing response body: %v\n", err)
156			}
157		}
158	}()
159
160	// Handle already exists (no content) case
161	if resp.StatusCode == http.StatusNoContent {
162		return nil, nil // Task already exists (not an error)
163	}
164
165	// Handle error status codes
166	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
167		respBody, _ := io.ReadAll(resp.Body)
168		return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody))
169	}
170
171	// Read and parse the response
172	respBody, err := io.ReadAll(resp.Body)
173	if err != nil {
174		return nil, fmt.Errorf("failed to read response body: %w", err)
175	}
176
177	var response CreateTaskResponse
178	err = json.Unmarshal(respBody, &response)
179	if err != nil {
180		return nil, fmt.Errorf("failed to parse response: %w", err)
181	}
182
183	return &response, nil
184}