diff --git a/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn.Test/DeadReportsMenuCleanupTests.cs b/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn.Test/DeadReportsMenuCleanupTests.cs
new file mode 100644
index 00000000..202a4db9
--- /dev/null
+++ b/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn.Test/DeadReportsMenuCleanupTests.cs
@@ -0,0 +1,249 @@
+/*
+The MIT License (MIT)
+
+Copyright (c) 2007 - 2026 Microting A/S
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+*/
+
+namespace ItemsPlanning.Pn.Test;
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using ItemsPlanning.Pn.Infrastructure.Helpers;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microting.eFormApi.BasePn.Infrastructure.Models.Application.NavigationMenu;
+using Microting.EformAngularFrontendBase.Infrastructure.Data;
+using Microting.EformAngularFrontendBase.Infrastructure.Data.Entities.Menu;
+using Microting.EformAngularFrontendBase.Infrastructure.Data.Entities.Permissions;
+using NSubstitute;
+using NUnit.Framework;
+using Testcontainers.MariaDb;
+
+///
+/// Plugin start removes the dead "Reports" menu entry (#2136) from the host's Angular database: the item,
+/// its template and their child rows go; a second start changes nothing; other menu entries are untouched.
+/// Runs against a real MariaDB, the way the host calls it.
+///
+[TestFixture]
+public class DeadReportsMenuCleanupTests
+{
+ private const string PlanningLink = "/plugins/items-planning-pn/plannings";
+
+ private readonly MariaDbContainer _mariaDbContainer = new MariaDbBuilder("mariadb:11.2")
+ .WithDatabase("items-planning-dead-reports-menu")
+ .WithPassword("secretpassword")
+ .Build();
+
+ private DbContextOptions _dbContextOptions;
+ private ServiceProvider _serviceProvider;
+ private IApplicationBuilder _appBuilder;
+
+ [OneTimeSetUp]
+ public async Task OneTimeSetUp()
+ {
+ await _mariaDbContainer.StartAsync();
+ var connectionString = _mariaDbContainer.GetConnectionString();
+
+ _dbContextOptions = new DbContextOptionsBuilder()
+ .UseMySql(connectionString, ServerVersion.AutoDetect(connectionString))
+ .Options;
+ // The plugin resolves BaseDbContext from the host's container; register the same options there.
+ _serviceProvider = new ServiceCollection()
+ .AddLogging()
+ .AddScoped(_ => new BaseDbContext(_dbContextOptions))
+ .BuildServiceProvider();
+
+ _appBuilder = Substitute.For();
+ _appBuilder.ApplicationServices.Returns(_serviceProvider);
+ }
+
+ [OneTimeTearDown]
+ public async Task OneTimeTearDown()
+ {
+ await _serviceProvider.DisposeAsync();
+ await _mariaDbContainer.DisposeAsync();
+ }
+
+ [SetUp]
+ public async Task SetUp()
+ {
+ await using var db = NewDbContext();
+ await db.Database.EnsureDeletedAsync();
+ await db.Database.EnsureCreatedAsync();
+ }
+
+ [Test]
+ public async Task PluginStart_RemovesTheDeadReportsEntry_AndASecondStartIsANoOp()
+ {
+ int planningItemId, planningTemplateId, reportsItemId, detachedItemId;
+ await using (var db = NewDbContext())
+ {
+ var securityGroup = new SecurityGroup { Name = "Example group" };
+ db.SecurityGroups.Add(securityGroup);
+ await db.SaveChangesAsync();
+
+ var dropdown = new MenuItem
+ {
+ Name = "Dropdown", E2EId = "items-planning-pn", Link = "", Type = MenuItemTypeEnum.Dropdown
+ };
+ db.MenuItems.Add(dropdown);
+ await db.SaveChangesAsync();
+
+ var planning = AddLinkWithTemplate(db, dropdown, securityGroup, "Planning",
+ "items-planning-pn-plannings", PlanningLink);
+ var reports = AddLinkWithTemplate(db, dropdown, securityGroup, "Reports",
+ DeadReportsMenuCleaner.E2EId, DeadReportsMenuCleaner.Link);
+ // An entry someone re-pointed elsewhere but that still hangs on the dead template: it stays,
+ // detached from the template.
+ var detached = new MenuItem
+ {
+ Name = "Example link",
+ E2EId = "example-link",
+ Link = "/example",
+ Type = MenuItemTypeEnum.Link,
+ Parent = dropdown,
+ MenuTemplate = reports.MenuTemplate
+ };
+ db.MenuItems.Add(detached);
+ await db.SaveChangesAsync();
+
+ planningItemId = planning.Id;
+ planningTemplateId = planning.MenuTemplateId!.Value;
+ reportsItemId = reports.Id;
+ detachedItemId = detached.Id;
+ }
+
+ // BaseDbContext seeds its own default menu, templates and permissions, so assert against a snapshot.
+ var before = await SnapshotAsync();
+
+ new EformItemsPlanningPlugin().Configure(_appBuilder);
+
+ var after = await SnapshotAsync();
+ Assert.That(after, Is.EqualTo(before with
+ {
+ MenuItemIds = before.MenuItemIds.Where(x => x != reportsItemId).ToList(),
+ MenuItemTranslations = before.MenuItemTranslations - 1,
+ MenuItemSecurityGroups = before.MenuItemSecurityGroups - 1,
+ MenuTemplates = before.MenuTemplates - 1,
+ MenuTemplateTranslations = before.MenuTemplateTranslations - 1,
+ MenuTemplatePermissions = before.MenuTemplatePermissions - 1
+ }).Using(SameSnapshot));
+
+ await using (var db = NewDbContext())
+ {
+ Assert.That(await db.MenuItems.AnyAsync(x => x.Link == DeadReportsMenuCleaner.Link), Is.False);
+ Assert.That(await db.MenuTemplates.AnyAsync(x => x.DefaultLink == DeadReportsMenuCleaner.Link),
+ Is.False);
+ Assert.That((await db.MenuItems.SingleAsync(x => x.Id == planningItemId)).MenuTemplateId,
+ Is.EqualTo(planningTemplateId));
+ Assert.That(await db.MenuItemTranslations.CountAsync(x => x.MenuItemId == planningItemId),
+ Is.EqualTo(1));
+ Assert.That(await db.MenuItemSecurityGroups.CountAsync(x => x.MenuItemId == planningItemId),
+ Is.EqualTo(1));
+ Assert.That((await db.MenuItems.SingleAsync(x => x.Id == detachedItemId)).MenuTemplateId, Is.Null);
+ }
+
+ new EformItemsPlanningPlugin().Configure(_appBuilder);
+
+ Assert.That(await SnapshotAsync(), Is.EqualTo(after).Using(SameSnapshot),
+ "a second start changes nothing");
+ }
+
+ [Test]
+ public void PluginStart_WithoutTheAngularDatabase_DoesNotThrow()
+ {
+ using var serviceProvider = new ServiceCollection().AddLogging().BuildServiceProvider();
+ var appBuilder = Substitute.For();
+ appBuilder.ApplicationServices.Returns(serviceProvider);
+
+ Assert.DoesNotThrow(() => new EformItemsPlanningPlugin().Configure(appBuilder));
+ }
+
+ private BaseDbContext NewDbContext() => new(_dbContextOptions);
+
+ private record Snapshot(
+ List MenuItemIds,
+ int MenuItemTranslations,
+ int MenuItemSecurityGroups,
+ int MenuTemplates,
+ int MenuTemplateTranslations,
+ int MenuTemplatePermissions,
+ int SecurityGroups,
+ int Permissions);
+
+ private static bool SameSnapshot(Snapshot x, Snapshot y) =>
+ x with { MenuItemIds = null } == y with { MenuItemIds = null }
+ && x.MenuItemIds.SequenceEqual(y.MenuItemIds);
+
+ private async Task SnapshotAsync()
+ {
+ await using var db = NewDbContext();
+ return new Snapshot(
+ await db.MenuItems.OrderBy(x => x.Id).Select(x => x.Id).ToListAsync(),
+ await db.MenuItemTranslations.CountAsync(),
+ await db.MenuItemSecurityGroups.CountAsync(),
+ await db.MenuTemplates.CountAsync(),
+ await db.MenuTemplateTranslations.CountAsync(),
+ await db.MenuTemplatePermissions.CountAsync(),
+ await db.SecurityGroups.CountAsync(),
+ await db.Permissions.CountAsync());
+ }
+
+ private static MenuItem AddLinkWithTemplate(BaseDbContext db, MenuItem parent, SecurityGroup securityGroup,
+ string name, string e2eId, string link)
+ {
+ var permissionType = new PermissionType { Name = $"{name} permissions" };
+ var template = new MenuTemplate
+ {
+ Name = name,
+ E2EId = e2eId,
+ DefaultLink = link,
+ Translations = { new MenuTemplateTranslation { Name = name, LocaleName = "en-US", Language = "English" } },
+ MenuTemplatePermissions =
+ {
+ new MenuTemplatePermission
+ {
+ Permission = new Permission
+ {
+ PermissionName = $"Obtain {name}",
+ ClaimName = $"{e2eId}_claim",
+ PermissionType = permissionType
+ }
+ }
+ }
+ };
+ var item = new MenuItem
+ {
+ Name = name,
+ E2EId = e2eId,
+ Link = link,
+ Type = MenuItemTypeEnum.Link,
+ Parent = parent,
+ MenuTemplate = template,
+ Translations = { new MenuItemTranslation { Name = name, LocaleName = "en-US", Language = "English" } },
+ MenuItemSecurityGroups = { new MenuItemSecurityGroup { SecurityGroup = securityGroup } }
+ };
+ db.MenuItems.Add(item);
+ return item;
+ }
+}
diff --git a/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn.Test/NavigationMenuTests.cs b/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn.Test/NavigationMenuTests.cs
new file mode 100644
index 00000000..198dbd54
--- /dev/null
+++ b/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn.Test/NavigationMenuTests.cs
@@ -0,0 +1,45 @@
+using System;
+using System.Linq;
+using ItemsPlanning.Pn.Infrastructure.Helpers;
+using ItemsPlanning.Pn.Services.ItemsPlanningLocalizationService;
+using NSubstitute;
+using NUnit.Framework;
+
+namespace ItemsPlanning.Pn.Test
+{
+ ///
+ /// The "reports" route was removed from the Angular module, so a menu entry linking to it matches no route
+ /// and the frontend's catch-all silently redirects to "My eForms" (#2136). The plugin must no longer offer it.
+ ///
+ [TestFixture]
+ public class NavigationMenuTests
+ {
+ [Test]
+ public void GetNavigationMenu_DoesNotOfferTheRemovedReportsPage()
+ {
+ var menu = new EformItemsPlanningPlugin().GetNavigationMenu(Substitute.For());
+
+ var items = menu.Concat(menu.SelectMany(x => x.ChildItems)).ToList();
+
+ Assert.That(items.Select(x => x.Link), Has.None.EqualTo(DeadReportsMenuCleaner.Link));
+ Assert.That(items.Where(x => x.MenuTemplate != null).Select(x => x.MenuTemplate.DefaultLink),
+ Has.None.EqualTo(DeadReportsMenuCleaner.Link));
+ Assert.That(items.Select(x => x.E2EId), Has.None.EqualTo(DeadReportsMenuCleaner.E2EId));
+ }
+
+ [Test]
+ public void HeaderMenu_DoesNotOfferTheRemovedReportsPage()
+ {
+ var localizationService = Substitute.For();
+ localizationService.GetString(Arg.Any()).Returns(x => x.Arg());
+ var serviceProvider = Substitute.For();
+ serviceProvider.GetService(typeof(IItemsPlanningLocalizationService)).Returns(localizationService);
+
+ var menu = new EformItemsPlanningPlugin().HeaderMenu(serviceProvider);
+
+ var links = menu.LeftMenu.SelectMany(x => x.MenuItems).Select(x => x.Link).ToList();
+ Assert.That(links, Has.None.EqualTo(DeadReportsMenuCleaner.Link));
+ Assert.That(links, Does.Contain("/plugins/items-planning-pn/plannings"));
+ }
+ }
+}
diff --git a/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn/EformItemsPlanningPlugin.cs b/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn/EformItemsPlanningPlugin.cs
index fed60a2e..e12789bf 100644
--- a/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn/EformItemsPlanningPlugin.cs
+++ b/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn/EformItemsPlanningPlugin.cs
@@ -41,6 +41,7 @@ namespace ItemsPlanning.Pn
using System.Reflection;
using Infrastructure.Data.Seed;
using Infrastructure.Data.Seed.Data;
+ using Infrastructure.Helpers;
using Infrastructure.Models.Settings;
using Services.ItemsPlanningTagsService;
using Microsoft.AspNetCore.Builder;
@@ -49,6 +50,8 @@ namespace ItemsPlanning.Pn
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+ using Microsoft.Extensions.Logging;
+ using Microting.EformAngularFrontendBase.Infrastructure.Data;
using Microting.eFormApi.BasePn;
using Microting.eFormApi.BasePn.Infrastructure.Consts;
using Microting.eFormApi.BasePn.Infrastructure.Database.Extensions;
@@ -197,6 +200,21 @@ public void ConfigureDbContext(IServiceCollection services, string connectionStr
public void Configure(IApplicationBuilder appBuilder)
{
+ using var scope = appBuilder.ApplicationServices.CreateScope();
+ var logger = scope.ServiceProvider.GetRequiredService>();
+ try
+ {
+ var baseDbContext = scope.ServiceProvider.GetService();
+ if (baseDbContext != null)
+ {
+ DeadReportsMenuCleaner.Remove(baseDbContext, logger);
+ }
+ }
+ catch (Exception e)
+ {
+ // A failed cleanup leaves a dead menu entry behind; it must never stop the host from starting.
+ logger.LogError(e, "Could not remove the dead items-planning Reports menu");
+ }
}
public List GetNavigationMenu(IServiceProvider serviceProvider)
@@ -317,75 +335,6 @@ public List GetNavigationMenu(IServiceProvider serviceProvi
}
},
new PluginMenuItemModel
- {
- Name = "Reports",
- E2EId = "items-planning-pn-reports",
- Link = "/plugins/items-planning-pn/reports",
- Type = MenuItemTypeEnum.Link,
- Position = 1,
- MenuTemplate = new PluginMenuTemplateModel()
- {
- Name = "Reports",
- E2EId = "items-planning-pn-reports",
- DefaultLink = "/plugins/items-planning-pn/reports",
- Permissions = new List(),
- Translations = new List
- {
- new PluginMenuTranslationModel
- {
- LocaleName = LocaleNames.English,
- Name = "Reports",
- Language = LanguageNames.English
- },
- new PluginMenuTranslationModel
- {
- LocaleName = LocaleNames.German,
- Name = "Berichte",
- Language = LanguageNames.German
- },
- new PluginMenuTranslationModel
- {
- LocaleName = LocaleNames.Danish,
- Name = "Rapporter",
- Language = LanguageNames.Danish
- },
- new PluginMenuTranslationModel
- {
- LocaleName = LocaleNames.Ukrainian,
- Name = "Звіти",
- Language = LanguageNames.Ukrainian
- }
- }
- },
- Translations = new List
- {
- new PluginMenuTranslationModel
- {
- LocaleName = LocaleNames.English,
- Name = "Reports",
- Language = LanguageNames.English
- },
- new PluginMenuTranslationModel
- {
- LocaleName = LocaleNames.German,
- Name = "Berichte",
- Language = LanguageNames.German
- },
- new PluginMenuTranslationModel
- {
- LocaleName = LocaleNames.Danish,
- Name = "Rapporter",
- Language = LanguageNames.Danish
- },
- new PluginMenuTranslationModel
- {
- LocaleName = LocaleNames.Ukrainian,
- Name = "Звіти",
- Language = LanguageNames.Ukrainian
- }
- }
- },
- new PluginMenuItemModel
{
Name = "Pairing",
E2EId = "items-planning-pn-pairing",
@@ -482,13 +431,6 @@ public MenuModel HeaderMenu(IServiceProvider serviceProvider)
Link = "/plugins/items-planning-pn/plannings",
Guards = new List() { ItemsPlanningClaims.GetPlannings },
Position = 0
- },
- new MenuItemModel()
- {
- Name = localizationService.GetString("Reports"),
- E2EId = "items-planning-pn-reports",
- Link = "/plugins/items-planning-pn/reports",
- Position = 2
}
}
});
diff --git a/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn/Infrastructure/Helpers/DeadReportsMenuCleaner.cs b/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn/Infrastructure/Helpers/DeadReportsMenuCleaner.cs
new file mode 100644
index 00000000..926529fb
--- /dev/null
+++ b/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn/Infrastructure/Helpers/DeadReportsMenuCleaner.cs
@@ -0,0 +1,88 @@
+/*
+The MIT License (MIT)
+
+Copyright (c) 2007 - 2026 Microting A/S
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+*/
+
+namespace ItemsPlanning.Pn.Infrastructure.Helpers;
+
+using System.Linq;
+using Microsoft.Extensions.Logging;
+using Microting.EformAngularFrontendBase.Infrastructure.Data;
+
+///
+/// The "reports" page left the Angular module in 2023, but the plugin kept registering a "Reports" menu
+/// entry for it. Clicking it matches no route and the frontend's catch-all silently lands on "My eForms"
+/// (#2136). The host only ever adds plugin menu entries, so the plugin removes its own dead one on start.
+/// Only rows that carry both the dead link and its E2E id are touched; a second run finds nothing.
+///
+public static class DeadReportsMenuCleaner
+{
+ public const string Link = "/plugins/items-planning-pn/reports";
+ public const string E2EId = "items-planning-pn-reports";
+
+ /// The number of menu items plus menu templates removed.
+ public static int Remove(BaseDbContext dbContext, ILogger logger)
+ {
+ var menuItems = dbContext.MenuItems
+ .Where(x => x.Link == Link && x.E2EId == E2EId)
+ .ToList();
+ var menuTemplates = dbContext.MenuTemplates
+ .Where(x => x.DefaultLink == Link && x.E2EId == E2EId)
+ .ToList();
+
+ if (menuItems.Count == 0 && menuTemplates.Count == 0)
+ {
+ return 0;
+ }
+
+ // Child rows are matched through their parent's link rather than an id list, so the queries need no
+ // IN (...) translation.
+ dbContext.MenuItemTranslations.RemoveRange(dbContext.MenuItemTranslations
+ .Where(x => x.MenuItem.Link == Link && x.MenuItem.E2EId == E2EId));
+ dbContext.MenuItemSecurityGroups.RemoveRange(dbContext.MenuItemSecurityGroups
+ .Where(x => x.MenuItem.Link == Link && x.MenuItem.E2EId == E2EId));
+ dbContext.MenuItems.RemoveRange(menuItems);
+
+ // A menu item that still points at a removed template would block the delete; detach it the way
+ // the host does when it removes a plugin's templates.
+ foreach (var remaining in dbContext.MenuItems
+ .Where(x => x.MenuTemplate.DefaultLink == Link && x.MenuTemplate.E2EId == E2EId)
+ .Where(x => x.Link != Link || x.E2EId != E2EId))
+ {
+ remaining.MenuTemplateId = null;
+ }
+
+ dbContext.MenuTemplateTranslations.RemoveRange(dbContext.MenuTemplateTranslations
+ .Where(x => x.MenuTemplate.DefaultLink == Link && x.MenuTemplate.E2EId == E2EId));
+ dbContext.MenuTemplatePermissions.RemoveRange(dbContext.MenuTemplatePermissions
+ .Where(x => x.MenuTemplate.DefaultLink == Link && x.MenuTemplate.E2EId == E2EId));
+ dbContext.MenuTemplates.RemoveRange(menuTemplates);
+
+ dbContext.SaveChanges();
+
+ logger.LogInformation(
+ "Removed the dead items-planning Reports menu: menu items [{MenuItemIds}], menu templates [{MenuTemplateIds}]",
+ string.Join(", ", menuItems.Select(x => x.Id)), string.Join(", ", menuTemplates.Select(x => x.Id)));
+
+ return menuItems.Count + menuTemplates.Count;
+ }
+}
diff --git a/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn/ItemsPlanning.Pn.csproj b/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn/ItemsPlanning.Pn.csproj
index f09aac69..d553e5eb 100644
--- a/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn/ItemsPlanning.Pn.csproj
+++ b/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn/ItemsPlanning.Pn.csproj
@@ -31,6 +31,7 @@
+