From 1f3f68e6f38faeebb8b9d50ce451ed3fe9a2de4a Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sun, 7 Jun 2026 11:05:40 +0100 Subject: [PATCH] Phase 5E Audit Activity --- PlotLine/Controllers/ProjectsController.cs | 8 +- PlotLine/Data/Repositories.cs | 47 +++ PlotLine/Models/CoreModels.cs | 21 + PlotLine/Program.cs | 2 + PlotLine/Services/CoreServices.cs | 432 ++++++++++++++++----- PlotLine/Sql/046_Phase5E_AuditActivity.sql | 355 +++++++++++++++++ PlotLine/ViewModels/CoreViewModels.cs | 6 + PlotLine/Views/Projects/Activity.cshtml | 55 +++ PlotLine/Views/Projects/Details.cshtml | 1 + 9 files changed, 832 insertions(+), 95 deletions(-) create mode 100644 PlotLine/Sql/046_Phase5E_AuditActivity.sql create mode 100644 PlotLine/Views/Projects/Activity.cshtml diff --git a/PlotLine/Controllers/ProjectsController.cs b/PlotLine/Controllers/ProjectsController.cs index 3d7decf..a9964bc 100644 --- a/PlotLine/Controllers/ProjectsController.cs +++ b/PlotLine/Controllers/ProjectsController.cs @@ -6,7 +6,7 @@ using PlotLine.ViewModels; namespace PlotLine.Controllers; [Authorize] -public sealed class ProjectsController(IProjectService projects) : Controller +public sealed class ProjectsController(IProjectService projects, IProjectActivityService activity) : Controller { public async Task Index() => View(await projects.ListAsync()); @@ -18,6 +18,12 @@ public sealed class ProjectsController(IProjectService projects) : Controller public async Task Create() => View("Edit", await projects.GetCreateAsync()); + public async Task Activity(int id) + { + var model = await activity.GetActivityAsync(id); + return model is null ? NotFound() : View(model); + } + public async Task Edit(int id) { var model = await projects.GetEditAsync(id); diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index 9f3f55f..693560d 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -29,6 +29,12 @@ public interface IProjectCollaborationRepository Task CreatePendingInvitationAsync(int projectId, string emailAddress, int invitedByUserId); } +public interface IProjectActivityRepository +{ + Task> ListForProjectAsync(int projectId); + Task RecordAsync(int projectId, int? userId, string activityType, string entityType, int? entityId, string? entityName, string? description = null); +} + public interface IBookRepository { Task> ListByProjectAsync(int projectId); @@ -159,6 +165,7 @@ public interface ICharacterRepository Task> ListRelationshipsByProjectAsync(int projectId); Task> ListRelationshipsByCharacterAsync(int characterId); Task> ListInitialRelationshipsByCharacterAsync(int characterId); + Task GetRelationshipAsync(int characterRelationshipId); Task SaveRelationshipAsync(CharacterRelationship relationship); Task ArchiveRelationshipAsync(int characterRelationshipId); Task> ListRelationshipEventsBySceneAsync(int sceneId); @@ -1306,6 +1313,37 @@ public sealed class ProjectCollaborationRepository(ISqlConnectionFactory connect } } +public sealed class ProjectActivityRepository(ISqlConnectionFactory connectionFactory) : IProjectActivityRepository +{ + public async Task> ListForProjectAsync(int projectId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.ProjectActivity_ListForProject", + new { ProjectID = projectId }, + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } + + public async Task RecordAsync(int projectId, int? userId, string activityType, string entityType, int? entityId, string? entityName, string? description = null) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.ProjectAudit_Record", + new + { + ProjectID = projectId, + UserID = userId, + ActivityType = activityType, + EntityType = entityType, + EntityID = entityId, + EntityName = entityName, + Description = description + }, + commandType: CommandType.StoredProcedure); + } +} + public sealed class BookRepository(ISqlConnectionFactory connectionFactory) : IBookRepository { public async Task> ListByProjectAsync(int projectId) @@ -2249,6 +2287,15 @@ public sealed class CharacterRepository(ISqlConnectionFactory connectionFactory) return rows.ToList(); } + public async Task GetRelationshipAsync(int characterRelationshipId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.Relationship_Get", + new { CharacterRelationshipID = characterRelationshipId }, + commandType: CommandType.StoredProcedure); + } + public async Task SaveRelationshipAsync(CharacterRelationship relationship) { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index 175962f..4aa97c4 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -46,6 +46,27 @@ public sealed class ProjectInvitation public DateTime? CancelledDateUTC { get; set; } } +public sealed class ProjectActivity +{ + public long ProjectActivityID { get; set; } + public int ProjectID { get; set; } + public int? UserID { get; set; } + public string? DisplayName { get; set; } + public string? Email { get; set; } + public string ActivityType { get; set; } = string.Empty; + public string EntityType { get; set; } = string.Empty; + public int? EntityID { get; set; } + public string? EntityName { get; set; } + public string? Description { get; set; } + public DateTime CreatedDateUTC { get; set; } + + public string UserLabel => !string.IsNullOrWhiteSpace(DisplayName) + ? DisplayName + : !string.IsNullOrWhiteSpace(Email) + ? Email + : "System"; +} + public sealed class GenreMetricPreset { public int GenreMetricPresetID { get; set; } diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 227f3ce..f1171e7 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -104,9 +104,11 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); 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 29bb803..6931ce1 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -27,6 +27,12 @@ public interface IProjectCollaborationService Task RemoveCollaboratorAsync(int projectId, int userId); } +public interface IProjectActivityService +{ + Task GetActivityAsync(int projectId); + Task RecordAsync(int projectId, string activityType, string entityType, int? entityId, string? entityName, string? description = null); +} + public sealed class ProjectCollaborationResult { public bool Succeeded { get; init; } @@ -38,6 +44,36 @@ public sealed class ProjectCollaborationResult public static ProjectCollaborationResult Inaccessible() => new() { NotFound = true, Message = "Project not found." }; } +public sealed class ProjectActivityService( + IProjectRepository projects, + IProjectActivityRepository activity, + ICurrentUserService currentUser) : IProjectActivityService +{ + public async Task GetActivityAsync(int projectId) + { + var project = currentUser.UserId.HasValue + ? await projects.GetForUserAsync(projectId, currentUser.UserId.Value) + : null; + return project is null + ? null + : new ProjectActivityViewModel + { + Project = project, + Activities = await activity.ListForProjectAsync(projectId) + }; + } + + public Task RecordAsync(int projectId, string activityType, string entityType, int? entityId, string? entityName, string? description = null) + { + if (projectId <= 0 || string.IsNullOrWhiteSpace(activityType) || string.IsNullOrWhiteSpace(entityType)) + { + return Task.CompletedTask; + } + + return activity.RecordAsync(projectId, currentUser.UserId, activityType, entityType, entityId, entityName, description); + } +} + public interface IBookService { Task GetDetailAsync(int bookId); @@ -257,7 +293,7 @@ public interface IAcceptanceService Task GetPhase1ChecklistAsync(int? projectId); } -public sealed class ProjectService(IProjectRepository projects, IBookRepository books, ICurrentUserService currentUser) : IProjectService +public sealed class ProjectService(IProjectRepository projects, IBookRepository books, IProjectActivityService activity, ICurrentUserService currentUser) : IProjectService { public Task> ListAsync() => currentUser.UserId.HasValue ? projects.ListForUserAsync(currentUser.UserId.Value) : Task.FromResult>([]); @@ -303,23 +339,36 @@ public sealed class ProjectService(IProjectRepository projects, IBookRepository .ToList(); } - public Task SaveAsync(ProjectEditViewModel model) + public async Task SaveAsync(ProjectEditViewModel model) { if (!currentUser.UserId.HasValue) { - return Task.FromResult(0); + return 0; } - return projects.SaveAsync(new Project + var isNew = model.ProjectID == 0; + var projectId = await projects.SaveAsync(new Project { ProjectID = model.ProjectID, ProjectName = model.ProjectName, Description = model.Description }, currentUser.UserId.Value, model.ProjectID == 0 ? model.GenreMetricPresetKey : null); + + await activity.RecordAsync(projectId, isNew ? "Created" : "Updated", "Project", projectId, model.ProjectName); + return projectId; } - public Task ArchiveAsync(int projectId) - => currentUser.UserId.HasValue ? projects.ArchiveForUserAsync(projectId, currentUser.UserId.Value) : Task.CompletedTask; + public async Task ArchiveAsync(int projectId) + { + if (!currentUser.UserId.HasValue) + { + return; + } + + var project = await projects.GetForUserAsync(projectId, currentUser.UserId.Value); + await projects.ArchiveForUserAsync(projectId, currentUser.UserId.Value); + await activity.RecordAsync(projectId, "Archived", "Project", projectId, project?.ProjectName); + } } public sealed class ProjectCollaborationService( @@ -329,6 +378,7 @@ public sealed class ProjectCollaborationService( IProjectAccessService access, ISubscriptionService subscriptions, IEmailQueueRepository emailQueue, + IProjectActivityService activity, ICurrentUserService currentUser, IOptions emailOptions, ILogger logger) : IProjectCollaborationService @@ -405,6 +455,7 @@ public sealed class ProjectCollaborationService( } await collaboration.AddCollaboratorAsync(model.ProjectID, user.UserID, currentUser.UserId.Value); + await activity.RecordAsync(model.ProjectID, "Shared", "Collaborator", user.UserID, user.DisplayName, $"Added collaborator {user.Email}."); await QueueInvitationNotificationAsync(user, model.ProjectID); return ProjectCollaborationResult.Success("Collaborator added."); } @@ -416,6 +467,7 @@ public sealed class ProjectCollaborationService( } await collaboration.CreatePendingInvitationAsync(model.ProjectID, email, currentUser.UserId.Value); + await activity.RecordAsync(model.ProjectID, "Shared", "Collaborator", null, email, "Pending invitation recorded."); return ProjectCollaborationResult.Success("Pending invitation recorded. When that person creates an account, this invitation can be completed in the collaboration workflow."); } @@ -438,6 +490,10 @@ public sealed class ProjectCollaborationService( } var removed = await collaboration.RemoveCollaboratorAsync(projectId, userId); + if (removed) + { + await activity.RecordAsync(projectId, "CollaboratorRemoved", "Collaborator", userId, null); + } return removed ? ProjectCollaborationResult.Success("Collaborator removed.") : ProjectCollaborationResult.Failure("Collaborator access was not found."); @@ -523,6 +579,7 @@ public sealed class BookService( IBookRepository books, IChapterRepository chapters, ISubscriptionService subscriptions, + IProjectActivityService activity, ICurrentUserService currentUser) : IBookService { public async Task GetDetailAsync(int bookId) @@ -590,7 +647,8 @@ public sealed class BookService( await SubscriptionLimitGuards.EnsureAllowedAsync(subscriptions.CanCreateBookAsync, currentUser.UserId); } - return await books.SaveAsync(new Book + var isNew = model.BookID == 0; + var bookId = await books.SaveAsync(new Book { BookID = model.BookID, ProjectID = model.ProjectID, @@ -598,9 +656,19 @@ public sealed class BookService( BookNumber = model.BookNumber, Description = model.Description }); + await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Book", bookId, model.BookTitle); + return bookId; } - public Task ArchiveAsync(int bookId) => books.ArchiveAsync(bookId); + public async Task ArchiveAsync(int bookId) + { + var book = await books.GetAsync(bookId); + await books.ArchiveAsync(bookId); + if (book is not null) + { + await activity.RecordAsync(book.ProjectID, "Archived", "Book", bookId, book.BookTitle); + } + } } public sealed class ChapterService( @@ -610,6 +678,7 @@ public sealed class ChapterService( ISceneRepository scenes, ILookupRepository lookups, ISubscriptionService subscriptions, + IProjectActivityService activity, ICurrentUserService currentUser) : IChapterService { public async Task GetDetailAsync(int chapterId) @@ -695,7 +764,8 @@ public sealed class ChapterService( await SubscriptionLimitGuards.EnsureAllowedAsync(userId => subscriptions.CanCreateChapterAsync(model.BookID, userId), currentUser.UserId); } - return await chapters.SaveAsync(new Chapter + var isNew = model.ChapterID == 0; + var chapterId = await chapters.SaveAsync(new Chapter { ChapterID = model.ChapterID, BookID = model.BookID, @@ -704,9 +774,24 @@ public sealed class ChapterService( Summary = model.Summary, RevisionStatusID = model.RevisionStatusID }); + var book = await books.GetAsync(model.BookID); + if (book is not null) + { + await activity.RecordAsync(book.ProjectID, isNew ? "Created" : "Updated", "Chapter", chapterId, model.ChapterTitle); + } + return chapterId; } - public Task ArchiveAsync(int chapterId) => chapters.ArchiveAsync(chapterId); + public async Task ArchiveAsync(int chapterId) + { + var chapter = await chapters.GetAsync(chapterId); + var book = chapter is null ? null : await books.GetAsync(chapter.BookID); + await chapters.ArchiveAsync(chapterId); + if (chapter is not null && book is not null) + { + await activity.RecordAsync(book.ProjectID, "Archived", "Chapter", chapterId, chapter.ChapterTitle); + } + } internal static int DefaultRevisionStatusId(LookupData lookupData) => lookupData.RevisionStatuses.FirstOrDefault(x => x.StatusName == "Planned")?.RevisionStatusID @@ -730,6 +815,7 @@ public sealed class SceneService( ISceneDependencyRepository sceneDependencies, IWriterWorkspaceRepository writerWorkspace, ISubscriptionService subscriptions, + IProjectActivityService activity, ICurrentUserService currentUser) : ISceneService { public async Task GetCreateAsync(int chapterId) @@ -807,7 +893,8 @@ public sealed class SceneService( public async Task SaveAsync(SceneEditViewModel model) { - if (model.SceneID == 0) + var isNew = model.SceneID == 0; + if (isNew) { var chapter = await chapters.GetAsync(model.ChapterID); if (chapter is null) @@ -846,10 +933,24 @@ public sealed class SceneService( Value = metric.Value, Notes = metric.Notes })); + var context = await GetContextAsync(model.ChapterID); + if (context is not null) + { + await activity.RecordAsync(context.Value.Project.ProjectID, isNew ? "Created" : "Updated", "Scene", sceneId, model.SceneTitle); + } return sceneId; } - public Task ArchiveAsync(int sceneId) => scenes.ArchiveAsync(sceneId); + public async Task ArchiveAsync(int sceneId) + { + var scene = await scenes.GetAsync(sceneId); + var context = scene is null ? null : await GetContextAsync(scene.ChapterID); + await scenes.ArchiveAsync(sceneId); + if (scene is not null && context is not null) + { + await activity.RecordAsync(context.Value.Project.ProjectID, "Archived", "Scene", sceneId, scene.SceneTitle); + } + } public async Task MoveAsync(int sceneId, string direction) { @@ -1877,7 +1978,7 @@ public sealed class TimelineService( } } -public sealed class PlotService(IProjectRepository projects, IBookRepository books, IPlotRepository plots) : IPlotService +public sealed class PlotService(IProjectRepository projects, IBookRepository books, IPlotRepository plots, IProjectActivityService activity) : IPlotService { public async Task GetPlotLinesAsync(int projectId) { @@ -1944,22 +2045,36 @@ public sealed class PlotService(IProjectRepository projects, IBookRepository boo }, lookupData, plotLines.Where(x => x.PlotLineID != plotLine.PlotLineID)); } - public Task SavePlotLineAsync(PlotLineEditViewModel model) => plots.SavePlotLineAsync(new PlotLineItem + public async Task SavePlotLineAsync(PlotLineEditViewModel model) { - PlotLineID = model.PlotLineID, - ProjectID = model.ProjectID, - BookID = model.BookID, - PlotLineName = model.PlotLineName, - PlotLineTypeID = model.PlotLineTypeID, - Description = model.Description, - ParentPlotLineID = model.ParentPlotLineID, - SortOrder = model.SortOrder, - Colour = string.IsNullOrWhiteSpace(model.Colour) ? "#2f6f63" : model.Colour, - IsMainPlot = model.IsMainPlot, - IsVisibleOnTimeline = model.IsVisibleOnTimeline - }); + var isNew = model.PlotLineID == 0; + var plotLineId = await plots.SavePlotLineAsync(new PlotLineItem + { + PlotLineID = model.PlotLineID, + ProjectID = model.ProjectID, + BookID = model.BookID, + PlotLineName = model.PlotLineName, + PlotLineTypeID = model.PlotLineTypeID, + Description = model.Description, + ParentPlotLineID = model.ParentPlotLineID, + SortOrder = model.SortOrder, + Colour = string.IsNullOrWhiteSpace(model.Colour) ? "#2f6f63" : model.Colour, + IsMainPlot = model.IsMainPlot, + IsVisibleOnTimeline = model.IsVisibleOnTimeline + }); + await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Plot Line", plotLineId, model.PlotLineName); + return plotLineId; + } - public Task ArchivePlotLineAsync(int plotLineId) => plots.ArchivePlotLineAsync(plotLineId); + public async Task ArchivePlotLineAsync(int plotLineId) + { + var plotLine = await plots.GetPlotLineAsync(plotLineId); + await plots.ArchivePlotLineAsync(plotLineId); + if (plotLine is not null) + { + await activity.RecordAsync(plotLine.ProjectID, "Archived", "Plot Line", plotLineId, plotLine.PlotLineName); + } + } public async Task GetPlotThreadsAsync(int projectId) { @@ -2025,21 +2140,39 @@ public sealed class PlotService(IProjectRepository projects, IBookRepository boo }, lookupData); } - public Task SavePlotThreadAsync(PlotThreadEditViewModel model) => plots.SavePlotThreadAsync(new PlotThread + public async Task SavePlotThreadAsync(PlotThreadEditViewModel model) { - PlotThreadID = model.PlotThreadID, - PlotLineID = model.PlotLineID, - ThreadTitle = model.ThreadTitle, - ThreadTypeID = model.ThreadTypeID, - ThreadStatusID = model.ThreadStatusID, - Importance = model.Importance, - Summary = model.Summary, - IntroducedSceneID = model.IntroducedSceneID, - PlannedResolutionSceneID = model.PlannedResolutionSceneID, - ActualResolutionSceneID = model.ActualResolutionSceneID - }); + var isNew = model.PlotThreadID == 0; + var plotThreadId = await plots.SavePlotThreadAsync(new PlotThread + { + PlotThreadID = model.PlotThreadID, + PlotLineID = model.PlotLineID, + ThreadTitle = model.ThreadTitle, + ThreadTypeID = model.ThreadTypeID, + ThreadStatusID = model.ThreadStatusID, + Importance = model.Importance, + Summary = model.Summary, + IntroducedSceneID = model.IntroducedSceneID, + PlannedResolutionSceneID = model.PlannedResolutionSceneID, + ActualResolutionSceneID = model.ActualResolutionSceneID + }); + var plotLine = await plots.GetPlotLineAsync(model.PlotLineID); + if (plotLine is not null) + { + await activity.RecordAsync(plotLine.ProjectID, isNew ? "Created" : "Updated", "Thread", plotThreadId, model.ThreadTitle); + } + return plotThreadId; + } - public Task ArchivePlotThreadAsync(int plotThreadId) => plots.ArchivePlotThreadAsync(plotThreadId); + public async Task ArchivePlotThreadAsync(int plotThreadId) + { + var plotThread = await plots.GetPlotThreadAsync(plotThreadId); + await plots.ArchivePlotThreadAsync(plotThreadId); + if (plotThread is not null) + { + await activity.RecordAsync(plotThread.ProjectID, "Archived", "Thread", plotThreadId, plotThread.ThreadTitle); + } + } public async Task AddThreadEventAsync(ThreadEventCreateViewModel model) { @@ -2075,7 +2208,7 @@ public sealed class PlotService(IProjectRepository projects, IBookRepository boo }); } - return await plots.SaveThreadEventAsync(new ThreadEvent + var threadEventId = await plots.SaveThreadEventAsync(new ThreadEvent { PlotThreadID = plotThreadId, SceneID = model.SceneID, @@ -2083,6 +2216,12 @@ public sealed class PlotService(IProjectRepository projects, IBookRepository boo EventTitle = title, EventDescription = model.EventDescription }); + var plotThread = await plots.GetPlotThreadAsync(plotThreadId); + if (plotThread is not null) + { + await activity.RecordAsync(plotThread.ProjectID, "Created", "Thread Event", threadEventId, title); + } + return threadEventId; } public Task DeleteThreadEventAsync(int threadEventId) => plots.DeleteThreadEventAsync(threadEventId); @@ -3239,7 +3378,7 @@ public sealed class ScenarioService(IProjectRepository projects, IBookRepository public Task ArchiveAsync(int scenarioId) => scenarios.ArchiveAsync(scenarioId); } -public sealed class ArchiveService(IArchiveRepository archives, ICurrentUserService currentUser) : IArchiveService +public sealed class ArchiveService(IArchiveRepository archives, IProjectActivityService activity, ICurrentUserService currentUser) : IArchiveService { private static readonly (string Value, string Label)[] EntityTypes = [ @@ -3277,14 +3416,48 @@ public sealed class ArchiveService(IArchiveRepository archives, ICurrentUserServ }; } - public Task ArchiveAsync(string entityType, int entityId, string? reason) => - archives.ArchiveAsync(entityType, entityId, reason); + public async Task ArchiveAsync(string entityType, int entityId, string? reason) + { + var item = await FindArchivedItemContextAsync(entityType, entityId); + await archives.ArchiveAsync(entityType, entityId, reason); + if (item?.ProjectID.HasValue == true) + { + await activity.RecordAsync(item.ProjectID.Value, "Archived", entityType, entityId, item.DisplayName, reason); + } + } - public Task RestoreAsync(string entityType, int entityId) => - archives.RestoreAsync(entityType, entityId); + public async Task RestoreAsync(string entityType, int entityId) + { + var item = await FindArchivedItemContextAsync(entityType, entityId); + await archives.RestoreAsync(entityType, entityId); + if (item?.ProjectID.HasValue == true) + { + await activity.RecordAsync(item.ProjectID.Value, "Restored", entityType, entityId, item.DisplayName); + } + } - public Task DeleteForeverAsync(string entityType, int entityId, string confirmationText) => - archives.DeleteForeverAsync(entityType, entityId, confirmationText); + public async Task DeleteForeverAsync(string entityType, int entityId, string confirmationText) + { + var item = await FindArchivedItemContextAsync(entityType, entityId); + var result = await archives.DeleteForeverAsync(entityType, entityId, confirmationText); + if (result.Succeeded && item?.ProjectID.HasValue == true) + { + await activity.RecordAsync(item.ProjectID.Value, "Deleted", entityType, entityId, item.DisplayName); + } + + return result; + } + + private async Task FindArchivedItemContextAsync(string entityType, int entityId) + { + if (!currentUser.UserId.HasValue) + { + return null; + } + + var rows = await archives.ListForUserAsync(null, entityType, currentUser.UserId.Value); + return rows.FirstOrDefault(x => x.EntityType == entityType && x.EntityID == entityId); + } } public sealed class AcceptanceService(IProjectRepository projects, IAcceptanceRepository acceptance, ICurrentUserService currentUser) : IAcceptanceService @@ -3312,7 +3485,7 @@ public sealed class AcceptanceService(IProjectRepository projects, IAcceptanceRe } } -public sealed class AssetService(IProjectRepository projects, IAssetRepository assets, ILocationRepository locations) : IAssetService +public sealed class AssetService(IProjectRepository projects, IAssetRepository assets, ILocationRepository locations, IProjectActivityService activity) : IAssetService { public async Task GetAssetsAsync(int projectId) { @@ -3416,21 +3589,35 @@ public sealed class AssetService(IProjectRepository projects, IAssetRepository a }; } - public Task SaveAssetAsync(StoryAssetEditViewModel model) => assets.SaveAssetAsync(new StoryAsset + public async Task SaveAssetAsync(StoryAssetEditViewModel model) { - StoryAssetID = model.StoryAssetID, - ProjectID = model.ProjectID, - AssetName = model.AssetName, - AssetKindID = model.AssetKindID, - Description = model.Description, - Importance = model.Importance, - CurrentStateID = model.CurrentStateID, - CurrentLocationID = model.CurrentLocationID, - IsResolved = model.IsResolved, - ShowInQuickAddBar = model.ShowInQuickAddBar - }); + var isNew = model.StoryAssetID == 0; + var assetId = await assets.SaveAssetAsync(new StoryAsset + { + StoryAssetID = model.StoryAssetID, + ProjectID = model.ProjectID, + AssetName = model.AssetName, + AssetKindID = model.AssetKindID, + Description = model.Description, + Importance = model.Importance, + CurrentStateID = model.CurrentStateID, + CurrentLocationID = model.CurrentLocationID, + IsResolved = model.IsResolved, + ShowInQuickAddBar = model.ShowInQuickAddBar + }); + await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Asset", assetId, model.AssetName); + return assetId; + } - public Task ArchiveAssetAsync(int storyAssetId) => assets.ArchiveAssetAsync(storyAssetId); + public async Task ArchiveAssetAsync(int storyAssetId) + { + var asset = await assets.GetAssetAsync(storyAssetId); + await assets.ArchiveAssetAsync(storyAssetId); + if (asset is not null) + { + await activity.RecordAsync(asset.ProjectID, "Archived", "Asset", storyAssetId, asset.AssetName); + } + } public async Task AddAssetEventAsync(AssetEventCreateViewModel model) { @@ -3453,13 +3640,14 @@ public sealed class AssetService(IProjectRepository projects, IAssetRepository a Importance = 5, CurrentStateID = model.FromStateID ?? lookupData.AssetStates.FirstOrDefault(x => x.StateName == "Unknown")?.AssetStateID }); + await activity.RecordAsync(model.ReturnProjectID.Value, "Created", "Asset", assetId, model.NewAssetName.Trim()); } var eventType = lookupData.AssetEventTypes.FirstOrDefault(x => x.AssetEventTypeID == model.AssetEventTypeID) ?? lookupData.AssetEventTypes.First(); var title = string.IsNullOrWhiteSpace(model.EventTitle) ? eventType.TypeName : model.EventTitle.Trim(); - return await assets.SaveEventAsync(new AssetEvent + var assetEventId = await assets.SaveEventAsync(new AssetEvent { StoryAssetID = assetId, SceneID = model.SceneID, @@ -3469,6 +3657,12 @@ public sealed class AssetService(IProjectRepository projects, IAssetRepository a EventTitle = title, EventDescription = model.EventDescription }); + var asset = await assets.GetAssetAsync(assetId); + if (asset is not null) + { + await activity.RecordAsync(asset.ProjectID, "Created", "Asset Event", assetEventId, title); + } + return assetEventId; } public Task DeleteAssetEventAsync(int assetEventId) => assets.DeleteEventAsync(assetEventId); @@ -3518,7 +3712,7 @@ public sealed class AssetService(IProjectRepository projects, IAssetRepository a => items.Select(x => new SelectListItem(new string(' ', x.Depth * 2) + x.LocationName, x.LocationID.ToString())).ToList(); } -public sealed class LocationService(IProjectRepository projects, ILocationRepository locations) : ILocationService +public sealed class LocationService(IProjectRepository projects, ILocationRepository locations, IProjectActivityService activity) : ILocationService { public async Task GetLocationsAsync(int projectId) { @@ -3602,34 +3796,54 @@ public sealed class LocationService(IProjectRepository projects, ILocationReposi }; } - public Task SaveAsync(LocationEditViewModel model) => locations.SaveAsync(new LocationItem + public async Task SaveAsync(LocationEditViewModel model) { - LocationID = model.LocationID, - ProjectID = model.ProjectID, - ParentLocationID = model.ParentLocationID == model.LocationID ? null : model.ParentLocationID, - LocationName = model.LocationName, - LocationTypeID = model.LocationTypeID, - Description = model.Description, - ShowInQuickAddBar = model.ShowInQuickAddBar - }); + var isNew = model.LocationID == 0; + var locationId = await locations.SaveAsync(new LocationItem + { + LocationID = model.LocationID, + ProjectID = model.ProjectID, + ParentLocationID = model.ParentLocationID == model.LocationID ? null : model.ParentLocationID, + LocationName = model.LocationName, + LocationTypeID = model.LocationTypeID, + Description = model.Description, + ShowInQuickAddBar = model.ShowInQuickAddBar + }); + await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Location", locationId, model.LocationName); + return locationId; + } - public Task ArchiveAsync(int locationId) => locations.ArchiveAsync(locationId); - - public Task SaveRelationshipAsync(LocationRelationshipEditViewModel model) => locations.SaveRelationshipAsync(new LocationRelationship + public async Task ArchiveAsync(int locationId) { - LocationRelationshipID = model.LocationRelationshipID, - FromLocationID = model.FromLocationID, - ToLocationID = model.ToLocationID, - LocationRelationshipTypeID = model.LocationRelationshipTypeID, - IsBidirectional = model.IsBidirectional, - Strength = model.Strength, - CanHearNormalSpeech = model.CanHearNormalSpeech, - CanHearLoudNoise = model.CanHearLoudNoise, - CanSeeMovement = model.CanSeeMovement, - CanSeeClearly = model.CanSeeClearly, - CanTravelDirectly = model.CanTravelDirectly, - Notes = model.Notes - }); + var location = await locations.GetAsync(locationId); + await locations.ArchiveAsync(locationId); + if (location is not null) + { + await activity.RecordAsync(location.ProjectID, "Archived", "Location", locationId, location.LocationName); + } + } + + public async Task SaveRelationshipAsync(LocationRelationshipEditViewModel model) + { + var isNew = model.LocationRelationshipID == 0; + var relationshipId = await locations.SaveRelationshipAsync(new LocationRelationship + { + LocationRelationshipID = model.LocationRelationshipID, + FromLocationID = model.FromLocationID, + ToLocationID = model.ToLocationID, + LocationRelationshipTypeID = model.LocationRelationshipTypeID, + IsBidirectional = model.IsBidirectional, + Strength = model.Strength, + CanHearNormalSpeech = model.CanHearNormalSpeech, + CanHearLoudNoise = model.CanHearLoudNoise, + CanSeeMovement = model.CanSeeMovement, + CanSeeClearly = model.CanSeeClearly, + CanTravelDirectly = model.CanTravelDirectly, + Notes = model.Notes + }); + await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Relationship", relationshipId, "Location relationship"); + return relationshipId; + } public Task ArchiveRelationshipAsync(int locationRelationshipId) => locations.ArchiveRelationshipAsync(locationRelationshipId); @@ -3662,6 +3876,7 @@ public sealed class CharacterService( IBookRepository books, ICharacterRepository characters, ISubscriptionService subscriptions, + IProjectActivityService activity, ICurrentUserService currentUser) : ICharacterService { public async Task GetCharactersAsync(int projectId) @@ -3782,7 +3997,8 @@ public sealed class CharacterService( await SubscriptionLimitGuards.EnsureAllowedAsync(userId => subscriptions.CanCreateCharacterAsync(model.ProjectID, userId), currentUser.UserId); } - return await characters.SaveCharacterAsync(new Character + var isNew = model.CharacterID == 0; + var characterId = await characters.SaveCharacterAsync(new Character { CharacterID = model.CharacterID, ProjectID = model.ProjectID, @@ -3797,11 +4013,29 @@ public sealed class CharacterService( ShowInQuickAddBar = model.ShowInQuickAddBar, DefaultDescription = model.DefaultDescription }); + await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Character", characterId, model.CharacterName); + return characterId; } - public Task ArchiveCharacterAsync(int characterId) => characters.ArchiveCharacterAsync(characterId); + public async Task ArchiveCharacterAsync(int characterId) + { + var character = await characters.GetCharacterAsync(characterId); + await characters.ArchiveCharacterAsync(characterId); + if (character is not null) + { + await activity.RecordAsync(character.ProjectID, "Archived", "Character", characterId, character.CharacterName); + } + } - public Task ArchiveRelationshipAsync(int characterRelationshipId) => characters.ArchiveRelationshipAsync(characterRelationshipId); + public async Task ArchiveRelationshipAsync(int characterRelationshipId) + { + var relationship = await characters.GetRelationshipAsync(characterRelationshipId); + await characters.ArchiveRelationshipAsync(characterRelationshipId); + if (relationship is not null) + { + await activity.RecordAsync(relationship.ProjectID, "Archived", "Relationship", characterRelationshipId, relationship.RelationshipTypeName); + } + } public async Task SaveInitialRelationshipAsync(InitialRelationshipEditViewModel model) { @@ -3815,7 +4049,8 @@ public sealed class CharacterService( throw new InvalidOperationException("Choose a relationship type."); } - return await characters.SaveRelationshipAsync(new CharacterRelationship + var isNew = model.CharacterRelationshipID == 0; + var relationshipId = await characters.SaveRelationshipAsync(new CharacterRelationship { CharacterRelationshipID = model.CharacterRelationshipID, ProjectID = model.ProjectID, @@ -3832,6 +4067,8 @@ public sealed class CharacterService( InitialIntensity = model.InitialIntensity, IsReciprocal = model.IsReciprocal }); + await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Relationship", relationshipId, "Character relationship"); + return relationshipId; } public async Task AddSceneCharacterAsync(SceneCharacterCreateViewModel model) @@ -3849,6 +4086,7 @@ public sealed class CharacterService( ProjectID = model.ReturnProjectID.Value, CharacterName = model.NewCharacterName.Trim() }); + await activity.RecordAsync(model.ReturnProjectID.Value, "Created", "Character", characterId, model.NewCharacterName.Trim()); } return await characters.SaveSceneCharacterAsync(new SceneCharacter @@ -3928,7 +4166,7 @@ public sealed class CharacterService( }); } - return await characters.SaveRelationshipEventAsync(new RelationshipEvent + var relationshipEventId = await characters.SaveRelationshipEventAsync(new RelationshipEvent { CharacterRelationshipID = relationshipId, SceneID = model.SceneID, @@ -3937,6 +4175,12 @@ public sealed class CharacterService( IsKnownToOtherCharacter = model.IsKnownToOtherCharacter, Description = model.Description }); + var relationship = await characters.GetRelationshipAsync(relationshipId); + if (relationship is not null) + { + await activity.RecordAsync(relationship.ProjectID, "Created", "Relationship Event", relationshipEventId, relationship.RelationshipTypeName); + } + return relationshipEventId; } public Task DeleteRelationshipEventAsync(int relationshipEventId) => characters.DeleteRelationshipEventAsync(relationshipEventId); diff --git a/PlotLine/Sql/046_Phase5E_AuditActivity.sql b/PlotLine/Sql/046_Phase5E_AuditActivity.sql new file mode 100644 index 0000000..d2c234c --- /dev/null +++ b/PlotLine/Sql/046_Phase5E_AuditActivity.sql @@ -0,0 +1,355 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF OBJECT_ID(N'dbo.ProjectActivity', N'U') IS NULL +BEGIN + CREATE TABLE dbo.ProjectActivity + ( + ProjectActivityID bigint IDENTITY(1,1) NOT NULL CONSTRAINT PK_ProjectActivity PRIMARY KEY, + ProjectID int NOT NULL, + UserID int NULL, + ActivityType nvarchar(50) NOT NULL, + EntityType nvarchar(100) NOT NULL, + EntityID int NULL, + EntityName nvarchar(300) NULL, + Description nvarchar(1000) NULL, + CreatedDateUTC datetime2 NOT NULL CONSTRAINT DF_ProjectActivity_CreatedDateUTC DEFAULT SYSUTCDATETIME(), + CONSTRAINT FK_ProjectActivity_Project FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID), + CONSTRAINT FK_ProjectActivity_User FOREIGN KEY (UserID) REFERENCES dbo.AppUser(UserID), + CONSTRAINT CK_ProjectActivity_ActivityType CHECK (ActivityType IN (N'Created', N'Updated', N'Deleted', N'Archived', N'Restored', N'Shared', N'CollaboratorRemoved')) + ); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_ProjectActivity_Project_Created' AND object_id = OBJECT_ID(N'dbo.ProjectActivity')) + CREATE INDEX IX_ProjectActivity_Project_Created ON dbo.ProjectActivity(ProjectID, CreatedDateUTC DESC) INCLUDE (UserID, ActivityType, EntityType, EntityID, EntityName); +GO + +DECLARE @AuditColumns TABLE (TableName sysname NOT NULL, ColumnName sysname NOT NULL, Definition nvarchar(200) NOT NULL); + +INSERT INTO @AuditColumns (TableName, ColumnName, Definition) +VALUES + (N'Projects', N'CreatedByUserID', N'int NULL'), + (N'Projects', N'ModifiedByUserID', N'int NULL'), + (N'Projects', N'CreatedDateUTC', N'datetime2 NULL'), + (N'Projects', N'ModifiedDateUTC', N'datetime2 NULL'), + (N'Books', N'CreatedByUserID', N'int NULL'), + (N'Books', N'ModifiedByUserID', N'int NULL'), + (N'Books', N'CreatedDateUTC', N'datetime2 NULL'), + (N'Books', N'ModifiedDateUTC', N'datetime2 NULL'), + (N'Chapters', N'CreatedByUserID', N'int NULL'), + (N'Chapters', N'ModifiedByUserID', N'int NULL'), + (N'Chapters', N'CreatedDateUTC', N'datetime2 NULL'), + (N'Chapters', N'ModifiedDateUTC', N'datetime2 NULL'), + (N'Scenes', N'CreatedByUserID', N'int NULL'), + (N'Scenes', N'ModifiedByUserID', N'int NULL'), + (N'Scenes', N'CreatedDateUTC', N'datetime2 NULL'), + (N'Scenes', N'ModifiedDateUTC', N'datetime2 NULL'), + (N'Characters', N'CreatedByUserID', N'int NULL'), + (N'Characters', N'ModifiedByUserID', N'int NULL'), + (N'Characters', N'CreatedDateUTC', N'datetime2 NULL'), + (N'Characters', N'ModifiedDateUTC', N'datetime2 NULL'), + (N'StoryAssets', N'CreatedByUserID', N'int NULL'), + (N'StoryAssets', N'ModifiedByUserID', N'int NULL'), + (N'StoryAssets', N'CreatedDateUTC', N'datetime2 NULL'), + (N'StoryAssets', N'ModifiedDateUTC', N'datetime2 NULL'), + (N'Locations', N'CreatedByUserID', N'int NULL'), + (N'Locations', N'ModifiedByUserID', N'int NULL'), + (N'Locations', N'CreatedDateUTC', N'datetime2 NULL'), + (N'Locations', N'ModifiedDateUTC', N'datetime2 NULL'), + (N'PlotLines', N'CreatedByUserID', N'int NULL'), + (N'PlotLines', N'ModifiedByUserID', N'int NULL'), + (N'PlotLines', N'CreatedDateUTC', N'datetime2 NULL'), + (N'PlotLines', N'ModifiedDateUTC', N'datetime2 NULL'), + (N'PlotThreads', N'CreatedByUserID', N'int NULL'), + (N'PlotThreads', N'ModifiedByUserID', N'int NULL'), + (N'PlotThreads', N'CreatedDateUTC', N'datetime2 NULL'), + (N'PlotThreads', N'ModifiedDateUTC', N'datetime2 NULL'), + (N'CharacterRelationships', N'CreatedByUserID', N'int NULL'), + (N'CharacterRelationships', N'ModifiedByUserID', N'int NULL'), + (N'CharacterRelationships', N'CreatedDateUTC', N'datetime2 NULL'), + (N'CharacterRelationships', N'ModifiedDateUTC', N'datetime2 NULL'), + (N'RelationshipEvents', N'CreatedByUserID', N'int NULL'), + (N'RelationshipEvents', N'ModifiedByUserID', N'int NULL'), + (N'RelationshipEvents', N'CreatedDateUTC', N'datetime2 NULL'), + (N'RelationshipEvents', N'ModifiedDateUTC', N'datetime2 NULL'), + (N'ThreadEvents', N'CreatedByUserID', N'int NULL'), + (N'ThreadEvents', N'ModifiedByUserID', N'int NULL'), + (N'ThreadEvents', N'CreatedDateUTC', N'datetime2 NULL'), + (N'ThreadEvents', N'ModifiedDateUTC', N'datetime2 NULL'), + (N'AssetEvents', N'CreatedByUserID', N'int NULL'), + (N'AssetEvents', N'ModifiedByUserID', N'int NULL'), + (N'AssetEvents', N'CreatedDateUTC', N'datetime2 NULL'), + (N'AssetEvents', N'ModifiedDateUTC', N'datetime2 NULL'), + (N'SceneWorkflow', N'CreatedByUserID', N'int NULL'), + (N'SceneWorkflow', N'ModifiedByUserID', N'int NULL'), + (N'SceneWorkflow', N'CreatedDateUTC', N'datetime2 NULL'), + (N'SceneWorkflow', N'ModifiedDateUTC', N'datetime2 NULL'), + (N'SceneNotes', N'CreatedByUserID', N'int NULL'), + (N'SceneNotes', N'ModifiedByUserID', N'int NULL'), + (N'SceneNotes', N'CreatedDateUTC', N'datetime2 NULL'), + (N'SceneNotes', N'ModifiedDateUTC', N'datetime2 NULL'), + (N'SceneChecklistItems', N'CreatedByUserID', N'int NULL'), + (N'SceneChecklistItems', N'ModifiedByUserID', N'int NULL'), + (N'SceneChecklistItems', N'CreatedDateUTC', N'datetime2 NULL'), + (N'SceneChecklistItems', N'ModifiedDateUTC', N'datetime2 NULL'), + (N'SceneAttachments', N'CreatedByUserID', N'int NULL'), + (N'SceneAttachments', N'ModifiedByUserID', N'int NULL'), + (N'SceneAttachments', N'CreatedDateUTC', N'datetime2 NULL'), + (N'SceneAttachments', N'ModifiedDateUTC', N'datetime2 NULL'), + (N'ChapterGoals', N'CreatedByUserID', N'int NULL'), + (N'ChapterGoals', N'ModifiedByUserID', N'int NULL'), + (N'ChapterGoals', N'CreatedDateUTC', N'datetime2 NULL'), + (N'ChapterGoals', N'ModifiedDateUTC', N'datetime2 NULL'); + +DECLARE @TableName sysname; +DECLARE @ColumnName sysname; +DECLARE @Definition nvarchar(200); +DECLARE @Sql nvarchar(max); +DECLARE audit_cursor CURSOR LOCAL FAST_FORWARD FOR SELECT TableName, ColumnName, Definition FROM @AuditColumns; + +OPEN audit_cursor; +FETCH NEXT FROM audit_cursor INTO @TableName, @ColumnName, @Definition; +WHILE @@FETCH_STATUS = 0 +BEGIN + IF OBJECT_ID(N'dbo.' + @TableName, N'U') IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.' + @TableName) AND name = @ColumnName) + BEGIN + SET @Sql = N'ALTER TABLE dbo.' + QUOTENAME(@TableName) + N' ADD ' + QUOTENAME(@ColumnName) + N' ' + @Definition + N';'; + EXEC sys.sp_executesql @Sql; + END; + + FETCH NEXT FROM audit_cursor INTO @TableName, @ColumnName, @Definition; +END; +CLOSE audit_cursor; +DEALLOCATE audit_cursor; +GO + +UPDATE p +SET CreatedDateUTC = COALESCE(p.CreatedDateUTC, p.CreatedDate), + ModifiedDateUTC = COALESCE(p.ModifiedDateUTC, p.UpdatedDate, p.CreatedDate), + CreatedByUserID = COALESCE(p.CreatedByUserID, ownerAccess.UserID), + ModifiedByUserID = COALESCE(p.ModifiedByUserID, ownerAccess.UserID) +FROM dbo.Projects p +OUTER APPLY +( + SELECT TOP (1) UserID + FROM dbo.ProjectUserAccess + WHERE ProjectID = p.ProjectID AND AccessRole = N'Owner' AND IsActive = 1 + ORDER BY ProjectUserAccessID +) ownerAccess; +GO + +UPDATE b +SET CreatedDateUTC = COALESCE(b.CreatedDateUTC, b.CreatedDate), + ModifiedDateUTC = COALESCE(b.ModifiedDateUTC, b.UpdatedDate, b.CreatedDate), + CreatedByUserID = COALESCE(b.CreatedByUserID, p.CreatedByUserID), + ModifiedByUserID = COALESCE(b.ModifiedByUserID, p.ModifiedByUserID) +FROM dbo.Books b +INNER JOIN dbo.Projects p ON p.ProjectID = b.ProjectID; +GO + +UPDATE c +SET CreatedDateUTC = COALESCE(c.CreatedDateUTC, c.CreatedDate), + ModifiedDateUTC = COALESCE(c.ModifiedDateUTC, c.UpdatedDate, c.CreatedDate), + CreatedByUserID = COALESCE(c.CreatedByUserID, b.CreatedByUserID), + ModifiedByUserID = COALESCE(c.ModifiedByUserID, b.ModifiedByUserID) +FROM dbo.Chapters c +INNER JOIN dbo.Books b ON b.BookID = c.BookID; +GO + +UPDATE s +SET CreatedDateUTC = COALESCE(s.CreatedDateUTC, s.CreatedDate), + ModifiedDateUTC = COALESCE(s.ModifiedDateUTC, s.UpdatedDate, s.CreatedDate), + CreatedByUserID = COALESCE(s.CreatedByUserID, c.CreatedByUserID), + ModifiedByUserID = COALESCE(s.ModifiedByUserID, c.ModifiedByUserID) +FROM dbo.Scenes s +INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID; +GO + +UPDATE child +SET CreatedDateUTC = COALESCE(child.CreatedDateUTC, child.CreatedDate), + ModifiedDateUTC = COALESCE(child.ModifiedDateUTC, child.UpdatedDate, child.CreatedDate), + CreatedByUserID = COALESCE(child.CreatedByUserID, p.CreatedByUserID), + ModifiedByUserID = COALESCE(child.ModifiedByUserID, p.ModifiedByUserID) +FROM dbo.Characters child +INNER JOIN dbo.Projects p ON p.ProjectID = child.ProjectID; +GO + +UPDATE child +SET CreatedDateUTC = COALESCE(child.CreatedDateUTC, child.CreatedDate), + ModifiedDateUTC = COALESCE(child.ModifiedDateUTC, child.UpdatedDate, child.CreatedDate), + CreatedByUserID = COALESCE(child.CreatedByUserID, p.CreatedByUserID), + ModifiedByUserID = COALESCE(child.ModifiedByUserID, p.ModifiedByUserID) +FROM dbo.StoryAssets child +INNER JOIN dbo.Projects p ON p.ProjectID = child.ProjectID; +GO + +UPDATE child +SET CreatedDateUTC = COALESCE(child.CreatedDateUTC, child.CreatedDate), + ModifiedDateUTC = COALESCE(child.ModifiedDateUTC, child.UpdatedDate, child.CreatedDate), + CreatedByUserID = COALESCE(child.CreatedByUserID, p.CreatedByUserID), + ModifiedByUserID = COALESCE(child.ModifiedByUserID, p.ModifiedByUserID) +FROM dbo.Locations child +INNER JOIN dbo.Projects p ON p.ProjectID = child.ProjectID; +GO + +UPDATE child +SET CreatedDateUTC = COALESCE(child.CreatedDateUTC, child.CreatedDate), + ModifiedDateUTC = COALESCE(child.ModifiedDateUTC, child.UpdatedDate, child.CreatedDate), + CreatedByUserID = COALESCE(child.CreatedByUserID, p.CreatedByUserID), + ModifiedByUserID = COALESCE(child.ModifiedByUserID, p.ModifiedByUserID) +FROM dbo.PlotLines child +INNER JOIN dbo.Projects p ON p.ProjectID = child.ProjectID; +GO + +UPDATE pt +SET CreatedDateUTC = COALESCE(pt.CreatedDateUTC, pt.CreatedDate), + ModifiedDateUTC = COALESCE(pt.ModifiedDateUTC, pt.UpdatedDate, pt.CreatedDate), + CreatedByUserID = COALESCE(pt.CreatedByUserID, pl.CreatedByUserID), + ModifiedByUserID = COALESCE(pt.ModifiedByUserID, pl.ModifiedByUserID) +FROM dbo.PlotThreads pt +INNER JOIN dbo.PlotLines pl ON pl.PlotLineID = pt.PlotLineID; +GO + +UPDATE cr +SET CreatedDateUTC = COALESCE(cr.CreatedDateUTC, cr.CreatedDate), + ModifiedDateUTC = COALESCE(cr.ModifiedDateUTC, cr.UpdatedDate, cr.CreatedDate), + CreatedByUserID = COALESCE(cr.CreatedByUserID, p.CreatedByUserID), + ModifiedByUserID = COALESCE(cr.ModifiedByUserID, p.ModifiedByUserID) +FROM dbo.CharacterRelationships cr +INNER JOIN dbo.Projects p ON p.ProjectID = cr.ProjectID; +GO + +UPDATE re +SET CreatedDateUTC = COALESCE(re.CreatedDateUTC, re.CreatedDate), + ModifiedDateUTC = COALESCE(re.ModifiedDateUTC, re.UpdatedDate, re.CreatedDate), + CreatedByUserID = COALESCE(re.CreatedByUserID, cr.CreatedByUserID), + ModifiedByUserID = COALESCE(re.ModifiedByUserID, cr.ModifiedByUserID) +FROM dbo.RelationshipEvents re +INNER JOIN dbo.CharacterRelationships cr ON cr.CharacterRelationshipID = re.CharacterRelationshipID; +GO + +CREATE OR ALTER PROCEDURE dbo.ProjectActivity_Create + @ProjectID int, + @UserID int = NULL, + @ActivityType nvarchar(50), + @EntityType nvarchar(100), + @EntityID int = NULL, + @EntityName nvarchar(300) = NULL, + @Description nvarchar(1000) = NULL +AS +BEGIN + SET NOCOUNT ON; + + INSERT dbo.ProjectActivity (ProjectID, UserID, ActivityType, EntityType, EntityID, EntityName, Description) + VALUES (@ProjectID, @UserID, @ActivityType, @EntityType, @EntityID, @EntityName, @Description); + + SELECT CAST(SCOPE_IDENTITY() AS bigint); +END; +GO + +CREATE OR ALTER PROCEDURE dbo.ProjectAudit_Record + @ProjectID int, + @UserID int = NULL, + @ActivityType nvarchar(50), + @EntityType nvarchar(100), + @EntityID int = NULL, + @EntityName nvarchar(300) = NULL, + @Description nvarchar(1000) = NULL +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @Now datetime2 = SYSUTCDATETIME(); + + EXEC dbo.ProjectActivity_Create @ProjectID, @UserID, @ActivityType, @EntityType, @EntityID, @EntityName, @Description; + + IF @UserID IS NULL OR @EntityID IS NULL + RETURN; + + IF @EntityType = N'Project' + UPDATE dbo.Projects SET CreatedByUserID = CASE WHEN @ActivityType = N'Created' THEN COALESCE(CreatedByUserID, @UserID) ELSE CreatedByUserID END, + CreatedDateUTC = COALESCE(CreatedDateUTC, CreatedDate, @Now), ModifiedByUserID = @UserID, ModifiedDateUTC = @Now WHERE ProjectID = @EntityID; + ELSE IF @EntityType = N'Book' + UPDATE dbo.Books SET CreatedByUserID = CASE WHEN @ActivityType = N'Created' THEN COALESCE(CreatedByUserID, @UserID) ELSE CreatedByUserID END, + CreatedDateUTC = COALESCE(CreatedDateUTC, CreatedDate, @Now), ModifiedByUserID = @UserID, ModifiedDateUTC = @Now WHERE BookID = @EntityID; + ELSE IF @EntityType = N'Chapter' + UPDATE dbo.Chapters SET CreatedByUserID = CASE WHEN @ActivityType = N'Created' THEN COALESCE(CreatedByUserID, @UserID) ELSE CreatedByUserID END, + CreatedDateUTC = COALESCE(CreatedDateUTC, CreatedDate, @Now), ModifiedByUserID = @UserID, ModifiedDateUTC = @Now WHERE ChapterID = @EntityID; + ELSE IF @EntityType = N'Scene' + UPDATE dbo.Scenes SET CreatedByUserID = CASE WHEN @ActivityType = N'Created' THEN COALESCE(CreatedByUserID, @UserID) ELSE CreatedByUserID END, + CreatedDateUTC = COALESCE(CreatedDateUTC, CreatedDate, @Now), ModifiedByUserID = @UserID, ModifiedDateUTC = @Now WHERE SceneID = @EntityID; + ELSE IF @EntityType = N'Character' + UPDATE dbo.Characters SET CreatedByUserID = CASE WHEN @ActivityType = N'Created' THEN COALESCE(CreatedByUserID, @UserID) ELSE CreatedByUserID END, + CreatedDateUTC = COALESCE(CreatedDateUTC, CreatedDate, @Now), ModifiedByUserID = @UserID, ModifiedDateUTC = @Now WHERE CharacterID = @EntityID; + ELSE IF @EntityType = N'Asset' + UPDATE dbo.StoryAssets SET CreatedByUserID = CASE WHEN @ActivityType = N'Created' THEN COALESCE(CreatedByUserID, @UserID) ELSE CreatedByUserID END, + CreatedDateUTC = COALESCE(CreatedDateUTC, CreatedDate, @Now), ModifiedByUserID = @UserID, ModifiedDateUTC = @Now WHERE StoryAssetID = @EntityID; + ELSE IF @EntityType = N'Location' + UPDATE dbo.Locations SET CreatedByUserID = CASE WHEN @ActivityType = N'Created' THEN COALESCE(CreatedByUserID, @UserID) ELSE CreatedByUserID END, + CreatedDateUTC = COALESCE(CreatedDateUTC, CreatedDate, @Now), ModifiedByUserID = @UserID, ModifiedDateUTC = @Now WHERE LocationID = @EntityID; + ELSE IF @EntityType = N'Plot Line' + UPDATE dbo.PlotLines SET CreatedByUserID = CASE WHEN @ActivityType = N'Created' THEN COALESCE(CreatedByUserID, @UserID) ELSE CreatedByUserID END, + CreatedDateUTC = COALESCE(CreatedDateUTC, CreatedDate, @Now), ModifiedByUserID = @UserID, ModifiedDateUTC = @Now WHERE PlotLineID = @EntityID; + ELSE IF @EntityType = N'Thread' + UPDATE dbo.PlotThreads SET CreatedByUserID = CASE WHEN @ActivityType = N'Created' THEN COALESCE(CreatedByUserID, @UserID) ELSE CreatedByUserID END, + CreatedDateUTC = COALESCE(CreatedDateUTC, CreatedDate, @Now), ModifiedByUserID = @UserID, ModifiedDateUTC = @Now WHERE PlotThreadID = @EntityID; + ELSE IF @EntityType = N'Relationship' + UPDATE dbo.CharacterRelationships SET CreatedByUserID = CASE WHEN @ActivityType = N'Created' THEN COALESCE(CreatedByUserID, @UserID) ELSE CreatedByUserID END, + CreatedDateUTC = COALESCE(CreatedDateUTC, CreatedDate, @Now), ModifiedByUserID = @UserID, ModifiedDateUTC = @Now WHERE CharacterRelationshipID = @EntityID; + ELSE IF @EntityType = N'Relationship Event' + UPDATE dbo.RelationshipEvents SET CreatedByUserID = CASE WHEN @ActivityType = N'Created' THEN COALESCE(CreatedByUserID, @UserID) ELSE CreatedByUserID END, + CreatedDateUTC = COALESCE(CreatedDateUTC, CreatedDate, @Now), ModifiedByUserID = @UserID, ModifiedDateUTC = @Now WHERE RelationshipEventID = @EntityID; + ELSE IF @EntityType = N'Thread Event' + UPDATE dbo.ThreadEvents SET CreatedByUserID = CASE WHEN @ActivityType = N'Created' THEN COALESCE(CreatedByUserID, @UserID) ELSE CreatedByUserID END, + CreatedDateUTC = COALESCE(CreatedDateUTC, CreatedDate, @Now), ModifiedByUserID = @UserID, ModifiedDateUTC = @Now WHERE ThreadEventID = @EntityID; + ELSE IF @EntityType = N'Asset Event' + UPDATE dbo.AssetEvents SET CreatedByUserID = CASE WHEN @ActivityType = N'Created' THEN COALESCE(CreatedByUserID, @UserID) ELSE CreatedByUserID END, + CreatedDateUTC = COALESCE(CreatedDateUTC, CreatedDate, @Now), ModifiedByUserID = @UserID, ModifiedDateUTC = @Now WHERE AssetEventID = @EntityID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.ProjectActivity_ListForProject + @ProjectID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT TOP (200) + pa.ProjectActivityID, + pa.ProjectID, + pa.UserID, + u.DisplayName, + u.Email, + pa.ActivityType, + pa.EntityType, + pa.EntityID, + pa.EntityName, + pa.Description, + pa.CreatedDateUTC + FROM dbo.ProjectActivity pa + LEFT JOIN dbo.AppUser u ON u.UserID = pa.UserID + WHERE pa.ProjectID = @ProjectID + ORDER BY pa.CreatedDateUTC DESC, pa.ProjectActivityID DESC; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Relationship_Get + @CharacterRelationshipID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT cr.CharacterRelationshipID, cr.ProjectID, cr.CharacterAID, ca.CharacterName AS CharacterAName, + cr.CharacterBID, cb.CharacterName AS CharacterBName, cr.RelationshipTypeID, rt.TypeName AS RelationshipTypeName, + cr.IsPermanent, cr.StartSceneID, cr.EndSceneID, cr.IsKnownToReader, cr.Notes, cr.CreatedDate, cr.UpdatedDate, cr.IsArchived + FROM dbo.CharacterRelationships cr + INNER JOIN dbo.Characters ca ON ca.CharacterID = cr.CharacterAID + INNER JOIN dbo.Characters cb ON cb.CharacterID = cr.CharacterBID + INNER JOIN dbo.RelationshipTypes rt ON rt.RelationshipTypeID = cr.RelationshipTypeID + WHERE cr.CharacterRelationshipID = @CharacterRelationshipID; +END; +GO diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index 36f7b61..828e693 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -44,6 +44,12 @@ public sealed class ProjectCollaboratorInviteViewModel public string EmailAddress { get; set; } = string.Empty; } +public sealed class ProjectActivityViewModel +{ + public Project Project { get; set; } = new(); + public IReadOnlyList Activities { get; set; } = []; +} + public sealed class BookEditViewModel { public int BookID { get; set; } diff --git a/PlotLine/Views/Projects/Activity.cshtml b/PlotLine/Views/Projects/Activity.cshtml new file mode 100644 index 0000000..d2114ee --- /dev/null +++ b/PlotLine/Views/Projects/Activity.cshtml @@ -0,0 +1,55 @@ +@model ProjectActivityViewModel +@{ + ViewData["Title"] = "Activity"; +} + + + +
+
+

Project activity

+

Activity

+
+ +
+ +
+ @if (!Model.Activities.Any()) + { +

No project activity has been recorded yet.

+ } + else + { +
+ + + + + + + + + + + + @foreach (var item in Model.Activities) + { + + + + + + + + } + +
DateUserActionItemDescription
@item.CreatedDateUTC.ToString("dd MMM yyyy HH:mm") UTC@item.UserLabel@item.ActivityType@item.EntityType@(string.IsNullOrWhiteSpace(item.EntityName) ? string.Empty : $" \"{item.EntityName}\"")@item.Description
+
+ } +
diff --git a/PlotLine/Views/Projects/Details.cshtml b/PlotLine/Views/Projects/Details.cshtml index 1a8168c..705aeab 100644 --- a/PlotLine/Views/Projects/Details.cshtml +++ b/PlotLine/Views/Projects/Details.cshtml @@ -44,6 +44,7 @@
Edit project + Activity @if (Model.Project.IsOwner) { Collaborators