From 738b9f496d5bbfc38fea9b03c79220a550201fd6 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sun, 14 Jun 2026 20:34:29 +0100 Subject: [PATCH] Phase 12F: Continuity Warning Management. --- .../ContinuityExplorerController.cs | 55 +++++++ PlotLine/Data/Repositories.cs | 71 ++++++++ PlotLine/Models/CoreModels.cs | 13 ++ PlotLine/Program.cs | 1 + PlotLine/Services/CoreServices.cs | 83 +++++++++- ...e12F_ContinuityWarningAcknowledgements.sql | 152 ++++++++++++++++++ PlotLine/ViewModels/CoreViewModels.cs | 4 + .../Views/ContinuityExplorer/Index.cshtml | 97 ++++++++++- 8 files changed, 474 insertions(+), 2 deletions(-) create mode 100644 PlotLine/Sql/065_Phase12F_ContinuityWarningAcknowledgements.sql diff --git a/PlotLine/Controllers/ContinuityExplorerController.cs b/PlotLine/Controllers/ContinuityExplorerController.cs index 141a0a2..338b3e2 100644 --- a/PlotLine/Controllers/ContinuityExplorerController.cs +++ b/PlotLine/Controllers/ContinuityExplorerController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using PlotLine.Models; using PlotLine.Services; using PlotLine.ViewModels; @@ -25,4 +26,58 @@ public sealed class ContinuityExplorerController(IStoryStateService storyState) { return RedirectToAction(nameof(Index), filter); } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task AcknowledgeWarning( + string warningType, + int? sceneId, + int? characterId, + int? assetId, + string? notes, + [Bind(Prefix = "ReturnFilter")] ContinuityFilter returnFilter) + { + if (returnFilter.ProjectID == 0 || string.IsNullOrWhiteSpace(warningType)) + { + return BadRequest("Warning identity is required."); + } + + await storyState.AcknowledgeWarningAsync(new ContinuityWarningAcknowledgement + { + ProjectID = returnFilter.ProjectID, + WarningType = warningType, + SceneID = sceneId, + CharacterID = characterId, + AssetID = assetId, + Notes = notes + }); + + return RedirectToAction(nameof(Index), returnFilter); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task RestoreWarning( + string warningType, + int? sceneId, + int? characterId, + int? assetId, + [Bind(Prefix = "ReturnFilter")] ContinuityFilter returnFilter) + { + if (returnFilter.ProjectID == 0 || string.IsNullOrWhiteSpace(warningType)) + { + return BadRequest("Warning identity is required."); + } + + await storyState.RestoreWarningAsync(new ContinuityWarningAcknowledgement + { + ProjectID = returnFilter.ProjectID, + WarningType = warningType, + SceneID = sceneId, + CharacterID = characterId, + AssetID = assetId + }); + + return RedirectToAction(nameof(Index), returnFilter); + } } diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index 594188d..c9dbecb 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -223,6 +223,14 @@ public interface IWarningRepository Task FinaliseSceneDependencyWarningsAsync(int projectId, int? bookId); } +public interface IContinuityWarningAcknowledgementRepository +{ + Task> ListByProjectAsync(int projectId); + Task GetAsync(int projectId, string warningType, int? sceneId, int? characterId, int? assetId); + Task SaveAsync(ContinuityWarningAcknowledgement acknowledgement); + Task DeleteAsync(int projectId, string warningType, int? sceneId, int? characterId, int? assetId); +} + public interface ISceneDependencyRepository { Task GetLookupsAsync(); @@ -606,6 +614,69 @@ public sealed class WarningRepository(ISqlConnectionFactory connectionFactory) : } } +public sealed class ContinuityWarningAcknowledgementRepository(ISqlConnectionFactory connectionFactory) : IContinuityWarningAcknowledgementRepository +{ + public async Task> ListByProjectAsync(int projectId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.ContinuityWarningAcknowledgement_ListByProject", + new { ProjectID = projectId }, + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } + + public async Task GetAsync(int projectId, string warningType, int? sceneId, int? characterId, int? assetId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.ContinuityWarningAcknowledgement_Get", + new + { + ProjectID = projectId, + WarningType = warningType, + SceneID = sceneId, + CharacterID = characterId, + AssetID = assetId + }, + commandType: CommandType.StoredProcedure); + } + + public async Task SaveAsync(ContinuityWarningAcknowledgement acknowledgement) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.ContinuityWarningAcknowledgement_Save", + new + { + acknowledgement.ProjectID, + acknowledgement.WarningType, + acknowledgement.SceneID, + acknowledgement.CharacterID, + acknowledgement.AssetID, + acknowledgement.AcknowledgedByUserID, + acknowledgement.Notes + }, + commandType: CommandType.StoredProcedure); + } + + public async Task DeleteAsync(int projectId, string warningType, int? sceneId, int? characterId, int? assetId) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.ContinuityWarningAcknowledgement_Delete", + new + { + ProjectID = projectId, + WarningType = warningType, + SceneID = sceneId, + CharacterID = characterId, + AssetID = assetId + }, + commandType: CommandType.StoredProcedure); + } +} + public sealed class AnalyticsData { public IReadOnlyList MetricPoints { get; init; } = []; diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index 16446a6..2592e79 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -1087,6 +1087,19 @@ public sealed class ContinuityWarning public DateTime LastDetectedDate { get; set; } } +public sealed class ContinuityWarningAcknowledgement +{ + public int ContinuityWarningAcknowledgementID { get; set; } + public int ProjectID { get; set; } + public string WarningType { get; set; } = string.Empty; + public int? SceneID { get; set; } + public int? CharacterID { get; set; } + public int? AssetID { get; set; } + public DateTime AcknowledgedDate { get; set; } + public int? AcknowledgedByUserID { get; set; } + public string? Notes { get; set; } +} + public sealed class WarningLookupData { public IReadOnlyList WarningTypes { get; init; } = []; diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 3fc82e4..e6cd0ef 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -92,6 +92,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index deff798..1829068 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -119,6 +119,10 @@ public interface IStoryStateService Task> GetAssetJourneyAsync(int projectId, int assetId, ContinuityFilter filter); Task> GetLocationActivityAsync(int projectId, int locationId, ContinuityFilter filter); Task> GetContinuityWarningsAsync(int projectId, ContinuityFilter filter); + Task AcknowledgeWarningAsync(ContinuityWarningAcknowledgement acknowledgement); + Task RestoreWarningAsync(ContinuityWarningAcknowledgement acknowledgement); + Task IsAcknowledgedAsync(int projectId, string warningType, int? sceneId, int? characterId, int? assetId); + Task> GetAcknowledgementsAsync(int projectId); } public interface IPlotService @@ -2216,7 +2220,9 @@ public sealed class StoryStateService( ITimelineRepository timelineRepository, IAssetRepository assets, ICharacterRepository characters, - ILocationRepository locations) : IStoryStateService + ILocationRepository locations, + IContinuityWarningAcknowledgementRepository warningAcknowledgements, + ICurrentUserService currentUser) : IStoryStateService { public async Task GetStoryStateAsync(int sceneId) { @@ -2550,7 +2556,11 @@ public sealed class StoryStateService( warnings.AddRange(BuildUnknownAssetLocationWarnings(assetTimeline.Assets, assetTimeline.Events, allAssetLocations, custodyCharacters, enabledAssetIds, context)); } + var acknowledgements = await GetAcknowledgementsAsync(projectId); + ApplyAcknowledgements(warnings, acknowledgements); + return warnings + .Where(x => filter.ShowAcknowledgedWarnings || !x.IsAcknowledged) .OrderBy(x => WarningSeveritySort(x.Severity)) .ThenBy(x => x.BookDisplayName) .ThenBy(x => x.ChapterDisplayName) @@ -2560,6 +2570,35 @@ public sealed class StoryStateService( .ToList(); } + public async Task AcknowledgeWarningAsync(ContinuityWarningAcknowledgement acknowledgement) + { + acknowledgement.WarningType = acknowledgement.WarningType.Trim(); + acknowledgement.Notes = string.IsNullOrWhiteSpace(acknowledgement.Notes) ? null : acknowledgement.Notes.Trim(); + acknowledgement.AcknowledgedByUserID = currentUser.UserId; + await warningAcknowledgements.SaveAsync(acknowledgement); + } + + public Task RestoreWarningAsync(ContinuityWarningAcknowledgement acknowledgement) + { + return warningAcknowledgements.DeleteAsync( + acknowledgement.ProjectID, + acknowledgement.WarningType.Trim(), + acknowledgement.SceneID, + acknowledgement.CharacterID, + acknowledgement.AssetID); + } + + public async Task IsAcknowledgedAsync(int projectId, string warningType, int? sceneId, int? characterId, int? assetId) + { + var acknowledgement = await warningAcknowledgements.GetAsync(projectId, warningType, sceneId, characterId, assetId); + return acknowledgement is not null; + } + + public async Task> GetAcknowledgementsAsync(int projectId) + { + return (await warningAcknowledgements.ListByProjectAsync(projectId)).ToList(); + } + private async Task GetRangeContextAsync(int projectId, ContinuityFilter filter) { NormaliseFilter(filter); @@ -3102,6 +3141,7 @@ public sealed class StoryStateService( var sceneContext = sceneId.HasValue ? context.SceneContexts.GetValueOrDefault(sceneId.Value) : null; return new ContinuityWarningViewModel { + WarningKey = BuildWarningKey(warningType, sceneId, characterId, assetId), WarningType = warningType, Severity = severity, Message = message, @@ -3114,6 +3154,47 @@ public sealed class StoryStateService( }; } + private static void ApplyAcknowledgements( + IReadOnlyList warnings, + IReadOnlyList acknowledgements) + { + var acknowledgementsByKey = acknowledgements.ToDictionary( + x => BuildWarningKey(x.WarningType, x.SceneID, x.CharacterID, x.AssetID), + StringComparer.OrdinalIgnoreCase); + + foreach (var warning in warnings) + { + if (!acknowledgementsByKey.TryGetValue(warning.WarningKey, out var acknowledgement)) + { + continue; + } + + warning.IsAcknowledged = true; + warning.AcknowledgementNotes = acknowledgement.Notes; + } + } + + private static string BuildWarningKey(string warningType, int? sceneId, int? characterId, int? assetId) + { + var parts = new List { warningType.Trim() }; + if (sceneId.HasValue) + { + parts.Add($"Scene:{sceneId.Value}"); + } + + if (characterId.HasValue) + { + parts.Add($"Character:{characterId.Value}"); + } + + if (assetId.HasValue) + { + parts.Add($"Asset:{assetId.Value}"); + } + + return string.Join("|", parts); + } + private static int WarningSeveritySort(string severity) { return severity switch diff --git a/PlotLine/Sql/065_Phase12F_ContinuityWarningAcknowledgements.sql b/PlotLine/Sql/065_Phase12F_ContinuityWarningAcknowledgements.sql new file mode 100644 index 0000000..3846434 --- /dev/null +++ b/PlotLine/Sql/065_Phase12F_ContinuityWarningAcknowledgements.sql @@ -0,0 +1,152 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF OBJECT_ID(N'dbo.ContinuityWarningAcknowledgements', N'U') IS NULL +BEGIN + CREATE TABLE dbo.ContinuityWarningAcknowledgements + ( + ContinuityWarningAcknowledgementID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_ContinuityWarningAcknowledgements PRIMARY KEY, + ProjectID int NOT NULL, + WarningType nvarchar(100) NOT NULL, + SceneID int NULL, + CharacterID int NULL, + AssetID int NULL, + AcknowledgedDate datetime2 NOT NULL CONSTRAINT DF_ContinuityWarningAcknowledgements_AcknowledgedDate DEFAULT SYSUTCDATETIME(), + AcknowledgedByUserID int NULL, + Notes nvarchar(500) NULL, + SceneIDKey AS ISNULL(SceneID, 0) PERSISTED, + CharacterIDKey AS ISNULL(CharacterID, 0) PERSISTED, + AssetIDKey AS ISNULL(AssetID, 0) PERSISTED, + CONSTRAINT FK_ContinuityWarningAcknowledgements_Projects FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID), + CONSTRAINT FK_ContinuityWarningAcknowledgements_Scenes FOREIGN KEY (SceneID) REFERENCES dbo.Scenes(SceneID), + CONSTRAINT FK_ContinuityWarningAcknowledgements_Characters FOREIGN KEY (CharacterID) REFERENCES dbo.Characters(CharacterID), + CONSTRAINT FK_ContinuityWarningAcknowledgements_Assets FOREIGN KEY (AssetID) REFERENCES dbo.StoryAssets(StoryAssetID), + CONSTRAINT FK_ContinuityWarningAcknowledgements_AppUser FOREIGN KEY (AcknowledgedByUserID) REFERENCES dbo.AppUser(UserID) + ); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_ContinuityWarningAcknowledgements_Identity' AND object_id = OBJECT_ID(N'dbo.ContinuityWarningAcknowledgements')) +BEGIN + CREATE UNIQUE INDEX UX_ContinuityWarningAcknowledgements_Identity + ON dbo.ContinuityWarningAcknowledgements(ProjectID, WarningType, SceneIDKey, CharacterIDKey, AssetIDKey); +END; +GO + +CREATE OR ALTER PROCEDURE dbo.ContinuityWarningAcknowledgement_ListByProject + @ProjectID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT ContinuityWarningAcknowledgementID, + ProjectID, + WarningType, + SceneID, + CharacterID, + AssetID, + AcknowledgedDate, + AcknowledgedByUserID, + Notes + FROM dbo.ContinuityWarningAcknowledgements + WHERE ProjectID = @ProjectID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.ContinuityWarningAcknowledgement_Get + @ProjectID int, + @WarningType nvarchar(100), + @SceneID int = NULL, + @CharacterID int = NULL, + @AssetID int = NULL +AS +BEGIN + SET NOCOUNT ON; + + SELECT ContinuityWarningAcknowledgementID, + ProjectID, + WarningType, + SceneID, + CharacterID, + AssetID, + AcknowledgedDate, + AcknowledgedByUserID, + Notes + FROM dbo.ContinuityWarningAcknowledgements + WHERE ProjectID = @ProjectID + AND WarningType = @WarningType + AND ISNULL(SceneID, 0) = ISNULL(@SceneID, 0) + AND ISNULL(CharacterID, 0) = ISNULL(@CharacterID, 0) + AND ISNULL(AssetID, 0) = ISNULL(@AssetID, 0); +END; +GO + +CREATE OR ALTER PROCEDURE dbo.ContinuityWarningAcknowledgement_Save + @ProjectID int, + @WarningType nvarchar(100), + @SceneID int = NULL, + @CharacterID int = NULL, + @AssetID int = NULL, + @AcknowledgedByUserID int = NULL, + @Notes nvarchar(500) = NULL +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @TrimmedWarningType nvarchar(100) = NULLIF(LTRIM(RTRIM(@WarningType)), N''); + DECLARE @TrimmedNotes nvarchar(500) = NULLIF(LTRIM(RTRIM(@Notes)), N''); + + IF @ProjectID <= 0 OR @TrimmedWarningType IS NULL + RETURN; + + IF EXISTS + ( + SELECT 1 + FROM dbo.ContinuityWarningAcknowledgements + WHERE ProjectID = @ProjectID + AND WarningType = @TrimmedWarningType + AND ISNULL(SceneID, 0) = ISNULL(@SceneID, 0) + AND ISNULL(CharacterID, 0) = ISNULL(@CharacterID, 0) + AND ISNULL(AssetID, 0) = ISNULL(@AssetID, 0) + ) + BEGIN + UPDATE dbo.ContinuityWarningAcknowledgements + SET AcknowledgedDate = SYSUTCDATETIME(), + AcknowledgedByUserID = @AcknowledgedByUserID, + Notes = @TrimmedNotes + WHERE ProjectID = @ProjectID + AND WarningType = @TrimmedWarningType + AND ISNULL(SceneID, 0) = ISNULL(@SceneID, 0) + AND ISNULL(CharacterID, 0) = ISNULL(@CharacterID, 0) + AND ISNULL(AssetID, 0) = ISNULL(@AssetID, 0); + END + ELSE + BEGIN + INSERT dbo.ContinuityWarningAcknowledgements + (ProjectID, WarningType, SceneID, CharacterID, AssetID, AcknowledgedByUserID, Notes) + VALUES + (@ProjectID, @TrimmedWarningType, @SceneID, @CharacterID, @AssetID, @AcknowledgedByUserID, @TrimmedNotes); + END; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.ContinuityWarningAcknowledgement_Delete + @ProjectID int, + @WarningType nvarchar(100), + @SceneID int = NULL, + @CharacterID int = NULL, + @AssetID int = NULL +AS +BEGIN + SET NOCOUNT ON; + + DELETE FROM dbo.ContinuityWarningAcknowledgements + WHERE ProjectID = @ProjectID + AND WarningType = @WarningType + AND ISNULL(SceneID, 0) = ISNULL(@SceneID, 0) + AND ISNULL(CharacterID, 0) = ISNULL(@CharacterID, 0) + AND ISNULL(AssetID, 0) = ISNULL(@AssetID, 0); +END; +GO diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index c7a75d1..e73341e 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -289,6 +289,7 @@ public sealed class ContinuityFilter public int? EndSceneID { get; set; } public string DisplayMode { get; set; } = "StoryState"; public int? SelectedSceneID { get; set; } + public bool ShowAcknowledgedWarnings { get; set; } public bool IncludeCharacters => EntityTypes.Contains("Characters", StringComparer.OrdinalIgnoreCase); public bool IncludeAssets => EntityTypes.Contains("Assets", StringComparer.OrdinalIgnoreCase); @@ -316,6 +317,7 @@ public sealed class ContinuityExplorerViewModel public sealed class ContinuityWarningViewModel { + public string WarningKey { get; set; } = string.Empty; public string WarningType { get; set; } = string.Empty; public string Severity { get; set; } = "Information"; public string Message { get; set; } = string.Empty; @@ -325,6 +327,8 @@ public sealed class ContinuityWarningViewModel public string SceneDisplayName { get; set; } = string.Empty; public string BookDisplayName { get; set; } = string.Empty; public string ChapterDisplayName { get; set; } = string.Empty; + public bool IsAcknowledged { get; set; } + public string? AcknowledgementNotes { get; set; } } public sealed class CharacterJourneyViewModel diff --git a/PlotLine/Views/ContinuityExplorer/Index.cshtml b/PlotLine/Views/ContinuityExplorer/Index.cshtml index 17bcdb8..54b40b1 100644 --- a/PlotLine/Views/ContinuityExplorer/Index.cshtml +++ b/PlotLine/Views/ContinuityExplorer/Index.cshtml @@ -161,6 +161,10 @@
+
@@ -377,9 +381,11 @@ else if (Model.Filter.DisplayMode == "ContinuityWarnings") Severity + Status Warning Type Message Scene + Actions @@ -387,8 +393,24 @@ else if (Model.Filter.DisplayMode == "ContinuityWarnings") { @warning.Severity + + @if (warning.IsAcknowledged) + { + Acknowledged + } + else + { + Active + } + @warning.WarningType - @warning.Message + +
@warning.Message
+ @if (warning.IsAcknowledged && !string.IsNullOrWhiteSpace(warning.AcknowledgementNotes)) + { + Note: @warning.AcknowledgementNotes + } + @if (warning.SceneID.HasValue) { @@ -399,6 +421,79 @@ else if (Model.Filter.DisplayMode == "ContinuityWarnings") Project-wide } + + @if (warning.IsAcknowledged) + { +
+ + + + + + @foreach (var entityType in Model.Filter.EntityTypes) + { + + } + @foreach (var characterId in Model.Filter.CharacterIDs) + { + + } + @foreach (var assetId in Model.Filter.AssetIDs) + { + + } + @foreach (var locationId in Model.Filter.LocationIDs) + { + + } + + + + + + + + + +
+ } + else + { +
+ + + + + + @foreach (var entityType in Model.Filter.EntityTypes) + { + + } + @foreach (var characterId in Model.Filter.CharacterIDs) + { + + } + @foreach (var assetId in Model.Filter.AssetIDs) + { + + } + @foreach (var locationId in Model.Filter.LocationIDs) + { + + } + + + + + + + + + + +
+ } + }