From a52e3940b1ca003f400829f46a94a6200473f1bb Mon Sep 17 00:00:00 2001 From: cavidelizade Date: Fri, 17 Jul 2026 01:01:40 +0400 Subject: [PATCH] fix(api): create a workspace and its owner membership atomically WorkspaceService.Create inserted the workspace, then added the owner membership in a separate statement and discarded the error. If that second write failed, the workspace existed with no members, and since access is gated on membership the owner was locked out of the workspace they just created, with no way to clean it up from the UI. Add WorkspaceStore.CreateWithOwner, which does both writes in one transaction (mirrors ProjectStore.CreateWithCreatorMember), and use it from the service. Closes #340 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/internal/service/workspace.go | 4 +--- apps/api/internal/store/workspace.go | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/api/internal/service/workspace.go b/apps/api/internal/service/workspace.go index e8221de..898bd88 100644 --- a/apps/api/internal/service/workspace.go +++ b/apps/api/internal/service/workspace.go @@ -142,11 +142,9 @@ func (s *WorkspaceService) Create(ctx context.Context, name, slug, organizationS CreatedByID: &ownerID, OrganizationSize: orgSize, } - if err := s.ws.Create(ctx, w); err != nil { + if err := s.ws.CreateWithOwner(ctx, w, ownerID); err != nil { return nil, err } - m := &model.WorkspaceMember{WorkspaceID: w.ID, MemberID: ownerID, Role: 20} - _ = s.ws.AddMember(ctx, m) return w, nil } diff --git a/apps/api/internal/store/workspace.go b/apps/api/internal/store/workspace.go index d6dbad1..c356f4e 100644 --- a/apps/api/internal/store/workspace.go +++ b/apps/api/internal/store/workspace.go @@ -22,6 +22,23 @@ func (s *WorkspaceStore) Create(ctx context.Context, w *model.Workspace) error { return s.db.WithContext(ctx).Create(w).Error } +// CreateWithOwner inserts a workspace and its owner membership in one +// transaction. Without this, a failed membership insert would leave a workspace +// with no members, and since access is gated on membership the owner would be +// locked out of the workspace they just created. +func (s *WorkspaceStore) CreateWithOwner(ctx context.Context, w *model.Workspace, ownerID uuid.UUID) error { + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(w).Error; err != nil { + return err + } + return tx.Create(&model.WorkspaceMember{ + WorkspaceID: w.ID, + MemberID: ownerID, + Role: model.RoleOwner, + }).Error + }) +} + func (s *WorkspaceStore) GetByID(ctx context.Context, id uuid.UUID) (*model.Workspace, error) { var w model.Workspace err := s.db.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&w).Error