Phase 5D Collaboration Foundation
This commit is contained in:
parent
b94ca66918
commit
9824b364b7
50
PlotLine/Controllers/ProjectCollaboratorsController.cs
Normal file
50
PlotLine/Controllers/ProjectCollaboratorsController.cs
Normal file
@ -0,0 +1,50 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PlotLine.Services;
|
||||
using PlotLine.ViewModels;
|
||||
|
||||
namespace PlotLine.Controllers;
|
||||
|
||||
[Authorize]
|
||||
public sealed class ProjectCollaboratorsController(IProjectCollaborationService collaboration) : Controller
|
||||
{
|
||||
public async Task<IActionResult> Index(int projectId)
|
||||
{
|
||||
var model = await collaboration.GetCollaboratorsAsync(projectId);
|
||||
return model is null ? NotFound() : View(model);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Add(ProjectCollaboratorInviteViewModel model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
TempData["CollaborationError"] = "Enter a valid collaborator email address.";
|
||||
return RedirectToAction(nameof(Index), new { projectId = model.ProjectID });
|
||||
}
|
||||
|
||||
var result = await collaboration.AddCollaboratorAsync(model);
|
||||
if (result.NotFound)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
TempData[result.Succeeded ? "CollaborationMessage" : "CollaborationError"] = result.Message;
|
||||
return RedirectToAction(nameof(Index), new { projectId = model.ProjectID });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Remove(int projectId, int userId)
|
||||
{
|
||||
var result = await collaboration.RemoveCollaboratorAsync(projectId, userId);
|
||||
if (result.NotFound)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
TempData[result.Succeeded ? "CollaborationMessage" : "CollaborationError"] = result.Message;
|
||||
return RedirectToAction(nameof(Index), new { projectId });
|
||||
}
|
||||
}
|
||||
@ -18,6 +18,17 @@ public interface IProjectRepository
|
||||
Task ArchiveForUserAsync(int projectId, int userId);
|
||||
}
|
||||
|
||||
public interface IProjectCollaborationRepository
|
||||
{
|
||||
Task<int?> GetOwnerUserIdAsync(int projectId);
|
||||
Task<IReadOnlyList<ProjectCollaborator>> ListCollaboratorsAsync(int projectId);
|
||||
Task<IReadOnlyList<ProjectInvitation>> ListPendingInvitationsAsync(int projectId);
|
||||
Task<bool> CollaboratorCountsForOwnerAsync(int ownerUserId, int collaboratorUserId);
|
||||
Task<bool> AddCollaboratorAsync(int projectId, int userId, int invitedByUserId);
|
||||
Task<bool> RemoveCollaboratorAsync(int projectId, int userId);
|
||||
Task<int> CreatePendingInvitationAsync(int projectId, string emailAddress, int invitedByUserId);
|
||||
}
|
||||
|
||||
public interface IBookRepository
|
||||
{
|
||||
Task<IReadOnlyList<Book>> ListByProjectAsync(int projectId);
|
||||
@ -1227,6 +1238,74 @@ public sealed class ProjectRepository(ISqlConnectionFactory connectionFactory) :
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ProjectCollaborationRepository(ISqlConnectionFactory connectionFactory) : IProjectCollaborationRepository
|
||||
{
|
||||
public async Task<int?> GetOwnerUserIdAsync(int projectId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<int?>(
|
||||
"dbo.ProjectCollaboration_GetOwner",
|
||||
new { ProjectID = projectId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ProjectCollaborator>> ListCollaboratorsAsync(int projectId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
var rows = await connection.QueryAsync<ProjectCollaborator>(
|
||||
"dbo.ProjectCollaboration_ListCollaborators",
|
||||
new { ProjectID = projectId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
return rows.ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ProjectInvitation>> ListPendingInvitationsAsync(int projectId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
var rows = await connection.QueryAsync<ProjectInvitation>(
|
||||
"dbo.ProjectCollaboration_ListPendingInvitations",
|
||||
new { ProjectID = projectId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
return rows.ToList();
|
||||
}
|
||||
|
||||
public async Task<bool> CollaboratorCountsForOwnerAsync(int ownerUserId, int collaboratorUserId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleAsync<bool>(
|
||||
"dbo.ProjectCollaboration_CollaboratorCountsForOwner",
|
||||
new { OwnerUserID = ownerUserId, CollaboratorUserID = collaboratorUserId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<bool> AddCollaboratorAsync(int projectId, int userId, int invitedByUserId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleAsync<bool>(
|
||||
"dbo.ProjectCollaboration_AddCollaborator",
|
||||
new { ProjectID = projectId, UserID = userId, InvitedByUserID = invitedByUserId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<bool> RemoveCollaboratorAsync(int projectId, int userId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleAsync<bool>(
|
||||
"dbo.ProjectCollaboration_RemoveCollaborator",
|
||||
new { ProjectID = projectId, UserID = userId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<int> CreatePendingInvitationAsync(int projectId, string emailAddress, int invitedByUserId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleAsync<int>(
|
||||
"dbo.ProjectCollaboration_CreatePendingInvitation",
|
||||
new { ProjectID = projectId, EmailAddress = emailAddress, InvitedByUserID = invitedByUserId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class BookRepository(ISqlConnectionFactory connectionFactory) : IBookRepository
|
||||
{
|
||||
public async Task<IReadOnlyList<Book>> ListByProjectAsync(int projectId)
|
||||
|
||||
@ -10,6 +10,40 @@ public sealed class Project
|
||||
public bool IsArchived { get; set; }
|
||||
public DateTime? ArchivedDate { get; set; }
|
||||
public string? ArchivedReason { get; set; }
|
||||
public string AccessRole { get; set; } = "Owner";
|
||||
public bool IsOwner => string.Equals(AccessRole, "Owner", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public sealed class ProjectCollaborator
|
||||
{
|
||||
public int ProjectUserAccessID { get; set; }
|
||||
public int ProjectID { get; set; }
|
||||
public int UserID { get; set; }
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string AccessRole { get; set; } = "Collaborator";
|
||||
public int? InvitedByUserID { get; set; }
|
||||
public string? InvitedByDisplayName { get; set; }
|
||||
public DateTime? InvitedDateUTC { get; set; }
|
||||
public DateTime? AcceptedDateUTC { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ProjectInvitation
|
||||
{
|
||||
public int ProjectInvitationID { get; set; }
|
||||
public int ProjectID { get; set; }
|
||||
public string EmailAddress { get; set; } = string.Empty;
|
||||
public string AccessRole { get; set; } = "Collaborator";
|
||||
public Guid InvitationToken { get; set; }
|
||||
public int InvitedByUserID { get; set; }
|
||||
public string? InvitedByDisplayName { get; set; }
|
||||
public DateTime InvitedDateUTC { get; set; }
|
||||
public DateTime? AcceptedDateUTC { get; set; }
|
||||
public int? AcceptedByUserID { get; set; }
|
||||
public bool IsAccepted { get; set; }
|
||||
public bool IsCancelled { get; set; }
|
||||
public DateTime? CancelledDateUTC { get; set; }
|
||||
}
|
||||
|
||||
public sealed class GenreMetricPreset
|
||||
|
||||
@ -103,8 +103,10 @@ public class Program
|
||||
builder.Services.AddScoped<IAcceptanceRepository, AcceptanceRepository>();
|
||||
builder.Services.AddScoped<IImportRepository, ImportRepository>();
|
||||
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
|
||||
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
|
||||
builder.Services.AddScoped<ISubscriptionRepository, SubscriptionRepository>();
|
||||
builder.Services.AddScoped<IProjectService, ProjectService>();
|
||||
builder.Services.AddScoped<IProjectCollaborationService, ProjectCollaborationService>();
|
||||
builder.Services.AddScoped<IBookService, BookService>();
|
||||
builder.Services.AddScoped<IChapterService, ChapterService>();
|
||||
builder.Services.AddScoped<ISceneService, SceneService>();
|
||||
|
||||
@ -2,6 +2,7 @@ using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PlotLine.Data;
|
||||
using PlotLine.Models;
|
||||
using PlotLine.ViewModels;
|
||||
@ -19,6 +20,24 @@ public interface IProjectService
|
||||
Task ArchiveAsync(int projectId);
|
||||
}
|
||||
|
||||
public interface IProjectCollaborationService
|
||||
{
|
||||
Task<ProjectCollaboratorsViewModel?> GetCollaboratorsAsync(int projectId);
|
||||
Task<ProjectCollaborationResult> AddCollaboratorAsync(ProjectCollaboratorInviteViewModel model);
|
||||
Task<ProjectCollaborationResult> RemoveCollaboratorAsync(int projectId, int userId);
|
||||
}
|
||||
|
||||
public sealed class ProjectCollaborationResult
|
||||
{
|
||||
public bool Succeeded { get; init; }
|
||||
public bool NotFound { get; init; }
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
public static ProjectCollaborationResult Success(string message) => new() { Succeeded = true, Message = message };
|
||||
public static ProjectCollaborationResult Failure(string message) => new() { Message = message };
|
||||
public static ProjectCollaborationResult Inaccessible() => new() { NotFound = true, Message = "Project not found." };
|
||||
}
|
||||
|
||||
public interface IBookService
|
||||
{
|
||||
Task<BookDetailViewModel?> GetDetailAsync(int bookId);
|
||||
@ -303,6 +322,185 @@ public sealed class ProjectService(IProjectRepository projects, IBookRepository
|
||||
=> currentUser.UserId.HasValue ? projects.ArchiveForUserAsync(projectId, currentUser.UserId.Value) : Task.CompletedTask;
|
||||
}
|
||||
|
||||
public sealed class ProjectCollaborationService(
|
||||
IProjectRepository projects,
|
||||
IProjectCollaborationRepository collaboration,
|
||||
IUserRepository users,
|
||||
IProjectAccessService access,
|
||||
ISubscriptionService subscriptions,
|
||||
IEmailQueueRepository emailQueue,
|
||||
ICurrentUserService currentUser,
|
||||
IOptions<EmailSettings> emailOptions,
|
||||
ILogger<ProjectCollaborationService> logger) : IProjectCollaborationService
|
||||
{
|
||||
private readonly EmailSettings _emailSettings = emailOptions.Value;
|
||||
|
||||
public async Task<ProjectCollaboratorsViewModel?> GetCollaboratorsAsync(int projectId)
|
||||
{
|
||||
if (!await access.CanOwnProjectAsync(projectId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var project = currentUser.UserId.HasValue ? await projects.GetForUserAsync(projectId, currentUser.UserId.Value) : null;
|
||||
if (project is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ProjectCollaboratorsViewModel
|
||||
{
|
||||
Project = project,
|
||||
Collaborators = await collaboration.ListCollaboratorsAsync(projectId),
|
||||
PendingInvitations = await collaboration.ListPendingInvitationsAsync(projectId),
|
||||
Invite = new ProjectCollaboratorInviteViewModel { ProjectID = projectId }
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ProjectCollaborationResult> AddCollaboratorAsync(ProjectCollaboratorInviteViewModel model)
|
||||
{
|
||||
if (!currentUser.UserId.HasValue || !await access.CanOwnProjectAsync(model.ProjectID))
|
||||
{
|
||||
return ProjectCollaborationResult.Inaccessible();
|
||||
}
|
||||
|
||||
var ownerUserId = await collaboration.GetOwnerUserIdAsync(model.ProjectID);
|
||||
if (!ownerUserId.HasValue || ownerUserId.Value != currentUser.UserId.Value)
|
||||
{
|
||||
return ProjectCollaborationResult.Inaccessible();
|
||||
}
|
||||
|
||||
var email = NormalizeEmail(model.EmailAddress);
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
return ProjectCollaborationResult.Failure("Enter a valid collaborator email address.");
|
||||
}
|
||||
|
||||
if (string.Equals(email, currentUser.Email, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ProjectCollaborationResult.Failure("Owners already have access to their own projects.");
|
||||
}
|
||||
|
||||
var user = await users.GetByEmailAsync(email);
|
||||
if (user is not null)
|
||||
{
|
||||
if (user.UserID == ownerUserId.Value)
|
||||
{
|
||||
return ProjectCollaborationResult.Failure("Owners already have access to their own projects.");
|
||||
}
|
||||
|
||||
var existingCollaborators = await collaboration.ListCollaboratorsAsync(model.ProjectID);
|
||||
if (existingCollaborators.Any(x => x.UserID == user.UserID))
|
||||
{
|
||||
return ProjectCollaborationResult.Success("That user already has collaborator access.");
|
||||
}
|
||||
|
||||
if (!await collaboration.CollaboratorCountsForOwnerAsync(ownerUserId.Value, user.UserID))
|
||||
{
|
||||
var limit = await subscriptions.CanInviteCollaboratorAsync(ownerUserId.Value);
|
||||
if (!limit.Allowed)
|
||||
{
|
||||
return ProjectCollaborationResult.Failure(limit.Message);
|
||||
}
|
||||
}
|
||||
|
||||
await collaboration.AddCollaboratorAsync(model.ProjectID, user.UserID, currentUser.UserId.Value);
|
||||
await QueueInvitationNotificationAsync(user, model.ProjectID);
|
||||
return ProjectCollaborationResult.Success("Collaborator added.");
|
||||
}
|
||||
|
||||
var pendingLimit = await subscriptions.CanInviteCollaboratorAsync(ownerUserId.Value);
|
||||
if (!pendingLimit.Allowed)
|
||||
{
|
||||
return ProjectCollaborationResult.Failure(pendingLimit.Message);
|
||||
}
|
||||
|
||||
await collaboration.CreatePendingInvitationAsync(model.ProjectID, email, currentUser.UserId.Value);
|
||||
return ProjectCollaborationResult.Success("Pending invitation recorded. When that person creates an account, this invitation can be completed in the collaboration workflow.");
|
||||
}
|
||||
|
||||
public async Task<ProjectCollaborationResult> RemoveCollaboratorAsync(int projectId, int userId)
|
||||
{
|
||||
if (!currentUser.UserId.HasValue || !await access.CanOwnProjectAsync(projectId))
|
||||
{
|
||||
return ProjectCollaborationResult.Inaccessible();
|
||||
}
|
||||
|
||||
var ownerUserId = await collaboration.GetOwnerUserIdAsync(projectId);
|
||||
if (!ownerUserId.HasValue || ownerUserId.Value != currentUser.UserId.Value)
|
||||
{
|
||||
return ProjectCollaborationResult.Inaccessible();
|
||||
}
|
||||
|
||||
if (userId == ownerUserId.Value)
|
||||
{
|
||||
return ProjectCollaborationResult.Failure("Project owners cannot be removed as collaborators.");
|
||||
}
|
||||
|
||||
var removed = await collaboration.RemoveCollaboratorAsync(projectId, userId);
|
||||
return removed
|
||||
? ProjectCollaborationResult.Success("Collaborator removed.")
|
||||
: ProjectCollaborationResult.Failure("Collaborator access was not found.");
|
||||
}
|
||||
|
||||
private async Task QueueInvitationNotificationAsync(AppUser user, int projectId)
|
||||
{
|
||||
var project = currentUser.UserId.HasValue ? await projects.GetForUserAsync(projectId, currentUser.UserId.Value) : null;
|
||||
var projectName = project?.ProjectName ?? "a project";
|
||||
var inviterName = CleanDisplayName(currentUser.DisplayName);
|
||||
var subject = $"You have been added to {projectName} in PlotWeaver Studio";
|
||||
var plainText = $"""
|
||||
You have been added as a collaborator.
|
||||
|
||||
Hello {CleanDisplayName(user.DisplayName)},
|
||||
|
||||
{inviterName} added you as a collaborator on "{projectName}" in PlotWeaver Studio. Sign in to PlotWeaver Studio to open the shared project.
|
||||
""";
|
||||
var html = $"""
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body style="font-family:Segoe UI,Arial,sans-serif;background:#f7f1e8;margin:0;padding:32px;color:#251c17;">
|
||||
<div style="max-width:620px;margin:0 auto;background:#fffaf2;border:1px solid #e4d4bd;border-radius:10px;padding:28px;">
|
||||
<p style="margin:0 0 8px;text-transform:uppercase;letter-spacing:.08em;color:#7a5b3b;font-size:12px;font-weight:700;">PlotWeaver Studio</p>
|
||||
<h1 style="margin:0 0 16px;font-size:24px;">You have been added as a collaborator</h1>
|
||||
<p>Hello {WebUtility.HtmlEncode(CleanDisplayName(user.DisplayName))},</p>
|
||||
<p>{WebUtility.HtmlEncode(inviterName)} added you as a collaborator on <strong>{WebUtility.HtmlEncode(projectName)}</strong>.</p>
|
||||
<p>Sign in to PlotWeaver Studio to open the shared project.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
|
||||
var emailQueueId = await emailQueue.CreateAsync(new EmailQueueItem
|
||||
{
|
||||
ToEmail = user.Email,
|
||||
ToName = CleanDisplayName(user.DisplayName),
|
||||
FromEmail = GetFromAddress(),
|
||||
FromName = CleanDisplayName(_emailSettings.FromName),
|
||||
Subject = subject,
|
||||
PlainTextBody = plainText,
|
||||
HtmlBody = html,
|
||||
MaxAttempts = 5
|
||||
});
|
||||
|
||||
logger.LogInformation("Queued collaborator invitation email {EmailQueueID} for user {UserID}.", emailQueueId, user.UserID);
|
||||
}
|
||||
|
||||
private static string NormalizeEmail(string? value) => value?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||
|
||||
private static string CleanDisplayName(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? "PlotWeaver Studio" : value.Trim();
|
||||
}
|
||||
|
||||
private string GetFromAddress()
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(_emailSettings.FromAddress)
|
||||
? "hello@plotweaver.studio"
|
||||
: _emailSettings.FromAddress.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
internal static class SubscriptionLimitGuards
|
||||
{
|
||||
public static async Task EnsureAllowedAsync(Func<int, Task<SubscriptionLimitResult>> validateAsync, int? userId)
|
||||
|
||||
@ -58,6 +58,7 @@ public sealed class ProjectAccessFilter(IProjectAccessService access, ICurrentUs
|
||||
private static readonly Dictionary<string, Dictionary<string, string>> ControllerParameterEntities = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Projects"] = new(StringComparer.OrdinalIgnoreCase) { ["id"] = "Project", ["projectId"] = "Project" },
|
||||
["ProjectCollaborators"] = new(StringComparer.OrdinalIgnoreCase) { ["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" },
|
||||
@ -81,6 +82,13 @@ public sealed class ProjectAccessFilter(IProjectAccessService access, ICurrentUs
|
||||
["StoryBible"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project" }
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, HashSet<string>> OwnerOnlyProjectActions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Projects"] = new(StringComparer.OrdinalIgnoreCase) { "Archive" },
|
||||
["ProjectCollaborators"] = new(StringComparer.OrdinalIgnoreCase) { "Index", "Add", "Remove" },
|
||||
["Exports"] = new(StringComparer.OrdinalIgnoreCase) { "ProjectBackup" }
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, Dictionary<string, string>> ActionIdEntities = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Characters"] = new(StringComparer.OrdinalIgnoreCase) { ["ArchiveRelationship"] = "CharacterRelationship" },
|
||||
@ -146,7 +154,7 @@ public sealed class ProjectAccessFilter(IProjectAccessService access, ICurrentUs
|
||||
foreach (var requirement in requirements)
|
||||
{
|
||||
var allowed = requirement.EntityType == "Project"
|
||||
? await access.CanAccessProjectAsync(requirement.EntityId)
|
||||
? await CheckProjectRequirementAsync(controllerName, descriptor.ActionName, requirement.EntityId)
|
||||
: await access.CanAccessEntityAsync(requirement.EntityType, requirement.EntityId);
|
||||
|
||||
if (!allowed)
|
||||
@ -159,6 +167,13 @@ public sealed class ProjectAccessFilter(IProjectAccessService access, ICurrentUs
|
||||
await next();
|
||||
}
|
||||
|
||||
private Task<bool> CheckProjectRequirementAsync(string controllerName, string actionName, int projectId)
|
||||
{
|
||||
return OwnerOnlyProjectActions.TryGetValue(controllerName, out var ownerOnlyActions) && ownerOnlyActions.Contains(actionName)
|
||||
? access.CanOwnProjectAsync(projectId)
|
||||
: access.CanAccessProjectAsync(projectId);
|
||||
}
|
||||
|
||||
private static HashSet<AccessRequirement> CollectRequirements(string controllerName, string actionName, IDictionary<string, object?> arguments)
|
||||
{
|
||||
var requirements = new HashSet<AccessRequirement>();
|
||||
|
||||
252
PlotLine/Sql/045_Phase5D_CollaborationFoundation.sql
Normal file
252
PlotLine/Sql/045_Phase5D_CollaborationFoundation.sql
Normal file
@ -0,0 +1,252 @@
|
||||
SET ANSI_NULLS ON;
|
||||
GO
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.ProjectInvitation', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.ProjectInvitation
|
||||
(
|
||||
ProjectInvitationID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_ProjectInvitation PRIMARY KEY,
|
||||
ProjectID int NOT NULL,
|
||||
EmailAddress nvarchar(256) NOT NULL,
|
||||
AccessRole nvarchar(50) NOT NULL,
|
||||
InvitationToken uniqueidentifier NOT NULL CONSTRAINT DF_ProjectInvitation_InvitationToken DEFAULT NEWID(),
|
||||
InvitedByUserID int NOT NULL,
|
||||
InvitedDateUTC datetime2 NOT NULL CONSTRAINT DF_ProjectInvitation_InvitedDateUTC DEFAULT SYSUTCDATETIME(),
|
||||
AcceptedDateUTC datetime2 NULL,
|
||||
AcceptedByUserID int NULL,
|
||||
IsAccepted bit NOT NULL CONSTRAINT DF_ProjectInvitation_IsAccepted DEFAULT 0,
|
||||
IsCancelled bit NOT NULL CONSTRAINT DF_ProjectInvitation_IsCancelled DEFAULT 0,
|
||||
CancelledDateUTC datetime2 NULL,
|
||||
CONSTRAINT FK_ProjectInvitation_Project FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID),
|
||||
CONSTRAINT FK_ProjectInvitation_InvitedByUser FOREIGN KEY (InvitedByUserID) REFERENCES dbo.AppUser(UserID),
|
||||
CONSTRAINT FK_ProjectInvitation_AcceptedByUser FOREIGN KEY (AcceptedByUserID) REFERENCES dbo.AppUser(UserID),
|
||||
CONSTRAINT CK_ProjectInvitation_AccessRole CHECK (AccessRole IN (N'Collaborator'))
|
||||
);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_ProjectInvitation_Project_Status' AND object_id = OBJECT_ID(N'dbo.ProjectInvitation'))
|
||||
CREATE INDEX IX_ProjectInvitation_Project_Status ON dbo.ProjectInvitation(ProjectID, IsAccepted, IsCancelled, InvitedDateUTC DESC);
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_ProjectInvitation_Email_Status' AND object_id = OBJECT_ID(N'dbo.ProjectInvitation'))
|
||||
CREATE INDEX IX_ProjectInvitation_Email_Status ON dbo.ProjectInvitation(EmailAddress, IsAccepted, IsCancelled);
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_ProjectInvitation_Project_Email_Pending' AND object_id = OBJECT_ID(N'dbo.ProjectInvitation'))
|
||||
CREATE UNIQUE INDEX UX_ProjectInvitation_Project_Email_Pending ON dbo.ProjectInvitation(ProjectID, EmailAddress) WHERE IsAccepted = 0 AND IsCancelled = 0;
|
||||
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,
|
||||
pua.AccessRole
|
||||
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,
|
||||
pua.AccessRole
|
||||
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.ProjectCollaboration_GetOwner
|
||||
@ProjectID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT TOP (1) pua.UserID
|
||||
FROM dbo.ProjectUserAccess pua
|
||||
WHERE pua.ProjectID = @ProjectID
|
||||
AND pua.AccessRole = N'Owner'
|
||||
AND pua.IsActive = 1
|
||||
ORDER BY pua.ProjectUserAccessID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.ProjectCollaboration_ListCollaborators
|
||||
@ProjectID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT pua.ProjectUserAccessID, pua.ProjectID, pua.UserID, u.Email, u.DisplayName,
|
||||
pua.AccessRole, pua.InvitedByUserID, invitedBy.DisplayName AS InvitedByDisplayName,
|
||||
pua.InvitedDateUTC, pua.AcceptedDateUTC, pua.IsActive
|
||||
FROM dbo.ProjectUserAccess pua
|
||||
INNER JOIN dbo.AppUser u ON u.UserID = pua.UserID
|
||||
LEFT JOIN dbo.AppUser invitedBy ON invitedBy.UserID = pua.InvitedByUserID
|
||||
WHERE pua.ProjectID = @ProjectID
|
||||
AND pua.AccessRole = N'Collaborator'
|
||||
AND pua.IsActive = 1
|
||||
ORDER BY u.DisplayName, u.Email;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.ProjectCollaboration_ListPendingInvitations
|
||||
@ProjectID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT pi.ProjectInvitationID, pi.ProjectID, pi.EmailAddress, pi.AccessRole, pi.InvitationToken,
|
||||
pi.InvitedByUserID, invitedBy.DisplayName AS InvitedByDisplayName, pi.InvitedDateUTC,
|
||||
pi.AcceptedDateUTC, pi.AcceptedByUserID, pi.IsAccepted, pi.IsCancelled, pi.CancelledDateUTC
|
||||
FROM dbo.ProjectInvitation pi
|
||||
INNER JOIN dbo.AppUser invitedBy ON invitedBy.UserID = pi.InvitedByUserID
|
||||
WHERE pi.ProjectID = @ProjectID
|
||||
AND pi.IsAccepted = 0
|
||||
AND pi.IsCancelled = 0
|
||||
ORDER BY pi.InvitedDateUTC DESC;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.ProjectCollaboration_AddCollaborator
|
||||
@ProjectID int,
|
||||
@UserID int,
|
||||
@InvitedByUserID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
IF EXISTS
|
||||
(
|
||||
SELECT 1
|
||||
FROM dbo.ProjectUserAccess
|
||||
WHERE ProjectID = @ProjectID
|
||||
AND UserID = @UserID
|
||||
AND AccessRole = N'Collaborator'
|
||||
)
|
||||
BEGIN
|
||||
UPDATE dbo.ProjectUserAccess
|
||||
SET IsActive = 1,
|
||||
InvitedByUserID = @InvitedByUserID,
|
||||
InvitedDateUTC = COALESCE(InvitedDateUTC, SYSUTCDATETIME()),
|
||||
AcceptedDateUTC = COALESCE(AcceptedDateUTC, SYSUTCDATETIME())
|
||||
WHERE ProjectID = @ProjectID
|
||||
AND UserID = @UserID
|
||||
AND AccessRole = N'Collaborator';
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
INSERT INTO dbo.ProjectUserAccess (ProjectID, UserID, AccessRole, InvitedByUserID, InvitedDateUTC, AcceptedDateUTC, IsActive)
|
||||
VALUES (@ProjectID, @UserID, N'Collaborator', @InvitedByUserID, SYSUTCDATETIME(), SYSUTCDATETIME(), 1);
|
||||
END;
|
||||
|
||||
UPDATE pi
|
||||
SET IsAccepted = 1,
|
||||
AcceptedDateUTC = COALESCE(AcceptedDateUTC, SYSUTCDATETIME()),
|
||||
AcceptedByUserID = @UserID
|
||||
FROM dbo.ProjectInvitation pi
|
||||
INNER JOIN dbo.AppUser u ON u.Email = pi.EmailAddress
|
||||
WHERE pi.ProjectID = @ProjectID
|
||||
AND u.UserID = @UserID
|
||||
AND pi.IsAccepted = 0
|
||||
AND pi.IsCancelled = 0;
|
||||
|
||||
SELECT CAST(1 AS bit);
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.ProjectCollaboration_RemoveCollaborator
|
||||
@ProjectID int,
|
||||
@UserID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
UPDATE dbo.ProjectUserAccess
|
||||
SET IsActive = 0
|
||||
WHERE ProjectID = @ProjectID
|
||||
AND UserID = @UserID
|
||||
AND AccessRole = N'Collaborator'
|
||||
AND IsActive = 1;
|
||||
|
||||
SELECT CAST(CASE WHEN @@ROWCOUNT = 1 THEN 1 ELSE 0 END AS bit);
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.ProjectCollaboration_CreatePendingInvitation
|
||||
@ProjectID int,
|
||||
@EmailAddress nvarchar(256),
|
||||
@InvitedByUserID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
IF EXISTS
|
||||
(
|
||||
SELECT 1
|
||||
FROM dbo.ProjectInvitation
|
||||
WHERE ProjectID = @ProjectID
|
||||
AND EmailAddress = @EmailAddress
|
||||
AND IsAccepted = 0
|
||||
AND IsCancelled = 0
|
||||
)
|
||||
BEGIN
|
||||
SELECT TOP (1) ProjectInvitationID
|
||||
FROM dbo.ProjectInvitation
|
||||
WHERE ProjectID = @ProjectID
|
||||
AND EmailAddress = @EmailAddress
|
||||
AND IsAccepted = 0
|
||||
AND IsCancelled = 0
|
||||
ORDER BY ProjectInvitationID DESC;
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
INSERT INTO dbo.ProjectInvitation (ProjectID, EmailAddress, AccessRole, InvitedByUserID)
|
||||
VALUES (@ProjectID, @EmailAddress, N'Collaborator', @InvitedByUserID);
|
||||
|
||||
SELECT CAST(SCOPE_IDENTITY() AS int);
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.ProjectCollaboration_CollaboratorCountsForOwner
|
||||
@OwnerUserID int,
|
||||
@CollaboratorUserID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT CAST(CASE WHEN EXISTS
|
||||
(
|
||||
SELECT 1
|
||||
FROM dbo.ProjectUserAccess ownerAccess
|
||||
INNER JOIN dbo.ProjectUserAccess collaboratorAccess ON collaboratorAccess.ProjectID = ownerAccess.ProjectID
|
||||
INNER JOIN dbo.Projects p ON p.ProjectID = ownerAccess.ProjectID
|
||||
WHERE ownerAccess.UserID = @OwnerUserID
|
||||
AND ownerAccess.AccessRole = N'Owner'
|
||||
AND ownerAccess.IsActive = 1
|
||||
AND collaboratorAccess.UserID = @CollaboratorUserID
|
||||
AND collaboratorAccess.AccessRole = N'Collaborator'
|
||||
AND collaboratorAccess.IsActive = 1
|
||||
AND p.IsArchived = 0
|
||||
)
|
||||
THEN 1 ELSE 0 END AS bit);
|
||||
END;
|
||||
GO
|
||||
@ -27,6 +27,23 @@ public sealed class ProjectDetailViewModel
|
||||
public IReadOnlyList<Book> Books { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ProjectCollaboratorsViewModel
|
||||
{
|
||||
public Project Project { get; set; } = new();
|
||||
public IReadOnlyList<ProjectCollaborator> Collaborators { get; set; } = [];
|
||||
public IReadOnlyList<ProjectInvitation> PendingInvitations { get; set; } = [];
|
||||
public ProjectCollaboratorInviteViewModel Invite { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ProjectCollaboratorInviteViewModel
|
||||
{
|
||||
public int ProjectID { get; set; }
|
||||
|
||||
[Required, EmailAddress, StringLength(256)]
|
||||
[Display(Name = "Collaborator email")]
|
||||
public string EmailAddress { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class BookEditViewModel
|
||||
{
|
||||
public int BookID { get; set; }
|
||||
|
||||
109
PlotLine/Views/ProjectCollaborators/Index.cshtml
Normal file
109
PlotLine/Views/ProjectCollaborators/Index.cshtml
Normal file
@ -0,0 +1,109 @@
|
||||
@model ProjectCollaboratorsViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Collaborators";
|
||||
}
|
||||
|
||||
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
|
||||
<a asp-controller="Projects" asp-action="Index">Projects</a>
|
||||
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">@Model.Project.ProjectName</a>
|
||||
<span>Collaborators</span>
|
||||
</nav>
|
||||
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Project access</p>
|
||||
<h1>Collaborators</h1>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<a class="btn btn-outline-secondary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">Back to project</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (TempData["CollaborationMessage"] is string collaborationMessage)
|
||||
{
|
||||
<div class="alert alert-success">@collaborationMessage</div>
|
||||
}
|
||||
|
||||
@if (TempData["CollaborationError"] is string collaborationError)
|
||||
{
|
||||
<div class="alert alert-warning">@collaborationError</div>
|
||||
}
|
||||
|
||||
<section class="edit-panel">
|
||||
<form asp-action="Add" method="post" class="row g-3 align-items-end">
|
||||
<input type="hidden" name="ProjectID" value="@Model.Project.ProjectID" />
|
||||
<div class="col-md-8">
|
||||
<label class="form-label" for="EmailAddress">Collaborator email</label>
|
||||
<input class="form-control" id="EmailAddress" name="EmailAddress" value="@Model.Invite.EmailAddress" autocomplete="email" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button class="btn btn-primary" type="submit">Add collaborator</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="list-section">
|
||||
<h2>Current collaborators</h2>
|
||||
@if (!Model.Collaborators.Any())
|
||||
{
|
||||
<p class="muted">No collaborators have access to this project yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Added</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var collaborator in Model.Collaborators)
|
||||
{
|
||||
<tr>
|
||||
<td>@collaborator.DisplayName</td>
|
||||
<td>@collaborator.Email</td>
|
||||
<td>@collaborator.InvitedDateUTC?.ToString("dd MMM yyyy")</td>
|
||||
<td class="text-end">
|
||||
<form asp-action="Remove" method="post" class="archive-button-form" data-confirm-message="Remove collaborator access for this project?">
|
||||
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
|
||||
<input type="hidden" name="userId" value="@collaborator.UserID" />
|
||||
<button class="btn btn-outline-danger btn-sm" type="submit">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (Model.PendingInvitations.Any())
|
||||
{
|
||||
<section class="list-section">
|
||||
<h2>Pending invitations</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Invited</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var invitation in Model.PendingInvitations)
|
||||
{
|
||||
<tr>
|
||||
<td>@invitation.EmailAddress</td>
|
||||
<td>@invitation.InvitedDateUTC.ToString("dd MMM yyyy")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@ -44,6 +44,10 @@
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<a class="btn btn-outline-secondary" asp-action="Edit" asp-route-id="@Model.Project.ProjectID">Edit project</a>
|
||||
@if (Model.Project.IsOwner)
|
||||
{
|
||||
<a class="btn btn-outline-secondary" asp-controller="ProjectCollaborators" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Collaborators</a>
|
||||
}
|
||||
<a class="btn btn-outline-primary" asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Timeline</a>
|
||||
<a class="btn btn-outline-secondary" asp-controller="Timeline" asp-action="Settings" asp-route-projectId="@Model.Project.ProjectID">Timeline Settings</a>
|
||||
<a class="btn btn-outline-secondary" asp-controller="ProjectMetrics" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Scene Metrics</a>
|
||||
@ -61,13 +65,19 @@
|
||||
<a class="btn btn-outline-primary" asp-controller="Dynamics" asp-action="Story" asp-route-projectId="@Model.Project.ProjectID">Story dynamics</a>
|
||||
<a class="btn btn-outline-secondary" asp-controller="Acceptance" asp-action="Phase1" asp-route-projectId="@Model.Project.ProjectID">Phase 1 Checklist</a>
|
||||
<a class="btn btn-outline-secondary" asp-controller="Archives" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Archived Items</a>
|
||||
@if (Model.Project.IsOwner)
|
||||
{
|
||||
<a class="btn btn-outline-secondary" asp-controller="Exports" asp-action="ProjectBackup" asp-route-projectId="@Model.Project.ProjectID">Download Project Backup</a>
|
||||
}
|
||||
<a class="btn btn-outline-secondary" asp-controller="ProjectRestorePoints" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Restore Points</a>
|
||||
<a class="btn btn-primary" asp-controller="Books" asp-action="Create" asp-route-projectId="@Model.Project.ProjectID">Add book</a>
|
||||
@if (Model.Project.IsOwner)
|
||||
{
|
||||
<form asp-action="Archive" method="post" class="archive-button-form" data-confirm-message="Archive this project? This will hide it from active lists and timelines, but the data will be kept and can be restored later.">
|
||||
<input type="hidden" name="id" value="@Model.Project.ProjectID" />
|
||||
<button class="btn btn-outline-danger" type="submit">Archive Project</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -29,14 +29,18 @@ else
|
||||
{
|
||||
<article class="plain-card">
|
||||
<h2>@project.ProjectName</h2>
|
||||
<p class="eyebrow">@project.AccessRole</p>
|
||||
<p>@(string.IsNullOrWhiteSpace(project.Description) ? "No description yet." : project.Description)</p>
|
||||
<div class="button-row">
|
||||
<a class="btn btn-outline-primary btn-sm" asp-action="Details" asp-route-id="@project.ProjectID">Open</a>
|
||||
<a class="btn btn-outline-secondary btn-sm" asp-action="Edit" asp-route-id="@project.ProjectID">Edit</a>
|
||||
@if (project.IsOwner)
|
||||
{
|
||||
<form asp-action="Archive" method="post" class="archive-button-form" data-confirm-message="Archive this project? This will hide it from active lists and timelines, but the data will be kept and can be restored later.">
|
||||
<input type="hidden" name="id" value="@project.ProjectID" />
|
||||
<button class="btn btn-outline-danger btn-sm" type="submit">Archive</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user