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