Phase 5E Audit Activity
This commit is contained in:
parent
9824b364b7
commit
1f3f68e6f3
@ -6,7 +6,7 @@ using PlotLine.ViewModels;
|
|||||||
namespace PlotLine.Controllers;
|
namespace PlotLine.Controllers;
|
||||||
|
|
||||||
[Authorize]
|
[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());
|
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> 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)
|
public async Task<IActionResult> Edit(int id)
|
||||||
{
|
{
|
||||||
var model = await projects.GetEditAsync(id);
|
var model = await projects.GetEditAsync(id);
|
||||||
|
|||||||
@ -29,6 +29,12 @@ public interface IProjectCollaborationRepository
|
|||||||
Task<int> CreatePendingInvitationAsync(int projectId, string emailAddress, int invitedByUserId);
|
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
|
public interface IBookRepository
|
||||||
{
|
{
|
||||||
Task<IReadOnlyList<Book>> ListByProjectAsync(int projectId);
|
Task<IReadOnlyList<Book>> ListByProjectAsync(int projectId);
|
||||||
@ -159,6 +165,7 @@ public interface ICharacterRepository
|
|||||||
Task<IReadOnlyList<CharacterRelationship>> ListRelationshipsByProjectAsync(int projectId);
|
Task<IReadOnlyList<CharacterRelationship>> ListRelationshipsByProjectAsync(int projectId);
|
||||||
Task<IReadOnlyList<CharacterRelationship>> ListRelationshipsByCharacterAsync(int characterId);
|
Task<IReadOnlyList<CharacterRelationship>> ListRelationshipsByCharacterAsync(int characterId);
|
||||||
Task<IReadOnlyList<CharacterRelationship>> ListInitialRelationshipsByCharacterAsync(int characterId);
|
Task<IReadOnlyList<CharacterRelationship>> ListInitialRelationshipsByCharacterAsync(int characterId);
|
||||||
|
Task<CharacterRelationship?> GetRelationshipAsync(int characterRelationshipId);
|
||||||
Task<int> SaveRelationshipAsync(CharacterRelationship relationship);
|
Task<int> SaveRelationshipAsync(CharacterRelationship relationship);
|
||||||
Task ArchiveRelationshipAsync(int characterRelationshipId);
|
Task ArchiveRelationshipAsync(int characterRelationshipId);
|
||||||
Task<IReadOnlyList<RelationshipEvent>> ListRelationshipEventsBySceneAsync(int sceneId);
|
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 sealed class BookRepository(ISqlConnectionFactory connectionFactory) : IBookRepository
|
||||||
{
|
{
|
||||||
public async Task<IReadOnlyList<Book>> ListByProjectAsync(int projectId)
|
public async Task<IReadOnlyList<Book>> ListByProjectAsync(int projectId)
|
||||||
@ -2249,6 +2287,15 @@ public sealed class CharacterRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return rows.ToList();
|
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)
|
public async Task<int> SaveRelationshipAsync(CharacterRelationship relationship)
|
||||||
{
|
{
|
||||||
using var connection = connectionFactory.CreateConnection();
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
|||||||
@ -46,6 +46,27 @@ public sealed class ProjectInvitation
|
|||||||
public DateTime? CancelledDateUTC { get; set; }
|
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 sealed class GenreMetricPreset
|
||||||
{
|
{
|
||||||
public int GenreMetricPresetID { get; set; }
|
public int GenreMetricPresetID { get; set; }
|
||||||
|
|||||||
@ -104,9 +104,11 @@ public class Program
|
|||||||
builder.Services.AddScoped<IImportRepository, ImportRepository>();
|
builder.Services.AddScoped<IImportRepository, ImportRepository>();
|
||||||
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
|
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
|
||||||
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
|
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
|
||||||
|
builder.Services.AddScoped<IProjectActivityRepository, ProjectActivityRepository>();
|
||||||
builder.Services.AddScoped<ISubscriptionRepository, SubscriptionRepository>();
|
builder.Services.AddScoped<ISubscriptionRepository, SubscriptionRepository>();
|
||||||
builder.Services.AddScoped<IProjectService, ProjectService>();
|
builder.Services.AddScoped<IProjectService, ProjectService>();
|
||||||
builder.Services.AddScoped<IProjectCollaborationService, ProjectCollaborationService>();
|
builder.Services.AddScoped<IProjectCollaborationService, ProjectCollaborationService>();
|
||||||
|
builder.Services.AddScoped<IProjectActivityService, ProjectActivityService>();
|
||||||
builder.Services.AddScoped<IBookService, BookService>();
|
builder.Services.AddScoped<IBookService, BookService>();
|
||||||
builder.Services.AddScoped<IChapterService, ChapterService>();
|
builder.Services.AddScoped<IChapterService, ChapterService>();
|
||||||
builder.Services.AddScoped<ISceneService, SceneService>();
|
builder.Services.AddScoped<ISceneService, SceneService>();
|
||||||
|
|||||||
@ -27,6 +27,12 @@ public interface IProjectCollaborationService
|
|||||||
Task<ProjectCollaborationResult> RemoveCollaboratorAsync(int projectId, int userId);
|
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 sealed class ProjectCollaborationResult
|
||||||
{
|
{
|
||||||
public bool Succeeded { get; init; }
|
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 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
|
public interface IBookService
|
||||||
{
|
{
|
||||||
Task<BookDetailViewModel?> GetDetailAsync(int bookId);
|
Task<BookDetailViewModel?> GetDetailAsync(int bookId);
|
||||||
@ -257,7 +293,7 @@ public interface IAcceptanceService
|
|||||||
Task<Phase1AcceptanceViewModel> GetPhase1ChecklistAsync(int? projectId);
|
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()
|
public Task<IReadOnlyList<Project>> ListAsync()
|
||||||
=> currentUser.UserId.HasValue ? projects.ListForUserAsync(currentUser.UserId.Value) : Task.FromResult<IReadOnlyList<Project>>([]);
|
=> currentUser.UserId.HasValue ? projects.ListForUserAsync(currentUser.UserId.Value) : Task.FromResult<IReadOnlyList<Project>>([]);
|
||||||
@ -303,23 +339,36 @@ public sealed class ProjectService(IProjectRepository projects, IBookRepository
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<int> SaveAsync(ProjectEditViewModel model)
|
public async Task<int> SaveAsync(ProjectEditViewModel model)
|
||||||
{
|
{
|
||||||
if (!currentUser.UserId.HasValue)
|
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,
|
ProjectID = model.ProjectID,
|
||||||
ProjectName = model.ProjectName,
|
ProjectName = model.ProjectName,
|
||||||
Description = model.Description
|
Description = model.Description
|
||||||
}, currentUser.UserId.Value, model.ProjectID == 0 ? model.GenreMetricPresetKey : null);
|
}, 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)
|
public async Task ArchiveAsync(int projectId)
|
||||||
=> currentUser.UserId.HasValue ? projects.ArchiveForUserAsync(projectId, currentUser.UserId.Value) : Task.CompletedTask;
|
{
|
||||||
|
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(
|
public sealed class ProjectCollaborationService(
|
||||||
@ -329,6 +378,7 @@ public sealed class ProjectCollaborationService(
|
|||||||
IProjectAccessService access,
|
IProjectAccessService access,
|
||||||
ISubscriptionService subscriptions,
|
ISubscriptionService subscriptions,
|
||||||
IEmailQueueRepository emailQueue,
|
IEmailQueueRepository emailQueue,
|
||||||
|
IProjectActivityService activity,
|
||||||
ICurrentUserService currentUser,
|
ICurrentUserService currentUser,
|
||||||
IOptions<EmailSettings> emailOptions,
|
IOptions<EmailSettings> emailOptions,
|
||||||
ILogger<ProjectCollaborationService> logger) : IProjectCollaborationService
|
ILogger<ProjectCollaborationService> logger) : IProjectCollaborationService
|
||||||
@ -405,6 +455,7 @@ public sealed class ProjectCollaborationService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await collaboration.AddCollaboratorAsync(model.ProjectID, user.UserID, currentUser.UserId.Value);
|
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);
|
await QueueInvitationNotificationAsync(user, model.ProjectID);
|
||||||
return ProjectCollaborationResult.Success("Collaborator added.");
|
return ProjectCollaborationResult.Success("Collaborator added.");
|
||||||
}
|
}
|
||||||
@ -416,6 +467,7 @@ public sealed class ProjectCollaborationService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await collaboration.CreatePendingInvitationAsync(model.ProjectID, email, currentUser.UserId.Value);
|
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.");
|
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);
|
var removed = await collaboration.RemoveCollaboratorAsync(projectId, userId);
|
||||||
|
if (removed)
|
||||||
|
{
|
||||||
|
await activity.RecordAsync(projectId, "CollaboratorRemoved", "Collaborator", userId, null);
|
||||||
|
}
|
||||||
return removed
|
return removed
|
||||||
? ProjectCollaborationResult.Success("Collaborator removed.")
|
? ProjectCollaborationResult.Success("Collaborator removed.")
|
||||||
: ProjectCollaborationResult.Failure("Collaborator access was not found.");
|
: ProjectCollaborationResult.Failure("Collaborator access was not found.");
|
||||||
@ -523,6 +579,7 @@ public sealed class BookService(
|
|||||||
IBookRepository books,
|
IBookRepository books,
|
||||||
IChapterRepository chapters,
|
IChapterRepository chapters,
|
||||||
ISubscriptionService subscriptions,
|
ISubscriptionService subscriptions,
|
||||||
|
IProjectActivityService activity,
|
||||||
ICurrentUserService currentUser) : IBookService
|
ICurrentUserService currentUser) : IBookService
|
||||||
{
|
{
|
||||||
public async Task<BookDetailViewModel?> GetDetailAsync(int bookId)
|
public async Task<BookDetailViewModel?> GetDetailAsync(int bookId)
|
||||||
@ -590,7 +647,8 @@ public sealed class BookService(
|
|||||||
await SubscriptionLimitGuards.EnsureAllowedAsync(subscriptions.CanCreateBookAsync, currentUser.UserId);
|
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,
|
BookID = model.BookID,
|
||||||
ProjectID = model.ProjectID,
|
ProjectID = model.ProjectID,
|
||||||
@ -598,9 +656,19 @@ public sealed class BookService(
|
|||||||
BookNumber = model.BookNumber,
|
BookNumber = model.BookNumber,
|
||||||
Description = model.Description
|
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(
|
public sealed class ChapterService(
|
||||||
@ -610,6 +678,7 @@ public sealed class ChapterService(
|
|||||||
ISceneRepository scenes,
|
ISceneRepository scenes,
|
||||||
ILookupRepository lookups,
|
ILookupRepository lookups,
|
||||||
ISubscriptionService subscriptions,
|
ISubscriptionService subscriptions,
|
||||||
|
IProjectActivityService activity,
|
||||||
ICurrentUserService currentUser) : IChapterService
|
ICurrentUserService currentUser) : IChapterService
|
||||||
{
|
{
|
||||||
public async Task<ChapterDetailViewModel?> GetDetailAsync(int chapterId)
|
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);
|
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,
|
ChapterID = model.ChapterID,
|
||||||
BookID = model.BookID,
|
BookID = model.BookID,
|
||||||
@ -704,9 +774,24 @@ public sealed class ChapterService(
|
|||||||
Summary = model.Summary,
|
Summary = model.Summary,
|
||||||
RevisionStatusID = model.RevisionStatusID
|
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)
|
internal static int DefaultRevisionStatusId(LookupData lookupData)
|
||||||
=> lookupData.RevisionStatuses.FirstOrDefault(x => x.StatusName == "Planned")?.RevisionStatusID
|
=> lookupData.RevisionStatuses.FirstOrDefault(x => x.StatusName == "Planned")?.RevisionStatusID
|
||||||
@ -730,6 +815,7 @@ public sealed class SceneService(
|
|||||||
ISceneDependencyRepository sceneDependencies,
|
ISceneDependencyRepository sceneDependencies,
|
||||||
IWriterWorkspaceRepository writerWorkspace,
|
IWriterWorkspaceRepository writerWorkspace,
|
||||||
ISubscriptionService subscriptions,
|
ISubscriptionService subscriptions,
|
||||||
|
IProjectActivityService activity,
|
||||||
ICurrentUserService currentUser) : ISceneService
|
ICurrentUserService currentUser) : ISceneService
|
||||||
{
|
{
|
||||||
public async Task<SceneEditViewModel?> GetCreateAsync(int chapterId)
|
public async Task<SceneEditViewModel?> GetCreateAsync(int chapterId)
|
||||||
@ -807,7 +893,8 @@ public sealed class SceneService(
|
|||||||
|
|
||||||
public async Task<int> SaveAsync(SceneEditViewModel model)
|
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);
|
var chapter = await chapters.GetAsync(model.ChapterID);
|
||||||
if (chapter is null)
|
if (chapter is null)
|
||||||
@ -846,10 +933,24 @@ public sealed class SceneService(
|
|||||||
Value = metric.Value,
|
Value = metric.Value,
|
||||||
Notes = metric.Notes
|
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;
|
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)
|
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)
|
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));
|
}, 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,
|
var isNew = model.PlotLineID == 0;
|
||||||
ProjectID = model.ProjectID,
|
var plotLineId = await plots.SavePlotLineAsync(new PlotLineItem
|
||||||
BookID = model.BookID,
|
{
|
||||||
PlotLineName = model.PlotLineName,
|
PlotLineID = model.PlotLineID,
|
||||||
PlotLineTypeID = model.PlotLineTypeID,
|
ProjectID = model.ProjectID,
|
||||||
Description = model.Description,
|
BookID = model.BookID,
|
||||||
ParentPlotLineID = model.ParentPlotLineID,
|
PlotLineName = model.PlotLineName,
|
||||||
SortOrder = model.SortOrder,
|
PlotLineTypeID = model.PlotLineTypeID,
|
||||||
Colour = string.IsNullOrWhiteSpace(model.Colour) ? "#2f6f63" : model.Colour,
|
Description = model.Description,
|
||||||
IsMainPlot = model.IsMainPlot,
|
ParentPlotLineID = model.ParentPlotLineID,
|
||||||
IsVisibleOnTimeline = model.IsVisibleOnTimeline
|
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)
|
public async Task<PlotThreadListViewModel?> GetPlotThreadsAsync(int projectId)
|
||||||
{
|
{
|
||||||
@ -2025,21 +2140,39 @@ public sealed class PlotService(IProjectRepository projects, IBookRepository boo
|
|||||||
}, lookupData);
|
}, lookupData);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<int> SavePlotThreadAsync(PlotThreadEditViewModel model) => plots.SavePlotThreadAsync(new PlotThread
|
public async Task<int> SavePlotThreadAsync(PlotThreadEditViewModel model)
|
||||||
{
|
{
|
||||||
PlotThreadID = model.PlotThreadID,
|
var isNew = model.PlotThreadID == 0;
|
||||||
PlotLineID = model.PlotLineID,
|
var plotThreadId = await plots.SavePlotThreadAsync(new PlotThread
|
||||||
ThreadTitle = model.ThreadTitle,
|
{
|
||||||
ThreadTypeID = model.ThreadTypeID,
|
PlotThreadID = model.PlotThreadID,
|
||||||
ThreadStatusID = model.ThreadStatusID,
|
PlotLineID = model.PlotLineID,
|
||||||
Importance = model.Importance,
|
ThreadTitle = model.ThreadTitle,
|
||||||
Summary = model.Summary,
|
ThreadTypeID = model.ThreadTypeID,
|
||||||
IntroducedSceneID = model.IntroducedSceneID,
|
ThreadStatusID = model.ThreadStatusID,
|
||||||
PlannedResolutionSceneID = model.PlannedResolutionSceneID,
|
Importance = model.Importance,
|
||||||
ActualResolutionSceneID = model.ActualResolutionSceneID
|
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)
|
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,
|
PlotThreadID = plotThreadId,
|
||||||
SceneID = model.SceneID,
|
SceneID = model.SceneID,
|
||||||
@ -2083,6 +2216,12 @@ public sealed class PlotService(IProjectRepository projects, IBookRepository boo
|
|||||||
EventTitle = title,
|
EventTitle = title,
|
||||||
EventDescription = model.EventDescription
|
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);
|
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 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 =
|
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) =>
|
public async Task ArchiveAsync(string entityType, int entityId, string? reason)
|
||||||
archives.ArchiveAsync(entityType, entityId, 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) =>
|
public async Task RestoreAsync(string entityType, int entityId)
|
||||||
archives.RestoreAsync(entityType, 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) =>
|
public async Task<ArchiveDeleteResult> DeleteForeverAsync(string entityType, int entityId, string confirmationText)
|
||||||
archives.DeleteForeverAsync(entityType, entityId, 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
|
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)
|
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,
|
var isNew = model.StoryAssetID == 0;
|
||||||
ProjectID = model.ProjectID,
|
var assetId = await assets.SaveAssetAsync(new StoryAsset
|
||||||
AssetName = model.AssetName,
|
{
|
||||||
AssetKindID = model.AssetKindID,
|
StoryAssetID = model.StoryAssetID,
|
||||||
Description = model.Description,
|
ProjectID = model.ProjectID,
|
||||||
Importance = model.Importance,
|
AssetName = model.AssetName,
|
||||||
CurrentStateID = model.CurrentStateID,
|
AssetKindID = model.AssetKindID,
|
||||||
CurrentLocationID = model.CurrentLocationID,
|
Description = model.Description,
|
||||||
IsResolved = model.IsResolved,
|
Importance = model.Importance,
|
||||||
ShowInQuickAddBar = model.ShowInQuickAddBar
|
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)
|
public async Task<int> AddAssetEventAsync(AssetEventCreateViewModel model)
|
||||||
{
|
{
|
||||||
@ -3453,13 +3640,14 @@ public sealed class AssetService(IProjectRepository projects, IAssetRepository a
|
|||||||
Importance = 5,
|
Importance = 5,
|
||||||
CurrentStateID = model.FromStateID ?? lookupData.AssetStates.FirstOrDefault(x => x.StateName == "Unknown")?.AssetStateID
|
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)
|
var eventType = lookupData.AssetEventTypes.FirstOrDefault(x => x.AssetEventTypeID == model.AssetEventTypeID)
|
||||||
?? lookupData.AssetEventTypes.First();
|
?? lookupData.AssetEventTypes.First();
|
||||||
var title = string.IsNullOrWhiteSpace(model.EventTitle) ? eventType.TypeName : model.EventTitle.Trim();
|
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,
|
StoryAssetID = assetId,
|
||||||
SceneID = model.SceneID,
|
SceneID = model.SceneID,
|
||||||
@ -3469,6 +3657,12 @@ public sealed class AssetService(IProjectRepository projects, IAssetRepository a
|
|||||||
EventTitle = title,
|
EventTitle = title,
|
||||||
EventDescription = model.EventDescription
|
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);
|
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();
|
=> 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)
|
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,
|
var isNew = model.LocationID == 0;
|
||||||
ProjectID = model.ProjectID,
|
var locationId = await locations.SaveAsync(new LocationItem
|
||||||
ParentLocationID = model.ParentLocationID == model.LocationID ? null : model.ParentLocationID,
|
{
|
||||||
LocationName = model.LocationName,
|
LocationID = model.LocationID,
|
||||||
LocationTypeID = model.LocationTypeID,
|
ProjectID = model.ProjectID,
|
||||||
Description = model.Description,
|
ParentLocationID = model.ParentLocationID == model.LocationID ? null : model.ParentLocationID,
|
||||||
ShowInQuickAddBar = model.ShowInQuickAddBar
|
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 async Task ArchiveAsync(int locationId)
|
||||||
|
|
||||||
public Task<int> SaveRelationshipAsync(LocationRelationshipEditViewModel model) => locations.SaveRelationshipAsync(new LocationRelationship
|
|
||||||
{
|
{
|
||||||
LocationRelationshipID = model.LocationRelationshipID,
|
var location = await locations.GetAsync(locationId);
|
||||||
FromLocationID = model.FromLocationID,
|
await locations.ArchiveAsync(locationId);
|
||||||
ToLocationID = model.ToLocationID,
|
if (location is not null)
|
||||||
LocationRelationshipTypeID = model.LocationRelationshipTypeID,
|
{
|
||||||
IsBidirectional = model.IsBidirectional,
|
await activity.RecordAsync(location.ProjectID, "Archived", "Location", locationId, location.LocationName);
|
||||||
Strength = model.Strength,
|
}
|
||||||
CanHearNormalSpeech = model.CanHearNormalSpeech,
|
}
|
||||||
CanHearLoudNoise = model.CanHearLoudNoise,
|
|
||||||
CanSeeMovement = model.CanSeeMovement,
|
public async Task<int> SaveRelationshipAsync(LocationRelationshipEditViewModel model)
|
||||||
CanSeeClearly = model.CanSeeClearly,
|
{
|
||||||
CanTravelDirectly = model.CanTravelDirectly,
|
var isNew = model.LocationRelationshipID == 0;
|
||||||
Notes = model.Notes
|
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);
|
public Task ArchiveRelationshipAsync(int locationRelationshipId) => locations.ArchiveRelationshipAsync(locationRelationshipId);
|
||||||
|
|
||||||
@ -3662,6 +3876,7 @@ public sealed class CharacterService(
|
|||||||
IBookRepository books,
|
IBookRepository books,
|
||||||
ICharacterRepository characters,
|
ICharacterRepository characters,
|
||||||
ISubscriptionService subscriptions,
|
ISubscriptionService subscriptions,
|
||||||
|
IProjectActivityService activity,
|
||||||
ICurrentUserService currentUser) : ICharacterService
|
ICurrentUserService currentUser) : ICharacterService
|
||||||
{
|
{
|
||||||
public async Task<CharacterListViewModel?> GetCharactersAsync(int projectId)
|
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);
|
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,
|
CharacterID = model.CharacterID,
|
||||||
ProjectID = model.ProjectID,
|
ProjectID = model.ProjectID,
|
||||||
@ -3797,11 +4013,29 @@ public sealed class CharacterService(
|
|||||||
ShowInQuickAddBar = model.ShowInQuickAddBar,
|
ShowInQuickAddBar = model.ShowInQuickAddBar,
|
||||||
DefaultDescription = model.DefaultDescription
|
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)
|
public async Task<int> SaveInitialRelationshipAsync(InitialRelationshipEditViewModel model)
|
||||||
{
|
{
|
||||||
@ -3815,7 +4049,8 @@ public sealed class CharacterService(
|
|||||||
throw new InvalidOperationException("Choose a relationship type.");
|
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,
|
CharacterRelationshipID = model.CharacterRelationshipID,
|
||||||
ProjectID = model.ProjectID,
|
ProjectID = model.ProjectID,
|
||||||
@ -3832,6 +4067,8 @@ public sealed class CharacterService(
|
|||||||
InitialIntensity = model.InitialIntensity,
|
InitialIntensity = model.InitialIntensity,
|
||||||
IsReciprocal = model.IsReciprocal
|
IsReciprocal = model.IsReciprocal
|
||||||
});
|
});
|
||||||
|
await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Relationship", relationshipId, "Character relationship");
|
||||||
|
return relationshipId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> AddSceneCharacterAsync(SceneCharacterCreateViewModel model)
|
public async Task<int> AddSceneCharacterAsync(SceneCharacterCreateViewModel model)
|
||||||
@ -3849,6 +4086,7 @@ public sealed class CharacterService(
|
|||||||
ProjectID = model.ReturnProjectID.Value,
|
ProjectID = model.ReturnProjectID.Value,
|
||||||
CharacterName = model.NewCharacterName.Trim()
|
CharacterName = model.NewCharacterName.Trim()
|
||||||
});
|
});
|
||||||
|
await activity.RecordAsync(model.ReturnProjectID.Value, "Created", "Character", characterId, model.NewCharacterName.Trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
return await characters.SaveSceneCharacterAsync(new SceneCharacter
|
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,
|
CharacterRelationshipID = relationshipId,
|
||||||
SceneID = model.SceneID,
|
SceneID = model.SceneID,
|
||||||
@ -3937,6 +4175,12 @@ public sealed class CharacterService(
|
|||||||
IsKnownToOtherCharacter = model.IsKnownToOtherCharacter,
|
IsKnownToOtherCharacter = model.IsKnownToOtherCharacter,
|
||||||
Description = model.Description
|
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);
|
public Task DeleteRelationshipEventAsync(int relationshipEventId) => characters.DeleteRelationshipEventAsync(relationshipEventId);
|
||||||
|
|||||||
355
PlotLine/Sql/046_Phase5E_AuditActivity.sql
Normal file
355
PlotLine/Sql/046_Phase5E_AuditActivity.sql
Normal 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
|
||||||
@ -44,6 +44,12 @@ public sealed class ProjectCollaboratorInviteViewModel
|
|||||||
public string EmailAddress { get; set; } = string.Empty;
|
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 sealed class BookEditViewModel
|
||||||
{
|
{
|
||||||
public int BookID { get; set; }
|
public int BookID { get; set; }
|
||||||
|
|||||||
55
PlotLine/Views/Projects/Activity.cshtml
Normal file
55
PlotLine/Views/Projects/Activity.cshtml
Normal 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>
|
||||||
@ -44,6 +44,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="button-row">
|
<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="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)
|
@if (Model.Project.IsOwner)
|
||||||
{
|
{
|
||||||
<a class="btn btn-outline-secondary" asp-controller="ProjectCollaborators" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Collaborators</a>
|
<a class="btn btn-outline-secondary" asp-controller="ProjectCollaborators" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Collaborators</a>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user