From bd810b88fdcfa9db068336951afd04653e7cf65a Mon Sep 17 00:00:00 2001 From: Farhan Yahaya Date: Tue, 9 Dec 2025 17:11:31 +0000 Subject: [PATCH 1/7] fix: saving workflow with name same as deleted workflow --- .../js/collaborative-editor/types/workflow.ts | 8 +- .../types/workflow.test.ts | 101 ++++++++++++++++++ lib/lightning/workflows.ex | 44 +++++++- lib/lightning/workflows/workflow.ex | 13 +++ test/lightning/workflows_test.exs | 70 ++++++++++++ 5 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 assets/test/collaborative-editor/types/workflow.test.ts diff --git a/assets/js/collaborative-editor/types/workflow.ts b/assets/js/collaborative-editor/types/workflow.ts index e8c794a079..870d2b78b0 100644 --- a/assets/js/collaborative-editor/types/workflow.ts +++ b/assets/js/collaborative-editor/types/workflow.ts @@ -17,14 +17,18 @@ import type { Trigger as TriggerType } from './trigger'; /** * Zod schema for workflow validation * - * Mirrors backend validation from lib/lightning/workflows/workflow.ex:81-89 + * Mirrors backend validation from lib/lightning/workflows/workflow.ex:81-103 */ export const WorkflowSchema = z.object({ id: z.string().uuid(), name: z .string() .min(1, "can't be blank") - .max(255, 'should be at most 255 character(s)'), + .max(255, 'should be at most 255 character(s)') + .refine( + name => !/_del\d*$/.test(name), + 'cannot end with _del followed by digits' + ), lock_version: z.number().int(), deleted_at: z.string().nullable(), diff --git a/assets/test/collaborative-editor/types/workflow.test.ts b/assets/test/collaborative-editor/types/workflow.test.ts new file mode 100644 index 0000000000..eb52f2267d --- /dev/null +++ b/assets/test/collaborative-editor/types/workflow.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest'; + +import { WorkflowSchema } from '#/collaborative-editor/types/workflow'; + +describe('WorkflowSchema', () => { + describe('name validation', () => { + it('should reject names ending with _del followed by digits', () => { + const invalidData = { + id: '123e4567-e89b-12d3-a456-426614174000', + name: 'My Workflow_del0001', + lock_version: 1, + deleted_at: null, + }; + + const result = WorkflowSchema.safeParse(invalidData); + expect(result.success).toBe(false); + }); + + it('should reject names ending with _del without digits', () => { + const invalidData = { + id: '123e4567-e89b-12d3-a456-426614174000', + name: 'My Workflow_del', + lock_version: 1, + deleted_at: null, + }; + + const result = WorkflowSchema.safeParse(invalidData); + expect(result.success).toBe(false); + }); + + it('should reject names ending with _del followed by 3 digits', () => { + const invalidData = { + id: '123e4567-e89b-12d3-a456-426614174000', + name: 'My Workflow_del123', + lock_version: 1, + deleted_at: null, + }; + + const result = WorkflowSchema.safeParse(invalidData); + expect(result.success).toBe(false); + }); + + it('should reject names ending with _del followed by 5 digits', () => { + const invalidData = { + id: '123e4567-e89b-12d3-a456-426614174000', + name: 'My Workflow_del12345', + lock_version: 1, + deleted_at: null, + }; + + const result = WorkflowSchema.safeParse(invalidData); + expect(result.success).toBe(false); + }); + + it('should allow normal workflow names', () => { + const validData = { + id: '123e4567-e89b-12d3-a456-426614174000', + name: 'My Workflow', + lock_version: 1, + deleted_at: null, + }; + + const result = WorkflowSchema.safeParse(validData); + expect(result.success).toBe(true); + }); + + it('should allow names with _del in the middle', () => { + const validData = { + id: '123e4567-e89b-12d3-a456-426614174000', + name: 'My_del_Workflow', + lock_version: 1, + deleted_at: null, + }; + + const result = WorkflowSchema.safeParse(validData); + expect(result.success).toBe(true); + }); + + it('should reject multiple invalid formats', () => { + const testCases = [ + 'Test_del0001', + 'Workflow_del9999', + 'Another Workflow_del0000', + 'Name_del', + 'Workflow_del1', + ]; + + testCases.forEach(name => { + const data = { + id: '123e4567-e89b-12d3-a456-426614174000', + name, + lock_version: 1, + deleted_at: null, + }; + + const result = WorkflowSchema.safeParse(data); + expect(result.success).toBe(false); + }); + }); + }); +}); diff --git a/lib/lightning/workflows.ex b/lib/lightning/workflows.ex index cbbfcc93d3..8c95ef4c8e 100644 --- a/lib/lightning/workflows.ex +++ b/lib/lightning/workflows.ex @@ -462,12 +462,16 @@ defmodule Lightning.Workflows do where: t.workflow_id == ^workflow.id ) + new_name = generate_deleted_workflow_name(workflow) + Multi.new() |> Multi.update( :workflow, - Workflow.request_deletion_changeset(workflow, %{ + workflow + |> Workflow.request_deletion_changeset(%{ "deleted_at" => DateTime.utc_now() }) + |> Ecto.Changeset.put_change(:name, new_name) ) |> Multi.insert(:audit, Audit.marked_for_deletion(workflow.id, actor)) |> Multi.update_all( @@ -486,6 +490,44 @@ defmodule Lightning.Workflows do end) end + defp generate_deleted_workflow_name(%Workflow{ + name: name, + project_id: project_id + }) do + # Escape special regex characters in the workflow name + escaped_name = Regex.escape(name) + pattern = "^#{escaped_name}_del[0-9]+$" + + # Find the workflow with the highest suffix by sorting names lexicographically + # This works because we zero-pad numbers: _del0001, _del0002, ..., _del0009, _del0010 + highest_suffix_name = + from(w in Workflow, + where: + w.project_id == ^project_id and + not is_nil(w.deleted_at) and + fragment("? ~ ?", w.name, ^pattern), + order_by: [desc: w.name], + limit: 1, + select: w.name + ) + |> Repo.one() + + next_number = + case highest_suffix_name do + nil -> + 1 + + name_with_suffix -> + case Regex.run(~r/_del(\d+)$/, name_with_suffix) do + [_, num] -> String.to_integer(num) + 1 + _ -> 1 + end + end + + # Zero-pad to 4 digits to ensure proper lexicographic sorting + "#{name}_del#{String.pad_leading(Integer.to_string(next_number), 4, "0")}" + end + defp notify_of_affected_kafka_triggers(%{triggers: triggers}) do triggers |> Enum.filter(&(&1.type == :kafka)) diff --git a/lib/lightning/workflows/workflow.ex b/lib/lightning/workflows/workflow.ex index 9493f005cb..e44d795a1a 100644 --- a/lib/lightning/workflows/workflow.ex +++ b/lib/lightning/workflows/workflow.ex @@ -83,11 +83,24 @@ defmodule Lightning.Workflows.Workflow do |> assoc_constraint(:project) |> validate_number(:concurrency, greater_than_or_equal_to: 1) |> validate_required([:name]) + |> validate_name_not_deleted_format() |> unique_constraint([:name, :project_id], message: "a workflow with this name already exists in this project." ) end + defp validate_name_not_deleted_format(changeset) do + validate_change(changeset, :name, fn :name, name -> + if Regex.match?(~r/_del\d*$/, name) do + [ + name: "cannot end with _del followed by digits" + ] + else + [] + end + end) + end + def request_deletion_changeset(workflow, attrs) do workflow |> cast(attrs, [:deleted_at]) diff --git a/test/lightning/workflows_test.exs b/test/lightning/workflows_test.exs index edd6d80755..73db9286c4 100644 --- a/test/lightning/workflows_test.exs +++ b/test/lightning/workflows_test.exs @@ -833,6 +833,76 @@ defmodule Lightning.WorkflowsTest do assert_received %KafkaTriggerUpdated{trigger_id: ^kafka_trigger_2_id} assert_received %KafkaTriggerUpdated{trigger_id: ^kafka_trigger_1_id} end + + test "mark_for_deletion/3 renames workflow with _del suffix" do + # Use a separate project to avoid pollution from setup + project = insert(:project) + user = insert(:user) + w1 = insert(:workflow, project: project, name: "Test Workflow") + + assert {:ok, %{workflow: workflow}} = Workflows.mark_for_deletion(w1, user) + assert workflow.name == "Test Workflow_del0001" + + # Test incrementing when deleting another workflow with the same name + w2 = insert(:workflow, project: project, name: "Test Workflow") + + assert {:ok, %{workflow: workflow2}} = + Workflows.mark_for_deletion(w2, user) + + assert workflow2.name == "Test Workflow_del0002" + + # Test incrementing again + w3 = insert(:workflow, project: project, name: "Test Workflow") + + assert {:ok, %{workflow: workflow3}} = + Workflows.mark_for_deletion(w3, user) + + assert workflow3.name == "Test Workflow_del0003" + end + + test "change_workflow/2 rejects names ending with _del followed by digits" do + project = insert(:project) + workflow = insert(:workflow, project: project, name: "Original Name") + + # Should reject _del with any digits + changeset = + Workflows.change_workflow(workflow, %{name: "My Workflow_del0001"}) + + refute changeset.valid? + + assert %{name: [error]} = errors_on(changeset) + + assert error =~ + "cannot end with _del followed by digits" + + # Should also reject _del with no digits + changeset = Workflows.change_workflow(workflow, %{name: "My Workflow_del"}) + refute changeset.valid? + + assert %{name: [error]} = errors_on(changeset) + assert error =~ "cannot end with _del followed by digits" + + # Should also reject _del with 3 digits + changeset = + Workflows.change_workflow(workflow, %{name: "My Workflow_del123"}) + + refute changeset.valid? + + assert %{name: [error]} = errors_on(changeset) + assert error =~ "cannot end with _del followed by digits" + + # Valid names should work (not ending with _del) + changeset = Workflows.change_workflow(workflow, %{name: "My Workflow"}) + errors = errors_on(changeset) + assert Map.get(errors, :name) == nil + + # Valid name with _del in the middle + changeset = + Workflows.change_workflow(workflow, %{name: "My_del_Workflow"}) + + errors = errors_on(changeset) + assert Map.get(errors, :name) == nil + end end describe "get_workflows_for/2" do From ffb24cb66f3bd1f3c530aeda10db88733994076a Mon Sep 17 00:00:00 2001 From: Farhan Yahaya Date: Tue, 9 Dec 2025 17:49:08 +0000 Subject: [PATCH 2/7] chore: update changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fce59acdec..7547a1cb92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ and this project adheres to ### Fixed +- Fix saving workflow with same name as deleted workflow + [#4165](https://github.com/OpenFn/lightning/pull/4165) + ## [2.15.0-pre4] - 2025-12-08 ### Added From fbfa34d99d7bbaff99570752e38e364525d5c776 Mon Sep 17 00:00:00 2001 From: Farhan Yahaya Date: Wed, 10 Dec 2025 11:54:29 +0000 Subject: [PATCH 3/7] fix: update wording & reduce collision --- .../js/collaborative-editor/types/workflow.ts | 6 +- .../types/workflow.test.ts | 101 ------------------ lib/lightning/workflows.ex | 5 +- lib/lightning/workflows/workflow.ex | 16 +-- test/lightning/workflows/workflow_test.exs | 2 +- test/lightning/workflows_test.exs | 69 ++++++------ .../live/workflow_live/edit_test.exs | 4 +- 7 files changed, 39 insertions(+), 164 deletions(-) delete mode 100644 assets/test/collaborative-editor/types/workflow.test.ts diff --git a/assets/js/collaborative-editor/types/workflow.ts b/assets/js/collaborative-editor/types/workflow.ts index 870d2b78b0..cace5d8c13 100644 --- a/assets/js/collaborative-editor/types/workflow.ts +++ b/assets/js/collaborative-editor/types/workflow.ts @@ -24,11 +24,7 @@ export const WorkflowSchema = z.object({ name: z .string() .min(1, "can't be blank") - .max(255, 'should be at most 255 character(s)') - .refine( - name => !/_del\d*$/.test(name), - 'cannot end with _del followed by digits' - ), + .max(255, 'should be at most 255 character(s)'), lock_version: z.number().int(), deleted_at: z.string().nullable(), diff --git a/assets/test/collaborative-editor/types/workflow.test.ts b/assets/test/collaborative-editor/types/workflow.test.ts deleted file mode 100644 index eb52f2267d..0000000000 --- a/assets/test/collaborative-editor/types/workflow.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { WorkflowSchema } from '#/collaborative-editor/types/workflow'; - -describe('WorkflowSchema', () => { - describe('name validation', () => { - it('should reject names ending with _del followed by digits', () => { - const invalidData = { - id: '123e4567-e89b-12d3-a456-426614174000', - name: 'My Workflow_del0001', - lock_version: 1, - deleted_at: null, - }; - - const result = WorkflowSchema.safeParse(invalidData); - expect(result.success).toBe(false); - }); - - it('should reject names ending with _del without digits', () => { - const invalidData = { - id: '123e4567-e89b-12d3-a456-426614174000', - name: 'My Workflow_del', - lock_version: 1, - deleted_at: null, - }; - - const result = WorkflowSchema.safeParse(invalidData); - expect(result.success).toBe(false); - }); - - it('should reject names ending with _del followed by 3 digits', () => { - const invalidData = { - id: '123e4567-e89b-12d3-a456-426614174000', - name: 'My Workflow_del123', - lock_version: 1, - deleted_at: null, - }; - - const result = WorkflowSchema.safeParse(invalidData); - expect(result.success).toBe(false); - }); - - it('should reject names ending with _del followed by 5 digits', () => { - const invalidData = { - id: '123e4567-e89b-12d3-a456-426614174000', - name: 'My Workflow_del12345', - lock_version: 1, - deleted_at: null, - }; - - const result = WorkflowSchema.safeParse(invalidData); - expect(result.success).toBe(false); - }); - - it('should allow normal workflow names', () => { - const validData = { - id: '123e4567-e89b-12d3-a456-426614174000', - name: 'My Workflow', - lock_version: 1, - deleted_at: null, - }; - - const result = WorkflowSchema.safeParse(validData); - expect(result.success).toBe(true); - }); - - it('should allow names with _del in the middle', () => { - const validData = { - id: '123e4567-e89b-12d3-a456-426614174000', - name: 'My_del_Workflow', - lock_version: 1, - deleted_at: null, - }; - - const result = WorkflowSchema.safeParse(validData); - expect(result.success).toBe(true); - }); - - it('should reject multiple invalid formats', () => { - const testCases = [ - 'Test_del0001', - 'Workflow_del9999', - 'Another Workflow_del0000', - 'Name_del', - 'Workflow_del1', - ]; - - testCases.forEach(name => { - const data = { - id: '123e4567-e89b-12d3-a456-426614174000', - name, - lock_version: 1, - deleted_at: null, - }; - - const result = WorkflowSchema.safeParse(data); - expect(result.success).toBe(false); - }); - }); - }); -}); diff --git a/lib/lightning/workflows.ex b/lib/lightning/workflows.ex index 8c95ef4c8e..878ddba367 100644 --- a/lib/lightning/workflows.ex +++ b/lib/lightning/workflows.ex @@ -499,7 +499,7 @@ defmodule Lightning.Workflows do pattern = "^#{escaped_name}_del[0-9]+$" # Find the workflow with the highest suffix by sorting names lexicographically - # This works because we zero-pad numbers: _del0001, _del0002, ..., _del0009, _del0010 + # This works because we zero-pad numbers: _del01, _del02, ..., _del09, _del10 highest_suffix_name = from(w in Workflow, where: @@ -524,8 +524,7 @@ defmodule Lightning.Workflows do end end - # Zero-pad to 4 digits to ensure proper lexicographic sorting - "#{name}_del#{String.pad_leading(Integer.to_string(next_number), 4, "0")}" + "#{name}_del#{String.pad_leading(Integer.to_string(next_number), 2, "0")}" end defp notify_of_affected_kafka_triggers(%{triggers: triggers}) do diff --git a/lib/lightning/workflows/workflow.ex b/lib/lightning/workflows/workflow.ex index e44d795a1a..d6bfc2f9ce 100644 --- a/lib/lightning/workflows/workflow.ex +++ b/lib/lightning/workflows/workflow.ex @@ -83,24 +83,12 @@ defmodule Lightning.Workflows.Workflow do |> assoc_constraint(:project) |> validate_number(:concurrency, greater_than_or_equal_to: 1) |> validate_required([:name]) - |> validate_name_not_deleted_format() |> unique_constraint([:name, :project_id], - message: "a workflow with this name already exists in this project." + message: + "a workflow with this name already exists(possibly pending deletion) in this project." ) end - defp validate_name_not_deleted_format(changeset) do - validate_change(changeset, :name, fn :name, name -> - if Regex.match?(~r/_del\d*$/, name) do - [ - name: "cannot end with _del followed by digits" - ] - else - [] - end - end) - end - def request_deletion_changeset(workflow, attrs) do workflow |> cast(attrs, [:deleted_at]) diff --git a/test/lightning/workflows/workflow_test.exs b/test/lightning/workflows/workflow_test.exs index 652bfd5026..26204d90e4 100644 --- a/test/lightning/workflows/workflow_test.exs +++ b/test/lightning/workflows/workflow_test.exs @@ -79,7 +79,7 @@ defmodule Lightning.Workflows.WorkflowTest do |> Workflow.changeset(%{name: "w1", project_id: p1.id}) |> Repo.insert() - assert "a workflow with this name already exists in this project." in errors_on( + assert "a workflow with this name already exists(possibly pending deletion) in this project." in errors_on( cs ).name diff --git a/test/lightning/workflows_test.exs b/test/lightning/workflows_test.exs index 73db9286c4..c8721068bf 100644 --- a/test/lightning/workflows_test.exs +++ b/test/lightning/workflows_test.exs @@ -52,7 +52,7 @@ defmodule Lightning.WorkflowsTest do assert %{ name: [ - "a workflow with this name already exists in this project." + "a workflow with this name already exists(possibly pending deletion) in this project." ] } = errors_on(changeset) @@ -841,7 +841,7 @@ defmodule Lightning.WorkflowsTest do w1 = insert(:workflow, project: project, name: "Test Workflow") assert {:ok, %{workflow: workflow}} = Workflows.mark_for_deletion(w1, user) - assert workflow.name == "Test Workflow_del0001" + assert workflow.name == "Test Workflow_del01" # Test incrementing when deleting another workflow with the same name w2 = insert(:workflow, project: project, name: "Test Workflow") @@ -849,7 +849,7 @@ defmodule Lightning.WorkflowsTest do assert {:ok, %{workflow: workflow2}} = Workflows.mark_for_deletion(w2, user) - assert workflow2.name == "Test Workflow_del0002" + assert workflow2.name == "Test Workflow_del02" # Test incrementing again w3 = insert(:workflow, project: project, name: "Test Workflow") @@ -857,51 +857,42 @@ defmodule Lightning.WorkflowsTest do assert {:ok, %{workflow: workflow3}} = Workflows.mark_for_deletion(w3, user) - assert workflow3.name == "Test Workflow_del0003" + assert workflow3.name == "Test Workflow_del03" end - test "change_workflow/2 rejects names ending with _del followed by digits" do + test "allows reusing workflow name after marking for deletion, then validates error when using deleted workflow name" do project = insert(:project) - workflow = insert(:workflow, project: project, name: "Original Name") - - # Should reject _del with any digits - changeset = - Workflows.change_workflow(workflow, %{name: "My Workflow_del0001"}) - - refute changeset.valid? - - assert %{name: [error]} = errors_on(changeset) - - assert error =~ - "cannot end with _del followed by digits" - - # Should also reject _del with no digits - changeset = Workflows.change_workflow(workflow, %{name: "My Workflow_del"}) - refute changeset.valid? - - assert %{name: [error]} = errors_on(changeset) - assert error =~ "cannot end with _del followed by digits" + user = insert(:user) + w1 = insert(:workflow, project: project, name: "My Workflow") - # Should also reject _del with 3 digits - changeset = - Workflows.change_workflow(workflow, %{name: "My Workflow_del123"}) + # Mark workflow for deletion + assert {:ok, %{workflow: deleted_workflow}} = + Workflows.mark_for_deletion(w1, user) - refute changeset.valid? + assert deleted_workflow.name == "My Workflow_del01" - assert %{name: [error]} = errors_on(changeset) - assert error =~ "cannot end with _del followed by digits" + # Should be able to create a new workflow with the original name + assert {:ok, new_workflow} = + Workflows.save_workflow( + %{name: "My Workflow", project_id: project.id}, + user + ) - # Valid names should work (not ending with _del) - changeset = Workflows.change_workflow(workflow, %{name: "My Workflow"}) - errors = errors_on(changeset) - assert Map.get(errors, :name) == nil + assert new_workflow.name == "My Workflow" + assert new_workflow.deleted_at == nil - # Valid name with _del in the middle - changeset = - Workflows.change_workflow(workflow, %{name: "My_del_Workflow"}) + # Should NOT be able to create another workflow with the deleted workflow's name + assert {:error, changeset} = + Workflows.save_workflow( + %{name: "My Workflow_del01", project_id: project.id}, + user + ) - errors = errors_on(changeset) - assert Map.get(errors, :name) == nil + assert errors_on(changeset) == %{ + name: [ + "a workflow with this name already exists(possibly pending deletion) in this project." + ] + } end end diff --git a/test/lightning_web/live/workflow_live/edit_test.exs b/test/lightning_web/live/workflow_live/edit_test.exs index efbaafaeea..5461bde623 100644 --- a/test/lightning_web/live/workflow_live/edit_test.exs +++ b/test/lightning_web/live/workflow_live/edit_test.exs @@ -1229,7 +1229,9 @@ defmodule LightningWeb.WorkflowLive.EditTest do }) |> render_submit() - assert html =~ "a workflow with this name already exists in this project." + assert html =~ + "a workflow with this name already exists(possibly pending deletion) in this project." + assert html =~ "Workflow could not be saved" assert view From 6d7c8a4215260f56105e96ae4fd502b51dabe286 Mon Sep 17 00:00:00 2001 From: Taylor Downs Date: Wed, 10 Dec 2025 18:18:07 +0100 Subject: [PATCH 4/7] fix small typo and caps in validation message --- lib/lightning/workflows/workflow.ex | 2 +- test/lightning/workflows/workflow_test.exs | 2 +- test/lightning/workflows_test.exs | 4 ++-- test/lightning_web/live/workflow_live/edit_test.exs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/lightning/workflows/workflow.ex b/lib/lightning/workflows/workflow.ex index d6bfc2f9ce..1bc3cc02c4 100644 --- a/lib/lightning/workflows/workflow.ex +++ b/lib/lightning/workflows/workflow.ex @@ -85,7 +85,7 @@ defmodule Lightning.Workflows.Workflow do |> validate_required([:name]) |> unique_constraint([:name, :project_id], message: - "a workflow with this name already exists(possibly pending deletion) in this project." + "A workflow with this name already exists (possibly pending deletion) in this project." ) end diff --git a/test/lightning/workflows/workflow_test.exs b/test/lightning/workflows/workflow_test.exs index 26204d90e4..fb70ebf476 100644 --- a/test/lightning/workflows/workflow_test.exs +++ b/test/lightning/workflows/workflow_test.exs @@ -79,7 +79,7 @@ defmodule Lightning.Workflows.WorkflowTest do |> Workflow.changeset(%{name: "w1", project_id: p1.id}) |> Repo.insert() - assert "a workflow with this name already exists(possibly pending deletion) in this project." in errors_on( + assert "a workflow with this name already exists (possibly pending deletion) in this project." in errors_on( cs ).name diff --git a/test/lightning/workflows_test.exs b/test/lightning/workflows_test.exs index c8721068bf..ca1abaf810 100644 --- a/test/lightning/workflows_test.exs +++ b/test/lightning/workflows_test.exs @@ -52,7 +52,7 @@ defmodule Lightning.WorkflowsTest do assert %{ name: [ - "a workflow with this name already exists(possibly pending deletion) in this project." + "A workflow with this name already exists (possibly pending deletion) in this project." ] } = errors_on(changeset) @@ -890,7 +890,7 @@ defmodule Lightning.WorkflowsTest do assert errors_on(changeset) == %{ name: [ - "a workflow with this name already exists(possibly pending deletion) in this project." + "a workflow with this name already exists (possibly pending deletion) in this project." ] } end diff --git a/test/lightning_web/live/workflow_live/edit_test.exs b/test/lightning_web/live/workflow_live/edit_test.exs index 5461bde623..b0ff7b0717 100644 --- a/test/lightning_web/live/workflow_live/edit_test.exs +++ b/test/lightning_web/live/workflow_live/edit_test.exs @@ -1230,7 +1230,7 @@ defmodule LightningWeb.WorkflowLive.EditTest do |> render_submit() assert html =~ - "a workflow with this name already exists(possibly pending deletion) in this project." + "A workflow with this name already exists (possibly pending deletion) in this project." assert html =~ "Workflow could not be saved" From 7a168946d0fa4dea5ffa614bcdcf13f8ac8ce4a3 Mon Sep 17 00:00:00 2001 From: Taylor Downs Date: Wed, 10 Dec 2025 18:19:04 +0100 Subject: [PATCH 5/7] save work --- test/lightning/workflows/workflow_test.exs | 2 +- test/lightning/workflows_test.exs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/lightning/workflows/workflow_test.exs b/test/lightning/workflows/workflow_test.exs index fb70ebf476..efccdec483 100644 --- a/test/lightning/workflows/workflow_test.exs +++ b/test/lightning/workflows/workflow_test.exs @@ -79,7 +79,7 @@ defmodule Lightning.Workflows.WorkflowTest do |> Workflow.changeset(%{name: "w1", project_id: p1.id}) |> Repo.insert() - assert "a workflow with this name already exists (possibly pending deletion) in this project." in errors_on( + assert "A workflow with this name already exists (possibly pending deletion) in this project." in errors_on( cs ).name diff --git a/test/lightning/workflows_test.exs b/test/lightning/workflows_test.exs index ca1abaf810..ab5f9a0c54 100644 --- a/test/lightning/workflows_test.exs +++ b/test/lightning/workflows_test.exs @@ -890,7 +890,7 @@ defmodule Lightning.WorkflowsTest do assert errors_on(changeset) == %{ name: [ - "a workflow with this name already exists (possibly pending deletion) in this project." + "A workflow with this name already exists (possibly pending deletion) in this project." ] } end From b2e9e0cc8f8361bcb733ee38fae443b499d21463 Mon Sep 17 00:00:00 2001 From: Taylor Downs Date: Thu, 11 Dec 2025 11:23:15 +0100 Subject: [PATCH 6/7] more --- lib/lightning/workflows.ex | 41 +++++++++++-------------------- test/lightning/workflows_test.exs | 31 +++++++++++++++++++---- 2 files changed, 40 insertions(+), 32 deletions(-) diff --git a/lib/lightning/workflows.ex b/lib/lightning/workflows.ex index 878ddba367..277076f749 100644 --- a/lib/lightning/workflows.ex +++ b/lib/lightning/workflows.ex @@ -462,7 +462,7 @@ defmodule Lightning.Workflows do where: t.workflow_id == ^workflow.id ) - new_name = generate_deleted_workflow_name(workflow) + new_name = append_del_to_name(workflow) Multi.new() |> Multi.update( @@ -490,41 +490,28 @@ defmodule Lightning.Workflows do end) end - defp generate_deleted_workflow_name(%Workflow{ - name: name, - project_id: project_id - }) do - # Escape special regex characters in the workflow name - escaped_name = Regex.escape(name) - pattern = "^#{escaped_name}_del[0-9]+$" + defp append_del_to_name(%Workflow{name: name, project_id: project_id}) do + base_name = "#{name}_del" - # Find the workflow with the highest suffix by sorting names lexicographically - # This works because we zero-pad numbers: _del01, _del02, ..., _del09, _del10 - highest_suffix_name = + existing_names = from(w in Workflow, where: w.project_id == ^project_id and - not is_nil(w.deleted_at) and - fragment("? ~ ?", w.name, ^pattern), - order_by: [desc: w.name], - limit: 1, + (w.name == ^base_name or like(w.name, ^"#{base_name}%")), select: w.name ) - |> Repo.one() + |> Repo.all() + |> MapSet.new() - next_number = - case highest_suffix_name do - nil -> - 1 + find_available_name(base_name, existing_names) + end - name_with_suffix -> - case Regex.run(~r/_del(\d+)$/, name_with_suffix) do - [_, num] -> String.to_integer(num) + 1 - _ -> 1 - end - end + defp find_available_name(base_name, existing_names, n \\ 0) do + candidate = if n == 0, do: base_name, else: "#{base_name}#{n}" - "#{name}_del#{String.pad_leading(Integer.to_string(next_number), 2, "0")}" + if MapSet.member?(existing_names, candidate), + do: find_available_name(base_name, existing_names, n + 1), + else: candidate end defp notify_of_affected_kafka_triggers(%{triggers: triggers}) do diff --git a/test/lightning/workflows_test.exs b/test/lightning/workflows_test.exs index ab5f9a0c54..aae13233fc 100644 --- a/test/lightning/workflows_test.exs +++ b/test/lightning/workflows_test.exs @@ -841,7 +841,7 @@ defmodule Lightning.WorkflowsTest do w1 = insert(:workflow, project: project, name: "Test Workflow") assert {:ok, %{workflow: workflow}} = Workflows.mark_for_deletion(w1, user) - assert workflow.name == "Test Workflow_del01" + assert workflow.name == "Test Workflow_del" # Test incrementing when deleting another workflow with the same name w2 = insert(:workflow, project: project, name: "Test Workflow") @@ -849,7 +849,7 @@ defmodule Lightning.WorkflowsTest do assert {:ok, %{workflow: workflow2}} = Workflows.mark_for_deletion(w2, user) - assert workflow2.name == "Test Workflow_del02" + assert workflow2.name == "Test Workflow_del1" # Test incrementing again w3 = insert(:workflow, project: project, name: "Test Workflow") @@ -857,7 +857,28 @@ defmodule Lightning.WorkflowsTest do assert {:ok, %{workflow: workflow3}} = Workflows.mark_for_deletion(w3, user) - assert workflow3.name == "Test Workflow_del03" + assert workflow3.name == "Test Workflow_del2" + end + + test "mark_for_deletion/3 does not conflict with active workflows ending in _del" do + # An active workflow named "water_gap_analysis_del" (where _del is Delaware) + # should not interfere with deleting "water_gap_analysis" + project = insert(:project) + user = insert(:user) + + # Active workflow with name ending in _del (not deleted) + _delaware_workflow = + insert(:workflow, project: project, name: "water_gap_analysis_del") + + # Workflow to be deleted + w1 = insert(:workflow, project: project, name: "water_gap_analysis") + + # Should still get _del suffix since the existing _del workflow is not deleted + assert {:ok, %{workflow: deleted}} = Workflows.mark_for_deletion(w1, user) + assert deleted.name == "water_gap_analysis_del1" + + assert {:ok, %{workflow: deleted}} = Workflows.mark_for_deletion(w1, user) + assert deleted.name == "water_gap_analysis_del2" end test "allows reusing workflow name after marking for deletion, then validates error when using deleted workflow name" do @@ -869,7 +890,7 @@ defmodule Lightning.WorkflowsTest do assert {:ok, %{workflow: deleted_workflow}} = Workflows.mark_for_deletion(w1, user) - assert deleted_workflow.name == "My Workflow_del01" + assert deleted_workflow.name == "My Workflow_del" # Should be able to create a new workflow with the original name assert {:ok, new_workflow} = @@ -884,7 +905,7 @@ defmodule Lightning.WorkflowsTest do # Should NOT be able to create another workflow with the deleted workflow's name assert {:error, changeset} = Workflows.save_workflow( - %{name: "My Workflow_del01", project_id: project.id}, + %{name: "My Workflow_del", project_id: project.id}, user ) From 039c8620051b3c571476c42c32ee4a206b698d37 Mon Sep 17 00:00:00 2001 From: Taylor Downs Date: Thu, 11 Dec 2025 14:00:48 +0100 Subject: [PATCH 7/7] what's in a name --- lib/lightning/workflows.ex | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/lightning/workflows.ex b/lib/lightning/workflows.ex index 277076f749..1999c4e036 100644 --- a/lib/lightning/workflows.ex +++ b/lib/lightning/workflows.ex @@ -462,7 +462,7 @@ defmodule Lightning.Workflows do where: t.workflow_id == ^workflow.id ) - new_name = append_del_to_name(workflow) + new_name = resolve_name_for_pending_deletion(workflow) Multi.new() |> Multi.update( @@ -490,7 +490,10 @@ defmodule Lightning.Workflows do end) end - defp append_del_to_name(%Workflow{name: name, project_id: project_id}) do + defp resolve_name_for_pending_deletion(%Workflow{ + name: name, + project_id: project_id + }) do base_name = "#{name}_del" existing_names =