diff --git a/apps/backend/internal/task/service/service_resources.go b/apps/backend/internal/task/service/service_resources.go index f7614948d3..e1e381b037 100644 --- a/apps/backend/internal/task/service/service_resources.go +++ b/apps/backend/internal/task/service/service_resources.go @@ -16,6 +16,7 @@ import ( ) const spritesTokenEnvKey = "SPRITES_API_TOKEN" +const workspaceDeletePageSize = 500 // Workspace operations @@ -90,6 +91,30 @@ func (s *Service) DeleteWorkspace(ctx context.Context, id string) error { if err != nil { return err } + tasks, err := s.listAllTasksForWorkspaceDelete(ctx, id) + if err != nil { + return err + } + for _, task := range tasks { + if task == nil || task.ID == "" { + continue + } + if err := s.DeleteTask(ctx, task.ID); err != nil { + return fmt.Errorf("delete task %s: %w", task.ID, err) + } + } + workflows, err := s.workflows.ListWorkflows(ctx, id, true) + if err != nil { + return fmt.Errorf("list workspace workflows: %w", err) + } + for _, workflow := range workflows { + if workflow == nil || workflow.ID == "" { + continue + } + if err := s.DeleteWorkflow(ctx, workflow.ID); err != nil { + return fmt.Errorf("delete workflow %s: %w", workflow.ID, err) + } + } if err := s.workspaces.DeleteWorkspace(ctx, id); err != nil { s.logger.Error("failed to delete workspace", zap.String("workspace_id", id), zap.Error(err)) return err @@ -99,6 +124,22 @@ func (s *Service) DeleteWorkspace(ctx context.Context, id string) error { return nil } +func (s *Service) listAllTasksForWorkspaceDelete(ctx context.Context, workspaceID string) ([]*models.Task, error) { + var all []*models.Task + for page := 1; ; page++ { + tasks, total, err := s.tasks.ListTasksByWorkspace( + ctx, workspaceID, "", "", "", page, workspaceDeletePageSize, true, true, false, false, + ) + if err != nil { + return nil, fmt.Errorf("list workspace tasks: %w", err) + } + all = append(all, tasks...) + if len(all) >= total || len(tasks) == 0 { + return all, nil + } + } +} + func normalizeOptionalID(value *string) *string { if value == nil { return nil diff --git a/apps/backend/internal/task/service/service_resources_test.go b/apps/backend/internal/task/service/service_resources_test.go index c77677b3e5..05dcc89ebd 100644 --- a/apps/backend/internal/task/service/service_resources_test.go +++ b/apps/backend/internal/task/service/service_resources_test.go @@ -129,6 +129,46 @@ func TestService_GetOfficeWorkflowIDs_DBError(t *testing.T) { } } +func TestService_DeleteWorkspaceDeletesWorkspaceOwnedTasksAndWorkflows(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-delete", Name: "Delete Me"}) + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-keep", Name: "Keep Me"}) + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-delete", WorkspaceID: "ws-delete", Name: "Doomed"}) + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-keep", WorkspaceID: "ws-keep", Name: "Keep"}) + if err := repo.CreateTask(ctx, &models.Task{ + ID: "task-delete", + WorkspaceID: "ws-delete", + WorkflowID: "wf-delete", + WorkflowStepID: "step-delete", + Title: "Delete task", + }); err != nil { + t.Fatalf("CreateTask: %v", err) + } + + if err := svc.DeleteWorkspace(ctx, "ws-delete"); err != nil { + t.Fatalf("DeleteWorkspace: %v", err) + } + + if _, err := repo.GetWorkspace(ctx, "ws-delete"); err == nil { + t.Fatalf("workspace should be deleted") + } + if _, err := repo.GetTask(ctx, "task-delete"); err == nil { + t.Fatalf("workspace task should be deleted") + } + workflows, err := repo.ListWorkflows(ctx, "ws-delete", true) + if err != nil { + t.Fatalf("ListWorkflows: %v", err) + } + if len(workflows) != 0 { + t.Fatalf("workspace workflows should be deleted, got %d", len(workflows)) + } + if _, err := repo.GetWorkflow(ctx, "wf-keep"); err != nil { + t.Fatalf("unrelated workflow should remain: %v", err) + } +} + // TestService_DeleteWorkflow_ArchivesChildTasks verifies the cascade fix for // issue #1279: workflow deletion archives any active child tasks instead of // leaving them with a dangling workflow_id (tasks.workflow_id has no FK, so diff --git a/apps/web/app/actions/workspaces.test.ts b/apps/web/app/actions/workspaces.test.ts index b7600ea782..3d6fb5287c 100644 --- a/apps/web/app/actions/workspaces.test.ts +++ b/apps/web/app/actions/workspaces.test.ts @@ -63,7 +63,7 @@ describe("deleteWorkspaceAction", () => { expect(fetchMock).toHaveBeenCalledTimes(1); const [url, init] = fetchMock.mock.calls[0]; - expect(url).toBe("http://backend.test/api/v1/office/workspaces/ws-1"); + expect(url).toBe("http://backend.test/api/v1/workspaces/ws-1"); expect(init.method).toBe("DELETE"); expect(JSON.parse(init.body as string)).toEqual({ confirm_name: "My Workspace" }); }); diff --git a/apps/web/app/actions/workspaces.ts b/apps/web/app/actions/workspaces.ts index 46790af4c1..fc3563dbc1 100644 --- a/apps/web/app/actions/workspaces.ts +++ b/apps/web/app/actions/workspaces.ts @@ -97,7 +97,7 @@ export async function updateWorkspaceAction( } export async function deleteWorkspaceAction(id: string, confirmName: string) { - await fetchJson(`${apiBaseUrl}/api/v1/office/workspaces/${id}`, { + await fetchJson(`${apiBaseUrl}/api/v1/workspaces/${id}`, { method: "DELETE", body: JSON.stringify({ confirm_name: confirmName }), });