Include TotalSummary in CRUD responses to eliminate redundant API calls

Backend changes:
- Add WithSummaryResponse wrappers for Task, TaskCompletion, and Residence CRUD
- Update services to return summary with all mutations (create, update, delete)
- Update handlers to pass through new response types
- Add getSummaryForUser helper for fetching summary in CRUD operations
- Wire ResidenceService into TaskService for summary access
- Add summary field to JoinResidenceResponse

This optimization eliminates the need for a separate getSummary() call after
every task/residence mutation, reducing network calls from 2 to 1.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-08 10:39:33 -06:00
parent f88409cfb4
commit 1a48fbfb20
9 changed files with 351 additions and 134 deletions

View File

@@ -161,7 +161,7 @@ func (h *ResidenceHandler) DeleteResidence(c *gin.Context) {
return
}
err = h.residenceService.DeleteResidence(uint(residenceID), user.ID)
response, err := h.residenceService.DeleteResidence(uint(residenceID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrResidenceNotFound):
@@ -174,7 +174,7 @@ func (h *ResidenceHandler) DeleteResidence(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.residence_deleted")})
c.JSON(http.StatusOK, response)
}
// GenerateShareCode handles POST /api/residences/:id/generate-share-code/

View File

@@ -55,13 +55,18 @@ func TestResidenceHandler_CreateResidence(t *testing.T) {
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "My House", response["name"])
assert.Equal(t, "123 Main St", response["street_address"])
assert.Equal(t, "Austin", response["city"])
assert.Equal(t, "TX", response["state_province"])
assert.Equal(t, "78701", response["postal_code"])
assert.Equal(t, "USA", response["country"]) // Default
assert.Equal(t, true, response["is_primary"])
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
residenceData := response["data"].(map[string]interface{})
assert.Equal(t, "My House", residenceData["name"])
assert.Equal(t, "123 Main St", residenceData["street_address"])
assert.Equal(t, "Austin", residenceData["city"])
assert.Equal(t, "TX", residenceData["state_province"])
assert.Equal(t, "78701", residenceData["postal_code"])
assert.Equal(t, "USA", residenceData["country"]) // Default
assert.Equal(t, true, residenceData["is_primary"])
})
t.Run("creation with optional fields", func(t *testing.T) {
@@ -91,11 +96,16 @@ func TestResidenceHandler_CreateResidence(t *testing.T) {
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, float64(3), response["bedrooms"])
assert.Equal(t, "2.5", response["bathrooms"]) // Decimal serializes as string
assert.Equal(t, float64(2000), response["square_footage"])
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
residenceData := response["data"].(map[string]interface{})
assert.Equal(t, float64(3), residenceData["bedrooms"])
assert.Equal(t, "2.5", residenceData["bathrooms"]) // Decimal serializes as string
assert.Equal(t, float64(2000), residenceData["square_footage"])
// Note: first residence becomes primary by default even if is_primary=false is specified
assert.Contains(t, []interface{}{true, false}, response["is_primary"])
assert.Contains(t, []interface{}{true, false}, residenceData["is_primary"])
})
t.Run("creation with missing required fields", func(t *testing.T) {
@@ -214,8 +224,13 @@ func TestResidenceHandler_UpdateResidence(t *testing.T) {
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "Updated Name", response["name"])
assert.Equal(t, "Dallas", response["city"])
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
residenceData := response["data"].(map[string]interface{})
assert.Equal(t, "Updated Name", residenceData["name"])
assert.Equal(t, "Dallas", residenceData["city"])
})
t.Run("shared user cannot update", func(t *testing.T) {
@@ -258,8 +273,14 @@ func TestResidenceHandler_DeleteResidence(t *testing.T) {
testutil.AssertStatusCode(t, w, http.StatusOK)
response := testutil.ParseJSON(t, w.Body.Bytes())
assert.Contains(t, response["message"], "deleted")
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
assert.Contains(t, response["data"], "deleted")
})
}
@@ -326,6 +347,10 @@ func TestResidenceHandler_JoinWithCode(t *testing.T) {
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// JoinResidenceResponse includes summary
assert.Contains(t, response, "residence")
assert.Contains(t, response, "summary")
residenceResp := response["residence"].(map[string]interface{})
assert.Equal(t, "Join Test", residenceResp["name"])
})
@@ -457,23 +482,34 @@ func TestResidenceHandler_JSONResponses(t *testing.T) {
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Required fields
assert.Contains(t, response, "id")
assert.Contains(t, response, "name")
assert.Contains(t, response, "street_address")
assert.Contains(t, response, "city")
assert.Contains(t, response, "state_province")
assert.Contains(t, response, "postal_code")
assert.Contains(t, response, "country")
assert.Contains(t, response, "is_primary")
assert.Contains(t, response, "is_active")
assert.Contains(t, response, "created_at")
assert.Contains(t, response, "updated_at")
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
residenceData := response["data"].(map[string]interface{})
// Required fields in residence data
assert.Contains(t, residenceData, "id")
assert.Contains(t, residenceData, "name")
assert.Contains(t, residenceData, "street_address")
assert.Contains(t, residenceData, "city")
assert.Contains(t, residenceData, "state_province")
assert.Contains(t, residenceData, "postal_code")
assert.Contains(t, residenceData, "country")
assert.Contains(t, residenceData, "is_primary")
assert.Contains(t, residenceData, "is_active")
assert.Contains(t, residenceData, "created_at")
assert.Contains(t, residenceData, "updated_at")
// Type checks
assert.IsType(t, float64(0), response["id"])
assert.IsType(t, "", response["name"])
assert.IsType(t, true, response["is_primary"])
assert.IsType(t, float64(0), residenceData["id"])
assert.IsType(t, "", residenceData["name"])
assert.IsType(t, true, residenceData["is_primary"])
// Summary should have expected fields
summary := response["summary"].(map[string]interface{})
assert.Contains(t, summary, "total_residences")
assert.Contains(t, summary, "total_tasks")
})
t.Run("list response returns array", func(t *testing.T) {

View File

@@ -156,7 +156,7 @@ func (h *TaskHandler) DeleteTask(c *gin.Context) {
return
}
err = h.taskService.DeleteTask(uint(taskID), user.ID)
response, err := h.taskService.DeleteTask(uint(taskID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
@@ -168,7 +168,7 @@ func (h *TaskHandler) DeleteTask(c *gin.Context) {
}
return
}
c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.task_deleted")})
c.JSON(http.StatusOK, response)
}
// MarkInProgress handles POST /api/tasks/:id/mark-in-progress/
@@ -192,7 +192,7 @@ func (h *TaskHandler) MarkInProgress(c *gin.Context) {
}
return
}
c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.task_in_progress"), "task": response})
c.JSON(http.StatusOK, response)
}
// CancelTask handles POST /api/tasks/:id/cancel/
@@ -218,7 +218,7 @@ func (h *TaskHandler) CancelTask(c *gin.Context) {
}
return
}
c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.task_cancelled"), "task": response})
c.JSON(http.StatusOK, response)
}
// UncancelTask handles POST /api/tasks/:id/uncancel/
@@ -242,7 +242,7 @@ func (h *TaskHandler) UncancelTask(c *gin.Context) {
}
return
}
c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.task_uncancelled"), "task": response})
c.JSON(http.StatusOK, response)
}
// ArchiveTask handles POST /api/tasks/:id/archive/
@@ -268,7 +268,7 @@ func (h *TaskHandler) ArchiveTask(c *gin.Context) {
}
return
}
c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.task_archived"), "task": response})
c.JSON(http.StatusOK, response)
}
// UnarchiveTask handles POST /api/tasks/:id/unarchive/
@@ -292,7 +292,7 @@ func (h *TaskHandler) UnarchiveTask(c *gin.Context) {
}
return
}
c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.task_unarchived"), "task": response})
c.JSON(http.StatusOK, response)
}
// === Task Completions ===
@@ -455,7 +455,7 @@ func (h *TaskHandler) DeleteCompletion(c *gin.Context) {
return
}
err = h.taskService.DeleteCompletion(uint(completionID), user.ID)
response, err := h.taskService.DeleteCompletion(uint(completionID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrCompletionNotFound):
@@ -467,7 +467,7 @@ func (h *TaskHandler) DeleteCompletion(c *gin.Context) {
}
return
}
c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.completion_deleted")})
c.JSON(http.StatusOK, response)
}
// === Lookups ===

