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}