From 1c79f105c960ddab2265cbfd8dfd728630b1ebfb Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Tue, 13 Jan 2026 13:58:53 -1000 Subject: Implement Trello write operations (Phase 3 Step 1) Add CreateCard and UpdateCard methods to Trello API client with full testability support: - Refactor TrelloClient with configurable baseURL for testing - Replace hardcoded trelloBaseURL constant with c.baseURL - Implement CreateCard with support for description and due date - Implement UpdateCard with flexible field updates - Add comprehensive test suite using httptest.NewServer - Tests cover success cases, error handling, and edge cases All tests pass. Write operations ready for Phase 3 UI integration. Co-Authored-By: Claude Sonnet 4.5 --- SESSION_STATE.md | 40 +++--- internal/api/trello.go | 111 ++++++++++++++-- internal/api/trello_test.go | 254 ++++++++++++++++++++++++++++++++++++ issues/phase3_step1_trello_write.md | 78 +++++++++++ 4 files changed, 449 insertions(+), 34 deletions(-) create mode 100644 internal/api/trello_test.go create mode 100644 issues/phase3_step1_trello_write.md diff --git a/SESSION_STATE.md b/SESSION_STATE.md index 4078ff7..570c987 100644 --- a/SESSION_STATE.md +++ b/SESSION_STATE.md @@ -1,30 +1,24 @@ # Session State -**Current Phase:** Phase 2.5 - Visual Overhaul & Polish -**Goal:** Implement "Glassmorphism" UI and clean up technical debt. +**Current Phase:** Phase 3: Interactivity & Write Operations +**Current Focus:** Step 1: Trello Write Operations -## Current Context -Critical bugs have been resolved. We are now ready to proceed with the visual overhaul. +## Active Issues +* `issues/phase3_step1_trello_write.md`: Implementing `CreateCard` and `UpdateCard` in Trello client. -## Current Issues -1. **[RESOLVED] Bug 002: Tab State Persistence** - * Issue: Tab selection is lost on page reload. - * Fix: Implemented URL query param syncing (`?tab=name`) and server-side restoration. - * Reference: `issues/bug_002_tab_state.md`. +## Completed Issues +* `issues/bug_002_tab_state.md`: Fixed tab state persistence. +* `issues/bug_001_template_rendering.md`: Fixed template error in notes tab. -2. **[RESOLVED] Bug 001: Template Rendering** - * Issue: `notes-tab` template error. - * Fix: Ensure data passed to `notes-tab` includes `Errors` field. - * Reference: `issues/bug_001_template_rendering.md`. +## Roadmap +1. **Phase 3: Interactivity** + * **Step 1: Trello Write Ops (Active)** + * Step 2: Todoist Write Ops + * Step 3: Unified Quick Add +2. **Phase 4: Security Hardening** + * Audit API keys handling. + * Rate limiting. ## Immediate Next Steps -1. **Phase 2.5 - Step 1: Foundation** - * Analyze current CSS/Tailwind setup. - * Define "Glassmorphism" theme (colors, blur effects, fonts). - * Update `tailwind.config.js` (if applicable) or `styles.css`. - * Create a `design_system.md` to document the new visual language. - -## Active Files -* `web/static/css/styles.css` (or similar) -* `tailwind.config.js` -* `web/templates/layout.html` +1. Implement `CreateCard` and `UpdateCard` in `internal/api/trello.go`. +2. Verify with `internal/api/trello_test.go`. diff --git a/internal/api/trello.go b/internal/api/trello.go index c78ebc8..5b87e30 100644 --- a/internal/api/trello.go +++ b/internal/api/trello.go @@ -6,7 +6,9 @@ import ( "fmt" "io" "net/http" + "net/url" "sort" + "strings" "sync" "time" @@ -21,14 +23,16 @@ const ( type TrelloClient struct { apiKey string token string + baseURL string httpClient *http.Client } // NewTrelloClient creates a new Trello API client func NewTrelloClient(apiKey, token string) *TrelloClient { return &TrelloClient{ - apiKey: apiKey, - token: token, + apiKey: apiKey, + token: token, + baseURL: trelloBaseURL, httpClient: &http.Client{ Timeout: 30 * time.Second, }, @@ -60,7 +64,7 @@ type trelloListResponse struct { // GetBoards fetches all boards for the authenticated user func (c *TrelloClient) GetBoards(ctx context.Context) ([]models.Board, error) { - url := fmt.Sprintf("%s/members/me/boards?key=%s&token=%s", trelloBaseURL, c.apiKey, c.token) + url := fmt.Sprintf("%s/members/me/boards?key=%s&token=%s", c.baseURL, c.apiKey, c.token) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { @@ -99,7 +103,7 @@ func (c *TrelloClient) GetBoards(ctx context.Context) ([]models.Board, error) { // GetCards fetches all cards for a specific board func (c *TrelloClient) GetCards(ctx context.Context, boardID string) ([]models.Card, error) { - url := fmt.Sprintf("%s/boards/%s/cards?key=%s&token=%s", trelloBaseURL, boardID, c.apiKey, c.token) + url := fmt.Sprintf("%s/boards/%s/cards?key=%s&token=%s", c.baseURL, boardID, c.apiKey, c.token) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { @@ -156,7 +160,7 @@ func (c *TrelloClient) GetCards(ctx context.Context, boardID string) ([]models.C // getLists fetches lists for a board and returns a map of list ID to name func (c *TrelloClient) getLists(ctx context.Context, boardID string) (map[string]string, error) { - url := fmt.Sprintf("%s/boards/%s/lists?key=%s&token=%s", trelloBaseURL, boardID, c.apiKey, c.token) + url := fmt.Sprintf("%s/boards/%s/lists?key=%s&token=%s", c.baseURL, boardID, c.apiKey, c.token) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { @@ -256,14 +260,99 @@ func (c *TrelloClient) GetBoardsWithCards(ctx context.Context) ([]models.Board, return boards, nil } -// CreateCard creates a new card (for Phase 2) +// CreateCard creates a new card in the specified list func (c *TrelloClient) CreateCard(ctx context.Context, listID, name, description string, dueDate *time.Time) (*models.Card, error) { - // This will be implemented in Phase 2 - return nil, fmt.Errorf("not implemented yet") + // Prepare request payload + data := url.Values{} + data.Set("key", c.apiKey) + data.Set("token", c.token) + data.Set("idList", listID) + data.Set("name", name) + + if description != "" { + data.Set("desc", description) + } + + if dueDate != nil { + data.Set("due", dueDate.Format(time.RFC3339)) + } + + // Create POST request + reqURL := c.baseURL + "/cards" + req, err := http.NewRequestWithContext(ctx, "POST", reqURL, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + // Execute request + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to create card: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("trello API error (status %d): %s", resp.StatusCode, string(body)) + } + + // Decode response + var apiCard trelloCardResponse + if err := json.NewDecoder(resp.Body).Decode(&apiCard); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + // Convert to our model + card := &models.Card{ + ID: apiCard.ID, + Name: apiCard.Name, + ListID: apiCard.IDList, + URL: apiCard.URL, + } + + // Parse due date if present + if apiCard.Due != nil && *apiCard.Due != "" { + parsedDate, err := time.Parse(time.RFC3339, *apiCard.Due) + if err == nil { + card.DueDate = &parsedDate + } + } + + return card, nil } -// UpdateCard updates a card (for Phase 2) +// UpdateCard updates a card with the specified changes func (c *TrelloClient) UpdateCard(ctx context.Context, cardID string, updates map[string]interface{}) error { - // This will be implemented in Phase 2 - return fmt.Errorf("not implemented yet") + // Prepare request payload + data := url.Values{} + data.Set("key", c.apiKey) + data.Set("token", c.token) + + // Add updates to payload + for key, value := range updates { + data.Set(key, fmt.Sprintf("%v", value)) + } + + // Create PUT request + reqURL := fmt.Sprintf("%s/cards/%s", c.baseURL, cardID) + req, err := http.NewRequestWithContext(ctx, "PUT", reqURL, strings.NewReader(data.Encode())) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + // Execute request + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to update card: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("trello API error (status %d): %s", resp.StatusCode, string(body)) + } + + return nil } diff --git a/internal/api/trello_test.go b/internal/api/trello_test.go new file mode 100644 index 0000000..b43b55e --- /dev/null +++ b/internal/api/trello_test.go @@ -0,0 +1,254 @@ +package api + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +func TestTrelloClient_CreateCard(t *testing.T) { + // Mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify method and path + if r.Method != "POST" { + t.Errorf("Expected POST request, got %s", r.Method) + } + if r.URL.Path != "/cards" { + t.Errorf("Expected path /cards, got %s", r.URL.Path) + } + + // Verify Content-Type + if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" { + t.Errorf("Expected Content-Type application/x-www-form-urlencoded, got %s", r.Header.Get("Content-Type")) + } + + // Parse form data + body, _ := io.ReadAll(r.Body) + values, _ := parseFormData(string(body)) + + // Verify required fields + if values["key"] != "test-key" { + t.Errorf("Expected key=test-key, got %s", values["key"]) + } + if values["token"] != "test-token" { + t.Errorf("Expected token=test-token, got %s", values["token"]) + } + if values["idList"] != "list-123" { + t.Errorf("Expected idList=list-123, got %s", values["idList"]) + } + if values["name"] != "Test Card" { + t.Errorf("Expected name=Test Card, got %s", values["name"]) + } + if values["desc"] != "Test description" { + t.Errorf("Expected desc=Test description, got %s", values["desc"]) + } + + // Return mock response + response := trelloCardResponse{ + ID: "card-456", + Name: "Test Card", + IDList: "list-123", + URL: "https://trello.com/c/card-456", + Desc: "Test description", + IDBoard: "board-789", + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + // Create client with mock server URL + client := &TrelloClient{ + apiKey: "test-key", + token: "test-token", + baseURL: server.URL, + httpClient: &http.Client{}, + } + + // Test CreateCard + ctx := context.Background() + card, err := client.CreateCard(ctx, "list-123", "Test Card", "Test description", nil) + + if err != nil { + t.Fatalf("CreateCard failed: %v", err) + } + + // Verify response + if card.ID != "card-456" { + t.Errorf("Expected card ID card-456, got %s", card.ID) + } + if card.Name != "Test Card" { + t.Errorf("Expected card name Test Card, got %s", card.Name) + } + if card.ListID != "list-123" { + t.Errorf("Expected list ID list-123, got %s", card.ListID) + } + if card.URL != "https://trello.com/c/card-456" { + t.Errorf("Expected URL https://trello.com/c/card-456, got %s", card.URL) + } +} + +func TestTrelloClient_CreateCard_WithDueDate(t *testing.T) { + dueDate := time.Date(2026, 1, 15, 12, 0, 0, 0, time.UTC) + + // Mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Parse form data + body, _ := io.ReadAll(r.Body) + values, _ := parseFormData(string(body)) + + // Verify due date is present + if values["due"] != dueDate.Format(time.RFC3339) { + t.Errorf("Expected due=%s, got %s", dueDate.Format(time.RFC3339), values["due"]) + } + + // Return mock response with due date + dueString := dueDate.Format(time.RFC3339) + response := trelloCardResponse{ + ID: "card-789", + Name: "Card with Due Date", + IDList: "list-456", + URL: "https://trello.com/c/card-789", + Due: &dueString, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + // Create client + client := &TrelloClient{ + apiKey: "test-key", + token: "test-token", + baseURL: server.URL, + httpClient: &http.Client{}, + } + + // Test CreateCard with due date + ctx := context.Background() + card, err := client.CreateCard(ctx, "list-456", "Card with Due Date", "", &dueDate) + + if err != nil { + t.Fatalf("CreateCard failed: %v", err) + } + + // Verify due date is set + if card.DueDate == nil { + t.Error("Expected due date to be set") + } else if !card.DueDate.Equal(dueDate) { + t.Errorf("Expected due date %v, got %v", dueDate, *card.DueDate) + } +} + +func TestTrelloClient_UpdateCard(t *testing.T) { + // Mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify method and path + if r.Method != "PUT" { + t.Errorf("Expected PUT request, got %s", r.Method) + } + if !strings.HasPrefix(r.URL.Path, "/cards/") { + t.Errorf("Expected path to start with /cards/, got %s", r.URL.Path) + } + + // Extract card ID from path + cardID := strings.TrimPrefix(r.URL.Path, "/cards/") + if cardID != "card-123" { + t.Errorf("Expected card ID card-123, got %s", cardID) + } + + // Parse form data + body, _ := io.ReadAll(r.Body) + values, _ := parseFormData(string(body)) + + // Verify updated fields + if values["name"] != "Updated Name" { + t.Errorf("Expected name=Updated Name, got %s", values["name"]) + } + if values["desc"] != "Updated description" { + t.Errorf("Expected desc=Updated description, got %s", values["desc"]) + } + + // Return 200 OK + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":"card-123","name":"Updated Name"}`)) + })) + defer server.Close() + + // Create client + client := &TrelloClient{ + apiKey: "test-key", + token: "test-token", + baseURL: server.URL, + httpClient: &http.Client{}, + } + + // Test UpdateCard + ctx := context.Background() + updates := map[string]interface{}{ + "name": "Updated Name", + "desc": "Updated description", + } + + err := client.UpdateCard(ctx, "card-123", updates) + + if err != nil { + t.Fatalf("UpdateCard failed: %v", err) + } +} + +func TestTrelloClient_UpdateCard_Error(t *testing.T) { + // Mock server that returns error + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"Invalid card ID"}`)) + })) + defer server.Close() + + // Create client + client := &TrelloClient{ + apiKey: "test-key", + token: "test-token", + baseURL: server.URL, + httpClient: &http.Client{}, + } + + // Test UpdateCard with error + ctx := context.Background() + updates := map[string]interface{}{ + "name": "Should Fail", + } + + err := client.UpdateCard(ctx, "invalid-card", updates) + + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "400") { + t.Errorf("Expected error to contain status 400, got: %v", err) + } +} + +// Helper function to parse form-encoded data +func parseFormData(data string) (map[string]string, error) { + values, err := url.ParseQuery(data) + if err != nil { + return nil, err + } + + result := make(map[string]string) + for key, vals := range values { + if len(vals) > 0 { + result[key] = vals[0] + } + } + return result, nil +} diff --git a/issues/phase3_step1_trello_write.md b/issues/phase3_step1_trello_write.md new file mode 100644 index 0000000..8f13e47 --- /dev/null +++ b/issues/phase3_step1_trello_write.md @@ -0,0 +1,78 @@ +# Phase 3 Step 1: Trello Write Operations + +**Status:** Active +**Priority:** High +**Feature:** Interactive Dashboard (Write Ops) + +## Description +Currently, the Trello client is read-only. We need to implement `CreateCard` and `UpdateCard` to enable interactivity (adding tasks, moving cards, completing items). + +## Requirements +1. **CreateCard:** + * Method: `POST /1/cards` + * Parameters: `name`, `idList`, `desc` (optional), `due` (optional). + * Returns: Created `models.Card`. + +2. **UpdateCard:** + * Method: `PUT /1/cards/{id}` + * Parameters: Flexible map of updates (e.g., `idList` to move, `closed=true` to archive). + * Returns: Updated `models.Card` (or just error). + +## Reproduction / Test Plan +Since we cannot hit the real Trello API in tests, we will use `httptest.Server` to mock the API responses. + +### `internal/api/trello_test.go` +```go +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "task-dashboard/internal/models" +) + +func TestTrelloClient_CreateCard(t *testing.T) { + // Mock Server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("Expected POST, got %s", r.Method) + } + if r.URL.Path != "/1/cards" { + t.Errorf("Expected /1/cards, got %s", r.URL.Path) + } + + // Verify params + r.ParseForm() + if r.Form.Get("name") != "New Task" { + t.Errorf("Expected name='New Task', got %s", r.Form.Get("name")) + } + + // Return mock response + card := models.Card{ + ID: "new-card-id", + Name: "New Task", + } + json.NewEncoder(w).Encode(card) + })) + defer server.Close() + + client := &TrelloClient{ + BaseURL: server.URL, + Key: "test-key", + Token: "test-token", + Client: server.Client(), + } + + card, err := client.CreateCard("list-id", "New Task", "Description", nil) + if err != nil { + t.Fatalf("CreateCard failed: %v", err) + } + if card.ID != "new-card-id" { + t.Errorf("Expected ID 'new-card-id', got %s", card.ID) + } +} +``` -- cgit v1.2.3