diff --git a/internal/dto/responses/task.go b/internal/dto/responses/task.go index cff8bab..89b4c1b 100644 --- a/internal/dto/responses/task.go +++ b/internal/dto/responses/task.go @@ -214,8 +214,20 @@ func NewTaskResponse(t *models.Task) TaskResponse { return NewTaskResponseWithThreshold(t, 30) } -// NewTaskResponseWithThreshold creates a TaskResponse with a custom days threshold for kanban column +// NewTaskResponseWithThreshold creates a TaskResponse with a custom days threshold for kanban column. +// WARNING: Uses UTC time for kanban column. Prefer NewTaskResponseWithTime for timezone-aware responses. func NewTaskResponseWithThreshold(t *models.Task, daysThreshold int) TaskResponse { + return newTaskResponseInternal(t, daysThreshold, time.Now().UTC()) +} + +// NewTaskResponseWithTime creates a TaskResponse with timezone-aware kanban column categorization. +// The `now` parameter should be the start of day in the user's timezone. +func NewTaskResponseWithTime(t *models.Task, daysThreshold int, now time.Time) TaskResponse { + return newTaskResponseInternal(t, daysThreshold, now) +} + +// newTaskResponseInternal is the internal implementation for creating task responses +func newTaskResponseInternal(t *models.Task, daysThreshold int, now time.Time) TaskResponse { resp := TaskResponse{ ID: t.ID, ResidenceID: t.ResidenceID, @@ -236,7 +248,7 @@ func NewTaskResponseWithThreshold(t *models.Task, daysThreshold int) TaskRespons IsArchived: t.IsArchived, ParentTaskID: t.ParentTaskID, CompletionCount: predicates.GetCompletionCount(t), - KanbanColumn: DetermineKanbanColumn(t, daysThreshold), + KanbanColumn: DetermineKanbanColumnWithTime(t, daysThreshold, now), CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt, } @@ -328,12 +340,19 @@ func NewTaskCompletionListResponse(completions []models.TaskCompletion) []TaskCo return results } -// NewTaskCompletionWithTaskResponse creates a TaskCompletionResponse with the updated task included +// NewTaskCompletionWithTaskResponse creates a TaskCompletionResponse with the updated task included. +// WARNING: Uses UTC time for kanban column. Prefer NewTaskCompletionWithTaskResponseWithTime for timezone-aware responses. func NewTaskCompletionWithTaskResponse(c *models.TaskCompletion, task *models.Task, daysThreshold int) TaskCompletionResponse { + return NewTaskCompletionWithTaskResponseWithTime(c, task, daysThreshold, time.Now().UTC()) +} + +// NewTaskCompletionWithTaskResponseWithTime creates a TaskCompletionResponse with timezone-aware kanban categorization. +// The `now` parameter should be the start of day in the user's timezone. +func NewTaskCompletionWithTaskResponseWithTime(c *models.TaskCompletion, task *models.Task, daysThreshold int, now time.Time) TaskCompletionResponse { resp := NewTaskCompletionResponse(c) if task != nil { - taskResp := NewTaskResponseWithThreshold(task, daysThreshold) + taskResp := NewTaskResponseWithTime(task, daysThreshold, now) resp.Task = &taskResp } @@ -343,10 +362,20 @@ func NewTaskCompletionWithTaskResponse(c *models.TaskCompletion, task *models.Ta // DetermineKanbanColumn determines which kanban column a task belongs to. // Delegates to internal/task/categorization package which is the single source // of truth for task categorization logic. +// +// WARNING: This uses UTC time which may cause incorrect categorization when +// server time is past midnight UTC but user's local time is still the previous day. +// Prefer DetermineKanbanColumnWithTime for timezone-aware categorization. func DetermineKanbanColumn(task *models.Task, daysThreshold int) string { return categorization.DetermineKanbanColumn(task, daysThreshold) } +// DetermineKanbanColumnWithTime determines which kanban column a task belongs to +// using a specific time (should be start of day in user's timezone). +func DetermineKanbanColumnWithTime(task *models.Task, daysThreshold int, now time.Time) string { + return categorization.DetermineKanbanColumnWithTime(task, daysThreshold, now) +} + // === Response Wrappers with Summary === // These wrap CRUD responses with TotalSummary to eliminate extra API calls diff --git a/internal/handlers/task_handler.go b/internal/handlers/task_handler.go index d4cb920..0850e47 100644 --- a/internal/handlers/task_handler.go +++ b/internal/handlers/task_handler.go @@ -103,13 +103,15 @@ func (h *TaskHandler) GetTasksByResidence(c *gin.Context) { // CreateTask handles POST /api/tasks/ func (h *TaskHandler) CreateTask(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) + userNow := middleware.GetUserNow(c) + var req requests.CreateTaskRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - response, err := h.taskService.CreateTask(&req, user.ID) + response, err := h.taskService.CreateTask(&req, user.ID, userNow) if err != nil { if errors.Is(err, services.ErrResidenceAccessDenied) { c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_access_denied")}) @@ -124,6 +126,8 @@ func (h *TaskHandler) CreateTask(c *gin.Context) { // UpdateTask handles PUT/PATCH /api/tasks/:id/ func (h *TaskHandler) UpdateTask(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) + userNow := middleware.GetUserNow(c) + taskID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")}) @@ -136,7 +140,7 @@ func (h *TaskHandler) UpdateTask(c *gin.Context) { return } - response, err := h.taskService.UpdateTask(uint(taskID), user.ID, &req) + response, err := h.taskService.UpdateTask(uint(taskID), user.ID, &req, userNow) if err != nil { switch { case errors.Is(err, services.ErrTaskNotFound): @@ -178,13 +182,15 @@ func (h *TaskHandler) DeleteTask(c *gin.Context) { // MarkInProgress handles POST /api/tasks/:id/mark-in-progress/ func (h *TaskHandler) MarkInProgress(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) + userNow := middleware.GetUserNow(c) + taskID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")}) return } - response, err := h.taskService.MarkInProgress(uint(taskID), user.ID) + response, err := h.taskService.MarkInProgress(uint(taskID), user.ID, userNow) if err != nil { switch { case errors.Is(err, services.ErrTaskNotFound): @@ -202,13 +208,15 @@ func (h *TaskHandler) MarkInProgress(c *gin.Context) { // CancelTask handles POST /api/tasks/:id/cancel/ func (h *TaskHandler) CancelTask(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) + userNow := middleware.GetUserNow(c) + taskID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")}) return } - response, err := h.taskService.CancelTask(uint(taskID), user.ID) + response, err := h.taskService.CancelTask(uint(taskID), user.ID, userNow) if err != nil { switch { case errors.Is(err, services.ErrTaskNotFound): @@ -228,13 +236,15 @@ func (h *TaskHandler) CancelTask(c *gin.Context) { // UncancelTask handles POST /api/tasks/:id/uncancel/ func (h *TaskHandler) UncancelTask(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) + userNow := middleware.GetUserNow(c) + taskID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")}) return } - response, err := h.taskService.UncancelTask(uint(taskID), user.ID) + response, err := h.taskService.UncancelTask(uint(taskID), user.ID, userNow) if err != nil { switch { case errors.Is(err, services.ErrTaskNotFound): @@ -252,13 +262,15 @@ func (h *TaskHandler) UncancelTask(c *gin.Context) { // ArchiveTask handles POST /api/tasks/:id/archive/ func (h *TaskHandler) ArchiveTask(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) + userNow := middleware.GetUserNow(c) + taskID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")}) return } - response, err := h.taskService.ArchiveTask(uint(taskID), user.ID) + response, err := h.taskService.ArchiveTask(uint(taskID), user.ID, userNow) if err != nil { switch { case errors.Is(err, services.ErrTaskNotFound): @@ -278,13 +290,15 @@ func (h *TaskHandler) ArchiveTask(c *gin.Context) { // UnarchiveTask handles POST /api/tasks/:id/unarchive/ func (h *TaskHandler) UnarchiveTask(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) + userNow := middleware.GetUserNow(c) + taskID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")}) return } - response, err := h.taskService.UnarchiveTask(uint(taskID), user.ID) + response, err := h.taskService.UnarchiveTask(uint(taskID), user.ID, userNow) if err != nil { switch { case errors.Is(err, services.ErrTaskNotFound): @@ -389,6 +403,8 @@ func (h *TaskHandler) GetCompletion(c *gin.Context) { // Supports both JSON and multipart form data (for image uploads) func (h *TaskHandler) CreateCompletion(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) + userNow := middleware.GetUserNow(c) + var req requests.CreateTaskCompletionRequest contentType := c.GetHeader("Content-Type") @@ -460,7 +476,7 @@ func (h *TaskHandler) CreateCompletion(c *gin.Context) { } } - response, err := h.taskService.CreateCompletion(&req, user.ID) + response, err := h.taskService.CreateCompletion(&req, user.ID, userNow) if err != nil { switch { case errors.Is(err, services.ErrTaskNotFound): diff --git a/internal/services/task_service.go b/internal/services/task_service.go index c238804..f0e5d29 100644 --- a/internal/services/task_service.go +++ b/internal/services/task_service.go @@ -149,8 +149,9 @@ func (s *TaskService) GetTasksByResidence(residenceID, userID uint, daysThreshol return &resp, nil } -// CreateTask creates a new task -func (s *TaskService) CreateTask(req *requests.CreateTaskRequest, userID uint) (*responses.TaskWithSummaryResponse, error) { +// CreateTask creates a new task. +// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization. +func (s *TaskService) CreateTask(req *requests.CreateTaskRequest, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) { // Check residence access hasAccess, err := s.residenceRepo.HasAccess(req.ResidenceID, userID) if err != nil { @@ -189,13 +190,14 @@ func (s *TaskService) CreateTask(req *requests.CreateTaskRequest, userID uint) ( } return &responses.TaskWithSummaryResponse{ - Data: responses.NewTaskResponse(task), + Data: responses.NewTaskResponseWithTime(task, 30, now), Summary: s.getSummaryForUser(userID), }, nil } -// UpdateTask updates a task -func (s *TaskService) UpdateTask(taskID, userID uint, req *requests.UpdateTaskRequest) (*responses.TaskWithSummaryResponse, error) { +// UpdateTask updates a task. +// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization. +func (s *TaskService) UpdateTask(taskID, userID uint, req *requests.UpdateTaskRequest, now time.Time) (*responses.TaskWithSummaryResponse, error) { task, err := s.taskRepo.FindByID(taskID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -268,7 +270,7 @@ func (s *TaskService) UpdateTask(taskID, userID uint, req *requests.UpdateTaskRe } return &responses.TaskWithSummaryResponse{ - Data: responses.NewTaskResponse(task), + Data: responses.NewTaskResponseWithTime(task, 30, now), Summary: s.getSummaryForUser(userID), }, nil } @@ -304,8 +306,9 @@ func (s *TaskService) DeleteTask(taskID, userID uint) (*responses.DeleteWithSumm // === Task Actions === -// MarkInProgress marks a task as in progress -func (s *TaskService) MarkInProgress(taskID, userID uint) (*responses.TaskWithSummaryResponse, error) { +// MarkInProgress marks a task as in progress. +// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization. +func (s *TaskService) MarkInProgress(taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) { task, err := s.taskRepo.FindByID(taskID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -334,13 +337,14 @@ func (s *TaskService) MarkInProgress(taskID, userID uint) (*responses.TaskWithSu } return &responses.TaskWithSummaryResponse{ - Data: responses.NewTaskResponse(task), + Data: responses.NewTaskResponseWithTime(task, 30, now), Summary: s.getSummaryForUser(userID), }, nil } -// CancelTask cancels a task -func (s *TaskService) CancelTask(taskID, userID uint) (*responses.TaskWithSummaryResponse, error) { +// CancelTask cancels a task. +// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization. +func (s *TaskService) CancelTask(taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) { task, err := s.taskRepo.FindByID(taskID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -373,13 +377,14 @@ func (s *TaskService) CancelTask(taskID, userID uint) (*responses.TaskWithSummar } return &responses.TaskWithSummaryResponse{ - Data: responses.NewTaskResponse(task), + Data: responses.NewTaskResponseWithTime(task, 30, now), Summary: s.getSummaryForUser(userID), }, nil } -// UncancelTask uncancels a task -func (s *TaskService) UncancelTask(taskID, userID uint) (*responses.TaskWithSummaryResponse, error) { +// UncancelTask uncancels a task. +// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization. +func (s *TaskService) UncancelTask(taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) { task, err := s.taskRepo.FindByID(taskID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -408,13 +413,14 @@ func (s *TaskService) UncancelTask(taskID, userID uint) (*responses.TaskWithSumm } return &responses.TaskWithSummaryResponse{ - Data: responses.NewTaskResponse(task), + Data: responses.NewTaskResponseWithTime(task, 30, now), Summary: s.getSummaryForUser(userID), }, nil } -// ArchiveTask archives a task -func (s *TaskService) ArchiveTask(taskID, userID uint) (*responses.TaskWithSummaryResponse, error) { +// ArchiveTask archives a task. +// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization. +func (s *TaskService) ArchiveTask(taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) { task, err := s.taskRepo.FindByID(taskID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -447,13 +453,14 @@ func (s *TaskService) ArchiveTask(taskID, userID uint) (*responses.TaskWithSumma } return &responses.TaskWithSummaryResponse{ - Data: responses.NewTaskResponse(task), + Data: responses.NewTaskResponseWithTime(task, 30, now), Summary: s.getSummaryForUser(userID), }, nil } -// UnarchiveTask unarchives a task -func (s *TaskService) UnarchiveTask(taskID, userID uint) (*responses.TaskWithSummaryResponse, error) { +// UnarchiveTask unarchives a task. +// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization. +func (s *TaskService) UnarchiveTask(taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) { task, err := s.taskRepo.FindByID(taskID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -482,15 +489,16 @@ func (s *TaskService) UnarchiveTask(taskID, userID uint) (*responses.TaskWithSum } return &responses.TaskWithSummaryResponse{ - Data: responses.NewTaskResponse(task), + Data: responses.NewTaskResponseWithTime(task, 30, now), Summary: s.getSummaryForUser(userID), }, nil } // === Task Completions === -// CreateCompletion creates a task completion -func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest, userID uint) (*responses.TaskCompletionWithSummaryResponse, error) { +// CreateCompletion creates a task completion. +// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization. +func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest, userID uint, now time.Time) (*responses.TaskCompletionWithSummaryResponse, error) { // Get the task task, err := s.taskRepo.FindByID(req.TaskID) if err != nil { @@ -600,7 +608,7 @@ func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest s.sendTaskCompletedNotification(task, completion) // Return completion with updated task (includes kanban_column for UI update) - resp := responses.NewTaskCompletionWithTaskResponse(completion, task, 30) + resp := responses.NewTaskCompletionWithTaskResponseWithTime(completion, task, 30, now) return &responses.TaskCompletionWithSummaryResponse{ Data: resp, Summary: s.getSummaryForUser(userID), diff --git a/internal/services/task_service_test.go b/internal/services/task_service_test.go index 9033fc4..ff911a6 100644 --- a/internal/services/task_service_test.go +++ b/internal/services/task_service_test.go @@ -39,7 +39,8 @@ func TestTaskService_CreateTask(t *testing.T) { Description: "Kitchen faucet is dripping", } - resp, err := service.CreateTask(req, user.ID) + now := time.Now().UTC() + resp, err := service.CreateTask(req, user.ID, now) require.NoError(t, err) assert.NotZero(t, resp.Data.ID) assert.Equal(t, "Fix leaky faucet", resp.Data.Title) @@ -74,7 +75,8 @@ func TestTaskService_CreateTask_WithOptionalFields(t *testing.T) { EstimatedCost: &cost, } - resp, err := service.CreateTask(req, user.ID) + now := time.Now().UTC() + resp, err := service.CreateTask(req, user.ID, now) require.NoError(t, err) // Note: Category and Priority are no longer preloaded for performance // Client resolves from cache using CategoryID and PriorityID @@ -100,7 +102,8 @@ func TestTaskService_CreateTask_AccessDenied(t *testing.T) { Title: "Test Task", } - _, err := service.CreateTask(req, otherUser.ID) + now := time.Now().UTC() + _, err := service.CreateTask(req, otherUser.ID, now) // When creating a task, residence access is checked first assert.ErrorIs(t, err, ErrResidenceAccessDenied) } @@ -180,7 +183,8 @@ func TestTaskService_UpdateTask(t *testing.T) { Description: &newDesc, } - resp, err := service.UpdateTask(task.ID, user.ID, req) + now := time.Now().UTC() + resp, err := service.UpdateTask(task.ID, user.ID, req, now) require.NoError(t, err) assert.Equal(t, "Updated Title", resp.Data.Title) assert.Equal(t, "Updated description", resp.Data.Description) @@ -215,7 +219,8 @@ func TestTaskService_CancelTask(t *testing.T) { residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task") - resp, err := service.CancelTask(task.ID, user.ID) + now := time.Now().UTC() + resp, err := service.CancelTask(task.ID, user.ID, now) require.NoError(t, err) assert.True(t, resp.Data.IsCancelled) } @@ -231,8 +236,9 @@ func TestTaskService_CancelTask_AlreadyCancelled(t *testing.T) { residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task") - service.CancelTask(task.ID, user.ID) - _, err := service.CancelTask(task.ID, user.ID) + now := time.Now().UTC() + service.CancelTask(task.ID, user.ID, now) + _, err := service.CancelTask(task.ID, user.ID, now) assert.ErrorIs(t, err, ErrTaskAlreadyCancelled) } @@ -247,8 +253,9 @@ func TestTaskService_UncancelTask(t *testing.T) { residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task") - service.CancelTask(task.ID, user.ID) - resp, err := service.UncancelTask(task.ID, user.ID) + now := time.Now().UTC() + service.CancelTask(task.ID, user.ID, now) + resp, err := service.UncancelTask(task.ID, user.ID, now) require.NoError(t, err) assert.False(t, resp.Data.IsCancelled) } @@ -264,7 +271,8 @@ func TestTaskService_ArchiveTask(t *testing.T) { residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task") - resp, err := service.ArchiveTask(task.ID, user.ID) + now := time.Now().UTC() + resp, err := service.ArchiveTask(task.ID, user.ID, now) require.NoError(t, err) assert.True(t, resp.Data.IsArchived) } @@ -280,8 +288,9 @@ func TestTaskService_UnarchiveTask(t *testing.T) { residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task") - service.ArchiveTask(task.ID, user.ID) - resp, err := service.UnarchiveTask(task.ID, user.ID) + now := time.Now().UTC() + service.ArchiveTask(task.ID, user.ID, now) + resp, err := service.UnarchiveTask(task.ID, user.ID, now) require.NoError(t, err) assert.False(t, resp.Data.IsArchived) } @@ -297,7 +306,8 @@ func TestTaskService_MarkInProgress(t *testing.T) { residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task") - resp, err := service.MarkInProgress(task.ID, user.ID) + now := time.Now().UTC() + resp, err := service.MarkInProgress(task.ID, user.ID, now) require.NoError(t, err) assert.True(t, resp.Data.InProgress) } @@ -318,7 +328,8 @@ func TestTaskService_CreateCompletion(t *testing.T) { Notes: "Completed successfully", } - resp, err := service.CreateCompletion(req, user.ID) + now := time.Now().UTC() + resp, err := service.CreateCompletion(req, user.ID, now) require.NoError(t, err) assert.NotZero(t, resp.Data.ID) assert.Equal(t, task.ID, resp.Data.TaskID) @@ -360,7 +371,8 @@ func TestTaskService_CreateCompletion_RecurringTask_ResetsInProgress(t *testing. Notes: "Monthly maintenance done", } - resp, err := service.CreateCompletion(req, user.ID) + now := time.Now().UTC() + resp, err := service.CreateCompletion(req, user.ID, now) require.NoError(t, err) assert.NotZero(t, resp.Data.ID) @@ -503,6 +515,7 @@ func TestTaskService_SharedUserAccess(t *testing.T) { ResidenceID: residence.ID, Title: "Shared User Task", } - _, err = service.CreateTask(req, sharedUser.ID) + now := time.Now().UTC() + _, err = service.CreateTask(req, sharedUser.ID, now) require.NoError(t, err) }