Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions apps/api/internal/handler/clear_dates_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
31 changes: 24 additions & 7 deletions apps/api/internal/handler/cycle.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package handler

import (
"encoding/json"
"errors"
"io"
"net/http"
"time"

Expand Down Expand Up @@ -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"})
Expand Down
40 changes: 40 additions & 0 deletions apps/api/internal/handler/dates.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 1 addition & 1 deletion apps/api/internal/handler/epic.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down
46 changes: 20 additions & 26 deletions apps/api/internal/handler/issue.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package handler

import (
"encoding/json"
"net/http"
"strconv"
"time"
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand All @@ -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"})
Expand Down
27 changes: 19 additions & 8 deletions apps/api/internal/handler/module.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package handler

import (
"encoding/json"
"errors"
"io"
"net/http"
Expand Down Expand Up @@ -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) {
Expand All @@ -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"})
Expand Down
8 changes: 4 additions & 4 deletions apps/api/internal/service/cycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
14 changes: 7 additions & 7 deletions apps/api/internal/service/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
if parentID != nil {
Expand Down Expand Up @@ -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))
Expand Down
Loading
Loading