diff --git a/apps/api/internal/service/issue.go b/apps/api/internal/service/issue.go index 5d651e8..3e4edfb 100644 --- a/apps/api/internal/service/issue.go +++ b/apps/api/internal/service/issue.go @@ -963,20 +963,9 @@ func (s *IssueService) ReplaceAssignees(ctx context.Context, workspaceSlug strin return err } prevAssignees, _ := s.is.ListAssigneesForIssue(ctx, issueID) - if err := s.is.ClearAssigneesForIssue(ctx, issueID); err != nil { + if err := s.is.ReplaceAssignees(ctx, issue.ID, issue.ProjectID, issue.WorkspaceID, assigneeIDs); err != nil { return err } - for _, assigneeID := range assigneeIDs { - a := &model.IssueAssignee{ - IssueID: issue.ID, - AssigneeID: assigneeID, - ProjectID: issue.ProjectID, - WorkspaceID: issue.WorkspaceID, - } - if err := s.is.AddAssignee(ctx, a); err != nil { - return err - } - } prevSet := uuidSet(prevAssignees) added := make([]uuid.UUID, 0, len(assigneeIDs)) for _, id := range assigneeIDs { @@ -998,20 +987,9 @@ func (s *IssueService) ReplaceLabels(ctx context.Context, workspaceSlug string, if err != nil { return err } - if err := s.is.ClearLabelsForIssue(ctx, issueID); err != nil { + if err := s.is.ReplaceLabels(ctx, issue.ID, issue.ProjectID, issue.WorkspaceID, labelIDs); err != nil { return err } - for _, labelID := range labelIDs { - l := &model.IssueLabel{ - IssueID: issue.ID, - LabelID: labelID, - ProjectID: issue.ProjectID, - WorkspaceID: issue.WorkspaceID, - } - if err := s.is.AddLabel(ctx, l); err != nil { - return err - } - } return nil } diff --git a/apps/api/internal/store/issue.go b/apps/api/internal/store/issue.go index 23c250c..6b098cf 100644 --- a/apps/api/internal/store/issue.go +++ b/apps/api/internal/store/issue.go @@ -445,6 +445,64 @@ func (s *IssueStore) ClearLabelsForIssue(ctx context.Context, issueID uuid.UUID) return s.db.WithContext(ctx).Where("issue_id = ?", issueID).Delete(&model.IssueLabel{}).Error } +// ReplaceAssignees swaps an issue's assignees for assigneeIDs in one +// transaction, so a failed insert can't leave the issue with the old rows +// deleted and only some new ones written. Duplicate ids are dropped to respect +// the (issue_id, assignee_id) unique constraint. +func (s *IssueStore) ReplaceAssignees(ctx context.Context, issueID, projectID, workspaceID uuid.UUID, assigneeIDs []uuid.UUID) error { + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("issue_id = ?", issueID).Delete(&model.IssueAssignee{}).Error; err != nil { + return err + } + seen := make(map[uuid.UUID]bool, len(assigneeIDs)) + rows := make([]model.IssueAssignee, 0, len(assigneeIDs)) + for _, id := range assigneeIDs { + if id == uuid.Nil || seen[id] { + continue + } + seen[id] = true + rows = append(rows, model.IssueAssignee{ + IssueID: issueID, + AssigneeID: id, + ProjectID: projectID, + WorkspaceID: workspaceID, + }) + } + if len(rows) == 0 { + return nil + } + return tx.Create(&rows).Error + }) +} + +// ReplaceLabels swaps an issue's labels for labelIDs in one transaction (see +// ReplaceAssignees). Duplicate ids are dropped. +func (s *IssueStore) ReplaceLabels(ctx context.Context, issueID, projectID, workspaceID uuid.UUID, labelIDs []uuid.UUID) error { + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("issue_id = ?", issueID).Delete(&model.IssueLabel{}).Error; err != nil { + return err + } + seen := make(map[uuid.UUID]bool, len(labelIDs)) + rows := make([]model.IssueLabel, 0, len(labelIDs)) + for _, id := range labelIDs { + if id == uuid.Nil || seen[id] { + continue + } + seen[id] = true + rows = append(rows, model.IssueLabel{ + IssueID: issueID, + LabelID: id, + ProjectID: projectID, + WorkspaceID: workspaceID, + }) + } + if len(rows) == 0 { + return nil + } + return tx.Create(&rows).Error + }) +} + // ListAssigneesForIssue returns assignee IDs for an issue. func (s *IssueStore) ListAssigneesForIssue(ctx context.Context, issueID uuid.UUID) ([]uuid.UUID, error) { var ids []uuid.UUID