fix(tags): stop re-creating a tag by name from reviving the removed row - #2120
Merged
Conversation
CreateItemsPlanningTag matched an existing tag by name without filtering on
WorkflowState, so deleting a tag and creating it again by the same name flipped
the removed row back to Created and handed back the SAME id.
That is harmful across plugin boundaries. backend-configuration's
AreaRulePlanningTag join stores ItemPlanningTagId as a bare int in a DIFFERENT
database - no foreign key, no navigation property, so nothing cascades.
DeleteItemsPlanningTag soft-deletes the PlanningTag and its PlanningsTags rows
but has no backend-configuration DbContext and cannot reach that join, so those
rows survive the delete still marked Created, pointing at a dead tag id.
Reviving the same id silently re-lights every one of them, and tasks regain a
tag that was deliberately removed from them.
The sibling bulk path already gets this right: BulkPlanningTags guards with
`Name == tagName && WorkflowState != Removed` and inserts a new row past a
removed one. The two create paths simply disagreed, and this was the divergent
one. Now they use the same predicate.
Consequences, all intended:
* The endpoint can return two different ids for the same name over time, with
the removed row still on disk. GetItemsPlanningTags already filters removed
rows, so nothing surfaces a duplicate in the UI, and PlanningTag.Name has no
unique index (verified against the entity, OnModelCreating and the model
snapshot - zero HasIndex calls), so same-name rows are already legal and
BulkPlanningTags has been creating them all along.
* Orphaned backend-configuration rows now keep pointing at an id nothing will
ever reuse. That is the correct end state and it is what lets the separate
one-off purge over there be final rather than something that must repeat.
Re-creating a LIVE tag stays an idempotent no-op returning the existing id -
the inline create-on-type affordances POST a name that may already exist and
expect the id back rather than an error. That path is untouched.
The resurrection branch is deleted rather than left unreachable. The endpoint's
only caller is ItemsPlanningTagsController; of the four frontend consumers,
three ignore the returned model and reload, and the one that reads res.model.id
(the calendar modal's persistTag) just appends it to a local array, where a
fresh id is what it actually wants.
No test: this plugin has no integration-test project. The sole test project is a
three-line Assert.That(true) canary with no fixture, DbContext harness or DI
wiring, so covering this would mean inventing a Testcontainers fixture from
scratch - separate work, not a surgical addition here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sXLtgzZU8QL9m84GqMkoJ
This was referenced Aug 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Root-cause fix for the tag-resurrection half of microting/eform-backendconfiguration-plugin#1125.
Problem
CreateItemsPlanningTagmatched an existing tag by name without filtering onWorkflowState, so deleting a tag and creating it again by the same name flipped the removed row back toCreatedand returned the same id.That is harmful across a plugin boundary. backend-configuration's
AreaRulePlanningTagjoin storesItemPlanningTagIdas a bareintin a different database — no foreign key, no navigation property, so nothing cascades.DeleteItemsPlanningTagsoft-deletes thePlanningTagand itsPlanningsTagsrows but has no backend-configurationDbContextand cannot reach that join. Those rows survive the delete still markedCreated, pointing at a dead tag id — and reviving the same id silently re-lights every one of them, so tasks regain a tag that was deliberately removed from them.The sibling bulk path already gets this right:
The two create paths simply disagreed, and this was the divergent one. Now they use the same predicate.
Change
Filter the name match on
WorkflowState != Removed. A removed tag is no longer found, so a genuinely newPlanningTagrow is inserted — exactly whatBulkPlanningTagsdoes. The now-unreachable resurrection branch is deleted rather than left as dead code.Consequences, all intended
GetItemsPlanningTagsalready filters removed rows, so nothing surfaces a duplicate in the UI.PlanningTag.Namehas no unique index — verified three ways: the entity carries only[Required][StringLength(250)];ItemsPlanningPnDbContext.OnModelCreatingcontains zeroHasIndexcalls; and the model snapshot'sPlanningTagblock ends atHasKey/ToTablewith no index, with no migration issuing aCreateIndexagainstPlanningTags. Same-name rows are already legal, andBulkPlanningTagshas been creating them all along. No schema change, no migration.What is deliberately untouched
Re-creating a live tag stays an idempotent no-op returning the existing id — the inline create-on-type affordances POST a name that may already exist and expect the id back rather than an error.
Caller audit
The endpoint's only caller is
ItemsPlanningTagsController. Of the four frontend consumers, three ignore the returned model and simply reload; the one that readsres.model.id(the calendar modal'spersistTag) appends it to a local array, where a fresh id is what it actually wants — the old behaviour was actively wrong there.Tests
None added. This plugin has no integration-test project: the sole test project is a three-line
Assert.That(true)canary with no fixture, DbContext harness or DI wiring (its csproj references Testcontainers and NSubstitute, but nothing uses them). Covering this would mean inventing a Testcontainers fixture from scratch — separate work, not a surgical addition here.Build:
dotnet build -c Debug→ 0 warnings, 0 errors.🤖 Generated with Claude Code
https://claude.ai/code/session_015sXLtgzZU8QL9m84GqMkoJ