Phase 5A Project User access

This commit is contained in:
Nick Beckley 2026-06-07 09:54:47 +01:00
parent 63597a8f65
commit 90b2212890
9 changed files with 864 additions and 42 deletions

View File

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

View File

@ -7,12 +7,12 @@ namespace PlotLine.Data;
public interface IImportRepository
{
Task<bool> ProjectTitleExistsAsync(string projectTitle);
Task<ImportResult> ImportAsync(PlotLineImportPackage package, string? importedProjectTitle);
Task<bool> ProjectTitleExistsAsync(string projectTitle, int userId);
Task<ImportResult> ImportAsync(PlotLineImportPackage package, string? importedProjectTitle, int userId);
Task<IReadOnlyDictionary<string, int>> GetIdentityMaxValuesAsync(IEnumerable<string> tableNames);
Task<ProjectBackupRelationshipImportLookups> GetRelationshipImportLookupsAsync();
Task<ProjectBackupRelationshipImportDiagnostics> GetRelationshipImportDiagnosticsAsync(int projectId);
Task<int> CreateProjectShellAsync(string projectName, string? description);
Task<int> 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<bool> ProjectTitleExistsAsync(string projectTitle)
public async Task<bool> ProjectTitleExistsAsync(string projectTitle, int userId)
{
using var connection = connectionFactory.CreateConnection();
var count = await connection.ExecuteScalarAsync<int>(
"""
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<int> CreateProjectShellAsync(string projectName, string? description)
public async Task<int> CreateProjectShellAsync(string projectName, string? description, int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"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<ImportResult> ImportAsync(PlotLineImportPackage package, string? importedProjectTitle)
public async Task<ImportResult> 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);

View File

@ -7,11 +7,15 @@ namespace PlotLine.Data;
public interface IProjectRepository
{
Task<IReadOnlyList<Project>> ListAsync();
Task<IReadOnlyList<Project>> ListForUserAsync(int userId);
Task<Project?> GetAsync(int projectId);
Task<Project?> GetForUserAsync(int projectId, int userId);
Task<IReadOnlyList<GenreMetricPreset>> ListGenreMetricPresetsAsync();
Task<int> SaveAsync(Project project);
Task<int> SaveAsync(Project project, int userId, string? genreMetricPresetKey);
Task<int> 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<IReadOnlyList<ArchivedItem>> ListAsync(int? projectId, string? entityType);
Task<IReadOnlyList<ArchivedItem>> ListForUserAsync(int? projectId, string? entityType, int userId);
Task<IReadOnlyList<Project>> ListProjectFiltersAsync();
Task<IReadOnlyList<Project>> ListProjectFiltersForUserAsync(int userId);
Task ArchiveAsync(string entityType, int entityId, string? reason);
Task RestoreAsync(string entityType, int entityId);
Task<ArchiveDeleteResult> DeleteForeverAsync(string entityType, int entityId, string confirmationText);
@ -1031,6 +1037,16 @@ public sealed class ArchiveRepository(ISqlConnectionFactory connectionFactory) :
return rows.ToList();
}
public async Task<IReadOnlyList<ArchivedItem>> ListForUserAsync(int? projectId, string? entityType, int userId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<ArchivedItem>(
"dbo.Archive_ListForUser",
new { ProjectID = projectId, EntityType = entityType, UserID = userId },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<IReadOnlyList<Project>> ListProjectFiltersAsync()
{
using var connection = connectionFactory.CreateConnection();
@ -1040,6 +1056,16 @@ public sealed class ArchiveRepository(ISqlConnectionFactory connectionFactory) :
return rows.ToList();
}
public async Task<IReadOnlyList<Project>> ListProjectFiltersForUserAsync(int userId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<Project>(
"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<IReadOnlyList<Project>> ListForUserAsync(int userId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<Project>("dbo.Project_ListForUser", new { UserID = userId }, commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<Project?> GetAsync(int projectId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<Project>("dbo.Project_Get", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure);
}
public async Task<Project?> GetForUserAsync(int projectId, int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<Project>("dbo.Project_GetForUser", new { ProjectID = projectId, UserID = userId }, commandType: CommandType.StoredProcedure);
}
public async Task<IReadOnlyList<GenreMetricPreset>> ListGenreMetricPresetsAsync()
{
using var connection = connectionFactory.CreateConnection();
@ -1160,12 +1199,18 @@ public sealed class ProjectRepository(ISqlConnectionFactory connectionFactory) :
public Task<int> SaveAsync(Project project) => SaveAsync(project, null);
public Task<int> SaveAsync(Project project, int userId, string? genreMetricPresetKey)
=> SaveInternalAsync(project, userId, genreMetricPresetKey);
public async Task<int> SaveAsync(Project project, string? genreMetricPresetKey)
=> await SaveInternalAsync(project, null, genreMetricPresetKey);
private async Task<int> SaveInternalAsync(Project project, int? userId, string? genreMetricPresetKey)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"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

View File

@ -18,7 +18,10 @@ public class Program
builder.Logging.AddConsole();
builder.Logging.AddDebug();
builder.Services.AddControllersWithViews();
builder.Services.AddControllersWithViews(options =>
{
options.Filters.Add<ProjectAccessFilter>();
});
builder.Services.AddHttpContextAccessor();
builder.Services.AddDistributedMemoryCache();
builder.Services.AddSession(options =>
@ -99,6 +102,7 @@ public class Program
builder.Services.AddScoped<IArchiveRepository, ArchiveRepository>();
builder.Services.AddScoped<IAcceptanceRepository, AcceptanceRepository>();
builder.Services.AddScoped<IImportRepository, ImportRepository>();
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
builder.Services.AddScoped<IProjectService, ProjectService>();
builder.Services.AddScoped<IBookService, BookService>();
builder.Services.AddScoped<IChapterService, ChapterService>();
@ -123,6 +127,7 @@ public class Program
builder.Services.AddScoped<IArchiveService, ArchiveService>();
builder.Services.AddScoped<IAcceptanceService, AcceptanceService>();
builder.Services.AddScoped<IImportService, ImportService>();
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
builder.Services.AddSingleton<IHelpService, HelpService>();
builder.Services.AddSingleton<IHelpMarkdownRenderer, HelpMarkdownRenderer>();
builder.Services.AddScoped<IAuthService, AuthService>();

View File

@ -238,13 +238,14 @@ public interface IAcceptanceService
Task<Phase1AcceptanceViewModel> 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<IReadOnlyList<Project>> ListAsync() => projects.ListAsync();
public Task<IReadOnlyList<Project>> ListAsync()
=> currentUser.UserId.HasValue ? projects.ListForUserAsync(currentUser.UserId.Value) : Task.FromResult<IReadOnlyList<Project>>([]);
public async Task<ProjectDetailViewModel?> 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<ProjectEditViewModel?> 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<int> SaveAsync(ProjectEditViewModel model) => projects.SaveAsync(new Project
public Task<int> 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<WriterDashboardViewModel> GetDashboardAsync(int? projectId)
{
var projectRows = await projects.ListAsync();
var projectRows = currentUser.UserId.HasValue
? await projects.ListForUserAsync(currentUser.UserId.Value)
: Array.Empty<Project>();
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<IReadOnlyList<WriterDashboardScene>> GetDashboardScenesAsync(IReadOnlyList<int> projectIds, string queue)
{
var rows = new List<WriterDashboardScene>();
foreach (var accessibleProjectId in projectIds)
{
rows.AddRange(await writerWorkspace.GetWriterDashboardScenesAsync(accessibleProjectId, queue));
}
return rows;
}
private async Task<IReadOnlyList<StoryBibleAttentionItem>> GetDashboardRemindersAsync(IReadOnlyList<int> projectIds)
{
var rows = new List<StoryBibleAttentionItem>();
foreach (var accessibleProjectId in projectIds)
{
rows.AddRange(await writerWorkspace.GetWriterDashboardRemindersAsync(accessibleProjectId));
}
return rows;
}
public async Task<ChapterWorkflowViewModel?> 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<ArchivedItemsViewModel> 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<Project>();
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<ArchivedItem>()
};
}
@ -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<Phase1AcceptanceViewModel> GetPhase1ChecklistAsync(int? projectId)
{
var projectRows = await projects.ListAsync();
var projectRows = currentUser.UserId.HasValue
? await projects.ListForUserAsync(currentUser.UserId.Value)
: Array.Empty<Project>();
var selectedProject = projectId.HasValue
? projectRows.FirstOrDefault(x => x.ProjectID == projectId.Value)
: projectRows.FirstOrDefault();

View File

@ -14,7 +14,7 @@ public interface IImportService
Task<string> BuildValidationReportAsync(ImportIndexViewModel model);
}
public sealed class ImportService(IImportRepository imports, ILogger<ImportService> logger) : IImportService
public sealed class ImportService(IImportRepository imports, ICurrentUserService currentUser, ILogger<ImportService> logger) : IImportService
{
private const int TitleMaxLength = 200;
private const int RelativeTimeMaxLength = 200;
@ -58,6 +58,16 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
public async Task<ImportResult> 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<ImportServi
&& TryReadProjectBackup(model.JsonText, out var backup, out var backupValidation)
&& backup is not null)
{
return await ImportProjectBackupAsync(model, backup, backupValidation);
return await ImportProjectBackupAsync(model, backup, backupValidation, userId.Value);
}
if (format == ProjectImportFormat.Unsupported)
@ -129,7 +139,7 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
try
{
var timer = Stopwatch.StartNew();
var result = await imports.ImportAsync(preview.Package, preview.HasDuplicateProjectTitle ? preview.DuplicateImportProjectTitle : null);
var result = await imports.ImportAsync(preview.Package, preview.HasDuplicateProjectTitle ? preview.DuplicateImportProjectTitle : null, userId.Value);
timer.Stop();
logger.LogInformation("JSON import committed in {ElapsedMilliseconds} ms for project {ProjectName}. Created {SceneCount} scenes and skipped/unresolved count {SkippedCount}/{UnresolvedCount}.",
timer.ElapsedMilliseconds,
@ -265,7 +275,8 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
}
var duplicate = !string.IsNullOrWhiteSpace(package.Project.Title)
&& await imports.ProjectTitleExistsAsync(package.Project.Title.Trim());
&& currentUser.UserId.HasValue
&& await imports.ProjectTitleExistsAsync(package.Project.Title.Trim(), currentUser.UserId.Value);
var duplicateTitle = duplicate
? $"{package.Project.Title.Trim()} (Imported {DateTime.Now:yyyy-MM-dd HH:mm})"
@ -302,7 +313,8 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
var package = BuildPreviewPackageFromBackup(backup);
var projectTitle = package.Project.Title;
var duplicate = !string.IsNullOrWhiteSpace(projectTitle)
&& await imports.ProjectTitleExistsAsync(projectTitle.Trim());
&& currentUser.UserId.HasValue
&& await imports.ProjectTitleExistsAsync(projectTitle.Trim(), currentUser.UserId.Value);
var duplicateTitle = duplicate
? $"{projectTitle.Trim()} (Imported {DateTime.Now:yyyy-MM-dd HH:mm})"
: null;
@ -323,7 +335,7 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
};
}
private async Task<ImportResult> ImportProjectBackupAsync(ImportIndexViewModel model, ProjectBackupExport backup, ImportValidationResult validation)
private async Task<ImportResult> 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<ImportServi
var projectName = preview.HasDuplicateProjectTitle
? preview.DuplicateImportProjectTitle!
: preview.Package!.Project.Title;
var projectId = await imports.CreateProjectShellAsync(projectName, backup.Project?.Description);
var projectId = await imports.CreateProjectShellAsync(projectName, backup.Project?.Description, userId);
var maxIds = await imports.GetIdentityMaxValuesAsync(ProjectBackupIdentityTables);
var relationshipLookups = await imports.GetRelationshipImportLookupsAsync();
var remapWarnings = new List<string>();

View File

@ -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<string> SkippedControllers = new(StringComparer.OrdinalIgnoreCase)
{
"Account",
"Home",
"Help",
"HelpDiagnostics",
"HelpDrawer",
"ManageAccount"
};
private static readonly Dictionary<string, string> 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<string, Dictionary<string, string>> 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<string, Dictionary<string, string>> 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<string, string> 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<AccessRequirement> CollectRequirements(string controllerName, string actionName, IDictionary<string, object?> arguments)
{
var requirements = new HashSet<AccessRequirement>();
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<AccessRequirement> 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);
}

View File

@ -0,0 +1,76 @@
using System.Data;
using Dapper;
using PlotLine.Data;
namespace PlotLine.Services;
public interface IProjectAccessRepository
{
Task<bool> UserCanAccessProjectAsync(int projectId, int userId);
Task<bool> UserCanOwnProjectAsync(int projectId, int userId);
Task<bool> UserCanAccessEntityAsync(string entityType, int entityId, int userId);
}
public interface IProjectAccessService
{
Task<bool> CanAccessProjectAsync(int projectId);
Task<bool> CanOwnProjectAsync(int projectId);
Task<bool> CanAccessEntityAsync(string entityType, int entityId);
}
public sealed class ProjectAccessRepository(ISqlConnectionFactory connectionFactory) : IProjectAccessRepository
{
public async Task<bool> UserCanAccessProjectAsync(int projectId, int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<bool>(
"dbo.ProjectAccess_UserCanAccess",
new { ProjectID = projectId, UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<bool> UserCanOwnProjectAsync(int projectId, int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<bool>(
"dbo.ProjectAccess_UserCanOwn",
new { ProjectID = projectId, UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<bool> UserCanAccessEntityAsync(string entityType, int entityId, int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<bool>(
"dbo.ProjectAccess_UserCanAccessEntity",
new { EntityType = entityType, EntityID = entityId, UserID = userId },
commandType: CommandType.StoredProcedure);
}
}
public sealed class ProjectAccessService(IProjectAccessRepository access, ICurrentUserService currentUser) : IProjectAccessService
{
public Task<bool> CanAccessProjectAsync(int projectId)
{
var userId = currentUser.UserId;
return userId.HasValue && projectId > 0
? access.UserCanAccessProjectAsync(projectId, userId.Value)
: Task.FromResult(false);
}
public Task<bool> CanOwnProjectAsync(int projectId)
{
var userId = currentUser.UserId;
return userId.HasValue && projectId > 0
? access.UserCanOwnProjectAsync(projectId, userId.Value)
: Task.FromResult(false);
}
public Task<bool> 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);
}
}

View File

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