diff --git a/apps/api/internal/handler/clear_dates_test.go b/apps/api/internal/handler/clear_dates_test.go new file mode 100644 index 00000000..74fab732 --- /dev/null +++ b/apps/api/internal/handler/clear_dates_test.go @@ -0,0 +1,98 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/model" + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +// reloadIssue reads a fresh copy — GORM's First does not reset a reused struct's +// NULL fields, so each assertion needs its own value. +func reloadIssue(t *testing.T, ts *testutil.TestServer, id uuid.UUID) model.Issue { + t.Helper() + var got model.Issue + require.NoError(t, ts.DB.First(&got, "id = ?", id).Error) + return got +} + +func TestIssue_ClearDates(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + issue := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + base := "/api/workspaces/" + w.Workspace.Slug + "/projects/" + w.Project.ID.String() + + "/issues/" + issue.ID.String() + "/" + + // Set both dates. + require.Equal(t, http.StatusOK, ts.PATCH(base, map[string]any{ + "start_date": "2026-03-15", "target_date": "2026-03-20", + }, w.Session).Code) + set := reloadIssue(t, ts, issue.ID) + require.NotNil(t, set.StartDate) + require.NotNil(t, set.TargetDate) + + // Clearing with null persists as NULL. + require.Equal(t, http.StatusOK, ts.PATCH(base, map[string]any{ + "start_date": nil, "target_date": nil, + }, w.Session).Code) + cleared := reloadIssue(t, ts, issue.ID) + require.Nil(t, cleared.StartDate, "start_date should clear to null") + require.Nil(t, cleared.TargetDate, "target_date should clear to null") + + // Omitting the field leaves it unchanged. + require.Equal(t, http.StatusOK, ts.PATCH(base, map[string]any{"start_date": "2026-04-01"}, w.Session).Code) + require.Equal(t, http.StatusOK, ts.PATCH(base, map[string]any{"name": "Renamed"}, w.Session).Code) + kept := reloadIssue(t, ts, issue.ID) + require.NotNil(t, kept.StartDate, "omitting start_date must not clear it") +} + +func TestCycle_ClearDates(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + cy := testutil.CreateCycle(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + base := "/api/workspaces/" + w.Workspace.Slug + "/projects/" + w.Project.ID.String() + + "/cycles/" + cy.ID.String() + "/" + + require.Equal(t, http.StatusOK, ts.PATCH(base, map[string]any{ + "start_date": "2026-03-15", "end_date": "2026-03-20", + }, w.Session).Code) + var set model.Cycle + require.NoError(t, ts.DB.First(&set, "id = ?", cy.ID).Error) + require.NotNil(t, set.StartDate) + require.NotNil(t, set.EndDate) + + require.Equal(t, http.StatusOK, ts.PATCH(base, map[string]any{ + "start_date": nil, "end_date": nil, + }, w.Session).Code) + var cleared model.Cycle + require.NoError(t, ts.DB.First(&cleared, "id = ?", cy.ID).Error) + require.Nil(t, cleared.StartDate, "cycle start_date should clear") + require.Nil(t, cleared.EndDate, "cycle end_date should clear") +} + +func TestModule_ClearDates(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + mod := testutil.CreateModule(t, ts.DB, w.Project.ID, w.Workspace.ID) + base := "/api/workspaces/" + w.Workspace.Slug + "/projects/" + w.Project.ID.String() + + "/modules/" + mod.ID.String() + "/" + + require.Equal(t, http.StatusOK, ts.PATCH(base, map[string]any{ + "start_date": "2026-03-15", "target_date": "2026-03-20", + }, w.Session).Code) + var set model.Module + require.NoError(t, ts.DB.First(&set, "id = ?", mod.ID).Error) + require.NotNil(t, set.StartDate) + require.NotNil(t, set.TargetDate) + + require.Equal(t, http.StatusOK, ts.PATCH(base, map[string]any{ + "start_date": nil, "target_date": nil, + }, w.Session).Code) + var cleared model.Module + require.NoError(t, ts.DB.First(&cleared, "id = ?", mod.ID).Error) + require.Nil(t, cleared.StartDate, "module start_date should clear") + require.Nil(t, cleared.TargetDate, "module target_date should clear") +} diff --git a/apps/api/internal/handler/cycle.go b/apps/api/internal/handler/cycle.go index 4eecec45..ac74ea99 100644 --- a/apps/api/internal/handler/cycle.go +++ b/apps/api/internal/handler/cycle.go @@ -1,6 +1,9 @@ package handler import ( + "encoding/json" + "errors" + "io" "net/http" "time" @@ -144,14 +147,28 @@ func (h *CycleHandler) Update(c *gin.Context) { return } var body struct { - Name string `json:"name"` - Description string `json:"description"` - Status string `json:"status"` - StartDate string `json:"start_date"` - EndDate string `json:"end_date"` + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` + StartDate json.RawMessage `json:"start_date"` + EndDate json.RawMessage `json:"end_date"` + } + // An empty PATCH body is allowed (a no-op patch); other parse errors are not. + if err := c.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) + return + } + startDateSet, startDate, ok := parseUpdatableDate(body.StartDate) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid start_date"}) + return + } + endDateSet, endDate, ok := parseUpdatableDate(body.EndDate) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid end_date"}) + return } - _ = c.ShouldBindJSON(&body) - cy, err := h.Cycle.Update(c.Request.Context(), slug, projectID, cycleID, user.ID, body.Name, body.Description, body.Status, parseOptionalTime(body.StartDate), parseOptionalTime(body.EndDate)) + cy, err := h.Cycle.Update(c.Request.Context(), slug, projectID, cycleID, user.ID, body.Name, body.Description, body.Status, startDateSet, startDate, endDateSet, endDate) if err != nil { if err == service.ErrCycleNotFound || err == service.ErrProjectForbidden || err == service.ErrProjectNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "Not found"}) diff --git a/apps/api/internal/handler/dates.go b/apps/api/internal/handler/dates.go new file mode 100644 index 00000000..738875e5 --- /dev/null +++ b/apps/api/internal/handler/dates.go @@ -0,0 +1,40 @@ +package handler + +import ( + "encoding/json" + "strings" + "time" +) + +// parseUpdatableDate reads a JSON date field for PATCH semantics so a set date +// can actually be cleared: +// +// key absent -> set=false (leave the value unchanged) +// null or "" -> set=true, t=nil (clear the date) +// "YYYY-MM-DD" -> set=true, t=&parsed +// RFC3339 timestamp -> set=true, t=&parsed +// +// ok=false signals an unparseable value; the caller should return 400. +func parseUpdatableDate(raw json.RawMessage) (set bool, t *time.Time, ok bool) { + if len(raw) == 0 { + return false, nil, true + } + if string(raw) == "null" { + return true, nil, true + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return false, nil, false + } + s = strings.TrimSpace(s) + if s == "" { + return true, nil, true + } + if parsed, err := time.Parse(time.RFC3339, s); err == nil { + return true, &parsed, true + } + if parsed, err := time.Parse("2006-01-02", s); err == nil { + return true, &parsed, true + } + return false, nil, false +} diff --git a/apps/api/internal/handler/epic.go b/apps/api/internal/handler/epic.go index 45f4eb0d..5538b800 100644 --- a/apps/api/internal/handler/epic.go +++ b/apps/api/internal/handler/epic.go @@ -205,7 +205,7 @@ func (h *EpicHandler) UpdateEpic(c *gin.Context) { tmp := body.LabelIDs labelIDs = &tmp } - epic, err := h.Issue.Update(c.Request.Context(), slug, projectID, eID, user.ID, name, priority, body.Description, body.StateID, assigneeIDs, labelIDs, nil, nil, nil, nil, nil, false, nil, nil) + epic, err := h.Issue.Update(c.Request.Context(), slug, projectID, eID, user.ID, name, priority, body.Description, body.StateID, assigneeIDs, labelIDs, false, nil, false, nil, nil, nil, nil, false, nil, nil) if err != nil { if err == service.ErrIssueNotFound || err == service.ErrProjectForbidden { c.JSON(http.StatusNotFound, gin.H{"error": "Not found"}) diff --git a/apps/api/internal/handler/issue.go b/apps/api/internal/handler/issue.go index 70cdf5ea..e241f967 100644 --- a/apps/api/internal/handler/issue.go +++ b/apps/api/internal/handler/issue.go @@ -1,6 +1,7 @@ package handler import ( + "encoding/json" "net/http" "strconv" "time" @@ -225,16 +226,16 @@ func (h *IssueHandler) Update(c *gin.Context) { Description *string `json:"description"` // description_html is an alias accepted for symmetry with the column // name on the GORM model — frontend can send either. - DescriptionHTML *string `json:"description_html"` - Priority string `json:"priority"` - StateID *uuid.UUID `json:"state_id"` - ParentID *uuid.UUID `json:"parent_id"` - StartDate *string `json:"start_date"` - TargetDate *string `json:"target_date"` - AssigneeIDs []uuid.UUID `json:"assignee_ids"` - LabelIDs []uuid.UUID `json:"label_ids"` - IsDraft *bool `json:"is_draft"` - Type string `json:"type"` + DescriptionHTML *string `json:"description_html"` + Priority string `json:"priority"` + StateID *uuid.UUID `json:"state_id"` + ParentID *uuid.UUID `json:"parent_id"` + StartDate json.RawMessage `json:"start_date"` + TargetDate json.RawMessage `json:"target_date"` + AssigneeIDs []uuid.UUID `json:"assignee_ids"` + LabelIDs []uuid.UUID `json:"label_ids"` + IsDraft *bool `json:"is_draft"` + Type string `json:"type"` // estimate_point_id: omitted = leave alone, "" = clear, uuid = set. EstimatePointID *string `json:"estimate_point_id"` // sort_order: manual ordering position (drag-to-reorder). @@ -270,22 +271,15 @@ func (h *IssueHandler) Update(c *gin.Context) { labelIDs = &tmp } - var startDate, targetDate *time.Time - if body.StartDate != nil && *body.StartDate != "" { - if t, err := time.Parse("2006-01-02", *body.StartDate); err == nil { - startDate = &t - } else { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid start_date"}) - return - } + startDateSet, startDate, ok := parseUpdatableDate(body.StartDate) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid start_date"}) + return } - if body.TargetDate != nil && *body.TargetDate != "" { - if t, err := time.Parse("2006-01-02", *body.TargetDate); err == nil { - targetDate = &t - } else { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid target_date"}) - return - } + targetDateSet, targetDate, ok := parseUpdatableDate(body.TargetDate) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid target_date"}) + return } var issueType *string @@ -305,7 +299,7 @@ func (h *IssueHandler) Update(c *gin.Context) { estimatePointID = &pid } } - issue, err := h.Issue.Update(c.Request.Context(), slug, projectID, issueID, user.ID, name, priority, description, body.StateID, assigneeIDs, labelIDs, startDate, targetDate, body.ParentID, body.IsDraft, issueType, estimatePointIDSet, estimatePointID, body.SortOrder) + issue, err := h.Issue.Update(c.Request.Context(), slug, projectID, issueID, user.ID, name, priority, description, body.StateID, assigneeIDs, labelIDs, startDateSet, startDate, targetDateSet, targetDate, body.ParentID, body.IsDraft, issueType, estimatePointIDSet, estimatePointID, body.SortOrder) if err != nil { if err == service.ErrIssueNotFound || err == service.ErrProjectForbidden { c.JSON(http.StatusNotFound, gin.H{"error": "Issue not found"}) diff --git a/apps/api/internal/handler/module.go b/apps/api/internal/handler/module.go index f35f05a1..af84c2bd 100644 --- a/apps/api/internal/handler/module.go +++ b/apps/api/internal/handler/module.go @@ -1,6 +1,7 @@ package handler import ( + "encoding/json" "errors" "io" "net/http" @@ -184,13 +185,13 @@ func (h *ModuleHandler) Update(c *gin.Context) { return } var body struct { - Name string `json:"name"` - Description string `json:"description"` - Status string `json:"status"` - StartDate string `json:"start_date"` - TargetDate string `json:"target_date"` - LeadID *string `json:"lead_id"` - MemberIDs *[]string `json:"member_ids"` + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` + StartDate json.RawMessage `json:"start_date"` + TargetDate json.RawMessage `json:"target_date"` + LeadID *string `json:"lead_id"` + MemberIDs *[]string `json:"member_ids"` } // An empty PATCH body is allowed (a no-op patch); other parse errors are not. if err := c.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) { @@ -208,7 +209,17 @@ func (h *ModuleHandler) Update(c *gin.Context) { return } } - mod, err := h.Module.Update(c.Request.Context(), slug, projectID, moduleID, user.ID, body.Name, body.Description, body.Status, parseOptionalDate(body.StartDate), parseOptionalDate(body.TargetDate), body.LeadID != nil, leadIDPtr, body.MemberIDs != nil, memberIDs) + startDateSet, startDate, ok := parseUpdatableDate(body.StartDate) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid start_date"}) + return + } + targetDateSet, targetDate, ok := parseUpdatableDate(body.TargetDate) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid target_date"}) + return + } + mod, err := h.Module.Update(c.Request.Context(), slug, projectID, moduleID, user.ID, body.Name, body.Description, body.Status, startDateSet, startDate, targetDateSet, targetDate, body.LeadID != nil, leadIDPtr, body.MemberIDs != nil, memberIDs) if err != nil { if err == service.ErrModuleNotFound || err == service.ErrProjectForbidden || err == service.ErrProjectNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "Not found"}) diff --git a/apps/api/internal/service/cycle.go b/apps/api/internal/service/cycle.go index 6e1e9169..05bf9df2 100644 --- a/apps/api/internal/service/cycle.go +++ b/apps/api/internal/service/cycle.go @@ -135,7 +135,7 @@ func (s *CycleService) Get(ctx context.Context, workspaceSlug string, projectID, return cy, nil } -func (s *CycleService) Update(ctx context.Context, workspaceSlug string, projectID, cycleID uuid.UUID, userID uuid.UUID, name, description, status string, startDate, endDate *time.Time) (*model.Cycle, error) { +func (s *CycleService) Update(ctx context.Context, workspaceSlug string, projectID, cycleID uuid.UUID, userID uuid.UUID, name, description, status string, startDateSet bool, startDate *time.Time, endDateSet bool, endDate *time.Time) (*model.Cycle, error) { cy, err := s.Get(ctx, workspaceSlug, projectID, cycleID, userID) if err != nil { return nil, err @@ -146,10 +146,10 @@ func (s *CycleService) Update(ctx context.Context, workspaceSlug string, project if description != "" { cy.Description = description } - if startDate != nil { - cy.StartDate = startDate + if startDateSet { + cy.StartDate = startDate // nil clears the date } - if endDate != nil { + if endDateSet { cy.EndDate = endDate } if err := s.cs.Update(ctx, cy); err != nil { diff --git a/apps/api/internal/service/issue.go b/apps/api/internal/service/issue.go index 0823f1d0..2d405a8d 100644 --- a/apps/api/internal/service/issue.go +++ b/apps/api/internal/service/issue.go @@ -336,7 +336,7 @@ func (s *IssueService) BulkUpdate(ctx context.Context, workspaceSlug string, pro n := 0 var firstErr error for _, id := range issueIDs { - if _, err := s.Update(ctx, workspaceSlug, projectID, id, userID, nil, priority, nil, stateID, nil, nil, nil, nil, nil, nil, nil, false, nil, nil); err != nil { + if _, err := s.Update(ctx, workspaceSlug, projectID, id, userID, nil, priority, nil, stateID, nil, nil, false, nil, false, nil, nil, nil, nil, false, nil, nil); err != nil { if firstErr == nil { firstErr = err } @@ -543,7 +543,7 @@ func (s *IssueService) Create(ctx context.Context, workspaceSlug string, project return issue, nil } -func (s *IssueService) Update(ctx context.Context, workspaceSlug string, projectID, issueID uuid.UUID, userID uuid.UUID, name, priority, description *string, stateID *uuid.UUID, assigneeIDs, labelIDs *[]uuid.UUID, startDate, targetDate *time.Time, parentID *uuid.UUID, isDraft *bool, issueType *string, estimatePointIDSet bool, estimatePointID *uuid.UUID, sortOrder *float64) (*model.Issue, error) { +func (s *IssueService) Update(ctx context.Context, workspaceSlug string, projectID, issueID uuid.UUID, userID uuid.UUID, name, priority, description *string, stateID *uuid.UUID, assigneeIDs, labelIDs *[]uuid.UUID, startDateSet bool, startDate *time.Time, targetDateSet bool, targetDate *time.Time, parentID *uuid.UUID, isDraft *bool, issueType *string, estimatePointIDSet bool, estimatePointID *uuid.UUID, sortOrder *float64) (*model.Issue, error) { issue, err := s.GetByID(ctx, workspaceSlug, projectID, issueID, userID) if err != nil { return nil, err @@ -571,10 +571,10 @@ func (s *IssueService) Update(ctx context.Context, workspaceSlug string, project if stateID != nil { issue.StateID = stateID } - if startDate != nil { - issue.StartDate = startDate + if startDateSet { + issue.StartDate = startDate // nil clears the date } - if targetDate != nil { + if targetDateSet { issue.TargetDate = targetDate } if parentID != nil { @@ -617,13 +617,13 @@ func (s *IssueService) Update(ctx context.Context, workspaceSlug string, project s.notify.IssueStateChanged(ctx, issue, userID, prevStateID, issue.StateID) } } - if startDate != nil && prevStart != dateString(issue.StartDate) { + if startDateSet && prevStart != dateString(issue.StartDate) { s.recordActivity(ctx, issue, userID, "start_date", prevStart, dateString(issue.StartDate)) if s.notify != nil { s.notify.IssueFieldChanged(ctx, issue, userID, "start_date", prevStart, dateString(issue.StartDate)) } } - if targetDate != nil && prevTarget != dateString(issue.TargetDate) { + if targetDateSet && prevTarget != dateString(issue.TargetDate) { s.recordActivity(ctx, issue, userID, "target_date", prevTarget, dateString(issue.TargetDate)) if s.notify != nil { s.notify.IssueFieldChanged(ctx, issue, userID, "target_date", prevTarget, dateString(issue.TargetDate)) diff --git a/apps/api/internal/service/module.go b/apps/api/internal/service/module.go index 7e5433e1..96961eeb 100644 --- a/apps/api/internal/service/module.go +++ b/apps/api/internal/service/module.go @@ -150,7 +150,7 @@ func (s *ModuleService) Get(ctx context.Context, workspaceSlug string, projectID return mod, nil } -func (s *ModuleService) Update(ctx context.Context, workspaceSlug string, projectID, moduleID uuid.UUID, userID uuid.UUID, name, description, status string, startDate, targetDate *time.Time, leadIDSet bool, leadID *uuid.UUID, memberIDsSet bool, memberIDs []uuid.UUID) (*model.Module, error) { +func (s *ModuleService) Update(ctx context.Context, workspaceSlug string, projectID, moduleID uuid.UUID, userID uuid.UUID, name, description, status string, startDateSet bool, startDate *time.Time, targetDateSet bool, targetDate *time.Time, leadIDSet bool, leadID *uuid.UUID, memberIDsSet bool, memberIDs []uuid.UUID) (*model.Module, error) { mod, err := s.Get(ctx, workspaceSlug, projectID, moduleID, userID) if err != nil { return nil, err @@ -164,10 +164,10 @@ func (s *ModuleService) Update(ctx context.Context, workspaceSlug string, projec if status != "" { mod.Status = status } - if startDate != nil { - mod.StartDate = startDate + if startDateSet { + mod.StartDate = startDate // nil clears the date } - if targetDate != nil { + if targetDateSet { mod.TargetDate = targetDate } if leadIDSet {