Fix timezone bug in task kanban categorization
Task creation/update responses were using UTC time for kanban column categorization, causing tasks to incorrectly appear as overdue when the server had passed midnight UTC but the user's local time was still the previous day. Changes: - Add timezone-aware response functions (NewTaskResponseWithTime, etc.) - Pass userNow from middleware to all task service methods - Update handlers to use timezone-aware time from X-Timezone header - Update tests to pass the now parameter 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -214,8 +214,20 @@ func NewTaskResponse(t *models.Task) TaskResponse {
|
|||||||
return NewTaskResponseWithThreshold(t, 30)
|
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 {
|
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{
|
resp := TaskResponse{
|
||||||
ID: t.ID,
|
ID: t.ID,
|
||||||
ResidenceID: t.ResidenceID,
|
ResidenceID: t.ResidenceID,
|
||||||
@@ -236,7 +248,7 @@ func NewTaskResponseWithThreshold(t *models.Task, daysThreshold int) TaskRespons
|
|||||||
IsArchived: t.IsArchived,
|
IsArchived: t.IsArchived,
|
||||||
ParentTaskID: t.ParentTaskID,
|
ParentTaskID: t.ParentTaskID,
|
||||||
CompletionCount: predicates.GetCompletionCount(t),
|
CompletionCount: predicates.GetCompletionCount(t),
|
||||||
KanbanColumn: DetermineKanbanColumn(t, daysThreshold),
|
KanbanColumn: DetermineKanbanColumnWithTime(t, daysThreshold, now),
|
||||||
CreatedAt: t.CreatedAt,
|
CreatedAt: t.CreatedAt,
|
||||||
UpdatedAt: t.UpdatedAt,
|
UpdatedAt: t.UpdatedAt,
|
||||||
}
|
}
|
||||||
@@ -328,12 +340,19 @@ func NewTaskCompletionListResponse(completions []models.TaskCompletion) []TaskCo
|
|||||||
return results
|
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 {
|
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)
|
resp := NewTaskCompletionResponse(c)
|
||||||
|
|
||||||
if task != nil {
|
if task != nil {
|
||||||
taskResp := NewTaskResponseWithThreshold(task, daysThreshold)
|
taskResp := NewTaskResponseWithTime(task, daysThreshold, now)
|
||||||
resp.Task = &taskResp
|
resp.Task = &taskResp
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,10 +362,20 @@ func NewTaskCompletionWithTaskResponse(c *models.TaskCompletion, task *models.Ta
|
|||||||
// DetermineKanbanColumn determines which kanban column a task belongs to.
|
// DetermineKanbanColumn determines which kanban column a task belongs to.
|
||||||
// Delegates to internal/task/categorization package which is the single source
|
// Delegates to internal/task/categorization package which is the single source
|
||||||
// of truth for task categorization logic.
|
// 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 {
|
func DetermineKanbanColumn(task *models.Task, daysThreshold int) string {
|
||||||
return categorization.DetermineKanbanColumn(task, daysThreshold)
|
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 ===
|
// === Response Wrappers with Summary ===
|
||||||
// These wrap CRUD responses with TotalSummary to eliminate extra API calls
|
// These wrap CRUD responses with TotalSummary to eliminate extra API calls
|
||||||
|
|
||||||
|
|||||||
@@ -103,13 +103,15 @@ func (h *TaskHandler) GetTasksByResidence(c *gin.Context) {
|
|||||||
// CreateTask handles POST /api/tasks/
|
// CreateTask handles POST /api/tasks/
|
||||||
func (h *TaskHandler) CreateTask(c *gin.Context) {
|
func (h *TaskHandler) CreateTask(c *gin.Context) {
|
||||||
user := c.MustGet(middleware.AuthUserKey).(*models.User)
|
user := c.MustGet(middleware.AuthUserKey).(*models.User)
|
||||||
|
userNow := middleware.GetUserNow(c)
|
||||||
|
|
||||||
var req requests.CreateTaskRequest
|
var req requests.CreateTaskRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := h.taskService.CreateTask(&req, user.ID)
|
response, err := h.taskService.CreateTask(&req, user.ID, userNow)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, services.ErrResidenceAccessDenied) {
|
if errors.Is(err, services.ErrResidenceAccessDenied) {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_access_denied")})
|
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/
|
// UpdateTask handles PUT/PATCH /api/tasks/:id/
|
||||||
func (h *TaskHandler) UpdateTask(c *gin.Context) {
|
func (h *TaskHandler) UpdateTask(c *gin.Context) {
|
||||||
user := c.MustGet(middleware.AuthUserKey).(*models.User)
|
user := c.MustGet(middleware.AuthUserKey).(*models.User)
|
||||||
|
userNow := middleware.GetUserNow(c)
|
||||||
|
|
||||||
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
|
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
|
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 {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, services.ErrTaskNotFound):
|
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/
|
// MarkInProgress handles POST /api/tasks/:id/mark-in-progress/
|
||||||
func (h *TaskHandler) MarkInProgress(c *gin.Context) {
|
func (h *TaskHandler) MarkInProgress(c *gin.Context) {
|
||||||
user := c.MustGet(middleware.AuthUserKey).(*models.User)
|
user := c.MustGet(middleware.AuthUserKey).(*models.User)
|
||||||
|
userNow := middleware.GetUserNow(c)
|
||||||
|
|
||||||
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
|
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := h.taskService.MarkInProgress(uint(taskID), user.ID)
|
response, err := h.taskService.MarkInProgress(uint(taskID), user.ID, userNow)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, services.ErrTaskNotFound):
|
case errors.Is(err, services.ErrTaskNotFound):
|
||||||
@@ -202,13 +208,15 @@ func (h *TaskHandler) MarkInProgress(c *gin.Context) {
|
|||||||
// CancelTask handles POST /api/tasks/:id/cancel/
|
// CancelTask handles POST /api/tasks/:id/cancel/
|
||||||
func (h *TaskHandler) CancelTask(c *gin.Context) {
|
func (h *TaskHandler) CancelTask(c *gin.Context) {
|
||||||
user := c.MustGet(middleware.AuthUserKey).(*models.User)
|
user := c.MustGet(middleware.AuthUserKey).(*models.User)
|
||||||
|
userNow := middleware.GetUserNow(c)
|
||||||
|
|
||||||
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
|
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := h.taskService.CancelTask(uint(taskID), user.ID)
|
response, err := h.taskService.CancelTask(uint(taskID), user.ID, userNow)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, services.ErrTaskNotFound):
|
case errors.Is(err, services.ErrTaskNotFound):
|
||||||
@@ -228,13 +236,15 @@ func (h *TaskHandler) CancelTask(c *gin.Context) {
|
|||||||
// UncancelTask handles POST /api/tasks/:id/uncancel/
|
// UncancelTask handles POST /api/tasks/:id/uncancel/
|
||||||
func (h *TaskHandler) UncancelTask(c *gin.Context) {
|
func (h *TaskHandler) UncancelTask(c *gin.Context) {
|
||||||
user := c.MustGet(middleware.AuthUserKey).(*models.User)
|
user := c.MustGet(middleware.AuthUserKey).(*models.User)
|
||||||
|
userNow := middleware.GetUserNow(c)
|
||||||
|
|
||||||
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
|
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := h.taskService.UncancelTask(uint(taskID), user.ID)
|
response, err := h.taskService.UncancelTask(uint(taskID), user.ID, userNow)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, services.ErrTaskNotFound):
|
case errors.Is(err, services.ErrTaskNotFound):
|
||||||
@@ -252,13 +262,15 @@ func (h *TaskHandler) UncancelTask(c *gin.Context) {
|
|||||||
// ArchiveTask handles POST /api/tasks/:id/archive/
|
// ArchiveTask handles POST /api/tasks/:id/archive/
|
||||||
func (h *TaskHandler) ArchiveTask(c *gin.Context) {
|
func (h *TaskHandler) ArchiveTask(c *gin.Context) {
|
||||||
user := c.MustGet(middleware.AuthUserKey).(*models.User)
|
user := c.MustGet(middleware.AuthUserKey).(*models.User)
|
||||||
|
userNow := middleware.GetUserNow(c)
|
||||||
|
|
||||||
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
|
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := h.taskService.ArchiveTask(uint(taskID), user.ID)
|
response, err := h.taskService.ArchiveTask(uint(taskID), user.ID, userNow)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, services.ErrTaskNotFound):
|
case errors.Is(err, services.ErrTaskNotFound):
|
||||||
@@ -278,13 +290,15 @@ func (h *TaskHandler) ArchiveTask(c *gin.Context) {
|
|||||||
// UnarchiveTask handles POST /api/tasks/:id/unarchive/
|
// UnarchiveTask handles POST /api/tasks/:id/unarchive/
|
||||||
func (h *TaskHandler) UnarchiveTask(c *gin.Context) {
|
func (h *TaskHandler) UnarchiveTask(c *gin.Context) {
|
||||||
user := c.MustGet(middleware.AuthUserKey).(*models.User)
|
user := c.MustGet(middleware.AuthUserKey).(*models.User)
|
||||||
|
userNow := middleware.GetUserNow(c)
|
||||||
|
|
||||||
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
|
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := h.taskService.UnarchiveTask(uint(taskID), user.ID)
|
response, err := h.taskService.UnarchiveTask(uint(taskID), user.ID, userNow)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, services.ErrTaskNotFound):
|
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)
|
// Supports both JSON and multipart form data (for image uploads)
|
||||||
func (h *TaskHandler) CreateCompletion(c *gin.Context) {
|
func (h *TaskHandler) CreateCompletion(c *gin.Context) {
|
||||||
user := c.MustGet(middleware.AuthUserKey).(*models.User)
|
user := c.MustGet(middleware.AuthUserKey).(*models.User)
|
||||||
|
userNow := middleware.GetUserNow(c)
|
||||||
|
|
||||||
var req requests.CreateTaskCompletionRequest
|
var req requests.CreateTaskCompletionRequest
|
||||||
|
|
||||||
contentType := c.GetHeader("Content-Type")
|
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 {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, services.ErrTaskNotFound):
|
case errors.Is(err, services.ErrTaskNotFound):
|
||||||
|
|||||||
@@ -149,8 +149,9 @@ func (s *TaskService) GetTasksByResidence(residenceID, userID uint, daysThreshol
|
|||||||
return &resp, nil
|
return &resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateTask creates a new task
|
// CreateTask creates a new task.
|
||||||
func (s *TaskService) CreateTask(req *requests.CreateTaskRequest, userID uint) (*responses.TaskWithSummaryResponse, error) {
|
// 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
|
// Check residence access
|
||||||
hasAccess, err := s.residenceRepo.HasAccess(req.ResidenceID, userID)
|
hasAccess, err := s.residenceRepo.HasAccess(req.ResidenceID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -189,13 +190,14 @@ func (s *TaskService) CreateTask(req *requests.CreateTaskRequest, userID uint) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &responses.TaskWithSummaryResponse{
|
return &responses.TaskWithSummaryResponse{
|
||||||
Data: responses.NewTaskResponse(task),
|
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
||||||
Summary: s.getSummaryForUser(userID),
|
Summary: s.getSummaryForUser(userID),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateTask updates a task
|
// UpdateTask updates a task.
|
||||||
func (s *TaskService) UpdateTask(taskID, userID uint, req *requests.UpdateTaskRequest) (*responses.TaskWithSummaryResponse, error) {
|
// 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)
|
task, err := s.taskRepo.FindByID(taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
@@ -268,7 +270,7 @@ func (s *TaskService) UpdateTask(taskID, userID uint, req *requests.UpdateTaskRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &responses.TaskWithSummaryResponse{
|
return &responses.TaskWithSummaryResponse{
|
||||||
Data: responses.NewTaskResponse(task),
|
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
||||||
Summary: s.getSummaryForUser(userID),
|
Summary: s.getSummaryForUser(userID),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -304,8 +306,9 @@ func (s *TaskService) DeleteTask(taskID, userID uint) (*responses.DeleteWithSumm
|
|||||||
|
|
||||||
// === Task Actions ===
|
// === Task Actions ===
|
||||||
|
|
||||||
// MarkInProgress marks a task as in progress
|
// MarkInProgress marks a task as in progress.
|
||||||
func (s *TaskService) MarkInProgress(taskID, userID uint) (*responses.TaskWithSummaryResponse, error) {
|
// 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)
|
task, err := s.taskRepo.FindByID(taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
@@ -334,13 +337,14 @@ func (s *TaskService) MarkInProgress(taskID, userID uint) (*responses.TaskWithSu
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &responses.TaskWithSummaryResponse{
|
return &responses.TaskWithSummaryResponse{
|
||||||
Data: responses.NewTaskResponse(task),
|
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
||||||
Summary: s.getSummaryForUser(userID),
|
Summary: s.getSummaryForUser(userID),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CancelTask cancels a task
|
// CancelTask cancels a task.
|
||||||
func (s *TaskService) CancelTask(taskID, userID uint) (*responses.TaskWithSummaryResponse, error) {
|
// 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)
|
task, err := s.taskRepo.FindByID(taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
@@ -373,13 +377,14 @@ func (s *TaskService) CancelTask(taskID, userID uint) (*responses.TaskWithSummar
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &responses.TaskWithSummaryResponse{
|
return &responses.TaskWithSummaryResponse{
|
||||||
Data: responses.NewTaskResponse(task),
|
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
||||||
Summary: s.getSummaryForUser(userID),
|
Summary: s.getSummaryForUser(userID),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UncancelTask uncancels a task
|
// UncancelTask uncancels a task.
|
||||||
func (s *TaskService) UncancelTask(taskID, userID uint) (*responses.TaskWithSummaryResponse, error) {
|
// 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)
|
task, err := s.taskRepo.FindByID(taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
@@ -408,13 +413,14 @@ func (s *TaskService) UncancelTask(taskID, userID uint) (*responses.TaskWithSumm
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &responses.TaskWithSummaryResponse{
|
return &responses.TaskWithSummaryResponse{
|
||||||
Data: responses.NewTaskResponse(task),
|
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
||||||
Summary: s.getSummaryForUser(userID),
|
Summary: s.getSummaryForUser(userID),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArchiveTask archives a task
|
// ArchiveTask archives a task.
|
||||||
func (s *TaskService) ArchiveTask(taskID, userID uint) (*responses.TaskWithSummaryResponse, error) {
|
// 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)
|
task, err := s.taskRepo.FindByID(taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
@@ -447,13 +453,14 @@ func (s *TaskService) ArchiveTask(taskID, userID uint) (*responses.TaskWithSumma
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &responses.TaskWithSummaryResponse{
|
return &responses.TaskWithSummaryResponse{
|
||||||
Data: responses.NewTaskResponse(task),
|
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
||||||
Summary: s.getSummaryForUser(userID),
|
Summary: s.getSummaryForUser(userID),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnarchiveTask unarchives a task
|
// UnarchiveTask unarchives a task.
|
||||||
func (s *TaskService) UnarchiveTask(taskID, userID uint) (*responses.TaskWithSummaryResponse, error) {
|
// 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)
|
task, err := s.taskRepo.FindByID(taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
@@ -482,15 +489,16 @@ func (s *TaskService) UnarchiveTask(taskID, userID uint) (*responses.TaskWithSum
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &responses.TaskWithSummaryResponse{
|
return &responses.TaskWithSummaryResponse{
|
||||||
Data: responses.NewTaskResponse(task),
|
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
||||||
Summary: s.getSummaryForUser(userID),
|
Summary: s.getSummaryForUser(userID),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Task Completions ===
|
// === Task Completions ===
|
||||||
|
|
||||||
// CreateCompletion creates a task completion
|
// CreateCompletion creates a task completion.
|
||||||
func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest, userID uint) (*responses.TaskCompletionWithSummaryResponse, error) {
|
// 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
|
// Get the task
|
||||||
task, err := s.taskRepo.FindByID(req.TaskID)
|
task, err := s.taskRepo.FindByID(req.TaskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -600,7 +608,7 @@ func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest
|
|||||||
s.sendTaskCompletedNotification(task, completion)
|
s.sendTaskCompletedNotification(task, completion)
|
||||||
|
|
||||||
// Return completion with updated task (includes kanban_column for UI update)
|
// 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{
|
return &responses.TaskCompletionWithSummaryResponse{
|
||||||
Data: resp,
|
Data: resp,
|
||||||
Summary: s.getSummaryForUser(userID),
|
Summary: s.getSummaryForUser(userID),
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ func TestTaskService_CreateTask(t *testing.T) {
|
|||||||
Description: "Kitchen faucet is dripping",
|
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)
|
require.NoError(t, err)
|
||||||
assert.NotZero(t, resp.Data.ID)
|
assert.NotZero(t, resp.Data.ID)
|
||||||
assert.Equal(t, "Fix leaky faucet", resp.Data.Title)
|
assert.Equal(t, "Fix leaky faucet", resp.Data.Title)
|
||||||
@@ -74,7 +75,8 @@ func TestTaskService_CreateTask_WithOptionalFields(t *testing.T) {
|
|||||||
EstimatedCost: &cost,
|
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)
|
require.NoError(t, err)
|
||||||
// Note: Category and Priority are no longer preloaded for performance
|
// Note: Category and Priority are no longer preloaded for performance
|
||||||
// Client resolves from cache using CategoryID and PriorityID
|
// Client resolves from cache using CategoryID and PriorityID
|
||||||
@@ -100,7 +102,8 @@ func TestTaskService_CreateTask_AccessDenied(t *testing.T) {
|
|||||||
Title: "Test Task",
|
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
|
// When creating a task, residence access is checked first
|
||||||
assert.ErrorIs(t, err, ErrResidenceAccessDenied)
|
assert.ErrorIs(t, err, ErrResidenceAccessDenied)
|
||||||
}
|
}
|
||||||
@@ -180,7 +183,8 @@ func TestTaskService_UpdateTask(t *testing.T) {
|
|||||||
Description: &newDesc,
|
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)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "Updated Title", resp.Data.Title)
|
assert.Equal(t, "Updated Title", resp.Data.Title)
|
||||||
assert.Equal(t, "Updated description", resp.Data.Description)
|
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")
|
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||||
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
|
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)
|
require.NoError(t, err)
|
||||||
assert.True(t, resp.Data.IsCancelled)
|
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")
|
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||||
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
|
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
|
||||||
|
|
||||||
service.CancelTask(task.ID, user.ID)
|
now := time.Now().UTC()
|
||||||
_, err := service.CancelTask(task.ID, user.ID)
|
service.CancelTask(task.ID, user.ID, now)
|
||||||
|
_, err := service.CancelTask(task.ID, user.ID, now)
|
||||||
assert.ErrorIs(t, err, ErrTaskAlreadyCancelled)
|
assert.ErrorIs(t, err, ErrTaskAlreadyCancelled)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,8 +253,9 @@ func TestTaskService_UncancelTask(t *testing.T) {
|
|||||||
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||||
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
|
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
|
||||||
|
|
||||||
service.CancelTask(task.ID, user.ID)
|
now := time.Now().UTC()
|
||||||
resp, err := service.UncancelTask(task.ID, user.ID)
|
service.CancelTask(task.ID, user.ID, now)
|
||||||
|
resp, err := service.UncancelTask(task.ID, user.ID, now)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.False(t, resp.Data.IsCancelled)
|
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")
|
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||||
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
|
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)
|
require.NoError(t, err)
|
||||||
assert.True(t, resp.Data.IsArchived)
|
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")
|
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||||
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
|
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
|
||||||
|
|
||||||
service.ArchiveTask(task.ID, user.ID)
|
now := time.Now().UTC()
|
||||||
resp, err := service.UnarchiveTask(task.ID, user.ID)
|
service.ArchiveTask(task.ID, user.ID, now)
|
||||||
|
resp, err := service.UnarchiveTask(task.ID, user.ID, now)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.False(t, resp.Data.IsArchived)
|
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")
|
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||||
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
|
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)
|
require.NoError(t, err)
|
||||||
assert.True(t, resp.Data.InProgress)
|
assert.True(t, resp.Data.InProgress)
|
||||||
}
|
}
|
||||||
@@ -318,7 +328,8 @@ func TestTaskService_CreateCompletion(t *testing.T) {
|
|||||||
Notes: "Completed successfully",
|
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)
|
require.NoError(t, err)
|
||||||
assert.NotZero(t, resp.Data.ID)
|
assert.NotZero(t, resp.Data.ID)
|
||||||
assert.Equal(t, task.ID, resp.Data.TaskID)
|
assert.Equal(t, task.ID, resp.Data.TaskID)
|
||||||
@@ -360,7 +371,8 @@ func TestTaskService_CreateCompletion_RecurringTask_ResetsInProgress(t *testing.
|
|||||||
Notes: "Monthly maintenance done",
|
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)
|
require.NoError(t, err)
|
||||||
assert.NotZero(t, resp.Data.ID)
|
assert.NotZero(t, resp.Data.ID)
|
||||||
|
|
||||||
@@ -503,6 +515,7 @@ func TestTaskService_SharedUserAccess(t *testing.T) {
|
|||||||
ResidenceID: residence.ID,
|
ResidenceID: residence.ID,
|
||||||
Title: "Shared User Task",
|
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)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user