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
5 changes: 5 additions & 0 deletions api/internal/handler/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
// ProjectHandler serves project and project-member/invite endpoints.
type ProjectHandler struct {
Project *service.ProjectService
State *service.StateService
}

func projectID(c *gin.Context) (uuid.UUID, bool) {
Expand Down Expand Up @@ -118,6 +119,10 @@ func (h *ProjectHandler) Create(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create project"})
return
}
if h.State != nil {
// Best-effort: project is already created; state listing also seeds on-demand.
_ = h.State.EnsureDefaultStates(c.Request.Context(), slug, p.ID, user.ID)
}
Comment thread
Copilot marked this conversation as resolved.
Comment thread
Copilot marked this conversation as resolved.

// If additional fields were provided, immediately apply them using the same logic as Update.
if body.Description != nil ||
Expand Down
14 changes: 14 additions & 0 deletions api/internal/handler/project_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,20 @@ func TestProject_Create_Success(t *testing.T) {
require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String())
body := testutil.MustJSONMap(t, rr)
assert.Equal(t, "My Project", body["name"])

projectID, _ := body["id"].(string)
require.NotEmpty(t, projectID)

statesRR := ts.GET("/api/workspaces/"+ws.Slug+"/projects/"+projectID+"/states/", session)
require.Equal(t, http.StatusOK, statesRR.Code, "body=%s", statesRR.Body.String())
states := testutil.DecodeJSON[[]map[string]any](t, statesRR)
require.Len(t, states, 5)
names := make([]string, len(states))
for i, st := range states {
name, _ := st["name"].(string)
names[i] = name
}
assert.ElementsMatch(t, []string{"Backlog", "Todo", "In Progress", "Done", "Cancelled"}, names)
}

func TestProject_Create_RequiresMembership(t *testing.T) {
Expand Down
21 changes: 21 additions & 0 deletions api/internal/handler/state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,27 @@ func TestState_CRUD(t *testing.T) {
require.Equal(t, http.StatusNoContent, rr4.Code)
}

func TestState_List_SeedsDefaultStates(t *testing.T) {
ts := testutil.NewTestServer(t)
user := testutil.CreateUser(t, ts.DB)
ws := testutil.CreateWorkspace(t, ts.DB, user.ID)
session := testutil.LoginAs(t, ts.DB, user)
project := testutil.CreateProject(t, ts.DB, ws.ID, user.ID)

base := "/api/workspaces/" + ws.Slug + "/projects/" + project.ID.String() + "/states/"
rr := ts.GET(base, session)
require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String())

list := testutil.DecodeJSON[[]map[string]any](t, rr)
require.Len(t, list, 5)
names := make([]string, len(list))
for i, st := range list {
name, _ := st["name"].(string)
names[i] = name
}
assert.ElementsMatch(t, []string{"Backlog", "Todo", "In Progress", "Done", "Cancelled"}, names)
}

