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// Source represents a task source like GitHub or other integrations
 21type Source struct {
 22	Source   string `json:"source"`
 23	SourceID string `json:"source_id"`
 24}
 25
 26// Task is only ever used in responses
 27type Task struct {
 28	ID             string   `json:"id,omitempty"`
 29	AreaID         string   `json:"area_id,omitempty"`
 30	GoalID         string   `json:"goal_id,omitempty"`
 31	Status         string   `json:"status,omitempty"`
 32	PreviousStatus string   `json:"previous_status,omitempty"`
 33	Estimate       int      `json:"estimate,omitempty"`
 34	Priority       int      `json:"priority,omitempty"`
 35	Progress       int      `json:"progress,omitempty"`
 36	Motivation     string   `json:"motivation,omitempty"`
 37	Eisenhower     int      `json:"eisenhower,omitempty"`
 38	Sources        []Source `json:"sources,omitempty"`
 39	ScheduledOn    string   `json:"scheduled_on,omitempty"`
 40	CompletedAt    string   `json:"completed_at,omitempty"`
 41	CreatedAt      string   `json:"created_at,omitempty"`
 42	UpdatedAt      string   `json:"updated_at,omitempty"`
 43}
 44
 45// CreateTaskRequest represents the request to create a task in Lunatask
 46type CreateTaskRequest struct {
 47	AreaID      string `json:"area_id" validate:"omitempty,uuid4"`
 48	GoalID      string `json:"goal_id,omitempty" validate:"omitempty,uuid4"` // Assuming GoalID remains optional for tasks
 49	Name        string `json:"name" validate:"required,max=100"`
 50	Note        string `json:"note,omitempty" validate:"omitempty"`
 51	Status      string `json:"status,omitempty" validate:"omitempty,oneof=later next started waiting completed"`
 52	Motivation  string `json:"motivation,omitempty" validate:"omitempty,oneof=must should want unknown"`
 53	Estimate    int    `json:"estimate,omitempty" validate:"omitempty,min=0,max=720"`
 54	Priority    int    `json:"priority,omitempty" validate:"omitempty,min=-2,max=2"`
 55	ScheduledOn string `json:"scheduled_on,omitempty" validate:"omitempty"`
 56	CompletedAt string `json:"completed_at,omitempty" validate:"omitempty"`
 57	Source      string `json:"source,omitempty" validate:"omitempty"`
 58}
 59
 60// CreateTaskResponse represents the response from Lunatask API when creating a task
 61type CreateTaskResponse struct {
 62	Task struct {
 63		ID string `json:"id"`
 64	} `json:"task"`
 65}
 66
 67// UpdateTaskResponse represents the response from Lunatask API when updating a task
 68type UpdateTaskResponse struct {
 69	Task Task `json:"task"`
 70}
 71
 72// DeleteTaskResponse represents the response from Lunatask API when deleting a task
 73type DeleteTaskResponse struct {
 74	Task Task `json:"task"`
 75}
 76
 77// ValidationError represents errors returned by the validator
 78type ValidationError struct {
 79	Field   string
 80	Tag     string
 81	Message string
 82}
 83
 84// Error implements the error interface for ValidationError
 85func (e ValidationError) Error() string {
 86	return e.Message
 87}
 88
 89// ValidateTask validates the create task request
 90func ValidateTask(task *CreateTaskRequest) error {
 91	validate := validator.New()
 92	if err := validate.Struct(task); err != nil {
 93		var invalidValidationError *validator.InvalidValidationError
 94		if errors.As(err, &invalidValidationError) {
 95			return fmt.Errorf("invalid validation error: %w", err)
 96		}
 97
 98		var validationErrs validator.ValidationErrors
 99		if errors.As(err, &validationErrs) {
100			var msgBuilder strings.Builder
101			msgBuilder.WriteString("task validation failed:")
102			for _, e := range validationErrs {
103				fmt.Fprintf(&msgBuilder, " field '%s' failed '%s' validation (was: %v);", e.Field(), e.Tag(), e.Value())
104			}
105			return errors.New(msgBuilder.String())
106		}
107		return fmt.Errorf("validation error: %w", err)
108	}
109	return nil
110}
111
112// CreateTask creates a new task in Lunatask
113func (c *Client) CreateTask(ctx context.Context, task *CreateTaskRequest) (*CreateTaskResponse, error) {
114	// Validate the task
115	if err := ValidateTask(task); err != nil {
116		return nil, err
117	}
118
119	// Marshal the task to JSON
120	payloadBytes, err := json.Marshal(task)
121	if err != nil {
122		return nil, fmt.Errorf("failed to marshal payload: %w", err)
123	}
124
125	// Create the request
126	req, err := http.NewRequestWithContext(
127		ctx,
128		"POST",
129		fmt.Sprintf("%s/tasks", c.BaseURL),
130		bytes.NewBuffer(payloadBytes),
131	)
132	if err != nil {
133		return nil, fmt.Errorf("failed to create HTTP request: %w", err)
134	}
135
136	// Set headers
137	req.Header.Set("Content-Type", "application/json")
138	req.Header.Set("Authorization", "bearer "+c.AccessToken)
139
140	// Send the request
141	resp, err := c.HTTPClient.Do(req)
142	if err != nil {
143		return nil, fmt.Errorf("failed to send HTTP request: %w", err)
144	}
145	defer func() {
146		if resp.Body != nil {
147			if err := resp.Body.Close(); err != nil {
148				// We're in a defer, so we can only log the error
149				fmt.Printf("Error closing response body: %v\n", err)
150			}
151		}
152	}()
153
154	// Handle already exists (no content) case
155	if resp.StatusCode == http.StatusNoContent {
156		return nil, nil // Task already exists (not an error)
157	}
158
159	// Handle error status codes
160	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
161		respBody, _ := io.ReadAll(resp.Body)
162		return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody))
163	}
164
165	// Read and parse the response
166	respBody, err := io.ReadAll(resp.Body)
167	if err != nil {
168		return nil, fmt.Errorf("failed to read response body: %w", err)
169	}
170
171	var response CreateTaskResponse
172	err = json.Unmarshal(respBody, &response)
173	if err != nil {
174		return nil, fmt.Errorf("failed to parse response: %w", err)
175	}
176
177	return &response, nil
178}
179
180// UpdateTask updates an existing task in Lunatask
181func (c *Client) UpdateTask(ctx context.Context, taskID string, task *CreateTaskRequest) (*UpdateTaskResponse, error) {
182	if taskID == "" {
183		return nil, errors.New("task ID cannot be empty")
184	}
185
186	// Validate the task payload
187	// Note: ValidateTask checks fields like Name, Priority, Estimate, etc.
188	// It's assumed that the API handles partial updates correctly,
189	// especially for fields like Name or AreaID in CreateTaskRequest that lack `omitempty`.
190	if err := ValidateTask(task); err != nil {
191		return nil, err
192	}
193
194	// Marshal the task to JSON
195	payloadBytes, err := json.Marshal(task)
196	if err != nil {
197		return nil, fmt.Errorf("failed to marshal payload: %w", err)
198	}
199
200	// Create the request
201	req, err := http.NewRequestWithContext(
202		ctx,
203		"PUT",
204		fmt.Sprintf("%s/tasks/%s", c.BaseURL, taskID),
205		bytes.NewBuffer(payloadBytes),
206	)
207	if err != nil {
208		return nil, fmt.Errorf("failed to create HTTP request: %w", err)
209	}
210
211	// Set headers
212	req.Header.Set("Content-Type", "application/json")
213	req.Header.Set("Authorization", "bearer "+c.AccessToken)
214
215	// Send the request
216	resp, err := c.HTTPClient.Do(req)
217	if err != nil {
218		return nil, fmt.Errorf("failed to send HTTP request: %w", err)
219	}
220	defer func() {
221		if resp.Body != nil {
222			if err := resp.Body.Close(); err != nil {
223				fmt.Printf("Error closing response body: %v\n", err)
224			}
225		}
226	}()
227
228	// Handle error status codes
229	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
230		respBody, _ := io.ReadAll(resp.Body)
231		// Consider specific handling for 404 Not Found if needed
232		return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody))
233	}
234
235	// Read and parse the response
236	respBody, err := io.ReadAll(resp.Body)
237	if err != nil {
238		return nil, fmt.Errorf("failed to read response body: %w", err)
239	}
240
241	var response UpdateTaskResponse
242	err = json.Unmarshal(respBody, &response)
243	if err != nil {
244		return nil, fmt.Errorf("failed to parse response: %w", err)
245	}
246
247	return &response, nil
248}
249
250// DeleteTask deletes a task in Lunatask
251func (c *Client) DeleteTask(ctx context.Context, taskID string) (*DeleteTaskResponse, error) {
252	if taskID == "" {
253		return nil, errors.New("task ID cannot be empty")
254	}
255
256	// Create the request
257	req, err := http.NewRequestWithContext(
258		ctx,
259		"DELETE",
260		fmt.Sprintf("%s/tasks/%s", c.BaseURL, taskID),
261		nil,
262	)
263	if err != nil {
264		return nil, fmt.Errorf("failed to create HTTP request: %w", err)
265	}
266
267	// Set headers
268	req.Header.Set("Authorization", "bearer "+c.AccessToken)
269
270	// Send the request
271	resp, err := c.HTTPClient.Do(req)
272	if err != nil {
273		return nil, fmt.Errorf("failed to send HTTP request: %w", err)
274	}
275	defer func() {
276		if resp.Body != nil {
277			if err := resp.Body.Close(); err != nil {
278				fmt.Printf("Error closing response body: %v\n", err)
279			}
280		}
281	}()
282
283	// Handle error status codes
284	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
285		respBody, _ := io.ReadAll(resp.Body)
286		return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody))
287	}
288
289	// Read and parse the response
290	respBody, err := io.ReadAll(resp.Body)
291	if err != nil {
292		return nil, fmt.Errorf("failed to read response body: %w", err)
293	}
294
295	var response DeleteTaskResponse
296	err = json.Unmarshal(respBody, &response)
297	if err != nil {
298		return nil, fmt.Errorf("failed to parse response: %w", err)
299	}
300
301	return &response, nil
302}