Phase 5E Audit Activity

This commit is contained in:
Nick Beckley 2026-06-07 11:05:40 +01:00
parent 9824b364b7
commit 1f3f68e6f3
9 changed files with 832 additions and 95 deletions

View File

@ -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<IActionResult> Index() => View(await projects.ListAsync());
@ -18,6 +18,12 @@ public sealed class ProjectsController(IProjectService projects) : Controller
public async Task<IActionResult> Create() => View("Edit", await projects.GetCreateAsync());
public async Task<IActionResult> Activity(int id)
{
var model = await activity.GetActivityAsync(id);
return model is null ? NotFound() : View(model);
}
public async Task<IActionResult> Edit(int id)
{
var model = await projects.GetEditAsync(id);

View File

@ -29,6 +29,12 @@ public interface IProjectCollaborationRepository
Task<int> CreatePendingInvitationAsync(int projectId, string emailAddress, int invitedByUserId);
}
public interface IProjectActivityRepository
{
Task<IReadOnlyList<ProjectActivity>> ListForProjectAsync(int projectId);
Task<long> RecordAsync(int projectId, int? userId, string activityType, string entityType, int? entityId, string? entityName, string? description = null);
}
public interface IBookRepository
{
Task<IReadOnlyList<Book>> ListByProjectAsync(int projectId);
@ -159,6 +165,7 @@ public interface ICharacterRepository
Task<IReadOnlyList<CharacterRelationship>> ListRelationshipsByProjectAsync(int projectId);
Task<IReadOnlyList<CharacterRelationship>> ListRelationshipsByCharacterAsync(int characterId);
Task<IReadOnlyList<CharacterRelationship>> ListInitialRelationshipsByCharacterAsync(int characterId);
Task<CharacterRelationship?> GetRelationshipAsync(int characterRelationshipId);
Task<int> SaveRelationshipAsync(CharacterRelationship relationship);
Task ArchiveRelationshipAsync(int characterRelationshipId);
Task<IReadOnlyList<RelationshipEvent>> ListRelationshipEventsBySceneAsync(int sceneId);
@ -1306,6 +1313,37 @@ public sealed class ProjectCollaborationRepository(ISqlConnectionFactory connect
}
}
public sealed class ProjectActivityRepository(ISqlConnectionFactory connectionFactory) : IProjectActivityRepository
{
public async Task<IReadOnlyList<ProjectActivity>> ListForProjectAsync(int projectId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<ProjectActivity>(
"dbo.ProjectActivity_ListForProject",
new { ProjectID = projectId },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<long> 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<long>(
"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<IReadOnlyList<Book>> ListByProjectAsync(int projectId)
@ -2249,6 +2287,15 @@ public sealed class CharacterRepository(ISqlConnectionFactory connectionFactory)
return rows.ToList();
}
public async Task<CharacterRelationship?> GetRelationshipAsync(int characterRelationshipId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<CharacterRelationship>(
"dbo.Relationship_Get",
new { CharacterRelationshipID = characterRelationshipId },
commandType: CommandType.StoredProcedure);
}
public async Task<int> SaveRelationshipAsync(CharacterRelationship relationship)
{
using var connection = connectionFactory.CreateConnection();

View File

@ -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; }

View File

@ -104,9 +104,11 @@ public class Program
builder.Services.AddScoped<IImportRepository, ImportRepository>();
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
builder.Services.AddScoped<IProjectActivityRepository, ProjectActivityRepository>();
builder.Services.AddScoped<ISubscriptionRepository, SubscriptionRepository>();
builder.Services.AddScoped<IProjectService, ProjectService>();
builder.Services.AddScoped<IProjectCollaborationService, ProjectCollaborationService>();
builder.Services.AddScoped<IProjectActivityService, ProjectActivityService>();
builder.Services.AddScoped<IBookService, BookService>();
builder.Services.AddScoped<IChapterService, ChapterService>();
builder.Services.AddScoped<ISceneService, SceneService>();

View File

@ -27,6 +27,12 @@ public interface IProjectCollaborationService
Task<ProjectCollaborationResult> RemoveCollaboratorAsync(int projectId, int userId);
}
public interface IProjectActivityService
{
Task<ProjectActivityViewModel?> 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<ProjectActivityViewModel?> 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<BookDetailViewModel?> GetDetailAsync(int bookId);
@ -257,7 +293,7 @@ public interface IAcceptanceService
Task<Phase1AcceptanceViewModel> 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<IReadOnlyList<Project>> ListAsync()
=> currentUser.UserId.HasValue ? projects.ListForUserAsync(currentUser.UserId.Value) : Task.FromResult<IReadOnlyList<Project>>([]);
@ -303,23 +339,36 @@ public sealed class ProjectService(IProjectRepository projects, IBookRepository
.ToList();
}
public Task<int> SaveAsync(ProjectEditViewModel model)
public async Task<int> 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<EmailSettings> emailOptions,
ILogger<ProjectCollaborationService> 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<BookDetailViewModel?> 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<ChapterDetailViewModel?> 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<SceneEditViewModel?> GetCreateAsync(int chapterId)
@ -807,7 +893,8 @@ public sealed class SceneService(
public async Task<int> 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<PlotLineListViewModel?> 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<int> SavePlotLineAsync(PlotLineEditViewModel model) => plots.SavePlotLineAsync(new PlotLineItem
public async Task<int> 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<PlotThreadListViewModel?> GetPlotThreadsAsync(int projectId)
{
@ -2025,21 +2140,39 @@ public sealed class PlotService(IProjectRepository projects, IBookRepository boo
}, lookupData);
}
public Task<int> SavePlotThreadAsync(PlotThreadEditViewModel model) => plots.SavePlotThreadAsync(new PlotThread
public async Task<int> 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<int> 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<ArchiveDeleteResult> DeleteForeverAsync(string entityType, int entityId, string confirmationText) =>
archives.DeleteForeverAsync(entityType, entityId, confirmationText);
public async Task<ArchiveDeleteResult> 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<ArchivedItem?> 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<StoryAssetListViewModel?> GetAssetsAsync(int projectId)
{
@ -3416,21 +3589,35 @@ public sealed class AssetService(IProjectRepository projects, IAssetRepository a
};
}
public Task<int> SaveAssetAsync(StoryAssetEditViewModel model) => assets.SaveAssetAsync(new StoryAsset
public async Task<int> 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<int> 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<LocationListViewModel?> GetLocationsAsync(int projectId)
{
@ -3602,34 +3796,54 @@ public sealed class LocationService(IProjectRepository projects, ILocationReposi
};
}
public Task<int> SaveAsync(LocationEditViewModel model) => locations.SaveAsync(new LocationItem
public async Task<int> 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<int> 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<int> 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<CharacterListViewModel?> 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<int> 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<int> 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);

View File

@ -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

View File

@ -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<ProjectActivity> Activities { get; set; } = [];
}
public sealed class BookEditViewModel
{
public int BookID { get; set; }

View File

@ -0,0 +1,55 @@
@model ProjectActivityViewModel
@{
ViewData["Title"] = "Activity";
}
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
<a asp-controller="Projects" asp-action="Index">Projects</a>
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">@Model.Project.ProjectName</a>
<span>Activity</span>
</nav>
<div class="page-heading">
<div>
<p class="eyebrow">Project activity</p>
<h1>Activity</h1>
</div>
<div class="button-row">
<a class="btn btn-outline-secondary" asp-action="Details" asp-route-id="@Model.Project.ProjectID">Back to project</a>
</div>
</div>
<section class="list-section">
@if (!Model.Activities.Any())
{
<p class="muted">No project activity has been recorded yet.</p>
}
else
{
<div class="table-responsive">
<table class="table align-middle">
<thead>
<tr>
<th>Date</th>
<th>User</th>
<th>Action</th>
<th>Item</th>
<th>Description</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.Activities)
{
<tr>
<td>@item.CreatedDateUTC.ToString("dd MMM yyyy HH:mm") UTC</td>
<td>@item.UserLabel</td>
<td>@item.ActivityType</td>
<td>@item.EntityType@(string.IsNullOrWhiteSpace(item.EntityName) ? string.Empty : $" \"{item.EntityName}\"")</td>
<td>@item.Description</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>

View File

@ -44,6 +44,7 @@
</div>
<div class="button-row">
<a class="btn btn-outline-secondary" asp-action="Edit" asp-route-id="@Model.Project.ProjectID">Edit project</a>
<a class="btn btn-outline-secondary" asp-action="Activity" asp-route-id="@Model.Project.ProjectID">Activity</a>
@if (Model.Project.IsOwner)
{
<a class="btn btn-outline-secondary" asp-controller="ProjectCollaborators" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Collaborators</a>