diff --git a/apps/api/internal/handler/project_create_member_test.go b/apps/api/internal/handler/project_create_member_test.go new file mode 100644 index 00000000..15f438d9 --- /dev/null +++ b/apps/api/internal/handler/project_create_member_test.go @@ -0,0 +1,63 @@ +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" +) + +// A regular workspace member who creates a project should become a project +// admin, so they can immediately manage the project they just made. +func TestProject_CreatorBecomesProjectAdmin(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + + member := testutil.CreateUser(t, ts.DB) + testutil.AddWorkspaceMember(t, ts.DB, w.Workspace.ID, member.ID, testutil.RoleMember) + session := testutil.LoginAs(t, ts.DB, member) + + base := "/api/workspaces/" + w.Workspace.Slug + "/projects/" + rr := ts.POST(base, map[string]any{"name": "Member Project", "identifier": "MBR"}, session) + require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String()) + projectID, _ := testutil.MustJSONMap(t, rr)["id"].(string) + require.NotEmpty(t, projectID) + + // A project_members row exists for the creator with at least admin role. + var pm model.ProjectMember + require.NoError(t, ts.DB. + Where("project_id = ? AND member_id = ? AND deleted_at IS NULL", projectID, member.ID). + First(&pm).Error) + require.GreaterOrEqual(t, pm.Role, model.RoleAdmin) + + // The creator can immediately save project settings (admin-only action). + rr2 := ts.PATCH(base+projectID+"/", map[string]any{"name": "Renamed"}, session) + require.Equal(t, http.StatusOK, rr2.Code, "body=%s", rr2.Body.String()) +} + +// Guard: creating a project must not add a stray membership for anyone else. +func TestProject_CreateAddsOnlyCreator(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + + base := "/api/workspaces/" + w.Workspace.Slug + "/projects/" + rr := ts.POST(base, map[string]any{"name": "Solo", "identifier": "SOLO"}, w.Session) + require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String()) + projectID, _ := testutil.MustJSONMap(t, rr)["id"].(string) + + var count int64 + require.NoError(t, ts.DB.Model(&model.ProjectMember{}). + Where("project_id = ? AND deleted_at IS NULL", projectID).Count(&count).Error) + require.Equal(t, int64(1), count) + + var pm model.ProjectMember + require.NoError(t, ts.DB. + Where("project_id = ? AND deleted_at IS NULL", projectID).First(&pm).Error) + require.NotNil(t, pm.MemberID) + require.Equal(t, w.User.ID, *pm.MemberID) + // Sanity: the created uuid parses. + require.NotEqual(t, uuid.Nil, uuid.MustParse(projectID)) +} diff --git a/apps/api/internal/service/project.go b/apps/api/internal/service/project.go index 604e139f..e02d1ce0 100644 --- a/apps/api/internal/service/project.go +++ b/apps/api/internal/service/project.go @@ -101,7 +101,10 @@ func (s *ProjectService) Create(ctx context.Context, workspaceSlug, name, identi Identifier: identifier, CreatedByID: &userID, } - if err := s.ps.Create(ctx, p); err != nil { + // Create the project and the creator's admin membership together, so the + // creator can manage the project they just made even if they're only a + // regular workspace member. + if err := s.ps.CreateWithCreatorMember(ctx, p, userID, model.RoleAdmin); err != nil { return nil, err } return p, nil diff --git a/apps/api/internal/store/project.go b/apps/api/internal/store/project.go index 6b08433e..4917d605 100644 --- a/apps/api/internal/store/project.go +++ b/apps/api/internal/store/project.go @@ -22,6 +22,24 @@ func (s *ProjectStore) Create(ctx context.Context, p *model.Project) error { return s.db.WithContext(ctx).Create(p).Error } +// CreateWithCreatorMember inserts a project and a project_members row for its +// creator at the given role, in one transaction, so a project never exists +// without its creator being able to manage it. +func (s *ProjectStore) CreateWithCreatorMember(ctx context.Context, p *model.Project, creatorID uuid.UUID, role int16) error { + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(p).Error; err != nil { + return err + } + member := &model.ProjectMember{ + ProjectID: p.ID, + WorkspaceID: p.WorkspaceID, + MemberID: &creatorID, + Role: role, + } + return tx.Create(member).Error + }) +} + func (s *ProjectStore) GetByID(ctx context.Context, id uuid.UUID) (*model.Project, error) { var p model.Project err := s.db.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&p).Error