From 90b2212890218c9d985ce52522ba97104584de97 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sun, 7 Jun 2026 09:54:47 +0100 Subject: [PATCH] Phase 5A Project User access --- PlotLine/Controllers/ProjectsController.cs | 5 + PlotLine/Data/ImportRepository.cs | 27 +- PlotLine/Data/Repositories.cs | 53 ++- PlotLine/Program.cs | 7 +- PlotLine/Services/CoreServices.cs | 89 ++++- PlotLine/Services/ImportServices.cs | 26 +- PlotLine/Services/ProjectAccessFilter.cs | 245 ++++++++++++ PlotLine/Services/ProjectAccessServices.cs | 76 ++++ .../Sql/042_Phase5A_ProjectUserAccess.sql | 378 ++++++++++++++++++ 9 files changed, 864 insertions(+), 42 deletions(-) create mode 100644 PlotLine/Services/ProjectAccessFilter.cs create mode 100644 PlotLine/Services/ProjectAccessServices.cs create mode 100644 PlotLine/Sql/042_Phase5A_ProjectUserAccess.sql diff --git a/PlotLine/Controllers/ProjectsController.cs b/PlotLine/Controllers/ProjectsController.cs index 02fad02..3d7decf 100644 --- a/PlotLine/Controllers/ProjectsController.cs +++ b/PlotLine/Controllers/ProjectsController.cs @@ -39,6 +39,11 @@ public sealed class ProjectsController(IProjectService projects) : Controller } var projectId = await projects.SaveAsync(model); + if (projectId == 0) + { + return NotFound(); + } + return RedirectToAction(nameof(Details), new { id = projectId }); } diff --git a/PlotLine/Data/ImportRepository.cs b/PlotLine/Data/ImportRepository.cs index 468578f..40dcbfd 100644 --- a/PlotLine/Data/ImportRepository.cs +++ b/PlotLine/Data/ImportRepository.cs @@ -7,12 +7,12 @@ namespace PlotLine.Data; public interface IImportRepository { - Task ProjectTitleExistsAsync(string projectTitle); - Task ImportAsync(PlotLineImportPackage package, string? importedProjectTitle); + Task ProjectTitleExistsAsync(string projectTitle, int userId); + Task ImportAsync(PlotLineImportPackage package, string? importedProjectTitle, int userId); Task> GetIdentityMaxValuesAsync(IEnumerable tableNames); Task GetRelationshipImportLookupsAsync(); Task GetRelationshipImportDiagnosticsAsync(int projectId); - Task CreateProjectShellAsync(string projectName, string? description); + Task CreateProjectShellAsync(string projectName, string? description, int userId); Task ApplyProjectBackupJsonAsync(int projectId, string jsonData); } @@ -20,16 +20,20 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : { private const string ImportMarker = "Imported via JSON Import Phase 1F"; - public async Task ProjectTitleExistsAsync(string projectTitle) + public async Task ProjectTitleExistsAsync(string projectTitle, int userId) { using var connection = connectionFactory.CreateConnection(); var count = await connection.ExecuteScalarAsync( """ SELECT COUNT(1) - FROM dbo.Projects - WHERE IsArchived = 0 AND ProjectName = @ProjectName; + FROM dbo.Projects p + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID + WHERE p.IsArchived = 0 + AND p.ProjectName = @ProjectName + AND pua.UserID = @UserID + AND pua.IsActive = 1; """, - new { ProjectName = projectTitle }); + new { ProjectName = projectTitle, UserID = userId }); return count > 0; } @@ -93,12 +97,12 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : commandType: CommandType.StoredProcedure); } - public async Task CreateProjectShellAsync(string projectName, string? description) + public async Task CreateProjectShellAsync(string projectName, string? description, int userId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.Project_Save", - new { ProjectID = 0, ProjectName = projectName, Description = description }, + new { ProjectID = 0, ProjectName = projectName, Description = description, UserID = userId }, commandType: CommandType.StoredProcedure); } @@ -118,7 +122,7 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : commandTimeout: 180); } - public async Task ImportAsync(PlotLineImportPackage package, string? importedProjectTitle) + public async Task ImportAsync(PlotLineImportPackage package, string? importedProjectTitle, int userId) { using var connection = connectionFactory.CreateConnection(); connection.Open(); @@ -156,7 +160,8 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : Description = CombineText( package.Project.Description, package.Project.Notes, - ImportMetadata(package)) + ImportMetadata(package)), + UserID = userId }, transaction, commandType: CommandType.StoredProcedure); diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index 1f4fef5..4a42286 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -7,11 +7,15 @@ namespace PlotLine.Data; public interface IProjectRepository { Task> ListAsync(); + Task> ListForUserAsync(int userId); Task GetAsync(int projectId); + Task GetForUserAsync(int projectId, int userId); Task> ListGenreMetricPresetsAsync(); Task SaveAsync(Project project); + Task SaveAsync(Project project, int userId, string? genreMetricPresetKey); Task SaveAsync(Project project, string? genreMetricPresetKey); Task ArchiveAsync(int projectId); + Task ArchiveForUserAsync(int projectId, int userId); } public interface IBookRepository @@ -275,7 +279,9 @@ public interface IScenarioRepository public interface IArchiveRepository { Task> ListAsync(int? projectId, string? entityType); + Task> ListForUserAsync(int? projectId, string? entityType, int userId); Task> ListProjectFiltersAsync(); + Task> ListProjectFiltersForUserAsync(int userId); Task ArchiveAsync(string entityType, int entityId, string? reason); Task RestoreAsync(string entityType, int entityId); Task DeleteForeverAsync(string entityType, int entityId, string confirmationText); @@ -1031,6 +1037,16 @@ public sealed class ArchiveRepository(ISqlConnectionFactory connectionFactory) : return rows.ToList(); } + public async Task> ListForUserAsync(int? projectId, string? entityType, int userId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.Archive_ListForUser", + new { ProjectID = projectId, EntityType = entityType, UserID = userId }, + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } + public async Task> ListProjectFiltersAsync() { using var connection = connectionFactory.CreateConnection(); @@ -1040,6 +1056,16 @@ public sealed class ArchiveRepository(ISqlConnectionFactory connectionFactory) : return rows.ToList(); } + public async Task> ListProjectFiltersForUserAsync(int userId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.Archive_ProjectFilterListForUser", + new { UserID = userId }, + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } + public async Task ArchiveAsync(string entityType, int entityId, string? reason) { using var connection = connectionFactory.CreateConnection(); @@ -1145,12 +1171,25 @@ public sealed class ProjectRepository(ISqlConnectionFactory connectionFactory) : return rows.ToList(); } + public async Task> ListForUserAsync(int userId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync("dbo.Project_ListForUser", new { UserID = userId }, commandType: CommandType.StoredProcedure); + return rows.ToList(); + } + public async Task GetAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync("dbo.Project_Get", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); } + public async Task GetForUserAsync(int projectId, int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync("dbo.Project_GetForUser", new { ProjectID = projectId, UserID = userId }, commandType: CommandType.StoredProcedure); + } + public async Task> ListGenreMetricPresetsAsync() { using var connection = connectionFactory.CreateConnection(); @@ -1160,12 +1199,18 @@ public sealed class ProjectRepository(ISqlConnectionFactory connectionFactory) : public Task SaveAsync(Project project) => SaveAsync(project, null); + public Task SaveAsync(Project project, int userId, string? genreMetricPresetKey) + => SaveInternalAsync(project, userId, genreMetricPresetKey); + public async Task SaveAsync(Project project, string? genreMetricPresetKey) + => await SaveInternalAsync(project, null, genreMetricPresetKey); + + private async Task SaveInternalAsync(Project project, int? userId, string? genreMetricPresetKey) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.Project_Save", - new { project.ProjectID, project.ProjectName, project.Description, GenreMetricPresetKey = genreMetricPresetKey }, + new { project.ProjectID, project.ProjectName, project.Description, GenreMetricPresetKey = genreMetricPresetKey, UserID = userId }, commandType: CommandType.StoredProcedure); } @@ -1174,6 +1219,12 @@ public sealed class ProjectRepository(ISqlConnectionFactory connectionFactory) : using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.Project_Archive", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); } + + public async Task ArchiveForUserAsync(int projectId, int userId) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync("dbo.Project_ArchiveForUser", new { ProjectID = projectId, UserID = userId }, commandType: CommandType.StoredProcedure); + } } public sealed class BookRepository(ISqlConnectionFactory connectionFactory) : IBookRepository diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 2280da9..59ea2d8 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -18,7 +18,10 @@ public class Program builder.Logging.AddConsole(); builder.Logging.AddDebug(); - builder.Services.AddControllersWithViews(); + builder.Services.AddControllersWithViews(options => + { + options.Filters.Add(); + }); builder.Services.AddHttpContextAccessor(); builder.Services.AddDistributedMemoryCache(); builder.Services.AddSession(options => @@ -99,6 +102,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -123,6 +127,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index a11f4ed..aa82b3e 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -238,13 +238,14 @@ public interface IAcceptanceService Task GetPhase1ChecklistAsync(int? projectId); } -public sealed class ProjectService(IProjectRepository projects, IBookRepository books) : IProjectService +public sealed class ProjectService(IProjectRepository projects, IBookRepository books, ICurrentUserService currentUser) : IProjectService { - public Task> ListAsync() => projects.ListAsync(); + public Task> ListAsync() + => currentUser.UserId.HasValue ? projects.ListForUserAsync(currentUser.UserId.Value) : Task.FromResult>([]); public async Task GetDetailAsync(int projectId) { - var project = await projects.GetAsync(projectId); + var project = currentUser.UserId.HasValue ? await projects.GetForUserAsync(projectId, currentUser.UserId.Value) : null; if (project is null) { return null; @@ -266,7 +267,7 @@ public sealed class ProjectService(IProjectRepository projects, IBookRepository public async Task GetEditAsync(int projectId) { - var project = await projects.GetAsync(projectId); + var project = currentUser.UserId.HasValue ? await projects.GetForUserAsync(projectId, currentUser.UserId.Value) : null; return project is null ? null : new ProjectEditViewModel { ProjectID = project.ProjectID, @@ -283,14 +284,23 @@ public sealed class ProjectService(IProjectRepository projects, IBookRepository .ToList(); } - public Task SaveAsync(ProjectEditViewModel model) => projects.SaveAsync(new Project + public Task SaveAsync(ProjectEditViewModel model) { - ProjectID = model.ProjectID, - ProjectName = model.ProjectName, - Description = model.Description - }, model.ProjectID == 0 ? model.GenreMetricPresetKey : null); + if (!currentUser.UserId.HasValue) + { + return Task.FromResult(0); + } - public Task ArchiveAsync(int projectId) => projects.ArchiveAsync(projectId); + return projects.SaveAsync(new Project + { + ProjectID = model.ProjectID, + ProjectName = model.ProjectName, + Description = model.Description + }, currentUser.UserId.Value, model.ProjectID == 0 ? model.GenreMetricPresetKey : null); + } + + public Task ArchiveAsync(int projectId) + => currentUser.UserId.HasValue ? projects.ArchiveForUserAsync(projectId, currentUser.UserId.Value) : Task.CompletedTask; } public sealed class BookService(IProjectRepository projects, IBookRepository books, IChapterRepository chapters) : IBookService @@ -2390,23 +2400,52 @@ public sealed class WriterWorkspaceService( IProjectRepository projects, IBookRepository books, IChapterRepository chapters, - IWriterWorkspaceRepository writerWorkspace) : IWriterWorkspaceService + IWriterWorkspaceRepository writerWorkspace, + ICurrentUserService currentUser) : IWriterWorkspaceService { public async Task GetDashboardAsync(int? projectId) { - var projectRows = await projects.ListAsync(); + var projectRows = currentUser.UserId.HasValue + ? await projects.ListForUserAsync(currentUser.UserId.Value) + : Array.Empty(); + var projectIds = projectId.HasValue + ? projectRows.Where(project => project.ProjectID == projectId.Value).Select(project => project.ProjectID).ToList() + : projectRows.Select(project => project.ProjectID).ToList(); + return new WriterDashboardViewModel { ProjectID = projectId, ProjectOptions = projectRows.Select(x => new SelectListItem(x.ProjectName, x.ProjectID.ToString(), x.ProjectID == projectId)).ToList(), - ContinueWriting = await writerWorkspace.GetWriterDashboardScenesAsync(projectId, "ContinueWriting"), - NeedsAttention = await writerWorkspace.GetWriterDashboardScenesAsync(projectId, "NeedsAttention"), - RevisionQueue = await writerWorkspace.GetWriterDashboardScenesAsync(projectId, "RevisionQueue"), - PolishingQueue = await writerWorkspace.GetWriterDashboardScenesAsync(projectId, "PolishingQueue"), - StoryHealthReminders = await writerWorkspace.GetWriterDashboardRemindersAsync(projectId) + ContinueWriting = await GetDashboardScenesAsync(projectIds, "ContinueWriting"), + NeedsAttention = await GetDashboardScenesAsync(projectIds, "NeedsAttention"), + RevisionQueue = await GetDashboardScenesAsync(projectIds, "RevisionQueue"), + PolishingQueue = await GetDashboardScenesAsync(projectIds, "PolishingQueue"), + StoryHealthReminders = await GetDashboardRemindersAsync(projectIds) }; } + private async Task> GetDashboardScenesAsync(IReadOnlyList projectIds, string queue) + { + var rows = new List(); + foreach (var accessibleProjectId in projectIds) + { + rows.AddRange(await writerWorkspace.GetWriterDashboardScenesAsync(accessibleProjectId, queue)); + } + + return rows; + } + + private async Task> GetDashboardRemindersAsync(IReadOnlyList projectIds) + { + var rows = new List(); + foreach (var accessibleProjectId in projectIds) + { + rows.AddRange(await writerWorkspace.GetWriterDashboardRemindersAsync(accessibleProjectId)); + } + + return rows; + } + public async Task GetChapterWorkflowAsync(int chapterId) { var chapter = await chapters.GetAsync(chapterId); @@ -2949,7 +2988,7 @@ public sealed class ScenarioService(IProjectRepository projects, IBookRepository public Task ArchiveAsync(int scenarioId) => scenarios.ArchiveAsync(scenarioId); } -public sealed class ArchiveService(IArchiveRepository archives) : IArchiveService +public sealed class ArchiveService(IArchiveRepository archives, ICurrentUserService currentUser) : IArchiveService { private static readonly (string Value, string Label)[] EntityTypes = [ @@ -2969,7 +3008,9 @@ public sealed class ArchiveService(IArchiveRepository archives) : IArchiveServic public async Task GetArchivedItemsAsync(int? projectId, string? entityType) { var normalisedType = EntityTypes.Any(x => x.Value == entityType) ? entityType : null; - var projects = await archives.ListProjectFiltersAsync(); + var projects = currentUser.UserId.HasValue + ? await archives.ListProjectFiltersForUserAsync(currentUser.UserId.Value) + : Array.Empty(); return new ArchivedItemsViewModel { ProjectID = projectId, @@ -2979,7 +3020,9 @@ public sealed class ArchiveService(IArchiveRepository archives) : IArchiveServic project.ProjectID.ToString(), project.ProjectID == projectId)).ToList(), EntityTypeOptions = EntityTypes.Select(type => new SelectListItem(type.Label, type.Value, type.Value == normalisedType)).ToList(), - Items = await archives.ListAsync(projectId, normalisedType) + Items = currentUser.UserId.HasValue + ? await archives.ListForUserAsync(projectId, normalisedType, currentUser.UserId.Value) + : Array.Empty() }; } @@ -2993,11 +3036,13 @@ public sealed class ArchiveService(IArchiveRepository archives) : IArchiveServic archives.DeleteForeverAsync(entityType, entityId, confirmationText); } -public sealed class AcceptanceService(IProjectRepository projects, IAcceptanceRepository acceptance) : IAcceptanceService +public sealed class AcceptanceService(IProjectRepository projects, IAcceptanceRepository acceptance, ICurrentUserService currentUser) : IAcceptanceService { public async Task GetPhase1ChecklistAsync(int? projectId) { - var projectRows = await projects.ListAsync(); + var projectRows = currentUser.UserId.HasValue + ? await projects.ListForUserAsync(currentUser.UserId.Value) + : Array.Empty(); var selectedProject = projectId.HasValue ? projectRows.FirstOrDefault(x => x.ProjectID == projectId.Value) : projectRows.FirstOrDefault(); diff --git a/PlotLine/Services/ImportServices.cs b/PlotLine/Services/ImportServices.cs index ccfb522..903e523 100644 --- a/PlotLine/Services/ImportServices.cs +++ b/PlotLine/Services/ImportServices.cs @@ -14,7 +14,7 @@ public interface IImportService Task BuildValidationReportAsync(ImportIndexViewModel model); } -public sealed class ImportService(IImportRepository imports, ILogger logger) : IImportService +public sealed class ImportService(IImportRepository imports, ICurrentUserService currentUser, ILogger logger) : IImportService { private const int TitleMaxLength = 200; private const int RelativeTimeMaxLength = 200; @@ -58,6 +58,16 @@ public sealed class ImportService(IImportRepository imports, ILogger ImportAsync(ImportIndexViewModel model) { + var userId = currentUser.UserId; + if (!userId.HasValue) + { + return new ImportResult + { + Succeeded = false, + Message = "Sign in before importing a project." + }; + } + model.JsonText = await ReadJsonTextAsync(model); var formatValidation = new ImportValidationResult(); var format = DetectImportFormat(model.JsonText, formatValidation); @@ -65,7 +75,7 @@ public sealed class ImportService(IImportRepository imports, ILogger ImportProjectBackupAsync(ImportIndexViewModel model, ProjectBackupExport backup, ImportValidationResult validation) + private async Task ImportProjectBackupAsync(ImportIndexViewModel model, ProjectBackupExport backup, ImportValidationResult validation, int userId) { var preview = await BuildProjectBackupPreviewAsync(backup, validation); model.Preview = preview; @@ -374,7 +386,7 @@ public sealed class ImportService(IImportRepository imports, ILogger(); diff --git a/PlotLine/Services/ProjectAccessFilter.cs b/PlotLine/Services/ProjectAccessFilter.cs new file mode 100644 index 0000000..64bff24 --- /dev/null +++ b/PlotLine/Services/ProjectAccessFilter.cs @@ -0,0 +1,245 @@ +using System.Collections; +using System.Reflection; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace PlotLine.Services; + +public sealed class ProjectAccessFilter(IProjectAccessService access, ICurrentUserService currentUser) : IAsyncActionFilter +{ + private static readonly HashSet SkippedControllers = new(StringComparer.OrdinalIgnoreCase) + { + "Account", + "Home", + "Help", + "HelpDiagnostics", + "HelpDrawer", + "ManageAccount" + }; + + private static readonly Dictionary ModelPropertyEntities = new(StringComparer.OrdinalIgnoreCase) + { + ["ProjectID"] = "Project", + ["ReturnProjectID"] = "Project", + ["BookID"] = "Book", + ["ReturnBookID"] = "Book", + ["InitialBookID"] = "Book", + ["ChapterID"] = "Chapter", + ["TargetChapterID"] = "Chapter", + ["SceneID"] = "Scene", + ["CurrentSceneID"] = "Scene", + ["ReturnSelectedSceneID"] = "Scene", + ["SelectedSceneID"] = "Scene", + ["AnchorSceneID"] = "Scene", + ["SourceSceneID"] = "Scene", + ["TargetSceneID"] = "Scene", + ["CharacterID"] = "Character", + ["RelatedCharacterID"] = "Character", + ["CharacterAID"] = "Character", + ["CharacterBID"] = "Character", + ["POVCharacterID"] = "Character", + ["FocusCharacterID"] = "Character", + ["StoryAssetID"] = "StoryAsset", + ["SourceAssetID"] = "StoryAsset", + ["TargetAssetID"] = "StoryAsset", + ["LocationID"] = "Location", + ["ParentLocationID"] = "Location", + ["FromLocationID"] = "Location", + ["ToLocationID"] = "Location", + ["PlotLineID"] = "PlotLine", + ["ParentPlotLineID"] = "PlotLine", + ["PlotThreadID"] = "PlotThread", + ["CharacterRelationshipID"] = "CharacterRelationship", + ["TimelineViewPresetID"] = "TimelineViewPreset", + ["ScenarioID"] = "Scenario" + }; + + private static readonly Dictionary> ControllerParameterEntities = new(StringComparer.OrdinalIgnoreCase) + { + ["Projects"] = new(StringComparer.OrdinalIgnoreCase) { ["id"] = "Project", ["projectId"] = "Project" }, + ["Books"] = new(StringComparer.OrdinalIgnoreCase) { ["id"] = "Book", ["bookId"] = "Book", ["projectId"] = "Project" }, + ["Chapters"] = new(StringComparer.OrdinalIgnoreCase) { ["id"] = "Chapter", ["chapterId"] = "Chapter", ["bookId"] = "Book" }, + ["Characters"] = new(StringComparer.OrdinalIgnoreCase) { ["id"] = "Character", ["characterId"] = "Character", ["projectId"] = "Project" }, + ["Locations"] = new(StringComparer.OrdinalIgnoreCase) { ["id"] = "Location", ["locationId"] = "Location", ["returnLocationId"] = "Location", ["projectId"] = "Project" }, + ["PlotLines"] = new(StringComparer.OrdinalIgnoreCase) { ["id"] = "PlotLine", ["plotLineId"] = "PlotLine", ["projectId"] = "Project" }, + ["PlotThreads"] = new(StringComparer.OrdinalIgnoreCase) { ["id"] = "PlotThread", ["plotThreadId"] = "PlotThread", ["plotLineId"] = "PlotLine", ["projectId"] = "Project" }, + ["ProjectMetrics"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project" }, + ["ProjectRestorePoints"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project", ["id"] = "ProjectRestorePoint" }, + ["RelationshipMap"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project", ["bookId"] = "Book", ["chapterId"] = "Chapter", ["sceneId"] = "Scene", ["focusCharacterId"] = "Character" }, + ["Scenarios"] = new(StringComparer.OrdinalIgnoreCase) { ["id"] = "Scenario", ["scenarioId"] = "Scenario", ["projectId"] = "Project", ["bookId"] = "Book", ["sceneId"] = "Scene", ["anchorSceneId"] = "Scene", ["targetChapterId"] = "Chapter" }, + ["StoryAssets"] = new(StringComparer.OrdinalIgnoreCase) { ["id"] = "StoryAsset", ["returnAssetId"] = "StoryAsset", ["projectId"] = "Project" }, + ["Timeline"] = new(StringComparer.OrdinalIgnoreCase) { ["id"] = "TimelineViewPreset", ["projectId"] = "Project", ["bookId"] = "Book" }, + ["Writer"] = new(StringComparer.OrdinalIgnoreCase) { ["id"] = "Chapter", ["projectId"] = "Project" }, + ["Warnings"] = new(StringComparer.OrdinalIgnoreCase) { ["id"] = "ContinuityWarning", ["projectId"] = "Project", ["bookId"] = "Book", ["sceneId"] = "Scene" }, + ["Analytics"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project", ["bookId"] = "Book" }, + ["Archives"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project" }, + ["Dynamics"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project", ["bookId"] = "Book", ["sceneId"] = "Scene" }, + ["Exports"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project", ["sceneId"] = "Scene" }, + ["Scenes"] = new(StringComparer.OrdinalIgnoreCase) { ["chapterId"] = "Chapter", ["sceneId"] = "Scene", ["projectId"] = "Project", ["bookId"] = "Book" }, + ["Acceptance"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project" }, + ["StoryBible"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project" } + }; + + private static readonly Dictionary> ActionIdEntities = new(StringComparer.OrdinalIgnoreCase) + { + ["Characters"] = new(StringComparer.OrdinalIgnoreCase) { ["ArchiveRelationship"] = "CharacterRelationship" }, + ["Dynamics"] = new(StringComparer.OrdinalIgnoreCase) { ["Character"] = "Character", ["Knowledge"] = "Character", ["Relationship"] = "CharacterRelationship" }, + ["Exports"] = new(StringComparer.OrdinalIgnoreCase) { ["Character"] = "Character", ["SceneBrief"] = "Scene", ["ChapterBrief"] = "Chapter" }, + ["Locations"] = new(StringComparer.OrdinalIgnoreCase) { ["ArchiveRelationship"] = "LocationRelationship" }, + ["Scenes"] = new(StringComparer.OrdinalIgnoreCase) + { + ["Create"] = "Chapter", + ["Edit"] = "Scene", + ["Archive"] = "Scene", + ["MoveUp"] = "Scene", + ["MoveDown"] = "Scene", + ["DeleteThreadEvent"] = "ThreadEvent", + ["DeleteAssetEvent"] = "AssetEvent", + ["DeleteSceneAssetLocation"] = "SceneAssetLocation", + ["DeleteAssetCustodyEvent"] = "AssetCustodyEvent", + ["DeleteSceneCharacter"] = "SceneCharacter", + ["DeleteSceneDependency"] = "SceneDependency", + ["DeleteCharacterKnowledge"] = "CharacterKnowledge", + ["DeleteRelationshipEvent"] = "RelationshipEvent", + ["DeleteNote"] = "SceneNote", + ["DeleteChecklistItem"] = "SceneChecklistItem", + ["DeleteAttachment"] = "SceneAttachment" + }, + ["StoryAssets"] = new(StringComparer.OrdinalIgnoreCase) { ["DeleteDependency"] = "AssetDependency" }, + ["Timeline"] = new(StringComparer.OrdinalIgnoreCase) { ["LoadPreset"] = "TimelineViewPreset", ["DeletePreset"] = "TimelineViewPreset" }, + ["Warnings"] = new(StringComparer.OrdinalIgnoreCase) { ["Dismiss"] = "ContinuityWarning", ["MarkIntentional"] = "ContinuityWarning", ["Restore"] = "ContinuityWarning" } + }; + + private static readonly Dictionary ArchiveEntityTypes = new(StringComparer.OrdinalIgnoreCase) + { + ["Project"] = "Project", + ["Book"] = "Book", + ["Chapter"] = "Chapter", + ["Scene"] = "Scene", + ["PlotLine"] = "PlotLine", + ["PlotThread"] = "PlotThread", + ["StoryAsset"] = "StoryAsset", + ["Character"] = "Character", + ["Location"] = "Location", + ["Scenario"] = "Scenario", + ["Relationship"] = "CharacterRelationship", + ["CharacterRelationship"] = "CharacterRelationship" + }; + + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + if (!currentUser.IsAuthenticated || context.ActionDescriptor is not ControllerActionDescriptor descriptor) + { + await next(); + return; + } + + var controllerName = descriptor.ControllerName; + if (SkippedControllers.Contains(controllerName)) + { + await next(); + return; + } + + var requirements = CollectRequirements(controllerName, descriptor.ActionName, context.ActionArguments); + foreach (var requirement in requirements) + { + var allowed = requirement.EntityType == "Project" + ? await access.CanAccessProjectAsync(requirement.EntityId) + : await access.CanAccessEntityAsync(requirement.EntityType, requirement.EntityId); + + if (!allowed) + { + context.Result = new NotFoundResult(); + return; + } + } + + await next(); + } + + private static HashSet CollectRequirements(string controllerName, string actionName, IDictionary arguments) + { + var requirements = new HashSet(); + var parameterMap = ControllerParameterEntities.GetValueOrDefault(controllerName); + var actionIdEntity = ActionIdEntities.GetValueOrDefault(controllerName)?.GetValueOrDefault(actionName); + + if (string.Equals(controllerName, "Archives", StringComparison.OrdinalIgnoreCase) + && arguments.TryGetValue("entityType", out var archiveEntityType) + && arguments.TryGetValue("entityId", out var archiveEntityId) + && archiveEntityType is string archiveEntityTypeValue + && ArchiveEntityTypes.TryGetValue(archiveEntityTypeValue, out var archiveAccessEntityType) + && TryGetInt(archiveEntityId, out var archiveId) + && archiveId > 0) + { + requirements.Add(new AccessRequirement(archiveAccessEntityType, archiveId)); + } + + foreach (var argument in arguments) + { + if (TryGetInt(argument.Value, out var id) && id > 0) + { + if (string.Equals(argument.Key, "id", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(actionIdEntity)) + { + requirements.Add(new AccessRequirement(actionIdEntity, id)); + continue; + } + + if (parameterMap is not null && parameterMap.TryGetValue(argument.Key, out var entityType)) + { + requirements.Add(new AccessRequirement(entityType, id)); + continue; + } + } + + CollectModelRequirements(argument.Value, requirements); + } + + return requirements; + } + + private static void CollectModelRequirements(object? value, HashSet requirements) + { + if (value is null || value is string || value.GetType().IsPrimitive || value is IFormFile) + { + return; + } + + if (value is IEnumerable enumerable && value is not IDictionary) + { + foreach (var item in enumerable) + { + CollectModelRequirements(item, requirements); + } + + return; + } + + foreach (var property in value.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + if (property.GetIndexParameters().Length > 0 || !ModelPropertyEntities.TryGetValue(property.Name, out var entityType)) + { + continue; + } + + if (TryGetInt(property.GetValue(value), out var id) && id > 0) + { + requirements.Add(new AccessRequirement(entityType, id)); + } + } + } + + private static bool TryGetInt(object? value, out int id) + { + id = 0; + return value switch + { + int intValue => (id = intValue) > 0, + string stringValue => int.TryParse(stringValue, out id) && id > 0, + _ => false + }; + } + + private readonly record struct AccessRequirement(string EntityType, int EntityId); +} diff --git a/PlotLine/Services/ProjectAccessServices.cs b/PlotLine/Services/ProjectAccessServices.cs new file mode 100644 index 0000000..fcaa5bd --- /dev/null +++ b/PlotLine/Services/ProjectAccessServices.cs @@ -0,0 +1,76 @@ +using System.Data; +using Dapper; +using PlotLine.Data; + +namespace PlotLine.Services; + +public interface IProjectAccessRepository +{ + Task UserCanAccessProjectAsync(int projectId, int userId); + Task UserCanOwnProjectAsync(int projectId, int userId); + Task UserCanAccessEntityAsync(string entityType, int entityId, int userId); +} + +public interface IProjectAccessService +{ + Task CanAccessProjectAsync(int projectId); + Task CanOwnProjectAsync(int projectId); + Task CanAccessEntityAsync(string entityType, int entityId); +} + +public sealed class ProjectAccessRepository(ISqlConnectionFactory connectionFactory) : IProjectAccessRepository +{ + public async Task UserCanAccessProjectAsync(int projectId, int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.ProjectAccess_UserCanAccess", + new { ProjectID = projectId, UserID = userId }, + commandType: CommandType.StoredProcedure); + } + + public async Task UserCanOwnProjectAsync(int projectId, int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.ProjectAccess_UserCanOwn", + new { ProjectID = projectId, UserID = userId }, + commandType: CommandType.StoredProcedure); + } + + public async Task UserCanAccessEntityAsync(string entityType, int entityId, int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.ProjectAccess_UserCanAccessEntity", + new { EntityType = entityType, EntityID = entityId, UserID = userId }, + commandType: CommandType.StoredProcedure); + } +} + +public sealed class ProjectAccessService(IProjectAccessRepository access, ICurrentUserService currentUser) : IProjectAccessService +{ + public Task CanAccessProjectAsync(int projectId) + { + var userId = currentUser.UserId; + return userId.HasValue && projectId > 0 + ? access.UserCanAccessProjectAsync(projectId, userId.Value) + : Task.FromResult(false); + } + + public Task CanOwnProjectAsync(int projectId) + { + var userId = currentUser.UserId; + return userId.HasValue && projectId > 0 + ? access.UserCanOwnProjectAsync(projectId, userId.Value) + : Task.FromResult(false); + } + + public Task CanAccessEntityAsync(string entityType, int entityId) + { + var userId = currentUser.UserId; + return userId.HasValue && entityId > 0 && !string.IsNullOrWhiteSpace(entityType) + ? access.UserCanAccessEntityAsync(entityType, entityId, userId.Value) + : Task.FromResult(false); + } +} diff --git a/PlotLine/Sql/042_Phase5A_ProjectUserAccess.sql b/PlotLine/Sql/042_Phase5A_ProjectUserAccess.sql new file mode 100644 index 0000000..a7d298a --- /dev/null +++ b/PlotLine/Sql/042_Phase5A_ProjectUserAccess.sql @@ -0,0 +1,378 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF OBJECT_ID(N'dbo.ProjectUserAccess', N'U') IS NULL +BEGIN + CREATE TABLE dbo.ProjectUserAccess + ( + ProjectUserAccessID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_ProjectUserAccess PRIMARY KEY, + ProjectID int NOT NULL, + UserID int NOT NULL, + AccessRole nvarchar(50) NOT NULL, + InvitedByUserID int NULL, + InvitedDateUTC datetime2 NULL, + AcceptedDateUTC datetime2 NULL, + IsActive bit NOT NULL CONSTRAINT DF_ProjectUserAccess_IsActive DEFAULT 1, + CONSTRAINT FK_ProjectUserAccess_Project FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID), + CONSTRAINT FK_ProjectUserAccess_User FOREIGN KEY (UserID) REFERENCES dbo.AppUser(UserID), + CONSTRAINT FK_ProjectUserAccess_InvitedByUser FOREIGN KEY (InvitedByUserID) REFERENCES dbo.AppUser(UserID), + CONSTRAINT CK_ProjectUserAccess_AccessRole CHECK (AccessRole IN (N'Owner', N'Collaborator')) + ); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_ProjectUserAccess_Project_User_Active' AND object_id = OBJECT_ID(N'dbo.ProjectUserAccess')) + CREATE UNIQUE INDEX UX_ProjectUserAccess_Project_User_Active ON dbo.ProjectUserAccess(ProjectID, UserID) WHERE IsActive = 1; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_ProjectUserAccess_User_Project' AND object_id = OBJECT_ID(N'dbo.ProjectUserAccess')) + CREATE INDEX IX_ProjectUserAccess_User_Project ON dbo.ProjectUserAccess(UserID, ProjectID, IsActive, AccessRole); +GO + +DECLARE @LegacyOwnerUserID int; +SELECT TOP (1) @LegacyOwnerUserID = UserID +FROM dbo.AppUser +ORDER BY UserID; + +IF @LegacyOwnerUserID IS NOT NULL +BEGIN + INSERT INTO dbo.ProjectUserAccess (ProjectID, UserID, AccessRole, AcceptedDateUTC, IsActive) + SELECT p.ProjectID, @LegacyOwnerUserID, N'Owner', SYSUTCDATETIME(), 1 + FROM dbo.Projects p + WHERE NOT EXISTS + ( + SELECT 1 + FROM dbo.ProjectUserAccess pua + WHERE pua.ProjectID = p.ProjectID + AND pua.UserID = @LegacyOwnerUserID + AND pua.IsActive = 1 + ); +END; +GO + +CREATE OR ALTER PROCEDURE dbo.ProjectAccess_EnsureOwner + @ProjectID int, + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + IF NOT EXISTS + ( + SELECT 1 + FROM dbo.ProjectUserAccess + WHERE ProjectID = @ProjectID + AND UserID = @UserID + AND IsActive = 1 + ) + BEGIN + INSERT INTO dbo.ProjectUserAccess (ProjectID, UserID, AccessRole, AcceptedDateUTC, IsActive) + VALUES (@ProjectID, @UserID, N'Owner', SYSUTCDATETIME(), 1); + END; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.ProjectAccess_UserCanAccess + @ProjectID int, + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT CAST(CASE WHEN EXISTS + ( + SELECT 1 + FROM dbo.ProjectUserAccess pua + INNER JOIN dbo.Projects p ON p.ProjectID = pua.ProjectID + WHERE pua.ProjectID = @ProjectID + AND pua.UserID = @UserID + AND pua.IsActive = 1 + AND p.IsArchived = 0 + ) + THEN 1 ELSE 0 END AS bit); +END; +GO + +CREATE OR ALTER PROCEDURE dbo.ProjectAccess_UserCanOwn + @ProjectID int, + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT CAST(CASE WHEN EXISTS + ( + SELECT 1 + FROM dbo.ProjectUserAccess pua + INNER JOIN dbo.Projects p ON p.ProjectID = pua.ProjectID + WHERE pua.ProjectID = @ProjectID + AND pua.UserID = @UserID + AND pua.AccessRole = N'Owner' + AND pua.IsActive = 1 + AND p.IsArchived = 0 + ) + THEN 1 ELSE 0 END AS bit); +END; +GO + +CREATE OR ALTER PROCEDURE dbo.ProjectAccess_GetProjectIDForEntity + @EntityType nvarchar(100), + @EntityID int +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @ProjectID int = NULL; + + IF @EntityType = N'Project' + SELECT @ProjectID = ProjectID FROM dbo.Projects WHERE ProjectID = @EntityID; + ELSE IF @EntityType = N'Book' + SELECT @ProjectID = ProjectID FROM dbo.Books WHERE BookID = @EntityID; + ELSE IF @EntityType = N'Chapter' + SELECT @ProjectID = b.ProjectID FROM dbo.Chapters c INNER JOIN dbo.Books b ON b.BookID = c.BookID WHERE c.ChapterID = @EntityID; + ELSE IF @EntityType = N'Scene' + SELECT @ProjectID = b.ProjectID FROM dbo.Scenes s INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID INNER JOIN dbo.Books b ON b.BookID = c.BookID WHERE s.SceneID = @EntityID; + ELSE IF @EntityType = N'Character' + SELECT @ProjectID = ProjectID FROM dbo.Characters WHERE CharacterID = @EntityID; + ELSE IF @EntityType = N'SceneCharacter' + SELECT @ProjectID = ch.ProjectID FROM dbo.SceneCharacters sc INNER JOIN dbo.Characters ch ON ch.CharacterID = sc.CharacterID WHERE sc.SceneCharacterID = @EntityID; + ELSE IF @EntityType = N'CharacterAttributeEvent' + SELECT @ProjectID = ch.ProjectID FROM dbo.CharacterAttributeEvents cae INNER JOIN dbo.Characters ch ON ch.CharacterID = cae.CharacterID WHERE cae.CharacterAttributeEventID = @EntityID; + ELSE IF @EntityType = N'CharacterKnowledge' + SELECT @ProjectID = ch.ProjectID FROM dbo.CharacterKnowledge ck INNER JOIN dbo.Characters ch ON ch.CharacterID = ck.CharacterID WHERE ck.CharacterKnowledgeID = @EntityID; + ELSE IF @EntityType = N'CharacterRelationship' + SELECT @ProjectID = ProjectID FROM dbo.CharacterRelationships WHERE CharacterRelationshipID = @EntityID; + ELSE IF @EntityType = N'RelationshipEvent' + SELECT @ProjectID = cr.ProjectID FROM dbo.RelationshipEvents re INNER JOIN dbo.CharacterRelationships cr ON cr.CharacterRelationshipID = re.CharacterRelationshipID WHERE re.RelationshipEventID = @EntityID; + ELSE IF @EntityType = N'StoryAsset' + SELECT @ProjectID = ProjectID FROM dbo.StoryAssets WHERE StoryAssetID = @EntityID; + ELSE IF @EntityType = N'AssetEvent' + SELECT @ProjectID = sa.ProjectID FROM dbo.AssetEvents ae INNER JOIN dbo.StoryAssets sa ON sa.StoryAssetID = ae.StoryAssetID WHERE ae.AssetEventID = @EntityID; + ELSE IF @EntityType = N'AssetDependency' + SELECT @ProjectID = sa.ProjectID FROM dbo.AssetDependencies ad INNER JOIN dbo.StoryAssets sa ON sa.StoryAssetID = ad.SourceAssetID WHERE ad.AssetDependencyID = @EntityID; + ELSE IF @EntityType = N'AssetCustodyEvent' + SELECT @ProjectID = sa.ProjectID FROM dbo.AssetCustodyEvents ace INNER JOIN dbo.StoryAssets sa ON sa.StoryAssetID = ace.StoryAssetID WHERE ace.AssetCustodyEventID = @EntityID; + ELSE IF @EntityType = N'Location' + SELECT @ProjectID = ProjectID FROM dbo.Locations WHERE LocationID = @EntityID; + ELSE IF @EntityType = N'LocationRelationship' + SELECT @ProjectID = l.ProjectID FROM dbo.LocationRelationships lr INNER JOIN dbo.Locations l ON l.LocationID = lr.FromLocationID WHERE lr.LocationRelationshipID = @EntityID; + ELSE IF @EntityType = N'SceneAssetLocation' + SELECT @ProjectID = l.ProjectID FROM dbo.SceneAssetLocations sal INNER JOIN dbo.Locations l ON l.LocationID = sal.LocationID WHERE sal.SceneAssetLocationID = @EntityID; + ELSE IF @EntityType = N'PlotLine' + SELECT @ProjectID = ProjectID FROM dbo.PlotLines WHERE PlotLineID = @EntityID; + ELSE IF @EntityType = N'PlotThread' + SELECT @ProjectID = pl.ProjectID FROM dbo.PlotThreads pt INNER JOIN dbo.PlotLines pl ON pl.PlotLineID = pt.PlotLineID WHERE pt.PlotThreadID = @EntityID; + ELSE IF @EntityType = N'ThreadEvent' + SELECT @ProjectID = pl.ProjectID FROM dbo.ThreadEvents te INNER JOIN dbo.PlotThreads pt ON pt.PlotThreadID = te.PlotThreadID INNER JOIN dbo.PlotLines pl ON pl.PlotLineID = pt.PlotLineID WHERE te.ThreadEventID = @EntityID; + ELSE IF @EntityType = N'SceneDependency' + SELECT @ProjectID = ProjectID FROM dbo.SceneDependencies WHERE SceneDependencyID = @EntityID; + ELSE IF @EntityType = N'SceneMetricType' + SELECT @ProjectID = ProjectID FROM dbo.SceneMetricTypes WHERE MetricTypeID = @EntityID; + ELSE IF @EntityType = N'TimelineViewPreset' + SELECT @ProjectID = ProjectID FROM dbo.TimelineViewPresets WHERE TimelineViewPresetID = @EntityID; + ELSE IF @EntityType = N'ContinuityWarning' + SELECT @ProjectID = ProjectID FROM dbo.ContinuityWarnings WHERE ContinuityWarningID = @EntityID; + ELSE IF @EntityType = N'Scenario' + SELECT @ProjectID = ProjectID FROM dbo.Scenarios WHERE ScenarioID = @EntityID; + ELSE IF @EntityType = N'ScenarioWarning' + SELECT @ProjectID = sc.ProjectID FROM dbo.ScenarioWarnings sw INNER JOIN dbo.Scenarios sc ON sc.ScenarioID = sw.ScenarioID WHERE sw.ScenarioWarningID = @EntityID; + ELSE IF @EntityType = N'ProjectRestorePoint' + SELECT @ProjectID = ProjectID FROM dbo.ProjectRestorePoints WHERE ProjectRestorePointID = @EntityID; + ELSE IF @EntityType = N'ChapterGoal' + SELECT @ProjectID = b.ProjectID FROM dbo.ChapterGoals cg INNER JOIN dbo.Chapters c ON c.ChapterID = cg.ChapterID INNER JOIN dbo.Books b ON b.BookID = c.BookID WHERE cg.ChapterGoalID = @EntityID; + ELSE IF @EntityType = N'SceneWorkflow' + SELECT @ProjectID = b.ProjectID FROM dbo.SceneWorkflow sw INNER JOIN dbo.Scenes s ON s.SceneID = sw.SceneID INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID INNER JOIN dbo.Books b ON b.BookID = c.BookID WHERE sw.SceneWorkflowID = @EntityID; + ELSE IF @EntityType = N'SceneNote' + SELECT @ProjectID = b.ProjectID FROM dbo.SceneNotes sn INNER JOIN dbo.Scenes s ON s.SceneID = sn.SceneID INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID INNER JOIN dbo.Books b ON b.BookID = c.BookID WHERE sn.SceneNoteID = @EntityID; + ELSE IF @EntityType = N'SceneChecklistItem' + SELECT @ProjectID = b.ProjectID FROM dbo.SceneChecklistItems sci INNER JOIN dbo.Scenes s ON s.SceneID = sci.SceneID INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID INNER JOIN dbo.Books b ON b.BookID = c.BookID WHERE sci.SceneChecklistItemID = @EntityID; + ELSE IF @EntityType = N'SceneAttachment' + SELECT @ProjectID = b.ProjectID FROM dbo.SceneAttachments sa INNER JOIN dbo.Scenes s ON s.SceneID = sa.SceneID INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID INNER JOIN dbo.Books b ON b.BookID = c.BookID WHERE sa.SceneAttachmentID = @EntityID; + + SELECT @ProjectID AS ProjectID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.ProjectAccess_UserCanAccessEntity + @EntityType nvarchar(100), + @EntityID int, + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @ProjectID int; + DECLARE @Project TABLE (ProjectID int NULL); + + INSERT INTO @Project + EXEC dbo.ProjectAccess_GetProjectIDForEntity @EntityType = @EntityType, @EntityID = @EntityID; + + SELECT @ProjectID = ProjectID FROM @Project; + + IF @ProjectID IS NULL + BEGIN + SELECT CAST(0 AS bit); + RETURN; + END; + + EXEC dbo.ProjectAccess_UserCanAccess @ProjectID = @ProjectID, @UserID = @UserID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Project_ListForUser + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT p.ProjectID, p.ProjectName, p.Description, p.CreatedDate, p.UpdatedDate, p.IsArchived + FROM dbo.Projects p + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID + WHERE p.IsArchived = 0 + AND pua.UserID = @UserID + AND pua.IsActive = 1 + ORDER BY p.ProjectName; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Project_GetForUser + @ProjectID int, + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT p.ProjectID, p.ProjectName, p.Description, p.CreatedDate, p.UpdatedDate, p.IsArchived + FROM dbo.Projects p + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID + WHERE p.ProjectID = @ProjectID + AND p.IsArchived = 0 + AND pua.UserID = @UserID + AND pua.IsActive = 1; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Project_Save + @ProjectID int = NULL, + @ProjectName nvarchar(200), + @Description nvarchar(max) = NULL, + @GenreMetricPresetKey nvarchar(50) = NULL, + @UserID int = NULL +AS +BEGIN + SET NOCOUNT ON; + + IF @ProjectID IS NULL OR @ProjectID = 0 + BEGIN + INSERT dbo.Projects (ProjectName, Description) + VALUES (@ProjectName, @Description); + + SET @ProjectID = CAST(SCOPE_IDENTITY() AS int); + + IF @UserID IS NOT NULL + EXEC dbo.ProjectAccess_EnsureOwner @ProjectID = @ProjectID, @UserID = @UserID; + + EXEC dbo.ProjectMetricPreset_Apply @ProjectID = @ProjectID, @GenreMetricPresetKey = @GenreMetricPresetKey; + + SELECT @ProjectID AS ProjectID; + RETURN; + END; + + UPDATE p + SET ProjectName = @ProjectName, + Description = @Description, + UpdatedDate = SYSUTCDATETIME() + FROM dbo.Projects p + WHERE p.ProjectID = @ProjectID + AND + ( + @UserID IS NULL + OR EXISTS + ( + SELECT 1 + FROM dbo.ProjectUserAccess pua + WHERE pua.ProjectID = p.ProjectID + AND pua.UserID = @UserID + AND pua.IsActive = 1 + ) + ); + + SELECT CASE WHEN @@ROWCOUNT = 1 THEN @ProjectID ELSE 0 END AS ProjectID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Project_ArchiveForUser + @ProjectID int, + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + UPDATE p + SET IsArchived = 1, + UpdatedDate = SYSUTCDATETIME() + FROM dbo.Projects p + WHERE p.ProjectID = @ProjectID + AND EXISTS + ( + SELECT 1 + FROM dbo.ProjectUserAccess pua + WHERE pua.ProjectID = p.ProjectID + AND pua.UserID = @UserID + AND pua.AccessRole = N'Owner' + AND pua.IsActive = 1 + ); +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Archive_ListForUser + @UserID int, + @ProjectID int = NULL, + @EntityType nvarchar(80) = NULL +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @Archived TABLE + ( + EntityType nvarchar(80), + EntityID int, + DisplayName nvarchar(500), + ProjectID int, + ProjectName nvarchar(200), + BookID int NULL, + BookTitle nvarchar(200) NULL, + ArchivedDate datetime2 NULL, + ArchivedReason nvarchar(500) NULL, + UpdatedDate datetime2 + ); + + INSERT INTO @Archived + EXEC dbo.Archive_List @ProjectID = @ProjectID, @EntityType = @EntityType; + + SELECT a.EntityType, a.EntityID, a.DisplayName, a.ProjectID, a.ProjectName, a.BookID, a.BookTitle, + a.ArchivedDate, a.ArchivedReason, a.UpdatedDate + FROM @Archived a + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = a.ProjectID + WHERE pua.UserID = @UserID + AND pua.IsActive = 1 + ORDER BY ISNULL(a.ArchivedDate, a.UpdatedDate) DESC, a.EntityType, a.DisplayName; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Archive_ProjectFilterListForUser + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT p.ProjectID, p.ProjectName, p.Description, p.CreatedDate, p.UpdatedDate, p.IsArchived + FROM dbo.Projects p + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID + WHERE pua.UserID = @UserID + AND pua.IsActive = 1 + ORDER BY p.IsArchived, p.ProjectName; +END; +GO