View File

@@ -55,11 +55,16 @@ func TestTaskHandler_CreateTask(t *testing.T) {
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "Fix leaky faucet", response["title"])
assert.Equal(t, "Kitchen faucet is dripping", response["description"])
assert.Equal(t, float64(residence.ID), response["residence_id"])
assert.Equal(t, false, response["is_cancelled"])
assert.Equal(t, false, response["is_archived"])
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
taskData := response["data"].(map[string]interface{})
assert.Equal(t, "Fix leaky faucet", taskData["title"])
assert.Equal(t, "Kitchen faucet is dripping", taskData["description"])
assert.Equal(t, float64(residence.ID), taskData["residence_id"])
assert.Equal(t, false, taskData["is_cancelled"])
assert.Equal(t, false, taskData["is_archived"])
})
t.Run("task creation with optional fields", func(t *testing.T) {
@@ -89,10 +94,15 @@ func TestTaskHandler_CreateTask(t *testing.T) {
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "Install new lights", response["title"])
assert.NotNil(t, response["category"])
assert.NotNil(t, response["priority"])
assert.Equal(t, "150.5", response["estimated_cost"]) // Decimal serializes as string
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
taskData := response["data"].(map[string]interface{})
assert.Equal(t, "Install new lights", taskData["title"])
assert.NotNil(t, taskData["category"])
assert.NotNil(t, taskData["priority"])
assert.Equal(t, "150.5", taskData["estimated_cost"]) // Decimal serializes as string
})
t.Run("task creation without residence access", func(t *testing.T) {
@@ -267,8 +277,13 @@ func TestTaskHandler_UpdateTask(t *testing.T) {
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "Updated Title", response["title"])
assert.Equal(t, "Updated description", response["description"])
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
taskData := response["data"].(map[string]interface{})
assert.Equal(t, "Updated Title", taskData["title"])
assert.Equal(t, "Updated description", taskData["description"])
})
}
@@ -287,8 +302,14 @@ func TestTaskHandler_DeleteTask(t *testing.T) {
testutil.AssertStatusCode(t, w, http.StatusOK)
response := testutil.ParseJSON(t, w.Body.Bytes())
assert.Contains(t, response["message"], "deleted")
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
assert.Contains(t, response["data"], "deleted")
})
}
@@ -311,10 +332,12 @@ func TestTaskHandler_CancelTask(t *testing.T) {
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Contains(t, response["message"], "cancelled")
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
taskResp := response["task"].(map[string]interface{})
assert.Equal(t, true, taskResp["is_cancelled"])
taskData := response["data"].(map[string]interface{})
assert.Equal(t, true, taskData["is_cancelled"])
})
t.Run("cancel already cancelled task", func(t *testing.T) {
@@ -348,8 +371,12 @@ func TestTaskHandler_UncancelTask(t *testing.T) {
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
taskResp := response["task"].(map[string]interface{})
assert.Equal(t, false, taskResp["is_cancelled"])
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
taskData := response["data"].(map[string]interface{})
assert.Equal(t, false, taskData["is_cancelled"])
})
}
@@ -372,8 +399,12 @@ func TestTaskHandler_ArchiveTask(t *testing.T) {
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
taskResp := response["task"].(map[string]interface{})
assert.Equal(t, true, taskResp["is_archived"])
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
taskData := response["data"].(map[string]interface{})
assert.Equal(t, true, taskData["is_archived"])
})
}
@@ -400,8 +431,12 @@ func TestTaskHandler_UnarchiveTask(t *testing.T) {
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
taskResp := response["task"].(map[string]interface{})
assert.Equal(t, false, taskResp["is_archived"])
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
taskData := response["data"].(map[string]interface{})
assert.Equal(t, false, taskData["is_archived"])
})
}
@@ -425,8 +460,10 @@ func TestTaskHandler_MarkInProgress(t *testing.T) {
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Contains(t, response["message"], "in progress")
assert.NotNil(t, response["task"])
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
assert.NotNil(t, response["data"])
})
}
@@ -456,9 +493,14 @@ func TestTaskHandler_CreateCompletion(t *testing.T) {
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
testutil.AssertJSONFieldExists(t, response, "id")
assert.Equal(t, float64(task.ID), response["task_id"])
assert.Equal(t, "Completed successfully", response["notes"])
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
completionData := response["data"].(map[string]interface{})
testutil.AssertJSONFieldExists(t, completionData, "id")
assert.Equal(t, float64(task.ID), completionData["task_id"])
assert.Equal(t, "Completed successfully", completionData["notes"])
})
}
@@ -548,8 +590,14 @@ func TestTaskHandler_DeleteCompletion(t *testing.T) {
testutil.AssertStatusCode(t, w, http.StatusOK)
response := testutil.ParseJSON(t, w.Body.Bytes())
assert.Contains(t, response["message"], "deleted")
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
assert.Contains(t, response["data"], "deleted")
})
}
@@ -645,22 +693,35 @@ func TestTaskHandler_JSONResponses(t *testing.T) {
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Required fields
assert.Contains(t, response, "id")
assert.Contains(t, response, "residence_id")
assert.Contains(t, response, "created_by_id")
assert.Contains(t, response, "title")
assert.Contains(t, response, "description")
assert.Contains(t, response, "is_cancelled")
assert.Contains(t, response, "is_archived")
assert.Contains(t, response, "created_at")
assert.Contains(t, response, "updated_at")
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
taskData := response["data"].(map[string]interface{})
// Required fields in task data
assert.Contains(t, taskData, "id")
assert.Contains(t, taskData, "residence_id")
assert.Contains(t, taskData, "created_by_id")
assert.Contains(t, taskData, "title")
assert.Contains(t, taskData, "description")
assert.Contains(t, taskData, "is_cancelled")
assert.Contains(t, taskData, "is_archived")
assert.Contains(t, taskData, "created_at")
assert.Contains(t, taskData, "updated_at")
// Type checks
assert.IsType(t, float64(0), response["id"])
assert.IsType(t, "", response["title"])
assert.IsType(t, false, response["is_cancelled"])
assert.IsType(t, false, response["is_archived"])
assert.IsType(t, float64(0), taskData["id"])
assert.IsType(t, "", taskData["title"])
assert.IsType(t, false, taskData["is_cancelled"])
assert.IsType(t, false, taskData["is_archived"])
// Summary should have expected fields
summary := response["summary"].(map[string]interface{})
assert.Contains(t, summary, "total_residences")
assert.Contains(t, summary, "total_tasks")
assert.Contains(t, summary, "total_pending")
assert.Contains(t, summary, "total_overdue")
})
t.Run("list response returns kanban board", func(t *testing.T) {