Skip to content

fix(tags): stop re-creating a tag by name from reviving the removed row - #2120

Merged
renemadsen merged 1 commit into
stablefrom
fix/tag-resurrection-on-recreate
Aug 27, 2026
Merged

fix(tags): stop re-creating a tag by name from reviving the removed row#2120
renemadsen merged 1 commit into
stablefrom
fix/tag-resurrection-on-recreate

Conversation

@renemadsen

Copy link
Copy Markdown
Member

Root-cause fix for the tag-resurrection half of microting/eform-backendconfiguration-plugin#1125.

Problem

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 returned the same id.

That is harmful across a plugin boundary. 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. Those rows survive the delete still marked Created, 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:

// BulkPlanningTags
if (await dbContext.PlanningTags.AnyAsync(x =>
        x.Name == tagName && x.WorkflowState != Constants.WorkflowStates.Removed)) continue;

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 new PlanningTag row is inserted — exactly what BulkPlanningTags does. The now-unreachable resurrection branch is deleted rather than left as dead code.

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.
  • PlanningTag.Name has no unique index — verified three ways: the entity carries only [Required][StringLength(250)]; ItemsPlanningPnDbContext.OnModelCreating contains zero HasIndex calls; and the model snapshot's PlanningTag block ends at HasKey/ToTable with no index, with no migration issuing a CreateIndex against PlanningTags. Same-name rows are already legal, and BulkPlanningTags has been creating them all along. No schema change, no migration.
  • 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.

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 reads res.model.id (the calendar modal's persistTag) 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 Debug0 warnings, 0 errors.

🤖 Generated with Claude Code

https://claude.ai/code/session_015sXLtgzZU8QL9m84GqMkoJ

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant