Phase 5A Project User access
This commit is contained in:
parent
63597a8f65
commit
90b2212890
@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>();
|
||||
|
||||
@ -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)
|
||||
{
|
||||
if (!currentUser.UserId.HasValue)
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
return projects.SaveAsync(new Project
|
||||
{
|
||||
ProjectID = model.ProjectID,
|
||||
ProjectName = model.ProjectName,
|
||||
Description = model.Description
|
||||
}, model.ProjectID == 0 ? model.GenreMetricPresetKey : null);
|
||||
}, currentUser.UserId.Value, model.ProjectID == 0 ? model.GenreMetricPresetKey : null);
|
||||
}
|
||||
|
||||
public Task ArchiveAsync(int projectId) => projects.ArchiveAsync(projectId);
|
||||
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();
|
||||
|
||||
@ -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>();
|
||||
|
||||
245
PlotLine/Services/ProjectAccessFilter.cs
Normal file
245
PlotLine/Services/ProjectAccessFilter.cs
Normal 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);
|
||||
}
|
||||
76
PlotLine/Services/ProjectAccessServices.cs
Normal file
76
PlotLine/Services/ProjectAccessServices.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
378
PlotLine/Sql/042_Phase5A_ProjectUserAccess.sql
Normal file
378
PlotLine/Sql/042_Phase5A_ProjectUserAccess.sql
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user