func TestState_NonMember404(t *testing.T) {
ts := testutil.NewTestServer(t)
w := testutil.SeedWorld(t, ts.DB)
Expand Down
2 changes: 1 addition & 1 deletion api/internal/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ func New(cfg Config) *gin.Engine {
Queue: cfg.Queue,
AppBaseURL: appBaseURL,
}
projectHandler := &handler.ProjectHandler{Project: projectSvc}
projectHandler := &handler.ProjectHandler{Project: projectSvc, State: stateSvc}
favoriteHandler := &handler.FavoriteHandler{Project: projectSvc, Favorites: userFavoriteStore}
stateHandler := &handler.StateHandler{State: stateSvc}
labelHandler := &handler.LabelHandler{Label: labelSvc}
Expand Down
83 changes: 73 additions & 10 deletions api/internal/service/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import (

var ErrStateNotFound = errors.New("state not found")

// DefaultProjectStateNames are seeded for new projects (Plane parity, without triage).
var DefaultProjectStateNames = []string{"Backlog", "Todo", "In Progress", "Done", "Cancelled"}

// StateService handles state (workflow) business logic.
type StateService struct {
ss *store.StateStore
Expand All @@ -22,34 +25,94 @@ func NewStateService(ss *store.StateStore, ps *store.ProjectStore, ws *store.Wor
return &StateService{ss: ss, ps: ps, ws: ws}
}

func (s *StateService) ensureProjectAccess(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID) error {
func (s *StateService) ensureProjectAccess(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID) (uuid.UUID, error) {
wrk, err := s.ws.GetBySlug(ctx, workspaceSlug)
if err != nil {
return ErrProjectForbidden
return uuid.Nil, ErrProjectForbidden
}
ok, _ := s.ws.IsMember(ctx, wrk.ID, userID)
if !ok {
return ErrProjectForbidden
return uuid.Nil, ErrProjectForbidden
}
inWorkspace, _ := s.ps.IsInWorkspace(ctx, projectID, wrk.ID)
if !inWorkspace {
return ErrProjectNotFound
return uuid.Nil, ErrProjectNotFound
}
return nil
return wrk.ID, nil
}

func (s *StateService) List(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID) ([]model.State, error) {
if err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID); err != nil {
workspaceID, err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID)
if err != nil {
return nil, err
}
list, err := s.ss.ListByProjectID(ctx, projectID)
if err != nil {
return nil, err
}
if len(list) > 0 {
return list, nil
}
if err := s.ensureDefaultStates(ctx, projectID, workspaceID); err != nil {
return nil, err
}
return s.ss.ListByProjectID(ctx, projectID)
}

// defaultProjectStates mirrors Plane's DEFAULT_STATES (without triage).
var defaultProjectStates = []struct {
name string
color string
sequence float64
group string
isDefault bool
}{
{name: "Backlog", color: "#60646C", sequence: 15000, group: "backlog", isDefault: true},
{name: "Todo", color: "#60646C", sequence: 25000, group: "unstarted"},
{name: "In Progress", color: "#F59E0B", sequence: 35000, group: "started"},
{name: "Done", color: "#46A758", sequence: 45000, group: "completed"},
{name: "Cancelled", color: "#9AA4BC", sequence: 55000, group: "canceled"},
}

func (s *StateService) ensureDefaultStates(ctx context.Context, projectID, workspaceID uuid.UUID) error {
list, err := s.ss.ListByProjectID(ctx, projectID)
if err != nil {
return err
}
Comment thread
Rafetikus marked this conversation as resolved.
if len(list) > 0 {
return nil
}
for _, def := range defaultProjectStates {
st := &model.State{
Name: def.name,
Color: def.color,
Sequence: def.sequence,
Group: def.group,
Default: def.isDefault,
ProjectID: projectID,
WorkspaceID: workspaceID,
}
if err := s.ss.RestoreOrCreateByNameAndProject(ctx, st); err != nil {
return err
}
}
return nil
}

// EnsureDefaultStates seeds workflow states for a project that has none (Plane parity).
func (s *StateService) EnsureDefaultStates(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID) error {
workspaceID, err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID)
if err != nil {
return err
}
return s.ensureDefaultStates(ctx, projectID, workspaceID)
}

func (s *StateService) Create(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID, name, color, group string) (*model.State, error) {
if err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID); err != nil {
workspaceID, err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID)
if err != nil {
return nil, err
}
wrk, _ := s.ws.GetBySlug(ctx, workspaceSlug)
if color == "" {
color = "#0d0d0d"
}
Expand All @@ -61,7 +124,7 @@ func (s *StateService) Create(ctx context.Context, workspaceSlug string, project
Color: color,
Group: group,
ProjectID: projectID,
WorkspaceID: wrk.ID,
WorkspaceID: workspaceID,
}
if err := s.ss.Create(ctx, st); err != nil {
return nil, err
Expand All @@ -70,7 +133,7 @@ func (s *StateService) Create(ctx context.Context, workspaceSlug string, project
}

func (s *StateService) GetByID(ctx context.Context, workspaceSlug string, projectID, stateID uuid.UUID, userID uuid.UUID) (*model.State, error) {
if err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID); err != nil {
if _, err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID); err != nil {
return nil, err
}
st, err := s.ss.GetByID(ctx, stateID)
Expand Down
30 changes: 30 additions & 0 deletions api/internal/store/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package store

import (
"context"
"errors"

"github.com/Devlaner/devlane/api/internal/model"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)

// StateStore handles state persistence.
Expand All @@ -17,6 +19,34 @@ func (s *StateStore) Create(ctx context.Context, st *model.State) error {
return s.db.WithContext(ctx).Create(st).Error
}

// RestoreOrCreateByNameAndProject inserts a default state, restoring a soft-deleted row
// with the same (name, project_id) when present so UNIQUE constraints do not block reseeding.
func (s *StateStore) RestoreOrCreateByNameAndProject(ctx context.Context, st *model.State) error {
var existing model.State
err := s.db.WithContext(ctx).Unscoped().
Where("name = ? AND project_id = ?", st.Name, st.ProjectID).
First(&existing).Error
if err == nil {
if !existing.DeletedAt.Valid {
return nil
}
existing.Color = st.Color
existing.Sequence = st.Sequence
existing.Group = st.Group
existing.Default = st.Default
existing.WorkspaceID = st.WorkspaceID
existing.DeletedAt = gorm.DeletedAt{}
return s.db.WithContext(ctx).Unscoped().Save(&existing).Error
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
return s.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "name"}, {Name: "project_id"}},
DoNothing: true,
}).Create(st).Error
Comment thread
Rafetikus marked this conversation as resolved.
}

func (s *StateStore) GetByID(ctx context.Context, id uuid.UUID) (*model.State, error) {
var st model.State
err := s.db.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&st).Error
Expand Down
Loading
Loading