diff --git a/internal/dto/responses/residence.go b/internal/dto/responses/residence.go index 1317202..6e6611b 100644 --- a/internal/dto/responses/residence.go +++ b/internal/dto/responses/residence.go @@ -69,6 +69,18 @@ type MyResidencesResponse struct { Summary TotalSummary `json:"summary"` } +// ResidenceWithSummaryResponse wraps ResidenceResponse with TotalSummary for CRUD operations +type ResidenceWithSummaryResponse struct { + Data ResidenceResponse `json:"data"` + Summary TotalSummary `json:"summary"` +} + +// ResidenceDeleteWithSummaryResponse for delete operations +type ResidenceDeleteWithSummaryResponse struct { + Data string `json:"data"` + Summary TotalSummary `json:"summary"` +} + // ShareCodeResponse represents a share code in the API response type ShareCodeResponse struct { ID uint `json:"id"` @@ -84,6 +96,7 @@ type ShareCodeResponse struct { type JoinResidenceResponse struct { Message string `json:"message"` Residence ResidenceResponse `json:"residence"` + Summary TotalSummary `json:"summary"` } // GenerateShareCodeResponse represents the response after generating a share code diff --git a/internal/dto/responses/task.go b/internal/dto/responses/task.go index 961c9a2..46632bd 100644 --- a/internal/dto/responses/task.go +++ b/internal/dto/responses/task.go @@ -371,3 +371,24 @@ func NewTaskCompletionWithTaskResponse(c *models.TaskCompletion, task *models.Ta func DetermineKanbanColumn(task *models.Task, daysThreshold int) string { return categorization.DetermineKanbanColumn(task, daysThreshold) } + +// === Response Wrappers with Summary === +// These wrap CRUD responses with TotalSummary to eliminate extra API calls + +// TaskWithSummaryResponse wraps TaskResponse with TotalSummary +type TaskWithSummaryResponse struct { + Data TaskResponse `json:"data"` + Summary TotalSummary `json:"summary"` +} + +// TaskCompletionWithSummaryResponse wraps TaskCompletionResponse with TotalSummary +type TaskCompletionWithSummaryResponse struct { + Data TaskCompletionResponse `json:"data"` + Summary TotalSummary `json:"summary"` +} + +// DeleteWithSummaryResponse for delete operations +type DeleteWithSummaryResponse struct { + Data string `json:"data"` + Summary TotalSummary `json:"summary"` +} diff --git a/internal/handlers/residence_handler.go b/internal/handlers/residence_handler.go index d93072e..9efb4f0 100644 --- a/internal/handlers/residence_handler.go +++ b/internal/handlers/residence_handler.go @@ -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/ diff --git a/internal/handlers/residence_handler_test.go b/internal/handlers/residence_handler_test.go index 5360223..1742f54 100644 --- a/internal/handlers/residence_handler_test.go +++ b/internal/handlers/residence_handler_test.go @@ -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) { diff --git a/internal/handlers/task_handler.go b/internal/handlers/task_handler.go index 0edf968..3b0ae3d 100644 --- a/internal/handlers/task_handler.go +++ b/internal/handlers/task_handler.go @@ -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 === diff --git a/internal/handlers/task_handler_test.go b/internal/handlers/task_handler_test.go index 7a2bb47..b777d3d 100644 --- a/internal/handlers/task_handler_test.go +++ b/internal/handlers/task_handler_test.go @@ -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) { diff --git a/internal/router/router.go b/internal/router/router.go index 377c8f9..8beeb59 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -96,9 +96,10 @@ func SetupRouter(deps *Dependencies) *gin.Engine { documentService := services.NewDocumentService(documentRepo, residenceRepo) notificationService := services.NewNotificationService(notificationRepo, deps.PushClient) - // Wire up notification and email services to task service (for task completion notifications) + // Wire up notification, email, and residence services to task service taskService.SetNotificationService(notificationService) taskService.SetEmailService(deps.EmailService) + taskService.SetResidenceService(residenceService) // For including TotalSummary in CRUD responses subscriptionService := services.NewSubscriptionService(subscriptionRepo, residenceRepo, taskRepo, contractorRepo, documentRepo) taskTemplateService := services.NewTaskTemplateService(taskTemplateRepo) diff --git a/internal/services/residence_service.go b/internal/services/residence_service.go index 01618d5..f7469d1 100644 --- a/internal/services/residence_service.go +++ b/internal/services/residence_service.go @@ -165,8 +165,17 @@ func (s *ResidenceService) GetSummary(userID uint) (*responses.TotalSummary, err return summary, nil } -// CreateResidence creates a new residence -func (s *ResidenceService) CreateResidence(req *requests.CreateResidenceRequest, ownerID uint) (*responses.ResidenceResponse, error) { +// getSummaryForUser is a helper that returns summary for a user, or empty summary on error +func (s *ResidenceService) getSummaryForUser(userID uint) responses.TotalSummary { + summary, err := s.GetSummary(userID) + if err != nil || summary == nil { + return responses.TotalSummary{} + } + return *summary +} + +// CreateResidence creates a new residence and returns it with updated summary +func (s *ResidenceService) CreateResidence(req *requests.CreateResidenceRequest, ownerID uint) (*responses.ResidenceWithSummaryResponse, error) { // TODO: Check subscription tier limits // count, err := s.residenceRepo.CountByOwner(ownerID) // if err != nil { @@ -217,12 +226,17 @@ func (s *ResidenceService) CreateResidence(req *requests.CreateResidenceRequest, return nil, err } - resp := responses.NewResidenceResponse(residence) - return &resp, nil + // Get updated summary + summary := s.getSummaryForUser(ownerID) + + return &responses.ResidenceWithSummaryResponse{ + Data: responses.NewResidenceResponse(residence), + Summary: summary, + }, nil } -// UpdateResidence updates a residence -func (s *ResidenceService) UpdateResidence(residenceID, userID uint, req *requests.UpdateResidenceRequest) (*responses.ResidenceResponse, error) { +// UpdateResidence updates a residence and returns it with updated summary +func (s *ResidenceService) UpdateResidence(residenceID, userID uint, req *requests.UpdateResidenceRequest) (*responses.ResidenceWithSummaryResponse, error) { // Check ownership isOwner, err := s.residenceRepo.IsOwner(residenceID, userID) if err != nil { @@ -303,22 +317,37 @@ func (s *ResidenceService) UpdateResidence(residenceID, userID uint, req *reques return nil, err } - resp := responses.NewResidenceResponse(residence) - return &resp, nil + // Get updated summary + summary := s.getSummaryForUser(userID) + + return &responses.ResidenceWithSummaryResponse{ + Data: responses.NewResidenceResponse(residence), + Summary: summary, + }, nil } -// DeleteResidence soft-deletes a residence -func (s *ResidenceService) DeleteResidence(residenceID, userID uint) error { +// DeleteResidence soft-deletes a residence and returns updated summary +func (s *ResidenceService) DeleteResidence(residenceID, userID uint) (*responses.ResidenceDeleteWithSummaryResponse, error) { // Check ownership isOwner, err := s.residenceRepo.IsOwner(residenceID, userID) if err != nil { - return err + return nil, err } if !isOwner { - return ErrNotResidenceOwner + return nil, ErrNotResidenceOwner } - return s.residenceRepo.Delete(residenceID) + if err := s.residenceRepo.Delete(residenceID); err != nil { + return nil, err + } + + // Get updated summary + summary := s.getSummaryForUser(userID) + + return &responses.ResidenceDeleteWithSummaryResponse{ + Data: "residence deleted", + Summary: summary, + }, nil } // GenerateShareCode generates a new share code for a residence @@ -427,9 +456,13 @@ func (s *ResidenceService) JoinWithCode(code string, userID uint) (*responses.Jo return nil, err } + // Get updated summary for the user + summary := s.getSummaryForUser(userID) + return &responses.JoinResidenceResponse{ Message: "Successfully joined residence", Residence: responses.NewResidenceResponse(residence), + Summary: summary, }, nil } diff --git a/internal/services/task_service.go b/internal/services/task_service.go index 9686dc0..9d5aea7 100644 --- a/internal/services/task_service.go +++ b/internal/services/task_service.go @@ -27,6 +27,7 @@ var ( type TaskService struct { taskRepo *repositories.TaskRepository residenceRepo *repositories.ResidenceRepository + residenceService *ResidenceService notificationService *NotificationService emailService *EmailService } @@ -49,6 +50,23 @@ func (s *TaskService) SetEmailService(es *EmailService) { s.emailService = es } +// SetResidenceService sets the residence service (for getting summary in CRUD responses) +func (s *TaskService) SetResidenceService(rs *ResidenceService) { + s.residenceService = rs +} + +// getSummaryForUser gets the total summary for a user (helper for CRUD responses) +func (s *TaskService) getSummaryForUser(userID uint) responses.TotalSummary { + if s.residenceService == nil { + return responses.TotalSummary{} + } + summary, err := s.residenceService.GetSummary(userID) + if err != nil || summary == nil { + return responses.TotalSummary{} + } + return *summary +} + // === Task CRUD === // GetTask gets a task by ID with access check @@ -131,7 +149,7 @@ func (s *TaskService) GetTasksByResidence(residenceID, userID uint, daysThreshol } // CreateTask creates a new task -func (s *TaskService) CreateTask(req *requests.CreateTaskRequest, userID uint) (*responses.TaskResponse, error) { +func (s *TaskService) CreateTask(req *requests.CreateTaskRequest, userID uint) (*responses.TaskWithSummaryResponse, error) { // Check residence access hasAccess, err := s.residenceRepo.HasAccess(req.ResidenceID, userID) if err != nil { @@ -168,12 +186,14 @@ func (s *TaskService) CreateTask(req *requests.CreateTaskRequest, userID uint) ( return nil, err } - resp := responses.NewTaskResponse(task) - return &resp, nil + return &responses.TaskWithSummaryResponse{ + Data: responses.NewTaskResponse(task), + Summary: s.getSummaryForUser(userID), + }, nil } // UpdateTask updates a task -func (s *TaskService) UpdateTask(taskID, userID uint, req *requests.UpdateTaskRequest) (*responses.TaskResponse, error) { +func (s *TaskService) UpdateTask(taskID, userID uint, req *requests.UpdateTaskRequest) (*responses.TaskWithSummaryResponse, error) { task, err := s.taskRepo.FindByID(taskID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -242,36 +262,45 @@ func (s *TaskService) UpdateTask(taskID, userID uint, req *requests.UpdateTaskRe return nil, err } - resp := responses.NewTaskResponse(task) - return &resp, nil + return &responses.TaskWithSummaryResponse{ + Data: responses.NewTaskResponse(task), + Summary: s.getSummaryForUser(userID), + }, nil } // DeleteTask deletes a task -func (s *TaskService) DeleteTask(taskID, userID uint) error { +func (s *TaskService) DeleteTask(taskID, userID uint) (*responses.DeleteWithSummaryResponse, error) { task, err := s.taskRepo.FindByID(taskID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return ErrTaskNotFound + return nil, ErrTaskNotFound } - return err + return nil, err } // Check access hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID) if err != nil { - return err + return nil, err } if !hasAccess { - return ErrTaskAccessDenied + return nil, ErrTaskAccessDenied } - return s.taskRepo.Delete(taskID) + if err := s.taskRepo.Delete(taskID); err != nil { + return nil, err + } + + return &responses.DeleteWithSummaryResponse{ + Data: "task deleted", + Summary: s.getSummaryForUser(userID), + }, nil } // === Task Actions === // MarkInProgress marks a task as in progress -func (s *TaskService) MarkInProgress(taskID, userID uint) (*responses.TaskResponse, error) { +func (s *TaskService) MarkInProgress(taskID, userID uint) (*responses.TaskWithSummaryResponse, error) { task, err := s.taskRepo.FindByID(taskID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -305,12 +334,14 @@ func (s *TaskService) MarkInProgress(taskID, userID uint) (*responses.TaskRespon return nil, err } - resp := responses.NewTaskResponse(task) - return &resp, nil + return &responses.TaskWithSummaryResponse{ + Data: responses.NewTaskResponse(task), + Summary: s.getSummaryForUser(userID), + }, nil } // CancelTask cancels a task -func (s *TaskService) CancelTask(taskID, userID uint) (*responses.TaskResponse, error) { +func (s *TaskService) CancelTask(taskID, userID uint) (*responses.TaskWithSummaryResponse, error) { task, err := s.taskRepo.FindByID(taskID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -342,12 +373,14 @@ func (s *TaskService) CancelTask(taskID, userID uint) (*responses.TaskResponse, return nil, err } - resp := responses.NewTaskResponse(task) - return &resp, nil + return &responses.TaskWithSummaryResponse{ + Data: responses.NewTaskResponse(task), + Summary: s.getSummaryForUser(userID), + }, nil } // UncancelTask uncancels a task -func (s *TaskService) UncancelTask(taskID, userID uint) (*responses.TaskResponse, error) { +func (s *TaskService) UncancelTask(taskID, userID uint) (*responses.TaskWithSummaryResponse, error) { task, err := s.taskRepo.FindByID(taskID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -375,12 +408,14 @@ func (s *TaskService) UncancelTask(taskID, userID uint) (*responses.TaskResponse return nil, err } - resp := responses.NewTaskResponse(task) - return &resp, nil + return &responses.TaskWithSummaryResponse{ + Data: responses.NewTaskResponse(task), + Summary: s.getSummaryForUser(userID), + }, nil } // ArchiveTask archives a task -func (s *TaskService) ArchiveTask(taskID, userID uint) (*responses.TaskResponse, error) { +func (s *TaskService) ArchiveTask(taskID, userID uint) (*responses.TaskWithSummaryResponse, error) { task, err := s.taskRepo.FindByID(taskID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -412,12 +447,14 @@ func (s *TaskService) ArchiveTask(taskID, userID uint) (*responses.TaskResponse, return nil, err } - resp := responses.NewTaskResponse(task) - return &resp, nil + return &responses.TaskWithSummaryResponse{ + Data: responses.NewTaskResponse(task), + Summary: s.getSummaryForUser(userID), + }, nil } // UnarchiveTask unarchives a task -func (s *TaskService) UnarchiveTask(taskID, userID uint) (*responses.TaskResponse, error) { +func (s *TaskService) UnarchiveTask(taskID, userID uint) (*responses.TaskWithSummaryResponse, error) { task, err := s.taskRepo.FindByID(taskID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -445,14 +482,16 @@ func (s *TaskService) UnarchiveTask(taskID, userID uint) (*responses.TaskRespons return nil, err } - resp := responses.NewTaskResponse(task) - return &resp, nil + return &responses.TaskWithSummaryResponse{ + Data: responses.NewTaskResponse(task), + Summary: s.getSummaryForUser(userID), + }, nil } // === Task Completions === // CreateCompletion creates a task completion -func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest, userID uint) (*responses.TaskCompletionResponse, error) { +func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest, userID uint) (*responses.TaskCompletionWithSummaryResponse, error) { // Get the task task, err := s.taskRepo.FindByID(req.TaskID) if err != nil { @@ -537,7 +576,10 @@ func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest // Non-fatal - still return the completion, just without the task log.Warn().Err(err).Uint("task_id", req.TaskID).Msg("Failed to reload task after completion") resp := responses.NewTaskCompletionResponse(completion) - return &resp, nil + return &responses.TaskCompletionWithSummaryResponse{ + Data: resp, + Summary: s.getSummaryForUser(userID), + }, nil } // Send notification to residence owner and other users @@ -545,7 +587,10 @@ func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest // Return completion with updated task (includes kanban_column for UI update) resp := responses.NewTaskCompletionWithTaskResponse(completion, task, 30) - return &resp, nil + return &responses.TaskCompletionWithSummaryResponse{ + Data: resp, + Summary: s.getSummaryForUser(userID), + }, nil } // sendTaskCompletedNotification sends notifications when a task is completed @@ -661,25 +706,32 @@ func (s *TaskService) ListCompletions(userID uint) ([]responses.TaskCompletionRe } // DeleteCompletion deletes a task completion -func (s *TaskService) DeleteCompletion(completionID, userID uint) error { +func (s *TaskService) DeleteCompletion(completionID, userID uint) (*responses.DeleteWithSummaryResponse, error) { completion, err := s.taskRepo.FindCompletionByID(completionID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return ErrCompletionNotFound + return nil, ErrCompletionNotFound } - return err + return nil, err } // Check access hasAccess, err := s.residenceRepo.HasAccess(completion.Task.ResidenceID, userID) if err != nil { - return err + return nil, err } if !hasAccess { - return ErrTaskAccessDenied + return nil, ErrTaskAccessDenied } - return s.taskRepo.DeleteCompletion(completionID) + if err := s.taskRepo.DeleteCompletion(completionID); err != nil { + return nil, err + } + + return &responses.DeleteWithSummaryResponse{ + Data: "completion deleted", + Summary: s.getSummaryForUser(userID), + }, nil } // GetCompletionsByTask gets all completions for a specific task