Phase 5E Audit Activity
This commit is contained in:
parent
9824b364b7
commit
1f3f68e6f3
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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>();
|
||||
|
||||
@ -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);
|
||||
|
||||
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 sealed class ProjectActivityViewModel
|
||||
{
|
||||
public Project Project { get; set; } = new();
|
||||
public IReadOnlyList<ProjectActivity> Activities { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class BookEditViewModel
|
||||
{
|
||||
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 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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user