", block.ModifiedOn, TestContext.Current.CancellationToken);
+
+ // History newest-first is [v2, original]; v2 has a predecessor (original) to diff against.
+ var history = await _service.GetHistoryAsync(block.ContentBlockId, TestContext.Current.CancellationToken);
+ var v2 = history[0];
+
+ var diff = await _service.GetHistoryVersionDiffAsync(block.ContentBlockId, v2.ContentHistoryId,
+ TestContext.Current.CancellationToken);
+
+ Assert.NotNull(diff);
+ Assert.True(diff.HasComparison);
+ Assert.True(diff.HasChanges);
+ Assert.Equal("originalAuthor", diff.OldModifiedBy);
+ Assert.Contains("());
+ }
+
+ [Fact]
+ public async Task GetHistoryVersionDiff_OriginalVersion_HasNoComparison()
+ {
+ var block = await SeedBlockAsync();
+ await _service.UpdateContentOnlyAsync(block.ContentBlockId, "
v2
", block.ModifiedOn, TestContext.Current.CancellationToken);
+
+ var history = await _service.GetHistoryAsync(block.ContentBlockId, TestContext.Current.CancellationToken);
+ var original = history[^1];
+
+ var diff = await _service.GetHistoryVersionDiffAsync(block.ContentBlockId, original.ContentHistoryId,
+ TestContext.Current.CancellationToken);
+
+ Assert.NotNull(diff);
+ Assert.False(diff.HasComparison);
+ Assert.Equal("
original
", diff.Content);
+ Assert.DoesNotContain("v2
", block.ModifiedOn, TestContext.Current.CancellationToken);
+
+ var history = await _service.GetHistoryAsync(block.ContentBlockId, TestContext.Current.CancellationToken);
+ var original = history[^1];
+
+ var diff = await _service.DiffContentAgainstHistoryAsync(block.ContentBlockId, original.ContentHistoryId,
+ "
current draft
", TestContext.Current.CancellationToken);
+
+ Assert.NotNull(diff);
+ Assert.True(diff.HasComparison);
+ Assert.True(diff.HasChanges);
+ Assert.Equal("originalAuthor", diff.OldModifiedBy);
+ Assert.Contains("v2", block.ModifiedOn, TestContext.Current.CancellationToken);
+
+ var history = await _service.GetHistoryAsync(block.ContentBlockId, TestContext.Current.CancellationToken);
+ var original = history[^1]; // holds "
original
"
+
+ // Current draft is byte-identical to the selected version: htmldiff emits no ins/del.
+ var diff = await _service.DiffContentAgainstHistoryAsync(block.ContentBlockId, original.ContentHistoryId,
+ "
original
", TestContext.Current.CancellationToken);
+
+ Assert.NotNull(diff);
+ Assert.True(diff.HasComparison);
+ Assert.False(diff.HasChanges);
+ Assert.DoesNotContain("
+/// Tests for Data.LeftNavMenu.GetLeftNavMenus display-time permission filtering (legacy CMS
+/// left-nav parity): an item with no permissions is visible to any logged-in user, an item is
+/// shown when the user holds any one of its permissions and hidden otherwise, and filtering is
+/// bypassed for management callers. Permission scenarios are driven by mocking IUserHelper.
+///
+public sealed class CmsLeftNavDisplayTests : IDisposable
+{
+ private readonly VIPERContext _context;
+ private readonly RAPSContext _rapsContext;
+ private readonly IUserHelper _userHelper;
+
+ public CmsLeftNavDisplayTests()
+ {
+ _context = new VIPERContext(new DbContextOptionsBuilder()
+ .UseInMemoryDatabase("VIPER_" + Guid.NewGuid()).Options);
+ _rapsContext = new RAPSContext(new DbContextOptionsBuilder()
+ .UseInMemoryDatabase("RAPS_" + Guid.NewGuid()).Options);
+ _userHelper = Substitute.For();
+ }
+
+ public void Dispose()
+ {
+ _context.Dispose();
+ _rapsContext.Dispose();
+ }
+
+ private async Task SeedMixedMenuAsync()
+ {
+ var friendlyName = "display-menu-" + Guid.NewGuid().ToString("N")[..8];
+ var menu = new LeftNavMenu
+ {
+ MenuHeaderText = "Display Menu",
+ System = "Viper",
+ FriendlyName = friendlyName,
+ ModifiedOn = DateTime.Now,
+ ModifiedBy = "test"
+ };
+ menu.LeftNavItems.Add(Item("Public", 1)); // no permissions
+ menu.LeftNavItems.Add(Item("Allowed", 2, "SVMSecure.Allowed")); // user holds this
+ menu.LeftNavItems.Add(Item("Forbidden", 3, "SVMSecure.Forbidden")); // user lacks this
+ _context.LeftNavMenus.Add(menu);
+ await _context.SaveChangesAsync(TestContext.Current.CancellationToken);
+ return friendlyName;
+ }
+
+ private static LeftNavItem Item(string text, int order, params string[] permissions)
+ {
+ var item = new LeftNavItem { MenuItemText = text, IsHeader = false, Url = "/" + text, DisplayOrder = order };
+ foreach (var p in permissions)
+ {
+ item.LeftNavItemToPermissions.Add(new LeftNavItemToPermission { Permission = p });
+ }
+ return item;
+ }
+
+ [Fact]
+ public async Task GetLeftNavMenus_HidesItemsTheUserLacksPermissionFor()
+ {
+ var friendlyName = await SeedMixedMenuAsync();
+ var user = new AaudUser { AaudUserId = 1, LoginId = "viewer" };
+ _userHelper.GetCurrentUser().Returns(user);
+ _userHelper.HasPermission(Arg.Any(), Arg.Any(), "SVMSecure.Allowed").Returns(true);
+ // HasPermission for "SVMSecure.Forbidden" defaults to false.
+
+ var menus = new DataLeftNav(_context, _rapsContext, _userHelper)
+ .GetLeftNavMenus(friendlyName: friendlyName);
+
+ var items = Assert.Single(menus!).MenuItems!;
+ // No-permission item and the held-permission item are visible; the unheld one is hidden.
+ // (Assert membership, not sequence — the in-memory provider does not honor the include OrderBy.)
+ Assert.Equal(new[] { "Allowed", "Public" }, items.Select(i => i.MenuItemText).OrderBy(t => t));
+ }
+
+ [Fact]
+ public async Task GetLeftNavMenus_AnonymousUser_HidesPermissionLessItems()
+ {
+ var friendlyName = await SeedMixedMenuAsync();
+ // An anonymous request has no signed-in user; GetCurrentUser returns null.
+ _userHelper.GetCurrentUser().Returns((AaudUser?)null);
+
+ var menus = new DataLeftNav(_context, _rapsContext, _userHelper)
+ .GetLeftNavMenus(friendlyName: friendlyName);
+
+ var items = Assert.Single(menus!).MenuItems!;
+ // Permission-less items are visible only to logged-in users, so an anonymous request sees
+ // none of them; holding no permissions, it sees the permissioned items too - nothing shows.
+ Assert.DoesNotContain("Public", items.Select(i => i.MenuItemText));
+ Assert.Empty(items);
+ }
+
+ [Fact]
+ public async Task GetLeftNavMenus_ReturnsAllItems_WhenFilteringDisabled()
+ {
+ var friendlyName = await SeedMixedMenuAsync();
+ _userHelper.GetCurrentUser().Returns(new AaudUser { AaudUserId = 1, LoginId = "viewer" });
+ // HasPermission would return false for every permission, but management callers bypass filtering.
+
+ var menus = new DataLeftNav(_context, _rapsContext, _userHelper)
+ .GetLeftNavMenus(friendlyName: friendlyName, filterItemsByPermissions: false);
+
+ var items = Assert.Single(menus!).MenuItems!;
+ Assert.Equal(new[] { "Allowed", "Forbidden", "Public" }, items.Select(i => i.MenuItemText).OrderBy(t => t));
+ }
+}
diff --git a/test/CMS/CmsLeftNavServiceTests.cs b/test/CMS/CmsLeftNavServiceTests.cs
new file mode 100644
index 000000000..3c43d2361
--- /dev/null
+++ b/test/CMS/CmsLeftNavServiceTests.cs
@@ -0,0 +1,347 @@
+using Microsoft.EntityFrameworkCore;
+using NSubstitute;
+using Viper.Areas.CMS.Models.DTOs;
+using Viper.Areas.CMS.Services;
+using Viper.Classes.SQLContext;
+using Viper.Models.VIPER;
+
+namespace Viper.test.CMS;
+
+///
+/// Tests for CmsLeftNavService: menu CRUD with cascade delete and the batch item save
+/// (add/update/delete/reorder in one call, replacing the legacy DataTables editor).
+///
+public sealed class CmsLeftNavServiceTests : IDisposable
+{
+ private readonly VIPERContext _context;
+ private readonly CmsLeftNavService _service;
+
+ public CmsLeftNavServiceTests()
+ {
+ _context = new VIPERContext(new DbContextOptionsBuilder()
+ .UseInMemoryDatabase("VIPER_" + Guid.NewGuid()).Options);
+ _service = new CmsLeftNavService(_context, Substitute.For());
+ }
+
+ public void Dispose()
+ {
+ _context.Dispose();
+ }
+
+ private async Task SeedMenuAsync(Action? customize = null)
+ {
+ var menu = new LeftNavMenu
+ {
+ MenuHeaderText = "Seeded Menu",
+ System = "Viper",
+ ViperSectionPath = "cats",
+ FriendlyName = "seeded-menu-" + Guid.NewGuid().ToString("N")[..8],
+ ModifiedOn = DateTime.Now.AddDays(-1),
+ ModifiedBy = "test"
+ };
+ customize?.Invoke(menu);
+ _context.LeftNavMenus.Add(menu);
+ await _context.SaveChangesAsync(TestContext.Current.CancellationToken);
+ return menu;
+ }
+
+ private static LeftNavItem MakeItem(string text, int order, bool isHeader = false, string? url = null, params string[] permissions)
+ {
+ var item = new LeftNavItem
+ {
+ MenuItemText = text,
+ IsHeader = isHeader,
+ Url = url,
+ DisplayOrder = order
+ };
+ foreach (var p in permissions)
+ {
+ item.LeftNavItemToPermissions.Add(new LeftNavItemToPermission { Permission = p });
+ }
+ return item;
+ }
+
+ [Fact]
+ public async Task GetMenus_FiltersBySystemAndSearch()
+ {
+ await SeedMenuAsync();
+ await SeedMenuAsync(m =>
+ {
+ m.MenuHeaderText = "Public Menu";
+ m.System = "Public";
+ });
+
+ var viperMenus = await _service.GetMenusAsync("Viper", null, null, TestContext.Current.CancellationToken);
+ var searched = await _service.GetMenusAsync(null, null, "Public", TestContext.Current.CancellationToken);
+
+ Assert.Single(viperMenus);
+ Assert.Single(searched);
+ Assert.Equal("Public Menu", searched[0].MenuHeaderText);
+ }
+
+ [Fact]
+ public async Task GetMenu_ReturnsItemsInDisplayOrder()
+ {
+ var menu = await SeedMenuAsync(m =>
+ {
+ m.LeftNavItems.Add(MakeItem("Second", 2, url: "/two"));
+ m.LeftNavItems.Add(MakeItem("First", 1, isHeader: true));
+ });
+
+ var dto = await _service.GetMenuAsync(menu.LeftNavMenuId, TestContext.Current.CancellationToken);
+
+ Assert.Equal(new[] { "First", "Second" }, dto!.Items.Select(i => i.MenuItemText).ToArray());
+ Assert.True(dto.Items[0].IsHeader);
+ }
+
+ [Fact]
+ public async Task CreateAndUpdateMenu_PersistFields()
+ {
+ var created = await _service.CreateMenuAsync(new LeftNavMenuAddEdit
+ {
+ MenuHeaderText = "New Menu",
+ System = "Viper",
+ ViperSectionPath = "students",
+ Page = "home",
+ FriendlyName = "new-menu"
+ }, TestContext.Current.CancellationToken);
+
+ Assert.True(created.LeftNavMenuId > 0);
+
+ var updated = await _service.UpdateMenuAsync(created.LeftNavMenuId, new LeftNavMenuAddEdit
+ {
+ MenuHeaderText = "Renamed Menu",
+ System = "Viper",
+ LastModifiedOn = created.ModifiedOn
+ }, TestContext.Current.CancellationToken);
+
+ Assert.Equal("Renamed Menu", updated!.MenuHeaderText);
+ Assert.Null(updated.ViperSectionPath);
+ }
+
+ [Fact]
+ public async Task CreateMenu_DuplicateFriendlyName_ThrowsCaseInsensitive()
+ {
+ await SeedMenuAsync(m => m.FriendlyName = "cats-home");
+
+ await Assert.ThrowsAsync(() => _service.CreateMenuAsync(new LeftNavMenuAddEdit
+ {
+ MenuHeaderText = "Dup",
+ System = "Viper",
+ FriendlyName = "CATS-HOME"
+ }, TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task UpdateMenu_DuplicateFriendlyName_Rejected_ButKeepingOwnIsAllowed()
+ {
+ var alpha = await SeedMenuAsync(m => m.FriendlyName = "alpha");
+ var beta = await SeedMenuAsync(m => m.FriendlyName = "beta");
+
+ // Renaming beta onto alpha's friendly name (any case) is rejected.
+ await Assert.ThrowsAsync(() => _service.UpdateMenuAsync(beta.LeftNavMenuId,
+ new LeftNavMenuAddEdit { MenuHeaderText = "B", System = "Viper", FriendlyName = "ALPHA", LastModifiedOn = beta.ModifiedOn },
+ TestContext.Current.CancellationToken));
+
+ // Keeping its own friendly name (self-match excluded) is allowed.
+ var updated = await _service.UpdateMenuAsync(alpha.LeftNavMenuId,
+ new LeftNavMenuAddEdit { MenuHeaderText = "A renamed", System = "Viper", FriendlyName = "ALPHA", LastModifiedOn = alpha.ModifiedOn },
+ TestContext.Current.CancellationToken);
+
+ Assert.NotNull(updated);
+ }
+
+ [Fact]
+ public async Task UpdateMenu_StaleLastModifiedOn_ThrowsConcurrency()
+ {
+ var menu = await SeedMenuAsync();
+
+ var ex = await Assert.ThrowsAsync(() => _service.UpdateMenuAsync(
+ menu.LeftNavMenuId,
+ new LeftNavMenuAddEdit
+ {
+ MenuHeaderText = "Renamed",
+ System = "Viper",
+ LastModifiedOn = menu.ModifiedOn.AddMinutes(-5)
+ },
+ TestContext.Current.CancellationToken));
+
+ // Names who saved and when so the user knows whose edit they'd clobber.
+ Assert.Contains("modified by test", ex.Message);
+ }
+
+ [Fact]
+ public async Task UpdateMenu_MissingLastModifiedOn_ThrowsArgumentException()
+ {
+ var menu = await SeedMenuAsync();
+
+ await Assert.ThrowsAsync(() => _service.UpdateMenuAsync(
+ menu.LeftNavMenuId,
+ new LeftNavMenuAddEdit { MenuHeaderText = "Renamed", System = "Viper" },
+ TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task DeleteMenu_CascadesToItemsAndPermissions()
+ {
+ var menu = await SeedMenuAsync(m => m.LeftNavItems.Add(MakeItem("Link", 1, url: "/x", permissions: "SVMSecure.CATS")));
+
+ var result = await _service.DeleteMenuAsync(menu.LeftNavMenuId, TestContext.Current.CancellationToken);
+
+ Assert.True(result);
+ Assert.Empty(await _context.LeftNavMenus.ToListAsync(TestContext.Current.CancellationToken));
+ Assert.Empty(await _context.LeftNavItems.ToListAsync(TestContext.Current.CancellationToken));
+ Assert.Empty(await _context.LeftNavItemToPermissions.ToListAsync(TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task SaveItems_AddsUpdatesDeletesAndReorders()
+ {
+ var menu = await SeedMenuAsync(m =>
+ {
+ m.LeftNavItems.Add(MakeItem("Keep Me", 1, url: "/keep", permissions: "SVMSecure.Old"));
+ m.LeftNavItems.Add(MakeItem("Delete Me", 2, url: "/delete"));
+ });
+ var keepId = menu.LeftNavItems.First(i => i.MenuItemText == "Keep Me").LeftNavItemId;
+
+ var dto = await _service.SaveItemsAsync(menu.LeftNavMenuId, ItemsSave(menu,
+ new LeftNavItemEdit { LeftNavItemId = 0, MenuItemText = "New Header", IsHeader = true },
+ new LeftNavItemEdit { LeftNavItemId = keepId, MenuItemText = "Keep Me Renamed", Url = "/kept", Permissions = new List { "SVMSecure.New" } }),
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(2, dto!.Items.Count);
+ Assert.Equal("New Header", dto.Items[0].MenuItemText);
+ Assert.Equal(1, dto.Items[0].DisplayOrder);
+ Assert.True(dto.Items[0].IsHeader);
+ Assert.Equal("Keep Me Renamed", dto.Items[1].MenuItemText);
+ Assert.Equal(2, dto.Items[1].DisplayOrder);
+ Assert.Equal(new List { "SVMSecure.New" }, dto.Items[1].Permissions);
+ // Deleted item's permissions are gone too
+ Assert.Equal(1, await _context.LeftNavItemToPermissions.CountAsync(TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task SaveItems_HeaderItems_DropUrl()
+ {
+ var menu = await SeedMenuAsync();
+
+ var dto = await _service.SaveItemsAsync(menu.LeftNavMenuId, ItemsSave(menu,
+ new LeftNavItemEdit { LeftNavItemId = 0, MenuItemText = "Header", IsHeader = true, Url = "/should-be-dropped" }),
+ TestContext.Current.CancellationToken);
+
+ Assert.Null(dto!.Items[0].Url);
+ }
+
+ [Fact]
+ public async Task SaveItems_EmptyHeaderText_IsAllowed()
+ {
+ // Legacy parity: menus use empty-text headers as spacer rows, and the display side
+ // renders them as blank dividers. A menu containing one must stay saveable.
+ var menu = await SeedMenuAsync();
+
+ var dto = await _service.SaveItemsAsync(menu.LeftNavMenuId, ItemsSave(menu,
+ new LeftNavItemEdit { LeftNavItemId = 0, MenuItemText = "", IsHeader = true },
+ new LeftNavItemEdit { LeftNavItemId = 0, MenuItemText = "A Link", IsHeader = false, Url = "/link" }),
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(2, dto!.Items.Count);
+ Assert.Equal("", dto.Items[0].MenuItemText);
+ Assert.True(dto.Items[0].IsHeader);
+ }
+
+ [Fact]
+ public async Task SaveItems_EmptyLinkText_Throws()
+ {
+ var menu = await SeedMenuAsync();
+
+ await Assert.ThrowsAsync(() =>
+ _service.SaveItemsAsync(menu.LeftNavMenuId, ItemsSave(menu,
+ new LeftNavItemEdit { LeftNavItemId = 0, MenuItemText = " ", IsHeader = false, Url = "/link" }),
+ TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task SaveItems_UnknownMenu_ReturnsNull()
+ {
+ var dto = await _service.SaveItemsAsync(9999,
+ new LeftNavItemsSave { Items = new List() }, TestContext.Current.CancellationToken);
+
+ Assert.Null(dto);
+ }
+
+ [Fact]
+ public async Task SaveItems_UnknownItemId_Throws_AndDoesNotResurrect()
+ {
+ var menu = await SeedMenuAsync(m => m.LeftNavItems.Add(MakeItem("Keep Me", 1, url: "/keep")));
+
+ // A stale client posts an id that no longer exists in the menu.
+ await Assert.ThrowsAsync(() => _service.SaveItemsAsync(menu.LeftNavMenuId,
+ ItemsSave(menu, new LeftNavItemEdit { LeftNavItemId = 987654, MenuItemText = "Deleted Elsewhere" }),
+ TestContext.Current.CancellationToken));
+
+ // Nothing was created; the menu still holds only its original item.
+ Assert.Equal(1, await _context.LeftNavItems.CountAsync(TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task SaveItems_DuplicateItemIds_ThrowsArgumentException()
+ {
+ var menu = await SeedMenuAsync(m => m.LeftNavItems.Add(MakeItem("Keep Me", 1, url: "/keep")));
+ var keepId = menu.LeftNavItems.First().LeftNavItemId;
+
+ await Assert.ThrowsAsync(() => _service.SaveItemsAsync(menu.LeftNavMenuId,
+ ItemsSave(menu,
+ new LeftNavItemEdit { LeftNavItemId = keepId, MenuItemText = "A" },
+ new LeftNavItemEdit { LeftNavItemId = keepId, MenuItemText = "B" }),
+ TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task SaveItems_StaleLastModifiedOn_ThrowsConcurrency()
+ {
+ // Item saves ride on the menu's ModifiedOn; a stale stamp means another editor saved
+ // (settings or items) after this client loaded, and the batch must not clobber them.
+ var menu = await SeedMenuAsync();
+
+ var request = new LeftNavItemsSave
+ {
+ LastModifiedOn = menu.ModifiedOn.AddMinutes(-5),
+ Items = new List { new() { LeftNavItemId = 0, MenuItemText = "Header", IsHeader = true } }
+ };
+
+ var ex = await Assert.ThrowsAsync(() =>
+ _service.SaveItemsAsync(menu.LeftNavMenuId, request, TestContext.Current.CancellationToken));
+ Assert.Contains("modified by test", ex.Message);
+ Assert.Equal(0, await _context.LeftNavItems.CountAsync(TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task SaveItems_NullItems_ThrowsArgumentException()
+ {
+ // "items": null binds past JsonRequired; it must be a 400, not a NullReferenceException,
+ // and must not be read as "delete everything".
+ var menu = await SeedMenuAsync(m => m.LeftNavItems.Add(MakeItem("Keep Me", 1, url: "/keep")));
+
+ await Assert.ThrowsAsync(() => _service.SaveItemsAsync(menu.LeftNavMenuId,
+ new LeftNavItemsSave { LastModifiedOn = menu.ModifiedOn, Items = null },
+ TestContext.Current.CancellationToken));
+ Assert.Equal(1, await _context.LeftNavItems.CountAsync(TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task SaveItems_MissingLastModifiedOn_ThrowsArgumentException()
+ {
+ var menu = await SeedMenuAsync();
+
+ var request = new LeftNavItemsSave
+ {
+ Items = new List { new() { LeftNavItemId = 0, MenuItemText = "Header", IsHeader = true } }
+ };
+
+ await Assert.ThrowsAsync(() =>
+ _service.SaveItemsAsync(menu.LeftNavMenuId, request, TestContext.Current.CancellationToken));
+ }
+
+ private static LeftNavItemsSave ItemsSave(LeftNavMenu menu, params LeftNavItemEdit[] items) =>
+ new() { LastModifiedOn = menu.ModifiedOn, Items = items.ToList() };
+}
diff --git a/web/Areas/CMS/Controllers/CMSContentController.cs b/web/Areas/CMS/Controllers/CMSContentController.cs
index 8b0399f31..fbf96d194 100644
--- a/web/Areas/CMS/Controllers/CMSContentController.cs
+++ b/web/Areas/CMS/Controllers/CMSContentController.cs
@@ -1,9 +1,12 @@
using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
+using System.ComponentModel.DataAnnotations;
+using Viper.Areas.CMS.Constants;
using Viper.Areas.CMS.Models;
+using Viper.Areas.CMS.Models.DTOs;
+using Viper.Areas.CMS.Services;
using Viper.Classes;
using Viper.Classes.SQLContext;
-using Viper.Models.VIPER;
+using Viper.Models;
using Viper.Services;
using Web.Authorization;
@@ -16,150 +19,292 @@ public class CMSContentController : ApiController
private readonly VIPERContext _context;
private readonly RAPSContext _rapsContext;
private readonly IHtmlSanitizerService _sanitizerService;
- public IUserHelper UserHelper { get; private set; }
+ private readonly ICmsContentBlockService _blockService;
+ private readonly ICmsFileStorageService _storage;
+ private readonly IUserHelper _userHelper;
- public CMSContentController(VIPERContext context, RAPSContext rapsContext, IHtmlSanitizerService sanitizerService)
+ public CMSContentController(VIPERContext context, RAPSContext rapsContext,
+ IHtmlSanitizerService sanitizerService, ICmsContentBlockService blockService,
+ ICmsFileStorageService storage, IUserHelper userHelper)
{
_context = context;
_rapsContext = rapsContext;
_sanitizerService = sanitizerService;
- UserHelper = new UserHelper();
+ _blockService = blockService;
+ _storage = storage;
+ _userHelper = userHelper;
}
//GET: content
[HttpGet]
- [Permission(Allow = "SVMSecure.CMS.ManageContentBlocks")]
- public ActionResult> GetContentBlocks()
+ [Permission(Allow = CmsPermissions.ManageContentBlocks)]
+ [ApiPagination(DefaultPerPage = 50, MaxPerPage = 500)]
+ public async Task>> GetContentBlocks(
+ ApiPagination? pagination,
+ string status = "active",
+ string? system = null,
+ string? viperSectionPath = null,
+ string? search = null,
+ bool? isPublic = null,
+ string? sortBy = "title",
+ bool descending = false,
+ CancellationToken ct = default)
{
- if (_context.ContentBlocks == null)
+ var page = pagination?.Page ?? 1;
+ var perPage = pagination?.PerPage ?? 50;
+ var (blocks, total) = await _blockService.GetContentBlocksAsync(status, system, viperSectionPath, search,
+ isPublic, page, perPage, sortBy, descending, ct);
+ if (pagination != null)
{
- return NotFound();
+ pagination.TotalRecords = total;
}
- return new Data.CMS(_context, _rapsContext, _sanitizerService).GetContentBlocks()?.ToList() ?? new List();
+ return blocks;
}
- //GET: content/fn/{friendlyName}
- [HttpGet("fn/{friendlyName}")]
- public ActionResult GetContentBlockByFn(string friendlyName)
+ //GET: content/section-paths
+ [HttpGet("section-paths")]
+ [Permission(Allow = CmsPermissions.ManageContentBlocks)]
+ public async Task>> GetViperSectionPaths(CancellationToken ct = default)
{
- var blocks = new Data.CMS(_context, _rapsContext, _sanitizerService).GetContentBlocksAllowed(null, friendlyName, null, null, null, null, null, null)?.ToList();
- if (blocks == null || blocks.Count == 0)
- {
- return NotFound();
- }
+ return await _blockService.GetViperSectionPathsAsync(ct);
+ }
- return blocks[0];
+ // GET: content/folders
+ // The section path IS a file folder (legacy parity): a block's files live in this folder.
+ // Exposed to content-block editors so the section path can be chosen from the real upload
+ // folders without requiring AllFiles (the /api/cms/files/folders endpoint is AllFiles-gated).
+ [HttpGet("folders")]
+ [Permission(Allow = CmsPermissions.ManageContentBlocks + "," + CmsPermissions.CreateContentBlock)]
+ public ActionResult> GetFolders()
+ {
+ return _storage.GetTopLevelFolders();
}
- //PUT: content/5
- [HttpPut("{contentBlockId}")]
- [Permission(Allow = "SVMSecure.CMS.ManageContentBlocks")]
- public async Task> UpdateContentBlock(int contentBlockId, CMSBlockAddEdit block)
+ //GET: content/5
+ [HttpGet("{contentBlockId:int}")]
+ [Permission(Allow = CmsPermissions.ManageContentBlocks)]
+ public async Task> GetContentBlock(int contentBlockId, CancellationToken ct = default)
{
- //check data is valid and block is found
- var existingBlock = await _context.ContentBlocks.FindAsync(contentBlockId);
- if (existingBlock == null)
+ var block = await _blockService.GetContentBlockAsync(contentBlockId, ct);
+ if (block == null)
{
return NotFound();
}
+ return block;
+ }
- if (contentBlockId != block.ContentBlockId)
+ //GET: content/fn/{friendlyName} — public display endpoint; permission filtering happens
+ //inside GetContentBlocksAllowed (public flag, block permissions, or CMS admin).
+ [HttpGet("fn/{friendlyName}")]
+ public ActionResult GetContentBlockByFn(string friendlyName)
+ {
+ // status: 1 = active only. A public display endpoint must never serve soft-deleted
+ // blocks (passing null would include DeletedOn != null rows).
+ var blocks = new Data.CMS(_context, _rapsContext, _sanitizerService)
+ .GetContentBlocksAllowed(null, friendlyName, null, null, null, null, null, 1)?.ToList();
+ if (blocks == null || blocks.Count == 0)
{
- return BadRequest();
+ return NotFound();
}
- string inputCheck = CheckBlockForRequiredFields(block);
- if (!string.IsNullOrEmpty(inputCheck))
+ // Project to the public DTO so this anonymous endpoint never serializes the raw entity
+ // graph (attached-file encryption Keys, server FilePaths, unsanitized ContentHistory,
+ // permission rows) nor management metadata (editor login ids, permission names,
+ // System/section placement). Content is already render-sanitized by
+ // GetContentBlocksAllowed; display consumers read only content + title.
+ return CmsContentBlockMapper.ToPublicDto(blocks[0]);
+ }
+
+ //GET: content/5/history
+ [HttpGet("{contentBlockId:int}/history")]
+ [Permission(Allow = CmsPermissions.ManageContentBlocks)]
+ public async Task>> GetHistory(int contentBlockId, CancellationToken ct = default)
+ {
+ return await _blockService.GetHistoryAsync(contentBlockId, ct);
+ }
+
+ //GET: content/5/history/12
+ [HttpGet("{contentBlockId:int}/history/{contentHistoryId:int}")]
+ [Permission(Allow = CmsPermissions.ManageContentBlocks)]
+ public async Task> GetHistoryVersion(int contentBlockId, int contentHistoryId,
+ CancellationToken ct = default)
+ {
+ var version = await _blockService.GetHistoryVersionAsync(contentBlockId, contentHistoryId, ct);
+ if (version == null)
{
- return BadRequest(inputCheck);
+ return NotFound();
}
+ return version;
+ }
- var friendlyNameCheck = new Data.CMS(_context, _rapsContext, _sanitizerService).GetContentBlocks(friendlyName: block.FriendlyName)?.FirstOrDefault();
- if (friendlyNameCheck != null && friendlyNameCheck.ContentBlockId != contentBlockId)
+ //GET: content/5/history/12/diff — rendered diff of this version against the previous version
+ [HttpGet("{contentBlockId:int}/history/{contentHistoryId:int}/diff")]
+ [Permission(Allow = CmsPermissions.ManageContentBlocks)]
+ public async Task> GetHistoryVersionDiff(int contentBlockId, int contentHistoryId,
+ CancellationToken ct = default)
+ {
+ var diff = await _blockService.GetHistoryVersionDiffAsync(contentBlockId, contentHistoryId, ct);
+ if (diff == null)
{
- return ValidationProblem("Friendly name must be unique");
+ return NotFound();
}
+ return diff;
+ }
- if (friendlyNameCheck != null)
+ //POST: content/5/history/12/diff — rendered diff of a posted draft against this version.
+ //POST (not GET) because the editor's current content rides in the body.
+ [HttpPost("{contentBlockId:int}/history/{contentHistoryId:int}/diff")]
+ [Permission(Allow = CmsPermissions.ManageContentBlocks)]
+ public async Task> DiffAgainstHistoryVersion(int contentBlockId, int contentHistoryId,
+ DiffAgainstHistoryRequest request, CancellationToken ct = default)
+ {
+ var diff = await _blockService.DiffContentAgainstHistoryAsync(contentBlockId, contentHistoryId, request.Content, ct);
+ if (diff == null)
{
- _context.Entry(friendlyNameCheck).State = EntityState.Detached;
+ return NotFound();
}
+ return diff;
+ }
- //modify database object
- ModifyBlockWithUserInput(existingBlock, block);
- _context.Entry(existingBlock).State = EntityState.Modified;
-
- //save history
- var contentHistory = new ContentHistory
+ //GET: content/history — cross-block edit-history viewer (the literal segment does not
+ //collide with the int-constrained {contentBlockId}/history route).
+ [HttpGet("history")]
+ [Permission(Allow = CmsPermissions.ManageContentBlocks)]
+ [ApiPagination(DefaultPerPage = 50, MaxPerPage = 500)]
+ public async Task>> GetHistoryEntries(
+ int? contentBlockId,
+ string? modifiedBy,
+ DateTime? from,
+ DateTime? to,
+ string? search,
+ ApiPagination? pagination,
+ CancellationToken ct = default)
+ {
+ var filter = new CmsContentHistoryFilter
{
ContentBlockId = contentBlockId,
- ContentBlockContent = block.Content,
- ModifiedOn = DateTime.Now,
- ModifiedBy = UserHelper.GetCurrentUser()?.LoginId
+ ModifiedBy = modifiedBy,
+ From = from,
+ To = to,
+ Search = search
};
- _context.ContentHistories.Add(contentHistory);
-
- //save and return the saved block
- await _context.SaveChangesAsync();
- var returnBlock = new Data.CMS(_context, _rapsContext, _sanitizerService).GetContentBlocks(contentBlockID: contentBlockId)?.FirstOrDefault();
- if (returnBlock == null)
+ var page = pagination?.Page ?? 1;
+ var perPage = pagination?.PerPage ?? 50;
+ var entries = await _blockService.GetHistoryEntriesAsync(filter, page, perPage, ct);
+ if (pagination != null)
{
- return NotFound();
+ pagination.TotalRecords = await _blockService.GetHistoryEntryCountAsync(filter, ct);
}
- return returnBlock;
+ return entries;
}
//POST: content
[HttpPost]
- [Permission(Allow = "SVMSecure.CMS.ManageContentBlocks")]
- public async Task> CreateContentBlock(CMSBlockAddEdit block)
+ [Permission(Allow = CmsPermissions.CreateContentBlock + "," + CmsPermissions.ManageContentBlocks)]
+ public async Task> CreateContentBlock(CMSBlockAddEdit block, CancellationToken ct = default)
{
string inputCheck = CheckBlockForRequiredFields(block);
if (!string.IsNullOrEmpty(inputCheck))
{
return BadRequest(inputCheck);
}
- var friendlyNameCheck = new Data.CMS(_context, _rapsContext, _sanitizerService).GetContentBlocks(friendlyName: block.FriendlyName)?.FirstOrDefault();
- if (friendlyNameCheck != null)
+
+ try
{
- return ValidationProblem("Friendly name must be unique");
+ return await _blockService.CreateContentBlockAsync(block, ct);
}
+ catch (ArgumentException ex)
+ {
+ return ValidationProblem(ex.Message);
+ }
+ }
- var newBlock = new ContentBlock();
- ModifyBlockWithUserInput(newBlock, block);
-
- _context.ContentBlocks.Add(newBlock);
- await _context.SaveChangesAsync();
+ //PUT: content/5
+ [HttpPut("{contentBlockId:int}")]
+ [Permission(Allow = CmsPermissions.ManageContentBlocks)]
+ public async Task> UpdateContentBlock(int contentBlockId, CMSBlockAddEdit block,
+ CancellationToken ct = default)
+ {
+ if (contentBlockId != block.ContentBlockId)
+ {
+ return BadRequest();
+ }
- var contentHistory = new ContentHistory
+ string inputCheck = CheckBlockForRequiredFields(block);
+ if (!string.IsNullOrEmpty(inputCheck))
{
- ContentBlockId = block.ContentBlockId,
- ContentBlockContent = block.Content,
- ModifiedOn = DateTime.Now,
- ModifiedBy = UserHelper.GetCurrentUser()?.LoginId
- };
- _context.ContentHistories.Add(contentHistory);
- await _context.SaveChangesAsync();
+ return BadRequest(inputCheck);
+ }
- return newBlock;
+ try
+ {
+ var updated = await _blockService.UpdateContentBlockAsync(contentBlockId, block, ct);
+ if (updated == null)
+ {
+ return NotFound();
+ }
+ return updated;
+ }
+ catch (CmsConcurrencyException ex)
+ {
+ return Conflict(ex.Message);
+ }
+ catch (ArgumentException ex)
+ {
+ return ValidationProblem(ex.Message);
+ }
}
- //DELETE: content/5
- [HttpDelete("{contentBlockId}")]
- [Permission(Allow = "SVMSecure.CMS.ManageContentBlocks")]
- public async Task> DeleteContentBlock(int contentBlockId)
+ //PATCH: content/5/content — quick save for content-only edits
+ [HttpPatch("{contentBlockId:int}/content")]
+ [Permission(Allow = CmsPermissions.ManageContentBlocks)]
+ public async Task> UpdateContentOnly(int contentBlockId,
+ ContentOnlyUpdate update, CancellationToken ct = default)
{
- var block = new Data.CMS(_context, _rapsContext, _sanitizerService).GetContentBlocks(contentBlockID: contentBlockId)?.FirstOrDefault();
- if (block == null)
+ try
{
- return NotFound();
+ var updated = await _blockService.UpdateContentOnlyAsync(contentBlockId, update.Content, update.LastModifiedOn, ct);
+ if (updated == null)
+ {
+ return NotFound();
+ }
+ return updated;
}
+ catch (CmsConcurrencyException ex)
+ {
+ return Conflict(ex.Message);
+ }
+ catch (ArgumentException ex)
+ {
+ return ValidationProblem(ex.Message);
+ }
+ }
- block.DeletedOn = DateTime.Now;
- block.ModifiedBy = UserHelper.GetCurrentUser()?.LoginId;
- _context.Entry(block).State = EntityState.Modified;
- await _context.SaveChangesAsync();
- return block;
+ //POST: content/5/restore
+ [HttpPost("{contentBlockId:int}/restore")]
+ [Permission(Allow = CmsPermissions.ManageContentBlocks)]
+ public async Task RestoreContentBlock(int contentBlockId, CancellationToken ct = default)
+ {
+ return await _blockService.RestoreAsync(contentBlockId, ct) ? NoContent() : NotFound();
+ }
+
+ //DELETE: content/5?permanent=true|false
+ [HttpDelete("{contentBlockId:int}")]
+ [Permission(Allow = CmsPermissions.ManageContentBlocks)]
+ public async Task DeleteContentBlock(int contentBlockId, bool permanent = false,
+ CancellationToken ct = default)
+ {
+ if (permanent)
+ {
+ // Permanent delete removes history and cannot be undone; admin only, matching the
+ // legacy "dev only" (SVMSecure.CATS.Admin) gate on permanent content-block delete.
+ if (!_userHelper.HasPermission(_rapsContext, _userHelper.GetCurrentUser(), CmsPermissions.Admin))
+ {
+ return ForbidApi();
+ }
+ return await _blockService.PermanentlyDeleteAsync(contentBlockId, ct) ? NoContent() : NotFound();
+ }
+ return await _blockService.SoftDeleteAsync(contentBlockId, ct) ? NoContent() : NotFound();
}
private static string CheckBlockForRequiredFields(CMSBlockAddEdit userInput)
@@ -175,39 +320,20 @@ private static string CheckBlockForRequiredFields(CMSBlockAddEdit userInput)
}
return errors;
}
+ }
- private void ModifyBlockWithUserInput(ContentBlock contentBlock, CMSBlockAddEdit userInput)
- {
- //update info
- contentBlock.Title = userInput.Title;
- contentBlock.Content = userInput.Content;
- contentBlock.FriendlyName = userInput.FriendlyName;
- contentBlock.System = userInput.System;
- contentBlock.Application = userInput.Application;
- contentBlock.Page = userInput.Page;
- contentBlock.ViperSectionPath = userInput.ViperSectionPath;
- contentBlock.AllowPublicAccess = userInput.AllowPublicAccess;
- contentBlock.BlockOrder = userInput.BlockOrder;
- contentBlock.ModifiedOn = DateTime.Now;
- contentBlock.ModifiedBy = UserHelper.GetCurrentUser()?.LoginId;
-
- //adjust permissions
- //remove content block permisisons that are not in the user input
- foreach (var cbp in contentBlock.ContentBlockToPermissions.Where(cbp => !userInput.Permissions.Contains(cbp.Permission)))
- {
- contentBlock.ContentBlockToPermissions.Remove(cbp);
- }
+ public class ContentOnlyUpdate
+ {
+ // AllowEmptyStrings: clearing a block's content is a legitimate save; only a JSON null
+ // (which would bypass the non-nullable default) gets the automatic 400.
+ [Required(AllowEmptyStrings = true)]
+ public string Content { get; set; } = string.Empty;
+ public DateTime? LastModifiedOn { get; set; }
+ }
- //add new content block permissions, if they are not in the existing list
- var existingPermissions = contentBlock.ContentBlockToPermissions.Select(p => p.Permission).ToList();
- foreach (var p in userInput.Permissions.Where(p => !existingPermissions.Contains(p)))
- {
- contentBlock.ContentBlockToPermissions.Add(new ContentBlockToPermission
- {
- Permission = p,
- ContentBlockId = userInput.ContentBlockId
- });
- }
- }
+ public class DiffAgainstHistoryRequest
+ {
+ [Required(AllowEmptyStrings = true)]
+ public string Content { get; set; } = string.Empty;
}
}
diff --git a/web/Areas/CMS/Controllers/CMSLeftNavController.cs b/web/Areas/CMS/Controllers/CMSLeftNavController.cs
new file mode 100644
index 000000000..957e23cc6
--- /dev/null
+++ b/web/Areas/CMS/Controllers/CMSLeftNavController.cs
@@ -0,0 +1,121 @@
+using Microsoft.AspNetCore.Mvc;
+using Viper.Areas.CMS.Constants;
+using Viper.Areas.CMS.Models.DTOs;
+using Viper.Areas.CMS.Services;
+using Viper.Classes;
+using Web.Authorization;
+
+namespace Viper.Areas.CMS.Controllers
+{
+ ///
+ /// Left navigation menu management. Display of menus to end users goes through
+ /// LayoutController / Data.LeftNavMenu, which filters items by user permission.
+ ///
+ [Route("/api/cms/left-navs")]
+ [Permission(Allow = CmsPermissions.ManageNavigation)]
+ public class CMSLeftNavController : ApiController
+ {
+ private readonly ICmsLeftNavService _leftNavService;
+
+ public CMSLeftNavController(ICmsLeftNavService leftNavService)
+ {
+ _leftNavService = leftNavService;
+ }
+
+ // GET /api/cms/left-navs
+ [HttpGet]
+ public async Task>> GetMenus(
+ string? system, string? viperSectionPath, string? search, CancellationToken ct = default)
+ {
+ return await _leftNavService.GetMenusAsync(system, viperSectionPath, search, ct);
+ }
+
+ // GET /api/cms/left-navs/5
+ [HttpGet("{leftNavMenuId:int}")]
+ public async Task> GetMenu(int leftNavMenuId, CancellationToken ct = default)
+ {
+ var menu = await _leftNavService.GetMenuAsync(leftNavMenuId, ct);
+ if (menu == null)
+ {
+ return NotFound();
+ }
+ return menu;
+ }
+
+ // POST /api/cms/left-navs
+ [HttpPost]
+ public async Task> CreateMenu(LeftNavMenuAddEdit menu, CancellationToken ct = default)
+ {
+ try
+ {
+ var created = await _leftNavService.CreateMenuAsync(menu, ct);
+ return CreatedAtAction(nameof(GetMenu), new { leftNavMenuId = created.LeftNavMenuId }, created);
+ }
+ catch (ArgumentException ex)
+ {
+ return BadRequest(ex.Message);
+ }
+ }
+
+ // PUT /api/cms/left-navs/5
+ [HttpPut("{leftNavMenuId:int}")]
+ public async Task> UpdateMenu(int leftNavMenuId, LeftNavMenuAddEdit menu,
+ CancellationToken ct = default)
+ {
+ try
+ {
+ var updated = await _leftNavService.UpdateMenuAsync(leftNavMenuId, menu, ct);
+ if (updated == null)
+ {
+ return NotFound();
+ }
+ return updated;
+ }
+ catch (ArgumentException ex)
+ {
+ return BadRequest(ex.Message);
+ }
+ // A stale edit must surface as 409 (someone saved first); CmsConcurrencyException
+ // derives from InvalidOperationException, so catch it explicitly rather than as a 500.
+ catch (CmsConcurrencyException ex)
+ {
+ return Conflict(ex.Message);
+ }
+ }
+
+ // PUT /api/cms/left-navs/5/items — full item list plus the menu's concurrency stamp;
+ // order follows the array
+ [HttpPut("{leftNavMenuId:int}/items")]
+ public async Task> SaveItems(int leftNavMenuId, LeftNavItemsSave request,
+ CancellationToken ct = default)
+ {
+ try
+ {
+ var updated = await _leftNavService.SaveItemsAsync(leftNavMenuId, request, ct);
+ if (updated == null)
+ {
+ return NotFound();
+ }
+ return updated;
+ }
+ catch (ArgumentException ex)
+ {
+ // Malformed request (e.g. duplicate item ids, missing LastModifiedOn).
+ return BadRequest(ex.Message);
+ }
+ catch (InvalidOperationException ex)
+ {
+ // Stale client: an outdated LastModifiedOn stamp (CmsConcurrencyException derives
+ // from InvalidOperationException) or a supplied item id no longer in the menu.
+ return Conflict(ex.Message);
+ }
+ }
+
+ // DELETE /api/cms/left-navs/5 — deletes the menu and all items (no soft delete, matching legacy)
+ [HttpDelete("{leftNavMenuId:int}")]
+ public async Task DeleteMenu(int leftNavMenuId, CancellationToken ct = default)
+ {
+ return await _leftNavService.DeleteMenuAsync(leftNavMenuId, ct) ? NoContent() : NotFound();
+ }
+ }
+}
diff --git a/web/Areas/CMS/Controllers/CMSLinkCollectionController.cs b/web/Areas/CMS/Controllers/CMSLinkCollectionController.cs
index edfb0696c..95cd24bf7 100644
--- a/web/Areas/CMS/Controllers/CMSLinkCollectionController.cs
+++ b/web/Areas/CMS/Controllers/CMSLinkCollectionController.cs
@@ -2,6 +2,7 @@
using Areas.CMS.Models.DTOs;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
+using Viper.Areas.CMS.Constants;
using Viper.Classes;
using Viper.Classes.SQLContext;
using Web.Authorization;
@@ -31,19 +32,13 @@ public async Task>> GetLinkCollectio
{
LinkCollectionId = lc.LinkCollectionId,
LinkCollection = lc.LinkCollectionName,
- LinkCollectionTagCategories = lc.LinkCollectionTagCategories.Select(tc => new LinkCollectionTagCategoryDto
- {
- LinkCollectionTagCategoryId = tc.LinkCollectionTagCategoryId,
- LinkCollectionId = tc.LinkCollectionId,
- LinkCollectionTagCategory = tc.LinkCollectionTagCategoryName,
- SortOrder = tc.SortOrder
- }).ToList()
+ LinkCollectionTagCategories = lc.LinkCollectionTagCategories.Select(ToTagCategoryDto).ToList()
});
return Ok(result);
}
- [Permission(Allow = "SVMSecure.CMS.ManageContentBlocks")]
+ [Permission(Allow = CmsPermissions.ManageContentBlocks)]
[HttpPost]
public async Task> PostLinkCollection(CreateLinkCollectionDto createDto)
{
@@ -68,10 +63,10 @@ public async Task> PostLinkCollection(CreateLink
LinkCollectionTagCategories = new List()
};
- return CreatedAtAction(nameof(linkCollection), new { id = linkCollection.LinkCollectionId }, result);
+ return CreatedAtAction(nameof(GetLinkCollections), new { linkCollectionName = linkCollection.LinkCollectionName }, result);
}
- [Permission(Allow = "SVMSecure.CMS.ManageContentBlocks")]
+ [Permission(Allow = CmsPermissions.ManageContentBlocks)]
[HttpPut("{id}")]
public async Task PutLinkCollection(int id, CreateLinkCollectionDto updateDto)
{
@@ -94,7 +89,7 @@ public async Task PutLinkCollection(int id, CreateLinkCollectionD
return Ok(updateDto);
}
- [Permission(Allow = "SVMSecure.CMS.ManageContentBlocks")]
+ [Permission(Allow = CmsPermissions.ManageContentBlocks)]
[HttpDelete("{id}")]
public async Task DeleteLinkCollection(int id)
{
@@ -104,6 +99,18 @@ public async Task DeleteLinkCollection(int id)
return NotFound();
}
+ // FK constraints are not ON DELETE CASCADE, so remove dependents before
+ // the collection. Queuing all removals into a single SaveChanges keeps it
+ // atomic. Order: link tags -> links -> tag categories -> collection.
+ var collectionLinks = await _context.Links
+ .Where(l => l.LinkCollectionId == id)
+ .ToListAsync();
+ var collectionLinkIds = collectionLinks.Select(l => l.LinkId).ToList();
+ _context.LinkTags.RemoveRange(
+ _context.LinkTags.Where(lt => EF.Parameter(collectionLinkIds).Contains(lt.LinkId)));
+ _context.Links.RemoveRange(collectionLinks);
+ _context.LinkCollectionTagCategories.RemoveRange(
+ _context.LinkCollectionTagCategories.Where(tc => tc.LinkCollectionId == id));
_context.LinkCollections.Remove(linkCollection);
await _context.SaveChangesAsync();
@@ -124,28 +131,25 @@ public async Task>> GetLi
.OrderBy(lc => lc.SortOrder)
.ToListAsync();
- var results = categories
- .Select(c => new LinkCollectionTagCategoryDto
- {
- LinkCollectionTagCategoryId = c.LinkCollectionTagCategoryId,
- LinkCollectionId = c.LinkCollectionId,
- LinkCollectionTagCategory = c.LinkCollectionTagCategoryName,
- SortOrder = c.SortOrder
- })
- .ToList();
+ var results = categories.Select(ToTagCategoryDto).ToList();
return Ok(results);
}
- [Permission(Allow = "SVMSecure.CMS.ManageContentBlocks")]
+ [Permission(Allow = CmsPermissions.ManageContentBlocks)]
[HttpPost("{collectionId}/tags")]
- public async Task> CreateLinkCollectionTagCategory(int collectionId, CreateLinkCollectionTagCategoryDto createLinkTagCategoryDto)
+ public async Task> CreateLinkCollectionTagCategory(int collectionId, CreateLinkCollectionTagCategoryDto createLinkTagCategoryDto)
{
if (!await _context.LinkCollections.AnyAsync(lc => lc.LinkCollectionId == collectionId))
{
return NotFound();
}
+ if (createLinkTagCategoryDto.LinkCollectionId != collectionId)
+ {
+ return BadRequest("Collection ID in route does not match the request body.");
+ }
+
if (await _context.LinkCollectionTagCategories
.AnyAsync(tc => tc.LinkCollectionTagCategoryName == createLinkTagCategoryDto.LinkCollectionTagCategory
&& tc.LinkCollectionId == collectionId))
@@ -161,10 +165,13 @@ public async Task> CreateLinkCo
};
_context.LinkCollectionTagCategories.Add(tagCategory);
await _context.SaveChangesAsync();
- return CreatedAtAction(nameof(tagCategory), new { id = tagCategory.LinkCollectionTagCategoryId }, createLinkTagCategoryDto);
+
+ // Return the saved DTO so the client receives the generated id. The follow-up
+ // tag-order PUT needs that id to address the newly created category.
+ return CreatedAtAction(nameof(GetLinkCollectionTagCategories), new { collectionId }, ToTagCategoryDto(tagCategory));
}
- [Permission(Allow = "SVMSecure.CMS.ManageContentBlocks")]
+ [Permission(Allow = CmsPermissions.ManageContentBlocks)]
[HttpPut("{collectionId}/tags/order")]
public async Task UpdateLinkCollectionTagCategoryOrder(int collectionId, List updateDto)
{
@@ -182,17 +189,25 @@ public async Task UpdateLinkCollectionTagCategoryOrder(int collec
return BadRequest("Mismatch in number of tag categories.");
}
+ // Membership + duplicate validation (a duplicated id with a matching count would
+ // silently update one row twice and skip another), mirroring UpdateLinkOrder.
+ var tagCategoriesById = tagCategories.ToDictionary(tc => tc.LinkCollectionTagCategoryId);
+ if (updateDto.Any(dto => !tagCategoriesById.ContainsKey(dto.LinkCollectionTagCategoryId))
+ || updateDto.Select(dto => dto.LinkCollectionTagCategoryId).Distinct().Count() != updateDto.Count)
+ {
+ return BadRequest("One or more tag category IDs are invalid.");
+ }
+
foreach (var dto in updateDto)
{
- var tagCategory = tagCategories.First(tc => tc.LinkCollectionTagCategoryId == dto.LinkCollectionTagCategoryId);
- tagCategory.SortOrder = dto.SortOrder;
+ tagCategoriesById[dto.LinkCollectionTagCategoryId].SortOrder = dto.SortOrder;
}
await _context.SaveChangesAsync();
return NoContent();
}
- [Permission(Allow = "SVMSecure.CMS.ManageContentBlocks")]
+ [Permission(Allow = CmsPermissions.ManageContentBlocks)]
[HttpDelete("{collectionId}/tags/{tagCategoryId}")]
public async Task DeleteLinkCollectionTagCategory(int collectionId, int tagCategoryId)
{
@@ -210,5 +225,13 @@ public async Task DeleteLinkCollectionTagCategory(int collectionI
await _context.SaveChangesAsync();
return NoContent();
}
+
+ private static LinkCollectionTagCategoryDto ToTagCategoryDto(LinkCollectionTagCategory tagCategory) => new()
+ {
+ LinkCollectionTagCategoryId = tagCategory.LinkCollectionTagCategoryId,
+ LinkCollectionId = tagCategory.LinkCollectionId,
+ LinkCollectionTagCategory = tagCategory.LinkCollectionTagCategoryName,
+ SortOrder = tagCategory.SortOrder
+ };
}
}
diff --git a/web/Areas/CMS/Controllers/CMSLinkCollectionLinks.cs b/web/Areas/CMS/Controllers/CMSLinkCollectionLinks.cs
index f4ec728e9..d8ed38265 100644
--- a/web/Areas/CMS/Controllers/CMSLinkCollectionLinks.cs
+++ b/web/Areas/CMS/Controllers/CMSLinkCollectionLinks.cs
@@ -117,7 +117,7 @@ public async Task> PostLink(int collectionId, CreateLinkDt
_context.Links.Add(link);
await _context.SaveChangesAsync();
- return CreatedAtAction(nameof(link), new { id = link.LinkId }, new LinkDto
+ return CreatedAtAction(nameof(GetLinks), new { collectionId }, new LinkDto
{
LinkId = link.LinkId,
LinkCollectionId = link.LinkCollectionId,
@@ -190,15 +190,19 @@ public async Task UpdateLinkOrder(int collectionId, List l.LinkCollectionId == collectionId)
.ToListAsync();
- if (links.Count != updateDto.Count)
+ // Membership validation, not just count: an unknown or duplicate LinkId slipped past the
+ // count-only guard and crashed the First() lookup below with a 500.
+ var linksById = links.ToDictionary(l => l.LinkId);
+ if (links.Count != updateDto.Count
+ || updateDto.Any(li => !linksById.ContainsKey(li.LinkId))
+ || updateDto.Select(li => li.LinkId).Distinct().Count() != updateDto.Count)
{
return BadRequest("One or more LinkIds are invalid.");
}
foreach (var li in updateDto)
{
- var link = links.First(l => l.LinkId == li.LinkId);
- link.SortOrder = li.SortOrder;
+ linksById[li.LinkId].SortOrder = li.SortOrder;
}
await _context.SaveChangesAsync();
diff --git a/web/Areas/CMS/Data/LeftNavMenu.cs b/web/Areas/CMS/Data/LeftNavMenu.cs
index 6f9d83387..ac7c210f4 100644
--- a/web/Areas/CMS/Data/LeftNavMenu.cs
+++ b/web/Areas/CMS/Data/LeftNavMenu.cs
@@ -8,14 +8,13 @@ public class LeftNavMenu
{
private readonly VIPERContext? _viperContext;
private readonly RAPSContext? _rapsContext;
+ private readonly IUserHelper _userHelper;
- public IUserHelper UserHelper { get; private set; }
-
- public LeftNavMenu(VIPERContext viperContext, RAPSContext rapsContext)
+ public LeftNavMenu(VIPERContext viperContext, RAPSContext rapsContext, IUserHelper? userHelper = null)
{
this._viperContext = viperContext;
this._rapsContext = rapsContext;
- UserHelper = new UserHelper();
+ _userHelper = userHelper ?? new UserHelper();
}
///
@@ -46,14 +45,17 @@ public LeftNavMenu(VIPERContext viperContext, RAPSContext rapsContext)
return null;
}
- var currentUser = UserHelper.GetCurrentUser();
+ var currentUser = _userHelper.GetCurrentUser();
List cmsMenus = new();
foreach (var m in menus)
{
- //by default, filter items based on user permissions
+ //by default, filter items based on user permissions; items with no
+ //permissions are visible to any LOGGED-IN user (legacy CMS behavior) - an
+ //anonymous request (e.g. an [AllowAnonymous] page rendering a menu) sees none
List items = m.LeftNavItems
.Where(item => !filterItemsByPermissions
- || item.LeftNavItemToPermissions.Any(p => UserHelper.HasPermission(_rapsContext, currentUser, p.Permission)))
+ || (currentUser != null && item.LeftNavItemToPermissions.Count == 0)
+ || item.LeftNavItemToPermissions.Any(p => _userHelper.HasPermission(_rapsContext, currentUser, p.Permission)))
.Select(item => new NavMenuItem(item))
.ToList();
cmsMenus.Add(new(m.MenuHeaderText, items));
diff --git a/web/Areas/CMS/Models/CMSBlockAddEdit.cs b/web/Areas/CMS/Models/CMSBlockAddEdit.cs
index c1ba7dafe..fa9bac352 100644
--- a/web/Areas/CMS/Models/CMSBlockAddEdit.cs
+++ b/web/Areas/CMS/Models/CMSBlockAddEdit.cs
@@ -23,5 +23,13 @@ public class CMSBlockAddEdit
public required bool AllowPublicAccess { get; set; }
public ICollection Permissions { get; set; } = new List();
+
+ public List FileGuids { get; set; } = new();
+
+ ///
+ /// ModifiedOn value the client loaded; updates with a stale value get 409 Conflict.
+ /// Required for updates (null gets 400); ignored on create.
+ ///
+ public DateTime? LastModifiedOn { get; set; }
}
}
diff --git a/web/Areas/CMS/Models/CmsContentBlockMapper.cs b/web/Areas/CMS/Models/CmsContentBlockMapper.cs
new file mode 100644
index 000000000..fdbe2dcea
--- /dev/null
+++ b/web/Areas/CMS/Models/CmsContentBlockMapper.cs
@@ -0,0 +1,36 @@
+using Riok.Mapperly.Abstractions;
+using Viper.Areas.CMS.Models.DTOs;
+using Viper.Models.VIPER;
+
+namespace Viper.Areas.CMS.Models
+{
+ [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.None)]
+ public static partial class CmsContentBlockMapper
+ {
+ public static ContentBlockDto ToDto(ContentBlock block)
+ {
+ var dto = ToDtoBase(block);
+ dto.Permissions = block.ContentBlockToPermissions.Select(p => p.Permission).OrderBy(p => p).ToList();
+ dto.Files = block.ContentBlockToFiles
+ .Select(f => ToFileDto(f.FileGuid, f.File.FriendlyName))
+ .OrderBy(f => f.FriendlyName)
+ .ToList();
+ return dto;
+ }
+
+ // Single source for an attached-file DTO's shape and public URL, shared with the
+ // content-block list projection so both build the friendly URL the same way.
+ public static ContentBlockFileDto ToFileDto(Guid fileGuid, string friendlyName) => new()
+ {
+ FileGuid = fileGuid,
+ FriendlyName = friendlyName,
+ Url = Data.CMS.GetFriendlyURL(friendlyName)
+ };
+
+ [MapperIgnoreTarget(nameof(ContentBlockDto.Permissions))]
+ [MapperIgnoreTarget(nameof(ContentBlockDto.Files))]
+ private static partial ContentBlockDto ToDtoBase(ContentBlock block);
+
+ public static partial PublicContentBlockDto ToPublicDto(ContentBlock block);
+ }
+}
diff --git a/web/Areas/CMS/Models/DTOs/ContentBlockDto.cs b/web/Areas/CMS/Models/DTOs/ContentBlockDto.cs
new file mode 100644
index 000000000..0aa693317
--- /dev/null
+++ b/web/Areas/CMS/Models/DTOs/ContentBlockDto.cs
@@ -0,0 +1,89 @@
+namespace Viper.Areas.CMS.Models.DTOs
+{
+ public class ContentBlockDto
+ {
+ public int ContentBlockId { get; set; }
+ public string Content { get; set; } = string.Empty;
+ public string? Title { get; set; }
+ public string System { get; set; } = string.Empty;
+ public string? Application { get; set; }
+ public string? Page { get; set; }
+ public string? ViperSectionPath { get; set; }
+ public int? BlockOrder { get; set; }
+ public string? FriendlyName { get; set; }
+ public bool AllowPublicAccess { get; set; }
+ public DateTime ModifiedOn { get; set; }
+ public string ModifiedBy { get; set; } = string.Empty;
+ public DateTime? DeletedOn { get; set; }
+ public List Permissions { get; set; } = new();
+ public List Files { get; set; } = new();
+ }
+
+ ///
+ /// Shape served by the anonymous display endpoint (content/fn/{friendlyName}). Carries only
+ /// what public rendering needs; the full ContentBlockDto would disclose editor login ids,
+ /// permission names, and placement metadata to unauthenticated callers.
+ ///
+ public class PublicContentBlockDto
+ {
+ public int ContentBlockId { get; set; }
+ public string Content { get; set; } = string.Empty;
+ public string? Title { get; set; }
+ public string? FriendlyName { get; set; }
+ }
+
+ public class ContentBlockFileDto
+ {
+ public Guid FileGuid { get; set; }
+ public string FriendlyName { get; set; } = string.Empty;
+ public string Url { get; set; } = string.Empty;
+ }
+
+ public class ContentHistoryListItemDto
+ {
+ public int ContentHistoryId { get; set; }
+ public DateTime? ModifiedOn { get; set; }
+ public string? ModifiedBy { get; set; }
+ }
+
+ public class ContentHistoryDto : ContentHistoryListItemDto
+ {
+ public string Content { get; set; } = string.Empty;
+ }
+
+ ///
+ /// A rendered HTML diff between two versions of a content block. Content holds the merged
+ /// markup with htmldiff.net's ins/del (diffins/diffdel/diffmod) markers. The Old/New stamps
+ /// describe each side so the viewer can label the comparison direction. HasComparison is false
+ /// when there is no other version to compare against (e.g. the original version); Content then
+ /// holds the version's own markup with no diff markers.
+ ///
+ public class ContentHistoryDiffDto
+ {
+ public string Content { get; set; } = string.Empty;
+ public bool HasComparison { get; set; }
+ // False when the two compared versions are identical (the diff carries no ins/del markers),
+ // so the viewer can say "identical" instead of showing an unchanged body that looks broken.
+ public bool HasChanges { get; set; }
+ public DateTime? OldModifiedOn { get; set; }
+ public string? OldModifiedBy { get; set; }
+ public DateTime? NewModifiedOn { get; set; }
+ public string? NewModifiedBy { get; set; }
+ }
+
+ ///
+ /// A single cross-block edit-history entry: a superseded content version with the block
+ /// it belongs to. Used by the content-block edit-history viewer.
+ ///
+ public class ContentHistoryAuditDto
+ {
+ public int ContentHistoryId { get; set; }
+ public int ContentBlockId { get; set; }
+ public string? Title { get; set; }
+ public string? FriendlyName { get; set; }
+ public string? Page { get; set; }
+ public DateTime? ModifiedOn { get; set; }
+ public string? ModifiedBy { get; set; }
+ public bool BlockDeleted { get; set; }
+ }
+}
diff --git a/web/Areas/CMS/Models/DTOs/LeftNavDtos.cs b/web/Areas/CMS/Models/DTOs/LeftNavDtos.cs
new file mode 100644
index 000000000..24756851e
--- /dev/null
+++ b/web/Areas/CMS/Models/DTOs/LeftNavDtos.cs
@@ -0,0 +1,102 @@
+using System.ComponentModel.DataAnnotations;
+using System.Text.Json.Serialization;
+using Viper.Areas.CMS.Validation;
+
+namespace Viper.Areas.CMS.Models.DTOs
+{
+ public class LeftNavMenuDto
+ {
+ public int LeftNavMenuId { get; set; }
+ public string? MenuHeaderText { get; set; }
+ public string System { get; set; } = string.Empty;
+ public string? ViperSectionPath { get; set; }
+ public string? Page { get; set; }
+ public string? FriendlyName { get; set; }
+ public DateTime ModifiedOn { get; set; }
+ public string ModifiedBy { get; set; } = string.Empty;
+ public List Items { get; set; } = new();
+ }
+
+ public class LeftNavItemDto
+ {
+ public int LeftNavItemId { get; set; }
+ public string? MenuItemText { get; set; }
+ public bool IsHeader { get; set; }
+ public string? Url { get; set; }
+ public int? DisplayOrder { get; set; }
+ public List Permissions { get; set; } = new();
+ }
+
+ // MaxLength values mirror the LeftNavMenu column sizes in VIPERContext so an over-long value
+ // is rejected as a 400 rather than surfacing as a 500 when SQL Server truncates/rejects it.
+ public class LeftNavMenuAddEdit
+ {
+ ///
+ /// ModifiedOn value the client loaded; updates with a stale value get 409 Conflict
+ /// and a missing value gets 400 (mirrors CMSBlockAddEdit.LastModifiedOn).
+ ///
+ public DateTime? LastModifiedOn { get; set; }
+
+ [Required]
+ [MaxLength(100)]
+ public string MenuHeaderText { get; set; } = string.Empty;
+
+ [Required]
+ [MaxLength(50)]
+ public string System { get; set; } = "Viper";
+
+ [MaxLength(100)]
+ public string? ViperSectionPath { get; set; }
+
+ [MaxLength(200)]
+ public string? Page { get; set; }
+
+ [MaxLength(100)]
+ public string? FriendlyName { get; set; }
+ }
+
+ ///
+ /// Batch item save: the full item list plus the menu's concurrency stamp. Items ride on the
+ /// menu's ModifiedOn, so a stale stamp gets 409 and a missing one 400, mirroring
+ /// LeftNavMenuAddEdit.LastModifiedOn - without it, two editors' batch saves silently
+ /// overwrite each other's item order and permissions.
+ ///
+ public class LeftNavItemsSave
+ {
+ public DateTime? LastModifiedOn { get; set; }
+
+ // JsonRequired: an under-posted (omitted) items property would default to an empty list
+ // and silently delete every item in the menu; deleting all items requires an explicit [].
+ // Nullable because "items": null still binds past JsonRequired; the service maps it to 400.
+ [JsonRequired]
+ public List? Items { get; set; }
+ }
+
+ ///
+ /// Full item list for a menu; saving replaces the menu's items (new items have id 0,
+ /// omitted ids are deleted, order follows the array).
+ ///
+ public class LeftNavItemEdit
+ {
+ // JsonRequired (not defaulting on omission): an under-posted id would silently turn an
+ // update into a new item, and an omitted isHeader would silently clear a header.
+ [JsonRequired]
+ public int LeftNavItemId { get; set; }
+
+ // Not [Required]: a header item may have empty text (legacy renders it as a blank
+ // spacer row). Links still need text - enforced in CmsLeftNavService.SaveItemsAsync.
+ [MaxLength(250)]
+ public string MenuItemText { get; set; } = string.Empty;
+
+ [JsonRequired]
+ public bool IsHeader { get; set; }
+
+ [MaxLength(250)]
+ [SafeUrl(AllowRelative = true)]
+ public string? Url { get; set; }
+
+ // Each permission is stored in the varchar(500) LeftNavItemToPermission.permission column.
+ [MaxLengthEach(500)]
+ public List Permissions { get; set; } = new();
+ }
+}
diff --git a/web/Areas/CMS/Services/CmsContentBlockService.cs b/web/Areas/CMS/Services/CmsContentBlockService.cs
new file mode 100644
index 000000000..f82859cbd
--- /dev/null
+++ b/web/Areas/CMS/Services/CmsContentBlockService.cs
@@ -0,0 +1,653 @@
+using Microsoft.EntityFrameworkCore;
+using HtmlDiffer = HtmlDiff.HtmlDiff;
+using Viper.Areas.CMS.Models;
+using Viper.Areas.CMS.Models.DTOs;
+using Viper.Classes.SQLContext;
+using Viper.Classes.Utilities;
+using Viper.Models.VIPER;
+using Viper.Services;
+
+namespace Viper.Areas.CMS.Services
+{
+ public interface ICmsContentBlockService
+ {
+ Task<(List Blocks, int Total)> GetContentBlocksAsync(string status, string? system,
+ string? viperSectionPath, string? search, bool? isPublic, int page, int perPage, string? sortBy,
+ bool descending, CancellationToken ct = default);
+
+ Task GetContentBlockAsync(int contentBlockId, CancellationToken ct = default);
+
+ Task CreateContentBlockAsync(CMSBlockAddEdit request, CancellationToken ct = default);
+
+ Task UpdateContentBlockAsync(int contentBlockId, CMSBlockAddEdit request, CancellationToken ct = default);
+
+ Task UpdateContentOnlyAsync(int contentBlockId, string content, DateTime? lastModifiedOn, CancellationToken ct = default);
+
+ Task SoftDeleteAsync(int contentBlockId, CancellationToken ct = default);
+
+ Task RestoreAsync(int contentBlockId, CancellationToken ct = default);
+
+ Task PermanentlyDeleteAsync(int contentBlockId, CancellationToken ct = default);
+
+ Task> GetHistoryAsync(int contentBlockId, CancellationToken ct = default);
+
+ Task GetHistoryVersionAsync(int contentBlockId, int contentHistoryId, CancellationToken ct = default);
+
+ Task GetHistoryVersionDiffAsync(int contentBlockId, int contentHistoryId, CancellationToken ct = default);
+
+ Task DiffContentAgainstHistoryAsync(int contentBlockId, int contentHistoryId, string currentContent, CancellationToken ct = default);
+
+ Task> GetHistoryEntriesAsync(CmsContentHistoryFilter filter, int page, int perPage, CancellationToken ct = default);
+
+ Task GetHistoryEntryCountAsync(CmsContentHistoryFilter filter, CancellationToken ct = default);
+
+ Task> GetViperSectionPathsAsync(CancellationToken ct = default);
+ }
+
+ ///
+ /// Filters for the cross-block edit-history viewer. Mirrors CmsFileAuditFilter so the
+ /// content-history page can reuse the file-audit page's filter UX.
+ ///
+ public class CmsContentHistoryFilter
+ {
+ public int? ContentBlockId { get; set; }
+ public string? ModifiedBy { get; set; }
+ public DateTime? From { get; set; }
+ public DateTime? To { get; set; }
+ public string? Search { get; set; }
+ }
+
+ ///
+ /// Management operations for CMS content blocks. Mirrors legacy ColdFusion
+ /// ContentBlocks.cfc semantics: content is stored raw and sanitized on render,
+ /// and ContentHistory receives the PREVIOUS content/author before each update
+ /// so history entries are the older versions, not copies of the current one.
+ ///
+ public class CmsContentBlockService : ICmsContentBlockService
+ {
+ private readonly VIPERContext _context;
+ private readonly IHtmlSanitizerService _sanitizer;
+ private readonly IUserHelper _userHelper;
+
+ public CmsContentBlockService(VIPERContext context, IHtmlSanitizerService sanitizer, IUserHelper userHelper)
+ {
+ _context = context;
+ _sanitizer = sanitizer;
+ _userHelper = userHelper;
+ }
+
+ public async Task<(List Blocks, int Total)> GetContentBlocksAsync(string status, string? system,
+ string? viperSectionPath, string? search, bool? isPublic, int page, int perPage, string? sortBy,
+ bool descending, CancellationToken ct = default)
+ {
+ // ApiPagination admits page=0, and Skip with a negative offset throws; clamp both knobs.
+ // The upper bound stops a caller from defeating pagination with a giant page size.
+ page = Math.Max(page, 1);
+ perPage = Math.Clamp(perPage, 1, 500);
+
+ var query = _context.ContentBlocks
+ .AsNoTracking()
+ .AsSplitQuery()
+ .TagWith("CmsContentBlockService.GetContentBlocks")
+ .AsQueryable();
+
+ query = status.ToLowerInvariant() switch
+ {
+ "active" => query.Where(b => b.DeletedOn == null),
+ "deleted" => query.Where(b => b.DeletedOn != null),
+ _ => query
+ };
+
+ if (!string.IsNullOrEmpty(system))
+ {
+ query = query.Where(b => b.System == system);
+ }
+ if (!string.IsNullOrEmpty(viperSectionPath))
+ {
+ query = query.Where(b => b.ViperSectionPath == viperSectionPath);
+ }
+ if (isPublic != null)
+ {
+ query = query.Where(b => b.AllowPublicAccess == isPublic);
+ }
+ if (!string.IsNullOrEmpty(search))
+ {
+ // Coalescing null columns to "" (never a match: search is non-empty here) keeps
+ // this a flat OR chain the SQL and in-memory providers both translate.
+ query = query.Where(b => (b.Title ?? "").Contains(search)
+ || (b.FriendlyName ?? "").Contains(search)
+ || (b.Page ?? "").Contains(search)
+ || b.Content.Contains(search));
+ }
+
+ int total = await query.CountAsync(ct);
+
+ query = (sortBy?.ToLowerInvariant(), descending) switch
+ {
+ ("vipersectionpath", false) => query.OrderBy(b => b.ViperSectionPath).ThenBy(b => b.Title),
+ ("vipersectionpath", true) => query.OrderByDescending(b => b.ViperSectionPath).ThenBy(b => b.Title),
+ ("page", false) => query.OrderBy(b => b.Page).ThenBy(b => b.Title),
+ ("page", true) => query.OrderByDescending(b => b.Page).ThenBy(b => b.Title),
+ ("modifiedon", false) => query.OrderBy(b => b.ModifiedOn),
+ ("modifiedon", true) => query.OrderByDescending(b => b.ModifiedOn),
+ (_, true) => query.OrderByDescending(b => b.Title),
+ _ => query.OrderBy(b => b.Title)
+ };
+
+ // List view: project without Content (it can be large) — the editor loads the full
+ // block. Two stages because GetFriendlyURL reads HttpContext and can't translate to SQL.
+ var blocks = await query
+ .Skip((page - 1) * perPage)
+ .Take(perPage)
+ .Select(b => new
+ {
+ b.ContentBlockId,
+ b.Title,
+ b.System,
+ b.Application,
+ b.Page,
+ b.ViperSectionPath,
+ b.BlockOrder,
+ b.FriendlyName,
+ b.AllowPublicAccess,
+ b.ModifiedOn,
+ b.ModifiedBy,
+ b.DeletedOn,
+ Permissions = b.ContentBlockToPermissions
+ .Select(p => p.Permission)
+ .OrderBy(p => p)
+ .ToList(),
+ Files = b.ContentBlockToFiles
+ .Select(f => new
+ {
+ f.FileGuid,
+ f.File.FriendlyName
+ })
+ .OrderBy(f => f.FriendlyName)
+ .ToList()
+ })
+ .ToListAsync(ct);
+
+ var dtos = blocks.Select(b => new ContentBlockDto
+ {
+ ContentBlockId = b.ContentBlockId,
+ Title = b.Title,
+ System = b.System,
+ Application = b.Application,
+ Page = b.Page,
+ ViperSectionPath = b.ViperSectionPath,
+ BlockOrder = b.BlockOrder,
+ FriendlyName = b.FriendlyName,
+ AllowPublicAccess = b.AllowPublicAccess,
+ ModifiedOn = b.ModifiedOn,
+ ModifiedBy = b.ModifiedBy,
+ DeletedOn = b.DeletedOn,
+ Permissions = b.Permissions,
+ Files = b.Files.Select(f => CmsContentBlockMapper.ToFileDto(f.FileGuid, f.FriendlyName)).ToList()
+ }).ToList();
+
+ return (dtos, total);
+ }
+
+ public async Task GetContentBlockAsync(int contentBlockId, CancellationToken ct = default)
+ {
+ var block = await LoadBlockAsync(contentBlockId, tracking: false, ct);
+ if (block == null)
+ {
+ return null;
+ }
+ var dto = CmsContentBlockMapper.ToDto(block);
+ // Sanitize for the editor with the same policy used on render, so what the
+ // editor shows matches what viewers will see.
+ dto.Content = _sanitizer.Sanitize(dto.Content);
+ return dto;
+ }
+
+ public async Task CreateContentBlockAsync(CMSBlockAddEdit request, CancellationToken ct = default)
+ {
+ await AssertFriendlyNameUniqueAsync(request.FriendlyName, null, ct);
+ var fileGuids = (request.FileGuids ?? new List()).Distinct().ToList();
+ await AssertFilesExistAsync(fileGuids, ct);
+
+ var block = new ContentBlock();
+ ApplyScalarFields(block, request);
+ block.ModifiedOn = DateTime.Now;
+ block.ModifiedBy = CurrentLoginId();
+
+ foreach (var permission in CleanList(request.Permissions))
+ {
+ block.ContentBlockToPermissions.Add(new ContentBlockToPermission { Permission = permission });
+ }
+ foreach (var fileGuid in fileGuids)
+ {
+ block.ContentBlockToFiles.Add(new ContentBlockToFile { FileGuid = fileGuid });
+ }
+
+ _context.ContentBlocks.Add(block);
+ await _context.SaveChangesAsync(ct);
+
+ return (await GetContentBlockAsync(block.ContentBlockId, ct))!;
+ }
+
+ public async Task UpdateContentBlockAsync(int contentBlockId, CMSBlockAddEdit request, CancellationToken ct = default)
+ {
+ var block = await LoadBlockAsync(contentBlockId, tracking: true, ct);
+ if (block == null)
+ {
+ return null;
+ }
+
+ AssertNotStale(block, request.LastModifiedOn);
+ await AssertFriendlyNameUniqueAsync(request.FriendlyName, contentBlockId, ct);
+ var fileGuids = (request.FileGuids ?? new List()).Distinct().ToList();
+ await AssertFilesExistAsync(fileGuids, ct);
+
+ SavePreviousVersionToHistory(block);
+ ApplyScalarFields(block, request);
+ block.ModifiedOn = DateTime.Now;
+ block.ModifiedBy = CurrentLoginId();
+
+ ApplyPermissionDeltas(block, CleanList(request.Permissions));
+ ApplyFileDeltas(block, fileGuids);
+
+ await _context.SaveChangesAsync(ct);
+ return await GetContentBlockAsync(contentBlockId, ct);
+ }
+
+ public async Task UpdateContentOnlyAsync(int contentBlockId, string content,
+ DateTime? lastModifiedOn, CancellationToken ct = default)
+ {
+ var block = await LoadBlockAsync(contentBlockId, tracking: true, ct);
+ if (block == null)
+ {
+ return null;
+ }
+
+ AssertNotStale(block, lastModifiedOn);
+ SavePreviousVersionToHistory(block);
+ block.Content = content;
+ block.ModifiedOn = DateTime.Now;
+ block.ModifiedBy = CurrentLoginId();
+
+ await _context.SaveChangesAsync(ct);
+ return await GetContentBlockAsync(contentBlockId, ct);
+ }
+
+ public async Task SoftDeleteAsync(int contentBlockId, CancellationToken ct = default)
+ {
+ var block = await _context.ContentBlocks.FindAsync(new object[] { contentBlockId }, ct);
+ if (block == null)
+ {
+ return false;
+ }
+ block.DeletedOn = DateTime.Now;
+ block.ModifiedOn = DateTime.Now;
+ block.ModifiedBy = CurrentLoginId();
+ await _context.SaveChangesAsync(ct);
+ return true;
+ }
+
+ public async Task RestoreAsync(int contentBlockId, CancellationToken ct = default)
+ {
+ var block = await _context.ContentBlocks.FindAsync(new object[] { contentBlockId }, ct);
+ if (block == null)
+ {
+ return false;
+ }
+ block.DeletedOn = null;
+ block.ModifiedOn = DateTime.Now;
+ block.ModifiedBy = CurrentLoginId();
+ await _context.SaveChangesAsync(ct);
+ return true;
+ }
+
+ public async Task PermanentlyDeleteAsync(int contentBlockId, CancellationToken ct = default)
+ {
+ var block = await _context.ContentBlocks
+ .Include(b => b.ContentBlockToPermissions)
+ .Include(b => b.ContentBlockToFiles)
+ .Include(b => b.ContentHistories)
+ .AsSplitQuery()
+ .FirstOrDefaultAsync(b => b.ContentBlockId == contentBlockId, ct);
+ if (block == null)
+ {
+ return false;
+ }
+
+ _context.RemoveRange(block.ContentHistories);
+ _context.RemoveRange(block.ContentBlockToFiles);
+ _context.RemoveRange(block.ContentBlockToPermissions);
+ _context.Remove(block);
+ await _context.SaveChangesAsync(ct);
+ return true;
+ }
+
+ public async Task> GetHistoryAsync(int contentBlockId, CancellationToken ct = default)
+ {
+ return await _context.ContentHistories
+ .AsNoTracking()
+ .Where(h => h.ContentBlockId == contentBlockId)
+ .OrderByDescending(h => h.ModifiedOn)
+ .ThenByDescending(h => h.ContentHistoryId)
+ .Select(h => new ContentHistoryListItemDto
+ {
+ ContentHistoryId = h.ContentHistoryId,
+ ModifiedOn = h.ModifiedOn,
+ ModifiedBy = h.ModifiedBy
+ })
+ .ToListAsync(ct);
+ }
+
+ public async Task GetHistoryVersionAsync(int contentBlockId, int contentHistoryId, CancellationToken ct = default)
+ {
+ var history = await _context.ContentHistories
+ .AsNoTracking()
+ .FirstOrDefaultAsync(h => h.ContentBlockId == contentBlockId && h.ContentHistoryId == contentHistoryId, ct);
+ if (history == null)
+ {
+ return null;
+ }
+ return new ContentHistoryDto
+ {
+ ContentHistoryId = history.ContentHistoryId,
+ ModifiedOn = history.ModifiedOn,
+ ModifiedBy = history.ModifiedBy,
+ Content = _sanitizer.Sanitize(history.ContentBlockContent)
+ };
+ }
+
+ public async Task GetHistoryVersionDiffAsync(int contentBlockId, int contentHistoryId, CancellationToken ct = default)
+ {
+ var selected = await _context.ContentHistories
+ .AsNoTracking()
+ .FirstOrDefaultAsync(h => h.ContentBlockId == contentBlockId && h.ContentHistoryId == contentHistoryId, ct);
+ if (selected == null)
+ {
+ return null;
+ }
+
+ // The "previous version" is the next-older history row for this block, by the same
+ // newest-first ordering the history list uses. History holds superseded versions only,
+ // so the current live block is never the comparison target here.
+ var previous = await _context.ContentHistories
+ .AsNoTracking()
+ .Where(h => h.ContentBlockId == contentBlockId
+ && (h.ModifiedOn < selected.ModifiedOn
+ || (h.ModifiedOn == selected.ModifiedOn && h.ContentHistoryId < selected.ContentHistoryId)))
+ .OrderByDescending(h => h.ModifiedOn)
+ .ThenByDescending(h => h.ContentHistoryId)
+ .FirstOrDefaultAsync(ct);
+
+ if (previous == null)
+ {
+ // Original version: nothing older to diff against. Return its own sanitized markup.
+ return new ContentHistoryDiffDto
+ {
+ Content = _sanitizer.Sanitize(selected.ContentBlockContent),
+ HasComparison = false,
+ NewModifiedOn = selected.ModifiedOn,
+ NewModifiedBy = selected.ModifiedBy
+ };
+ }
+
+ var diffHtml = BuildDiffHtml(previous.ContentBlockContent, selected.ContentBlockContent);
+ return new ContentHistoryDiffDto
+ {
+ Content = diffHtml,
+ HasComparison = true,
+ HasChanges = DiffHasChanges(diffHtml),
+ OldModifiedOn = previous.ModifiedOn,
+ OldModifiedBy = previous.ModifiedBy,
+ NewModifiedOn = selected.ModifiedOn,
+ NewModifiedBy = selected.ModifiedBy
+ };
+ }
+
+ public async Task DiffContentAgainstHistoryAsync(int contentBlockId, int contentHistoryId, string currentContent, CancellationToken ct = default)
+ {
+ var selected = await _context.ContentHistories
+ .AsNoTracking()
+ .FirstOrDefaultAsync(h => h.ContentBlockId == contentBlockId && h.ContentHistoryId == contentHistoryId, ct);
+ if (selected == null)
+ {
+ return null;
+ }
+
+ // The history version is the "old" side and the editor's current draft is the "new" side,
+ // so ins/del read as what the draft adds/removes relative to that version.
+ var diffHtml = BuildDiffHtml(selected.ContentBlockContent, currentContent);
+ return new ContentHistoryDiffDto
+ {
+ Content = diffHtml,
+ HasComparison = true,
+ HasChanges = DiffHasChanges(diffHtml),
+ OldModifiedOn = selected.ModifiedOn,
+ OldModifiedBy = selected.ModifiedBy
+ };
+ }
+
+ // Sanitize both sides with the render-time policy first so the diff compares what actually
+ // renders, then diff, then re-sanitize the merged result. The second pass re-parses through
+ // AngleSharp (balancing/closing the malformed tags htmldiff.net can emit) and strips anything
+ // off-policy, so we no longer trust the diff library's output — SanitizeDiff keeps only the
+ // / change markers on top of our normal allowlist.
+ private string BuildDiffHtml(string oldContent, string newContent)
+ {
+ var oldSafe = _sanitizer.Sanitize(oldContent);
+ var newSafe = _sanitizer.Sanitize(newContent);
+ var diff = HtmlDiffer.Execute(oldSafe, newSafe);
+ return _sanitizer.SanitizeDiff(diff);
+ }
+
+ // htmldiff.net only wraps actual changes in /; identical inputs come back with
+ // neither. Their absence means the two versions render the same.
+ private static bool DiffHasChanges(string diffHtml)
+ {
+ return diffHtml.Contains("> GetHistoryEntriesAsync(CmsContentHistoryFilter filter, int page, int perPage, CancellationToken ct = default)
+ {
+ // ApiPagination admits page=0, and Skip with a negative offset throws; clamp both knobs.
+ // The upper bound stops a caller from defeating pagination with a giant page size.
+ page = Math.Max(page, 1);
+ perPage = Math.Clamp(perPage, 1, 500);
+
+ return await BuildHistoryQuery(filter)
+ .OrderByDescending(x => x.History.ModifiedOn)
+ .ThenByDescending(x => x.History.ContentHistoryId)
+ .Skip((page - 1) * perPage)
+ .Take(perPage)
+ .Select(x => new ContentHistoryAuditDto
+ {
+ ContentHistoryId = x.History.ContentHistoryId,
+ ContentBlockId = x.History.ContentBlockId,
+ Title = x.Block.Title,
+ FriendlyName = x.Block.FriendlyName,
+ Page = x.Block.Page,
+ ModifiedOn = x.History.ModifiedOn,
+ ModifiedBy = x.History.ModifiedBy,
+ BlockDeleted = x.Block.DeletedOn != null
+ })
+ .ToListAsync(ct);
+ }
+
+ public async Task GetHistoryEntryCountAsync(CmsContentHistoryFilter filter, CancellationToken ct = default)
+ {
+ return await BuildHistoryQuery(filter).CountAsync(ct);
+ }
+
+ // Join history to its block so the block's title/page are filterable and projectable
+ // (a join, not a correlated subquery). Each row is a superseded prior version.
+ private IQueryable BuildHistoryQuery(CmsContentHistoryFilter filter)
+ {
+ var query =
+ from h in _context.ContentHistories.AsNoTracking()
+ join b in _context.ContentBlocks.AsNoTracking() on h.ContentBlockId equals b.ContentBlockId
+ select new HistoryWithBlock { History = h, Block = b };
+
+ if (filter.ContentBlockId != null)
+ {
+ query = query.Where(x => x.History.ContentBlockId == filter.ContentBlockId);
+ }
+ if (!string.IsNullOrEmpty(filter.ModifiedBy))
+ {
+ query = query.Where(x => x.History.ModifiedBy == filter.ModifiedBy);
+ }
+ if (filter.From != null)
+ {
+ query = query.Where(x => x.History.ModifiedOn >= filter.From);
+ }
+ if (filter.To != null)
+ {
+ // Treat the To date as inclusive through end of day.
+ var to = DateRangeHelper.ExclusiveUpperBound(filter.To.Value);
+ query = query.Where(x => x.History.ModifiedOn < to);
+ }
+ if (!string.IsNullOrEmpty(filter.Search))
+ {
+ query = query.Where(x => (x.Block.Title ?? "").Contains(filter.Search)
+ || (x.Block.FriendlyName ?? "").Contains(filter.Search)
+ || (x.Block.Page ?? "").Contains(filter.Search));
+ }
+ return query;
+ }
+
+ public async Task> GetViperSectionPathsAsync(CancellationToken ct = default)
+ {
+ return await _context.ContentBlocks
+ .AsNoTracking()
+ .Where(b => b.ViperSectionPath != null && b.ViperSectionPath != "")
+ .Select(b => b.ViperSectionPath!)
+ .Distinct()
+ .OrderBy(p => p)
+ .ToListAsync(ct);
+ }
+
+ private async Task LoadBlockAsync(int contentBlockId, bool tracking, CancellationToken ct)
+ {
+ var query = _context.ContentBlocks
+ .Include(b => b.ContentBlockToPermissions)
+ .Include(b => b.ContentBlockToFiles)
+ .ThenInclude(f => f.File)
+ .AsSplitQuery();
+ if (!tracking)
+ {
+ query = query.AsNoTracking();
+ }
+ return await query.FirstOrDefaultAsync(b => b.ContentBlockId == contentBlockId, ct);
+ }
+
+ private static void AssertNotStale(ContentBlock block, DateTime? lastModifiedOn) =>
+ CmsServiceHelpers.AssertNotStale("content block", block.ModifiedOn, block.ModifiedBy, lastModifiedOn);
+
+ private async Task AssertFilesExistAsync(List fileGuids, CancellationToken ct)
+ {
+ if (fileGuids.Count == 0)
+ {
+ return;
+ }
+ int existing = await _context.Files.CountAsync(f => EF.Parameter(fileGuids).Contains(f.FileGuid), ct);
+ if (existing != fileGuids.Count)
+ {
+ throw new ArgumentException("One or more attached files do not exist.");
+ }
+ }
+
+ private async Task AssertFriendlyNameUniqueAsync(string? friendlyName, int? exceptBlockId, CancellationToken ct)
+ {
+ if (string.IsNullOrEmpty(friendlyName))
+ {
+ return;
+ }
+ // Case-insensitive like the left-nav sibling: SQL Server's default collation treats
+ // "Name" and "name" as equal, so the pre-check must too (and so must test providers).
+ var normalized = friendlyName.ToLowerInvariant();
+ bool taken = await _context.ContentBlocks
+ .AnyAsync(b => b.FriendlyName != null && b.FriendlyName.ToLower() == normalized
+ && (exceptBlockId == null || b.ContentBlockId != exceptBlockId), ct);
+ if (taken)
+ {
+ throw new ArgumentException("Friendly name must be unique.");
+ }
+ }
+
+ private void SavePreviousVersionToHistory(ContentBlock block)
+ {
+ // Store the version being replaced, stamped with ITS author/time (legacy semantics).
+ _context.ContentHistories.Add(new ContentHistory
+ {
+ ContentBlockId = block.ContentBlockId,
+ ContentBlockContent = block.Content,
+ ModifiedOn = block.ModifiedOn,
+ ModifiedBy = block.ModifiedBy
+ });
+ }
+
+ private static void ApplyScalarFields(ContentBlock block, CMSBlockAddEdit request)
+ {
+ block.Title = request.Title;
+ block.Content = request.Content;
+ block.FriendlyName = request.FriendlyName;
+ block.System = request.System;
+ block.Application = request.Application;
+ block.Page = request.Page;
+ block.ViperSectionPath = request.ViperSectionPath;
+ block.AllowPublicAccess = request.AllowPublicAccess;
+ block.BlockOrder = request.BlockOrder;
+ }
+
+ private void ApplyPermissionDeltas(ContentBlock block, List requested)
+ {
+ var existing = block.ContentBlockToPermissions.ToList();
+ foreach (var cbp in existing.Where(cbp => !requested.Contains(cbp.Permission, StringComparer.OrdinalIgnoreCase)))
+ {
+ block.ContentBlockToPermissions.Remove(cbp);
+ _context.Remove(cbp);
+ }
+ var existingNames = existing.Select(p => p.Permission).ToHashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var permission in requested.Where(p => !existingNames.Contains(p)))
+ {
+ block.ContentBlockToPermissions.Add(new ContentBlockToPermission
+ {
+ ContentBlockId = block.ContentBlockId,
+ Permission = permission
+ });
+ }
+ }
+
+ private void ApplyFileDeltas(ContentBlock block, List requested)
+ {
+ var existing = block.ContentBlockToFiles.ToList();
+ foreach (var cbf in existing.Where(cbf => !requested.Contains(cbf.FileGuid)))
+ {
+ block.ContentBlockToFiles.Remove(cbf);
+ _context.Remove(cbf);
+ }
+ var existingGuids = existing.Select(f => f.FileGuid).ToHashSet();
+ foreach (var fileGuid in requested.Where(g => !existingGuids.Contains(g)))
+ {
+ block.ContentBlockToFiles.Add(new ContentBlockToFile
+ {
+ ContentBlockId = block.ContentBlockId,
+ FileGuid = fileGuid
+ });
+ }
+ }
+
+ private string CurrentLoginId()
+ {
+ return _userHelper.GetCurrentUser()?.LoginId ?? "unknown";
+ }
+
+ // Wrapper so the history/block join can be filtered and ordered before projection.
+ private sealed class HistoryWithBlock
+ {
+ public ContentHistory History { get; set; } = null!;
+ public ContentBlock Block { get; set; } = null!;
+ }
+
+ private static List CleanList(ICollection? values) => CmsServiceHelpers.CleanList(values);
+ }
+}
diff --git a/web/Areas/CMS/Services/CmsLeftNavService.cs b/web/Areas/CMS/Services/CmsLeftNavService.cs
new file mode 100644
index 000000000..11a28f3b6
--- /dev/null
+++ b/web/Areas/CMS/Services/CmsLeftNavService.cs
@@ -0,0 +1,321 @@
+using Microsoft.EntityFrameworkCore;
+using Viper.Areas.CMS.Models.DTOs;
+using Viper.Classes.SQLContext;
+using Viper.Models.VIPER;
+
+namespace Viper.Areas.CMS.Services
+{
+ public interface ICmsLeftNavService
+ {
+ Task> GetMenusAsync(string? system, string? viperSectionPath, string? search, CancellationToken ct = default);
+
+ Task GetMenuAsync(int leftNavMenuId, CancellationToken ct = default);
+
+ Task CreateMenuAsync(LeftNavMenuAddEdit request, CancellationToken ct = default);
+
+ Task UpdateMenuAsync(int leftNavMenuId, LeftNavMenuAddEdit request, CancellationToken ct = default);
+
+ Task DeleteMenuAsync(int leftNavMenuId, CancellationToken ct = default);
+
+ ///
+ /// Replace the menu's items with the supplied list: items with id 0 are added,
+ /// existing ids are updated, omitted ids are deleted, and DisplayOrder follows
+ /// the array order. Matches the legacy editor's batch save. The request's
+ /// LastModifiedOn stamp guards the whole batch against concurrent saves.
+ ///
+ Task SaveItemsAsync(int leftNavMenuId, LeftNavItemsSave request, CancellationToken ct = default);
+ }
+
+ ///
+ /// Management operations for CMS left navigation menus (legacy LeftNavs.cfc /
+ /// LeftNavAjax.cfc). Menu deletes cascade to items and item permissions; there is
+ /// no soft delete for menus, matching legacy.
+ ///
+ public class CmsLeftNavService : ICmsLeftNavService
+ {
+ private readonly VIPERContext _context;
+ private readonly IUserHelper _userHelper;
+
+ public CmsLeftNavService(VIPERContext context, IUserHelper userHelper)
+ {
+ _context = context;
+ _userHelper = userHelper;
+ }
+
+ public async Task> GetMenusAsync(string? system, string? viperSectionPath, string? search, CancellationToken ct = default)
+ {
+ var query = _context.LeftNavMenus
+ .AsNoTracking()
+ .Include(m => m.LeftNavItems)
+ .ThenInclude(i => i.LeftNavItemToPermissions)
+ .AsSplitQuery()
+ .TagWith("CmsLeftNavService.GetMenus")
+ .AsQueryable();
+
+ if (!string.IsNullOrEmpty(system))
+ {
+ query = query.Where(m => m.System == system);
+ }
+ if (!string.IsNullOrEmpty(viperSectionPath))
+ {
+ query = query.Where(m => m.ViperSectionPath == viperSectionPath);
+ }
+ if (!string.IsNullOrEmpty(search))
+ {
+ query = query.Where(m => (m.MenuHeaderText != null && m.MenuHeaderText.Contains(search))
+ || (m.FriendlyName != null && m.FriendlyName.Contains(search))
+ || (m.Page != null && m.Page.Contains(search)));
+ }
+
+ var menus = await query
+ .OrderBy(m => m.MenuHeaderText)
+ .ToListAsync(ct);
+ return menus.Select(ToDto).ToList();
+ }
+
+ public async Task GetMenuAsync(int leftNavMenuId, CancellationToken ct = default)
+ {
+ var menu = await LoadMenuAsync(leftNavMenuId, tracking: false, ct);
+ return menu == null ? null : ToDto(menu);
+ }
+
+ public async Task CreateMenuAsync(LeftNavMenuAddEdit request, CancellationToken ct = default)
+ {
+ await AssertFriendlyNameUniqueAsync(request.FriendlyName, null, ct);
+
+ var menu = new LeftNavMenu();
+ ApplyMenuFields(menu, request);
+ menu.ModifiedOn = DateTime.Now;
+ menu.ModifiedBy = CurrentLoginId();
+
+ _context.LeftNavMenus.Add(menu);
+ await _context.SaveChangesAsync(ct);
+ return ToDto(menu);
+ }
+
+ public async Task UpdateMenuAsync(int leftNavMenuId, LeftNavMenuAddEdit request, CancellationToken ct = default)
+ {
+ var menu = await LoadMenuAsync(leftNavMenuId, tracking: true, ct);
+ if (menu == null)
+ {
+ return null;
+ }
+
+ AssertNotStale(menu, request.LastModifiedOn);
+ await AssertFriendlyNameUniqueAsync(request.FriendlyName, leftNavMenuId, ct);
+
+ ApplyMenuFields(menu, request);
+ menu.ModifiedOn = DateTime.Now;
+ menu.ModifiedBy = CurrentLoginId();
+
+ await _context.SaveChangesAsync(ct);
+ return ToDto(menu);
+ }
+
+ public async Task DeleteMenuAsync(int leftNavMenuId, CancellationToken ct = default)
+ {
+ var menu = await LoadMenuAsync(leftNavMenuId, tracking: true, ct);
+ if (menu == null)
+ {
+ return false;
+ }
+
+ foreach (var item in menu.LeftNavItems)
+ {
+ _context.RemoveRange(item.LeftNavItemToPermissions);
+ }
+ _context.RemoveRange(menu.LeftNavItems);
+ _context.Remove(menu);
+ await _context.SaveChangesAsync(ct);
+ return true;
+ }
+
+ public async Task SaveItemsAsync(int leftNavMenuId, LeftNavItemsSave request, CancellationToken ct = default)
+ {
+ // JsonRequired on the DTO catches an omitted property, but an explicit "items": null
+ // still binds; 400 rather than a NullReferenceException 500.
+ var items = request.Items
+ ?? throw new ArgumentException("Items is required - send an empty array to remove all items.");
+
+ // Reject duplicate ids up front so every caller gets the guard (was in the controller).
+ if (items.Where(i => i.LeftNavItemId > 0).GroupBy(i => i.LeftNavItemId).Any(g => g.Count() > 1))
+ {
+ throw new ArgumentException("Duplicate item ids are not allowed.");
+ }
+
+ // Links need text; a header may be blank - legacy menus use empty headers as spacer
+ // rows and the display side still renders them that way.
+ if (items.Any(i => !i.IsHeader && string.IsNullOrWhiteSpace(i.MenuItemText)))
+ {
+ throw new ArgumentException("Every link needs text - fill in the empty link before saving.");
+ }
+
+ var menu = await LoadMenuAsync(leftNavMenuId, tracking: true, ct);
+ if (menu == null)
+ {
+ return null;
+ }
+
+ // Item saves bump the menu's ModifiedOn, so the same stamp guards both endpoints:
+ // without this, two editors' batch saves silently overwrite each other's items.
+ AssertNotStale(menu, request.LastModifiedOn);
+
+ var existingById = menu.LeftNavItems.ToDictionary(i => i.LeftNavItemId);
+
+ // A supplied id that isn't in this menu means the client's copy is stale (the item was
+ // deleted, or belongs to another menu). Reject rather than silently resurrecting it as a
+ // new row.
+ if (items.Any(i => i.LeftNavItemId > 0 && !existingById.ContainsKey(i.LeftNavItemId)))
+ {
+ throw new InvalidOperationException(
+ "One or more items no longer exist in this menu. Reload and try again.");
+ }
+
+ var requestedIds = items.Where(i => i.LeftNavItemId > 0).Select(i => i.LeftNavItemId).ToHashSet();
+
+ // Delete items not present in the request.
+ foreach (var item in menu.LeftNavItems.Where(i => !requestedIds.Contains(i.LeftNavItemId)).ToList())
+ {
+ _context.RemoveRange(item.LeftNavItemToPermissions);
+ _context.Remove(item);
+ menu.LeftNavItems.Remove(item);
+ }
+
+ int order = 1;
+ foreach (var requested in items)
+ {
+ if (requested.LeftNavItemId > 0 && existingById.TryGetValue(requested.LeftNavItemId, out var existing))
+ {
+ existing.MenuItemText = requested.MenuItemText;
+ existing.IsHeader = requested.IsHeader;
+ existing.Url = requested.IsHeader ? null : requested.Url;
+ existing.DisplayOrder = order;
+ ApplyItemPermissionDeltas(existing, CleanList(requested.Permissions));
+ }
+ else
+ {
+ var newItem = new LeftNavItem
+ {
+ LeftNavMenuId = leftNavMenuId,
+ MenuItemText = requested.MenuItemText,
+ IsHeader = requested.IsHeader,
+ Url = requested.IsHeader ? null : requested.Url,
+ DisplayOrder = order
+ };
+ foreach (var permission in CleanList(requested.Permissions))
+ {
+ newItem.LeftNavItemToPermissions.Add(new LeftNavItemToPermission { Permission = permission });
+ }
+ menu.LeftNavItems.Add(newItem);
+ }
+ order++;
+ }
+
+ menu.ModifiedOn = DateTime.Now;
+ menu.ModifiedBy = CurrentLoginId();
+
+ await _context.SaveChangesAsync(ct);
+ return await GetMenuAsync(leftNavMenuId, ct);
+ }
+
+ // FriendlyName resolves menus in LayoutController via FirstOrDefault, so duplicates make
+ // resolution nondeterministic. Reject them case-insensitively at write time. SQL Server's
+ // default collation is case-insensitive; ToLower keeps the check correct on other providers.
+ private async Task AssertFriendlyNameUniqueAsync(string? friendlyName, int? exceptMenuId, CancellationToken ct)
+ {
+ if (string.IsNullOrEmpty(friendlyName))
+ {
+ return;
+ }
+
+ // Invariant on the client side (culture-sensitive ToLower mishandles e.g. Turkish i);
+ // the EF-side ToLower below translates to SQL LOWER and never runs in .NET.
+ var normalized = friendlyName.ToLowerInvariant();
+ bool taken = await _context.LeftNavMenus
+ .AnyAsync(m => m.FriendlyName != null && m.FriendlyName.ToLower() == normalized
+ && (exceptMenuId == null || m.LeftNavMenuId != exceptMenuId), ct);
+ if (taken)
+ {
+ throw new ArgumentException("Friendly name must be unique.");
+ }
+ }
+
+ private static void AssertNotStale(LeftNavMenu menu, DateTime? lastModifiedOn) =>
+ CmsServiceHelpers.AssertNotStale("menu", menu.ModifiedOn, menu.ModifiedBy, lastModifiedOn);
+
+ private async Task LoadMenuAsync(int leftNavMenuId, bool tracking, CancellationToken ct)
+ {
+ var query = _context.LeftNavMenus
+ .Include(m => m.LeftNavItems)
+ .ThenInclude(i => i.LeftNavItemToPermissions)
+ .AsSplitQuery();
+ if (!tracking)
+ {
+ query = query.AsNoTracking();
+ }
+ return await query.FirstOrDefaultAsync(m => m.LeftNavMenuId == leftNavMenuId, ct);
+ }
+
+ private void ApplyItemPermissionDeltas(LeftNavItem item, List requested)
+ {
+ var existing = item.LeftNavItemToPermissions.ToList();
+ foreach (var p in existing.Where(p => !requested.Contains(p.Permission, StringComparer.OrdinalIgnoreCase)))
+ {
+ item.LeftNavItemToPermissions.Remove(p);
+ _context.Remove(p);
+ }
+ var existingNames = existing.Select(p => p.Permission).ToHashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var permission in requested.Where(p => !existingNames.Contains(p)))
+ {
+ item.LeftNavItemToPermissions.Add(new LeftNavItemToPermission
+ {
+ LeftNavItemId = item.LeftNavItemId,
+ Permission = permission
+ });
+ }
+ }
+
+ private static void ApplyMenuFields(LeftNavMenu menu, LeftNavMenuAddEdit request)
+ {
+ menu.MenuHeaderText = request.MenuHeaderText;
+ menu.System = request.System;
+ menu.ViperSectionPath = request.ViperSectionPath;
+ menu.Page = request.Page;
+ menu.FriendlyName = request.FriendlyName;
+ }
+
+ private static LeftNavMenuDto ToDto(LeftNavMenu menu)
+ {
+ return new LeftNavMenuDto
+ {
+ LeftNavMenuId = menu.LeftNavMenuId,
+ MenuHeaderText = menu.MenuHeaderText,
+ System = menu.System,
+ ViperSectionPath = menu.ViperSectionPath,
+ Page = menu.Page,
+ FriendlyName = menu.FriendlyName,
+ ModifiedOn = menu.ModifiedOn,
+ ModifiedBy = menu.ModifiedBy,
+ Items = menu.LeftNavItems
+ .OrderBy(i => i.DisplayOrder)
+ .Select(i => new LeftNavItemDto
+ {
+ LeftNavItemId = i.LeftNavItemId,
+ MenuItemText = i.MenuItemText,
+ IsHeader = i.IsHeader,
+ Url = i.Url,
+ DisplayOrder = i.DisplayOrder,
+ Permissions = i.LeftNavItemToPermissions.Select(p => p.Permission).OrderBy(p => p).ToList()
+ })
+ .ToList()
+ };
+ }
+
+ private string CurrentLoginId()
+ {
+ return _userHelper.GetCurrentUser()?.LoginId ?? "unknown";
+ }
+
+ private static List CleanList(List? values) => CmsServiceHelpers.CleanList(values);
+ }
+}
diff --git a/web/Areas/CMS/Services/CmsNavMenu.cs b/web/Areas/CMS/Services/CmsNavMenu.cs
index 0b151aeee..4d2b893d6 100644
--- a/web/Areas/CMS/Services/CmsNavMenu.cs
+++ b/web/Areas/CMS/Services/CmsNavMenu.cs
@@ -1,3 +1,4 @@
+using Viper.Areas.CMS.Constants;
using Viper.Classes;
using Viper.Classes.SQLContext;
@@ -6,20 +7,60 @@ namespace Viper.Areas.CMS.Services
public class CmsNavMenu
{
private readonly RAPSContext _rapsContext;
- public CmsNavMenu(RAPSContext context)
+ private readonly IUserHelper _userHelper;
+
+ public CmsNavMenu(RAPSContext context, IUserHelper? userHelper = null)
{
_rapsContext = context;
+ _userHelper = userHelper ?? new UserHelper();
}
public NavMenu Nav()
{
- UserHelper userHelper = new UserHelper();
+ var user = _userHelper.GetCurrentUser();
+ bool canManageBlocks = _userHelper.HasPermission(_rapsContext, user, CmsPermissions.ManageContentBlocks);
+ bool canCreateBlocks = _userHelper.HasPermission(_rapsContext, user, CmsPermissions.CreateContentBlock);
+ bool canManageFiles = _userHelper.HasPermission(_rapsContext, user, CmsPermissions.AllFiles);
+ bool canManageNav = _userHelper.HasPermission(_rapsContext, user, CmsPermissions.ManageNavigation);
var nav = new List();
- if (userHelper.HasPermission(_rapsContext, userHelper.GetCurrentUser(), "SVMSecure.CMS.ManageContentBlocks"))
+ if (canManageBlocks || canCreateBlocks || canManageFiles || canManageNav)
+ {
+ nav.Add(new NavMenuItem { MenuItemText = "Home", MenuItemURL = "Home", IndentLevel = 1 });
+ }
+
+ if (canManageBlocks || canCreateBlocks)
+ {
+ nav.Add(new NavMenuItem { MenuItemText = "Content Blocks", IsHeader = true });
+ if (canManageBlocks)
+ {
+ nav.Add(new NavMenuItem { MenuItemText = "Manage Content Blocks", MenuItemURL = "ManageContentBlocks", IndentLevel = 1 });
+ }
+ if (canCreateBlocks)
+ {
+ nav.Add(new NavMenuItem { MenuItemText = "Add Content Block", MenuItemURL = "ManageContentBlocks/Edit", IndentLevel = 1 });
+ }
+ if (canManageBlocks)
+ {
+ nav.Add(new NavMenuItem { MenuItemText = "Edit History", MenuItemURL = "ManageContentBlocks/History", IndentLevel = 1 });
+ nav.Add(new NavMenuItem { MenuItemText = "Manage Link Collections", MenuItemURL = "ManageLinkCollections", IndentLevel = 1 });
+ }
+ }
+
+ if (canManageFiles)
+ {
+ nav.Add(new NavMenuItem { MenuItemText = "Files", IsHeader = true });
+ nav.Add(new NavMenuItem { MenuItemText = "Manage Files", MenuItemURL = "ManageFiles", IndentLevel = 1 });
+ nav.Add(new NavMenuItem { MenuItemText = "Add File", MenuItemURL = "ManageFiles?upload=1", IndentLevel = 1 });
+ nav.Add(new NavMenuItem { MenuItemText = "Audit Trail", MenuItemURL = "ManageFiles/Audit", IndentLevel = 1 });
+ }
+
+ if (canManageNav)
{
- nav.Add(new NavMenuItem { MenuItemText = "Manage Link Collections", MenuItemURL = "ManageLinkCollections" });
+ nav.Add(new NavMenuItem { MenuItemText = "Left Navigation Menus", IsHeader = true });
+ nav.Add(new NavMenuItem { MenuItemText = "Manage Left-Nav Menus", MenuItemURL = "ManageLeftNav", IndentLevel = 1 });
+ nav.Add(new NavMenuItem { MenuItemText = "Add Left-Nav Menu", MenuItemURL = "ManageLeftNav?add=1", IndentLevel = 1 });
}
return new NavMenu("Content Management System", nav);