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"` // Assuming GoalID remains optional for tasks
 65	Name        string `json:"name" validate:"required,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// UpdateTaskResponse represents the response from Lunatask API when updating a task
 84type UpdateTaskResponse struct {
 85	Task Task `json:"task"`
 86}
 87
 88// DeleteTaskResponse represents the response from Lunatask API when deleting a task
 89type DeleteTaskResponse struct {
 90	Task Task `json:"task"`
 91}
 92
 93// ValidationError represents errors returned by the validator
 94type ValidationError struct {
 95	Field   string
 96	Tag     string
 97	Message string
 98}
 99
100// Error implements the error interface for ValidationError
101func (e ValidationError) Error() string {
102	return e.Message
103}
104
105// ValidateTask validates the create task request
106func ValidateTask(task *CreateTaskRequest) error {
107	validate := validator.New()
108	if err := validate.Struct(task); err != nil {
109		var invalidValidationError *validator.InvalidValidationError
110		if errors.As(err, &invalidValidationError) {
111			return fmt.Errorf("invalid validation error: %w", err)
112		}
113
114		var validationErrs validator.ValidationErrors
115		if errors.As(err, &validationErrs) {
116			var msgBuilder strings.Builder
117			msgBuilder.WriteString("task validation failed:")
118			for _, e := range validationErrs {
119				fmt.Fprintf(&msgBuilder, " field '%s' failed '%s' validation (was: %v);", e.Field(), e.Tag(), e.Value())
120			}
121			return errors.New(msgBuilder.String())
122		}
123		return fmt.Errorf("validation error: %w", err)
124	}
125	return nil
126}
127
128// CreateTask creates a new task in Lunatask
129func (c *Client) CreateTask(ctx context.Context, task *CreateTaskRequest) (*CreateTaskResponse, error) {
130	// Validate the task
131	if err := ValidateTask(task); err != nil {
132		return nil, err
133	}
134
135	// Marshal the task to JSON
136	payloadBytes, err := json.Marshal(task)
137	if err != nil {
138		return nil, fmt.Errorf("failed to marshal payload: %w", err)
139	}
140
141	// Create the request
142	req, err := http.NewRequestWithContext(
143		ctx,
144		"POST",
145		fmt.Sprintf("%s/tasks", c.BaseURL),
146		bytes.NewBuffer(payloadBytes),
147	)
148	if err != nil {
149		return nil, fmt.Errorf("failed to create HTTP request: %w", err)
150	}
151
152	// Set headers
153	req.Header.Set("Content-Type", "application/json")
154	req.Header.Set("Authorization", "bearer "+c.AccessToken)
155
156	// Send the request
157	resp, err := c.HTTPClient.Do(req)
158	if err != nil {
159		return nil, fmt.Errorf("failed to send HTTP request: %w", err)
160	}
161	defer func() {
162		if resp.Body != nil {
163			if err := resp.Body.Close(); err != nil {
164				// We're in a defer, so we can only log the error
165				fmt.Printf("Error closing response body: %v\n", err)
166			}
167		}
168	}()
169
170	// Handle already exists (no content) case
171	if resp.StatusCode == http.StatusNoContent {
172		return nil, nil // Task already exists (not an error)
173	}
174
175	// Handle error status codes
176	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
177		respBody, _ := io.ReadAll(resp.Body)
178		return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody))
179	}
180
181	// Read and parse the response
182	respBody, err := io.ReadAll(resp.Body)
183	if err != nil {
184		return nil, fmt.Errorf("failed to read response body: %w", err)
185	}
186
187	var response CreateTaskResponse
188	err = json.Unmarshal(respBody, &response)
189	if err != nil {
190		return nil, fmt.Errorf("failed to parse response: %w", err)
191	}
192
193	return &response, nil
194}
195
196// UpdateTask updates an existing task in Lunatask
197func (c *Client) UpdateTask(ctx context.Context, taskID string, task *CreateTaskRequest) (*UpdateTaskResponse, error) {
198	if taskID == "" {
199		return nil, errors.New("task ID cannot be empty")
200	}
201
202	// Validate the task payload
203	// Note: ValidateTask checks fields like Name, Priority, Estimate, etc.
204	// It's assumed that the API handles partial updates correctly,
205	// especially for fields like Name or AreaID in CreateTaskRequest that lack `omitempty`.
206	if err := ValidateTask(task); err != nil {
207		return nil, err
208	}
209
210	// Marshal the task to JSON
211	payloadBytes, err := json.Marshal(task)
212	if err != nil {
213		return nil, fmt.Errorf("failed to marshal payload: %w", err)
214	}
215
216	// Create the request
217	req, err := http.NewRequestWithContext(
218		ctx,
219		"PUT",
220		fmt.Sprintf("%s/tasks/%s", c.BaseURL, taskID),
221		bytes.NewBuffer(payloadBytes),
222	)
223	if err != nil {
224		return nil, fmt.Errorf("failed to create HTTP request: %w", err)
225	}
226
227	// Set headers
228	req.Header.Set("Content-Type", "application/json")
229	req.Header.Set("Authorization", "bearer "+c.AccessToken)
230
231	// Send the request
232	resp, err := c.HTTPClient.Do(req)
233	if err != nil {
234		return nil, fmt.Errorf("failed to send HTTP request: %w", err)
235	}
236	defer func() {
237		if resp.Body != nil {
238			if err := resp.Body.Close(); err != nil {
239				fmt.Printf("Error closing response body: %v\n", err)
240			}
241		}
242	}()
243
244	// Handle error status codes
245	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
246		respBody, _ := io.ReadAll(resp.Body)
247		// Consider specific handling for 404 Not Found if needed
248		return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody))
249	}
250
251	// Read and parse the response
252	respBody, err := io.ReadAll(resp.Body)
253	if err != nil {
254		return nil, fmt.Errorf("failed to read response body: %w", err)
255	}
256
257	var response UpdateTaskResponse
258	err = json.Unmarshal(respBody, &response)
259	if err != nil {
260		return nil, fmt.Errorf("failed to parse response: %w", err)
261	}
262
263	return &response, nil
264}
265
266// DeleteTask deletes a task in Lunatask
267func (c *Client) DeleteTask(ctx context.Context, taskID string) (*DeleteTaskResponse, error) {
268	if taskID == "" {
269		return nil, errors.New("task ID cannot be empty")
270	}
271
272	// Create the request
273	req, err := http.NewRequestWithContext(
274		ctx,
275		"DELETE",
276		fmt.Sprintf("%s/tasks/%s", c.BaseURL, taskID),
277		nil,
278	)
279	if err != nil {
280		return nil, fmt.Errorf("failed to create HTTP request: %w", err)
281	}
282
283	// Set headers
284	req.Header.Set("Authorization", "bearer "+c.AccessToken)
285
286	// Send the request
287	resp, err := c.HTTPClient.Do(req)
288	if err != nil {
289		return nil, fmt.Errorf("failed to send HTTP request: %w", err)
290	}
291	defer func() {
292		if resp.Body != nil {
293			if err := resp.Body.Close(); err != nil {
294				fmt.Printf("Error closing response body: %v\n", err)
295			}
296		}
297	}()
298
299	// Handle error status codes
300	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
301		respBody, _ := io.ReadAll(resp.Body)
302		return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody))
303	}
304
305	// Read and parse the response
306	respBody, err := io.ReadAll(resp.Body)
307	if err != nil {
308		return nil, fmt.Errorf("failed to read response body: %w", err)
309	}
310
311	var response DeleteTaskResponse
312	err = json.Unmarshal(respBody, &response)
313	if err != nil {
314		return nil, fmt.Errorf("failed to parse response: %w", err)
315	}
316
317	return &response, nil
318}