From 43e838c55c1fb860871b699508dd17694c91c863 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Thu, 16 Apr 2026 04:26:48 +0600 Subject: [PATCH] Fix cascading deletion gaps for user and team cleanup - Add ON DELETE CASCADE to users_teams, oauth_providers, admin_permissions and ON DELETE SET NULL (with nullable columns) to team_api_keys.created_by, hosts.created_by, host_tokens.created_by so HardDeleteExpiredUsers no longer fails with FK violations - User account deletion now cascades to sole-owned teams via DeleteTeamInternal, preventing orphaned teams with live sandboxes after account removal - ListActiveSandboxesByTeam now includes hibernated sandboxes so their disk snapshots are cleaned up during team deletion - Team soft-delete now hard-deletes sandbox metric points, metric snapshots, API keys, and channels to prevent data accumulation on deleted teams - Extract deleteTeamCore() to deduplicate shared logic across DeleteTeam, AdminDeleteTeam, and DeleteTeamInternal - Fix ListAPIKeysByTeamWithCreator to use LEFT JOIN after created_by became nullable, and update handler to read pgtype.Text.String for creator_email --- .../20260415221116_cascade_user_delete.sql | 72 +++++++++++++++++++ db/queries/api_keys.sql | 5 +- db/queries/channels.sql | 3 + db/queries/metrics.sql | 7 ++ db/queries/sandboxes.sql | 2 +- db/queries/teams.sql | 12 ++++ internal/api/handlers_apikeys.go | 2 +- internal/api/handlers_me.go | 17 +++++ internal/api/server.go | 2 +- pkg/db/api_keys.sql.go | 13 +++- pkg/db/channels.sql.go | 9 +++ pkg/db/metrics.sql.go | 19 +++++ pkg/db/sandboxes.sql.go | 2 +- pkg/db/teams.sql.go | 33 +++++++++ pkg/service/team.go | 69 +++++++++--------- 15 files changed, 223 insertions(+), 44 deletions(-) create mode 100644 db/migrations/20260415221116_cascade_user_delete.sql diff --git a/db/migrations/20260415221116_cascade_user_delete.sql b/db/migrations/20260415221116_cascade_user_delete.sql new file mode 100644 index 0000000..ac01674 --- /dev/null +++ b/db/migrations/20260415221116_cascade_user_delete.sql @@ -0,0 +1,72 @@ +-- +goose Up + +-- users_teams: remove membership when user is deleted +ALTER TABLE users_teams DROP CONSTRAINT users_teams_user_id_fkey; +ALTER TABLE users_teams ADD CONSTRAINT users_teams_user_id_fkey + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + +-- oauth_providers: remove auth links when user is deleted +ALTER TABLE oauth_providers DROP CONSTRAINT oauth_providers_user_id_fkey; +ALTER TABLE oauth_providers ADD CONSTRAINT oauth_providers_user_id_fkey + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + +-- admin_permissions: remove permissions when user is deleted +ALTER TABLE admin_permissions DROP CONSTRAINT admin_permissions_user_id_fkey; +ALTER TABLE admin_permissions ADD CONSTRAINT admin_permissions_user_id_fkey + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + +-- team_api_keys.created_by: make nullable, SET NULL on user delete +ALTER TABLE team_api_keys ALTER COLUMN created_by DROP NOT NULL; +ALTER TABLE team_api_keys DROP CONSTRAINT team_api_keys_created_by_fkey; +ALTER TABLE team_api_keys ADD CONSTRAINT team_api_keys_created_by_fkey + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL; + +-- hosts.created_by: make nullable, SET NULL on user delete +ALTER TABLE hosts ALTER COLUMN created_by DROP NOT NULL; +ALTER TABLE hosts DROP CONSTRAINT hosts_created_by_fkey; +ALTER TABLE hosts ADD CONSTRAINT hosts_created_by_fkey + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL; + +-- host_tokens.created_by: make nullable, SET NULL on user delete +ALTER TABLE host_tokens ALTER COLUMN created_by DROP NOT NULL; +ALTER TABLE host_tokens DROP CONSTRAINT host_tokens_created_by_fkey; +ALTER TABLE host_tokens ADD CONSTRAINT host_tokens_created_by_fkey + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL; + +-- +goose Down + +-- Revert host_tokens.created_by +ALTER TABLE host_tokens DROP CONSTRAINT host_tokens_created_by_fkey; +UPDATE host_tokens SET created_by = '00000000-0000-0000-0000-000000000000' WHERE created_by IS NULL; +ALTER TABLE host_tokens ALTER COLUMN created_by SET NOT NULL; +ALTER TABLE host_tokens ADD CONSTRAINT host_tokens_created_by_fkey + FOREIGN KEY (created_by) REFERENCES users(id); + +-- Revert hosts.created_by +ALTER TABLE hosts DROP CONSTRAINT hosts_created_by_fkey; +UPDATE hosts SET created_by = '00000000-0000-0000-0000-000000000000' WHERE created_by IS NULL; +ALTER TABLE hosts ALTER COLUMN created_by SET NOT NULL; +ALTER TABLE hosts ADD CONSTRAINT hosts_created_by_fkey + FOREIGN KEY (created_by) REFERENCES users(id); + +-- Revert team_api_keys.created_by +ALTER TABLE team_api_keys DROP CONSTRAINT team_api_keys_created_by_fkey; +UPDATE team_api_keys SET created_by = '00000000-0000-0000-0000-000000000000' WHERE created_by IS NULL; +ALTER TABLE team_api_keys ALTER COLUMN created_by SET NOT NULL; +ALTER TABLE team_api_keys ADD CONSTRAINT team_api_keys_created_by_fkey + FOREIGN KEY (created_by) REFERENCES users(id); + +-- Revert admin_permissions +ALTER TABLE admin_permissions DROP CONSTRAINT admin_permissions_user_id_fkey; +ALTER TABLE admin_permissions ADD CONSTRAINT admin_permissions_user_id_fkey + FOREIGN KEY (user_id) REFERENCES users(id); + +-- Revert oauth_providers +ALTER TABLE oauth_providers DROP CONSTRAINT oauth_providers_user_id_fkey; +ALTER TABLE oauth_providers ADD CONSTRAINT oauth_providers_user_id_fkey + FOREIGN KEY (user_id) REFERENCES users(id); + +-- Revert users_teams +ALTER TABLE users_teams DROP CONSTRAINT users_teams_user_id_fkey; +ALTER TABLE users_teams ADD CONSTRAINT users_teams_user_id_fkey + FOREIGN KEY (user_id) REFERENCES users(id); diff --git a/db/queries/api_keys.sql b/db/queries/api_keys.sql index 7ea9645..be064a1 100644 --- a/db/queries/api_keys.sql +++ b/db/queries/api_keys.sql @@ -13,7 +13,7 @@ SELECT * FROM team_api_keys WHERE team_id = $1 ORDER BY created_at DESC; SELECT k.id, k.team_id, k.name, k.key_hash, k.key_prefix, k.created_by, k.created_at, k.last_used, u.email AS creator_email FROM team_api_keys k -JOIN users u ON u.id = k.created_by +LEFT JOIN users u ON u.id = k.created_by WHERE k.team_id = $1 ORDER BY k.created_at DESC; @@ -22,3 +22,6 @@ DELETE FROM team_api_keys WHERE id = $1 AND team_id = $2; -- name: UpdateAPIKeyLastUsed :exec UPDATE team_api_keys SET last_used = NOW() WHERE id = $1; + +-- name: DeleteAPIKeysByTeam :exec +DELETE FROM team_api_keys WHERE team_id = $1; diff --git a/db/queries/channels.sql b/db/queries/channels.sql index 5772c99..6df0449 100644 --- a/db/queries/channels.sql +++ b/db/queries/channels.sql @@ -22,6 +22,9 @@ RETURNING *; -- name: DeleteChannelByTeam :exec DELETE FROM channels WHERE id = $1 AND team_id = $2; +-- name: DeleteAllChannelsByTeam :exec +DELETE FROM channels WHERE team_id = $1; + -- name: ListChannelsForEvent :many SELECT * FROM channels WHERE team_id = $1 diff --git a/db/queries/metrics.sql b/db/queries/metrics.sql index f58d480..6c612c6 100644 --- a/db/queries/metrics.sql +++ b/db/queries/metrics.sql @@ -51,6 +51,13 @@ WHERE sandbox_id = $1 AND tier = $2; DELETE FROM sandbox_metric_points WHERE ts < EXTRACT(EPOCH FROM NOW() - INTERVAL '30 days')::BIGINT; +-- name: DeleteMetricsSnapshotsByTeam :exec +DELETE FROM sandbox_metrics_snapshots WHERE team_id = $1; + +-- name: DeleteMetricPointsByTeam :exec +DELETE FROM sandbox_metric_points +WHERE sandbox_id IN (SELECT id FROM sandboxes WHERE team_id = $1); + -- name: SampleSandboxMetrics :many -- Aggregates per-team resource usage from the live sandboxes table. -- Groups by all teams that have any sandbox row (including stopped) so that diff --git a/db/queries/sandboxes.sql b/db/queries/sandboxes.sql index 73843f8..2bf5db7 100644 --- a/db/queries/sandboxes.sql +++ b/db/queries/sandboxes.sql @@ -62,7 +62,7 @@ WHERE id = ANY($1::uuid[]); -- name: ListActiveSandboxesByTeam :many SELECT * FROM sandboxes -WHERE team_id = $1 AND status IN ('running', 'paused', 'starting') +WHERE team_id = $1 AND status IN ('running', 'paused', 'starting', 'hibernated') ORDER BY created_at DESC; -- name: MarkSandboxesMissingByHost :exec diff --git a/db/queries/teams.sql b/db/queries/teams.sql index d94341d..3444b8c 100644 --- a/db/queries/teams.sql +++ b/db/queries/teams.sql @@ -74,6 +74,18 @@ WHERE t.id != '00000000-0000-0000-0000-000000000000' ORDER BY t.deleted_at ASC NULLS FIRST, t.created_at DESC LIMIT $1 OFFSET $2; +-- name: ListSoleOwnedTeams :many +-- Returns teams where the user is the owner and no other members exist. +SELECT t.id FROM teams t +JOIN users_teams ut ON ut.team_id = t.id +WHERE ut.user_id = $1 + AND ut.role = 'owner' + AND t.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM users_teams ut2 + WHERE ut2.team_id = t.id AND ut2.user_id <> $1 + ); + -- name: CountTeamsAdmin :one SELECT COUNT(*)::int AS total FROM teams diff --git a/internal/api/handlers_apikeys.go b/internal/api/handlers_apikeys.go index a4c077a..9fc315d 100644 --- a/internal/api/handlers_apikeys.go +++ b/internal/api/handlers_apikeys.go @@ -63,7 +63,7 @@ func apiKeyWithCreatorToResponse(k db.ListAPIKeysByTeamWithCreatorRow) apiKeyRes Name: k.Name, KeyPrefix: k.KeyPrefix, CreatedBy: id.FormatUserID(k.CreatedBy), - CreatorEmail: k.CreatorEmail, + CreatorEmail: k.CreatorEmail.String, } if k.CreatedAt.Valid { resp.CreatedAt = k.CreatedAt.Time.Format(time.RFC3339) diff --git a/internal/api/handlers_me.go b/internal/api/handlers_me.go index 0f0f928..aefb7d7 100644 --- a/internal/api/handlers_me.go +++ b/internal/api/handlers_me.go @@ -22,6 +22,7 @@ import ( "git.omukk.dev/wrenn/wrenn/pkg/auth/oauth" "git.omukk.dev/wrenn/wrenn/pkg/db" "git.omukk.dev/wrenn/wrenn/pkg/id" + "git.omukk.dev/wrenn/wrenn/pkg/service" ) const ( @@ -37,6 +38,7 @@ type meHandler struct { mailer email.Mailer oauthRegistry *oauth.Registry redirectURL string + teamSvc *service.TeamService } func newMeHandler( @@ -47,6 +49,7 @@ func newMeHandler( mailer email.Mailer, registry *oauth.Registry, redirectURL string, + teamSvc *service.TeamService, ) *meHandler { return &meHandler{ db: db, @@ -56,6 +59,7 @@ func newMeHandler( mailer: mailer, oauthRegistry: registry, redirectURL: strings.TrimRight(redirectURL, "/"), + teamSvc: teamSvc, } } @@ -507,6 +511,19 @@ func (h *meHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) { return } + // Delete all teams the user solely owns (no other members). + soleTeams, err := h.db.ListSoleOwnedTeams(ctx, ac.UserID) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to list owned teams") + return + } + for _, teamID := range soleTeams { + if err := h.teamSvc.DeleteTeamInternal(ctx, teamID); err != nil { + slog.Warn("account delete: failed to delete sole-owned team", + "team_id", id.FormatTeamID(teamID), "error", err) + } + } + if err := h.db.SoftDeleteUser(ctx, ac.UserID); err != nil { writeError(w, http.StatusInternalServerError, "db_error", "failed to delete account") return diff --git a/internal/api/server.go b/internal/api/server.go index aaeddcf..6433644 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -84,7 +84,7 @@ func New( ptyH := newPtyHandler(queries, pool) processH := newProcessHandler(queries, pool) adminCapsules := newAdminCapsuleHandler(sandboxSvc, queries, pool, al) - meH := newMeHandler(queries, pgPool, rdb, jwtSecret, mailer, oauthRegistry, oauthRedirectURL) + meH := newMeHandler(queries, pgPool, rdb, jwtSecret, mailer, oauthRegistry, oauthRedirectURL, teamSvc) // OpenAPI spec and docs. r.Get("/openapi.yaml", serveOpenAPI) diff --git a/pkg/db/api_keys.sql.go b/pkg/db/api_keys.sql.go index 4b8d369..55f1bce 100644 --- a/pkg/db/api_keys.sql.go +++ b/pkg/db/api_keys.sql.go @@ -25,6 +25,15 @@ func (q *Queries) DeleteAPIKey(ctx context.Context, arg DeleteAPIKeyParams) erro return err } +const deleteAPIKeysByTeam = `-- name: DeleteAPIKeysByTeam :exec +DELETE FROM team_api_keys WHERE team_id = $1 +` + +func (q *Queries) DeleteAPIKeysByTeam(ctx context.Context, teamID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteAPIKeysByTeam, teamID) + return err +} + const getAPIKeyByHash = `-- name: GetAPIKeyByHash :one SELECT id, team_id, name, key_hash, key_prefix, created_by, created_at, last_used FROM team_api_keys WHERE key_hash = $1 ` @@ -120,7 +129,7 @@ const listAPIKeysByTeamWithCreator = `-- name: ListAPIKeysByTeamWithCreator :man SELECT k.id, k.team_id, k.name, k.key_hash, k.key_prefix, k.created_by, k.created_at, k.last_used, u.email AS creator_email FROM team_api_keys k -JOIN users u ON u.id = k.created_by +LEFT JOIN users u ON u.id = k.created_by WHERE k.team_id = $1 ORDER BY k.created_at DESC ` @@ -134,7 +143,7 @@ type ListAPIKeysByTeamWithCreatorRow struct { CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` LastUsed pgtype.Timestamptz `json:"last_used"` - CreatorEmail string `json:"creator_email"` + CreatorEmail pgtype.Text `json:"creator_email"` } func (q *Queries) ListAPIKeysByTeamWithCreator(ctx context.Context, teamID pgtype.UUID) ([]ListAPIKeysByTeamWithCreatorRow, error) { diff --git a/pkg/db/channels.sql.go b/pkg/db/channels.sql.go index 18f9048..d668700 100644 --- a/pkg/db/channels.sql.go +++ b/pkg/db/channels.sql.go @@ -11,6 +11,15 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const deleteAllChannelsByTeam = `-- name: DeleteAllChannelsByTeam :exec +DELETE FROM channels WHERE team_id = $1 +` + +func (q *Queries) DeleteAllChannelsByTeam(ctx context.Context, teamID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteAllChannelsByTeam, teamID) + return err +} + const deleteChannelByTeam = `-- name: DeleteChannelByTeam :exec DELETE FROM channels WHERE id = $1 AND team_id = $2 ` diff --git a/pkg/db/metrics.sql.go b/pkg/db/metrics.sql.go index f522dc2..886daca 100644 --- a/pkg/db/metrics.sql.go +++ b/pkg/db/metrics.sql.go @@ -11,6 +11,25 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const deleteMetricPointsByTeam = `-- name: DeleteMetricPointsByTeam :exec +DELETE FROM sandbox_metric_points +WHERE sandbox_id IN (SELECT id FROM sandboxes WHERE team_id = $1) +` + +func (q *Queries) DeleteMetricPointsByTeam(ctx context.Context, teamID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteMetricPointsByTeam, teamID) + return err +} + +const deleteMetricsSnapshotsByTeam = `-- name: DeleteMetricsSnapshotsByTeam :exec +DELETE FROM sandbox_metrics_snapshots WHERE team_id = $1 +` + +func (q *Queries) DeleteMetricsSnapshotsByTeam(ctx context.Context, teamID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteMetricsSnapshotsByTeam, teamID) + return err +} + const deleteSandboxMetricPoints = `-- name: DeleteSandboxMetricPoints :exec DELETE FROM sandbox_metric_points WHERE sandbox_id = $1 diff --git a/pkg/db/sandboxes.sql.go b/pkg/db/sandboxes.sql.go index e33818d..c48c9ab 100644 --- a/pkg/db/sandboxes.sql.go +++ b/pkg/db/sandboxes.sql.go @@ -190,7 +190,7 @@ func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (S const listActiveSandboxesByTeam = `-- name: ListActiveSandboxesByTeam :many SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, template_id, template_team_id, metadata FROM sandboxes -WHERE team_id = $1 AND status IN ('running', 'paused', 'starting') +WHERE team_id = $1 AND status IN ('running', 'paused', 'starting', 'hibernated') ORDER BY created_at DESC ` diff --git a/pkg/db/teams.sql.go b/pkg/db/teams.sql.go index 0700db4..7947107 100644 --- a/pkg/db/teams.sql.go +++ b/pkg/db/teams.sql.go @@ -284,6 +284,39 @@ func (q *Queries) InsertTeamMember(ctx context.Context, arg InsertTeamMemberPara return err } +const listSoleOwnedTeams = `-- name: ListSoleOwnedTeams :many +SELECT t.id FROM teams t +JOIN users_teams ut ON ut.team_id = t.id +WHERE ut.user_id = $1 + AND ut.role = 'owner' + AND t.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM users_teams ut2 + WHERE ut2.team_id = t.id AND ut2.user_id <> $1 + ) +` + +// Returns teams where the user is the owner and no other members exist. +func (q *Queries) ListSoleOwnedTeams(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) { + rows, err := q.db.Query(ctx, listSoleOwnedTeams, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []pgtype.UUID + for rows.Next() { + var id pgtype.UUID + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listTeamsAdmin = `-- name: ListTeamsAdmin :many SELECT t.id, diff --git a/pkg/service/team.go b/pkg/service/team.go index 858c7e2..0376406 100644 --- a/pkg/service/team.go +++ b/pkg/service/team.go @@ -169,6 +169,12 @@ func (s *TeamService) DeleteTeam(ctx context.Context, teamID, callerUserID pgtyp return fmt.Errorf("forbidden: only the owner can delete a team") } + return s.deleteTeamCore(ctx, teamID) +} + +// deleteTeamCore contains the shared team deletion logic: +// destroy active sandboxes, clean up templates, soft-delete the team. +func (s *TeamService) deleteTeamCore(ctx context.Context, teamID pgtype.UUID) error { // Collect active sandboxes and stop them. sandboxes, err := s.DB.ListActiveSandboxesByTeam(ctx, teamID) if err != nil { @@ -202,6 +208,24 @@ func (s *TeamService) DeleteTeam(ctx context.Context, teamID, callerUserID pgtyp } } + // Delete sandbox metrics for this team. + if err := s.DB.DeleteMetricPointsByTeam(ctx, teamID); err != nil { + slog.Warn("team delete: failed to delete metric points", "team_id", id.FormatTeamID(teamID), "error", err) + } + if err := s.DB.DeleteMetricsSnapshotsByTeam(ctx, teamID); err != nil { + slog.Warn("team delete: failed to delete metrics snapshots", "team_id", id.FormatTeamID(teamID), "error", err) + } + + // Delete all API keys for this team. + if err := s.DB.DeleteAPIKeysByTeam(ctx, teamID); err != nil { + slog.Warn("team delete: failed to delete API keys", "team_id", id.FormatTeamID(teamID), "error", err) + } + + // Delete all channels for this team. + if err := s.DB.DeleteAllChannelsByTeam(ctx, teamID); err != nil { + slog.Warn("team delete: failed to delete channels", "team_id", id.FormatTeamID(teamID), "error", err) + } + // Clean up team-owned templates from all hosts in the background. go s.cleanupTeamTemplates(context.Background(), teamID) @@ -497,6 +521,13 @@ func (s *TeamService) AdminListTeams(ctx context.Context, limit, offset int32) ( return rows, total, nil } +// DeleteTeamInternal soft-deletes a team and destroys all its active sandboxes. +// Used for system-initiated deletions (e.g. cascading from user account deletion) +// where no caller role check is needed. +func (s *TeamService) DeleteTeamInternal(ctx context.Context, teamID pgtype.UUID) error { + return s.deleteTeamCore(ctx, teamID) +} + // AdminDeleteTeam soft-deletes a team and destroys all its active sandboxes. // Unlike DeleteTeam, this does not require the caller to be the team owner — // it is admin-only (caller must verify admin status). @@ -509,41 +540,5 @@ func (s *TeamService) AdminDeleteTeam(ctx context.Context, teamID pgtype.UUID) e return fmt.Errorf("team not found") } - // Destroy active sandboxes (same logic as DeleteTeam). - sandboxes, err := s.DB.ListActiveSandboxesByTeam(ctx, teamID) - if err != nil { - return fmt.Errorf("list active sandboxes: %w", err) - } - - var stopIDs []pgtype.UUID - for _, sb := range sandboxes { - host, hostErr := s.DB.GetHost(ctx, sb.HostID) - if hostErr == nil { - agent, agentErr := s.HostPool.GetForHost(host) - if agentErr == nil { - if _, err := agent.DestroySandbox(ctx, connect.NewRequest(&pb.DestroySandboxRequest{ - SandboxId: id.FormatSandboxID(sb.ID), - })); err != nil && connect.CodeOf(err) != connect.CodeNotFound { - slog.Warn("admin team delete: failed to destroy sandbox", "sandbox_id", id.FormatSandboxID(sb.ID), "error", err) - } - } - } - stopIDs = append(stopIDs, sb.ID) - } - - if len(stopIDs) > 0 { - if err := s.DB.BulkUpdateStatusByIDs(ctx, db.BulkUpdateStatusByIDsParams{ - Column1: stopIDs, - Status: "stopped", - }); err != nil { - return fmt.Errorf("update sandbox statuses: %w", err) - } - } - - go s.cleanupTeamTemplates(context.Background(), teamID) - - if err := s.DB.SoftDeleteTeam(ctx, teamID); err != nil { - return fmt.Errorf("soft delete team: %w", err) - } - return nil + return s.deleteTeamCore(ctx, teamID) }