diff --git a/PlotLine/Controllers/ProjectCollaboratorsController.cs b/PlotLine/Controllers/ProjectCollaboratorsController.cs new file mode 100644 index 0000000..47586df --- /dev/null +++ b/PlotLine/Controllers/ProjectCollaboratorsController.cs @@ -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 Index(int projectId) + { + var model = await collaboration.GetCollaboratorsAsync(projectId); + return model is null ? NotFound() : View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task 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 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 }); + } +} diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index 4a42286..9f3f55f 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -18,6 +18,17 @@ public interface IProjectRepository Task ArchiveForUserAsync(int projectId, int userId); } +public interface IProjectCollaborationRepository +{ + Task GetOwnerUserIdAsync(int projectId); + Task> ListCollaboratorsAsync(int projectId); + Task> ListPendingInvitationsAsync(int projectId); + Task CollaboratorCountsForOwnerAsync(int ownerUserId, int collaboratorUserId); + Task AddCollaboratorAsync(int projectId, int userId, int invitedByUserId); + Task RemoveCollaboratorAsync(int projectId, int userId); + Task CreatePendingInvitationAsync(int projectId, string emailAddress, int invitedByUserId); +} + public interface IBookRepository { Task> ListByProjectAsync(int projectId); @@ -1227,6 +1238,74 @@ public sealed class ProjectRepository(ISqlConnectionFactory connectionFactory) : } } +public sealed class ProjectCollaborationRepository(ISqlConnectionFactory connectionFactory) : IProjectCollaborationRepository +{ + public async Task GetOwnerUserIdAsync(int projectId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.ProjectCollaboration_GetOwner", + new { ProjectID = projectId }, + commandType: CommandType.StoredProcedure); + } + + public async Task> ListCollaboratorsAsync(int projectId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.ProjectCollaboration_ListCollaborators", + new { ProjectID = projectId }, + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } + + public async Task> ListPendingInvitationsAsync(int projectId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.ProjectCollaboration_ListPendingInvitations", + new { ProjectID = projectId }, + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } + + public async Task CollaboratorCountsForOwnerAsync(int ownerUserId, int collaboratorUserId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.ProjectCollaboration_CollaboratorCountsForOwner", + new { OwnerUserID = ownerUserId, CollaboratorUserID = collaboratorUserId }, + commandType: CommandType.StoredProcedure); + } + + public async Task AddCollaboratorAsync(int projectId, int userId, int invitedByUserId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.ProjectCollaboration_AddCollaborator", + new { ProjectID = projectId, UserID = userId, InvitedByUserID = invitedByUserId }, + commandType: CommandType.StoredProcedure); + } + + public async Task RemoveCollaboratorAsync(int projectId, int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.ProjectCollaboration_RemoveCollaborator", + new { ProjectID = projectId, UserID = userId }, + commandType: CommandType.StoredProcedure); + } + + public async Task CreatePendingInvitationAsync(int projectId, string emailAddress, int invitedByUserId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.ProjectCollaboration_CreatePendingInvitation", + new { ProjectID = projectId, EmailAddress = emailAddress, InvitedByUserID = invitedByUserId }, + commandType: CommandType.StoredProcedure); + } +} + public sealed class BookRepository(ISqlConnectionFactory connectionFactory) : IBookRepository { public async Task> ListByProjectAsync(int projectId) diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index 95cf86b..175962f 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -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 diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 950c8b6..227f3ce 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -103,8 +103,10 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index e37a910..29bb803 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -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 GetCollaboratorsAsync(int projectId); + Task AddCollaboratorAsync(ProjectCollaboratorInviteViewModel model); + Task 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 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 emailOptions, + ILogger logger) : IProjectCollaborationService +{ + private readonly EmailSettings _emailSettings = emailOptions.Value; + + public async Task 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 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 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 = $""" + + + +
+

PlotWeaver Studio

+

You have been added as a collaborator

+

Hello {WebUtility.HtmlEncode(CleanDisplayName(user.DisplayName))},

+

{WebUtility.HtmlEncode(inviterName)} added you as a collaborator on {WebUtility.HtmlEncode(projectName)}.

+

Sign in to PlotWeaver Studio to open the shared project.

+
+ + + """; + + 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> validateAsync, int? userId) diff --git a/PlotLine/Services/ProjectAccessFilter.cs b/PlotLine/Services/ProjectAccessFilter.cs index 64bff24..20ba295 100644 --- a/PlotLine/Services/ProjectAccessFilter.cs +++ b/PlotLine/Services/ProjectAccessFilter.cs @@ -58,6 +58,7 @@ public sealed class ProjectAccessFilter(IProjectAccessService access, ICurrentUs private static readonly Dictionary> 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> 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> 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 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 CollectRequirements(string controllerName, string actionName, IDictionary arguments) { var requirements = new HashSet(); diff --git a/PlotLine/Sql/045_Phase5D_CollaborationFoundation.sql b/PlotLine/Sql/045_Phase5D_CollaborationFoundation.sql new file mode 100644 index 0000000..d65d5f5 --- /dev/null +++ b/PlotLine/Sql/045_Phase5D_CollaborationFoundation.sql @@ -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 diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index 51d8a2d..36f7b61 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -27,6 +27,23 @@ public sealed class ProjectDetailViewModel public IReadOnlyList Books { get; set; } = []; } +public sealed class ProjectCollaboratorsViewModel +{ + public Project Project { get; set; } = new(); + public IReadOnlyList Collaborators { get; set; } = []; + public IReadOnlyList 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; } diff --git a/PlotLine/Views/ProjectCollaborators/Index.cshtml b/PlotLine/Views/ProjectCollaborators/Index.cshtml new file mode 100644 index 0000000..329beed --- /dev/null +++ b/PlotLine/Views/ProjectCollaborators/Index.cshtml @@ -0,0 +1,109 @@ +@model ProjectCollaboratorsViewModel +@{ + ViewData["Title"] = "Collaborators"; +} + + + +
+
+

Project access

+

Collaborators

+
+ +
+ +@if (TempData["CollaborationMessage"] is string collaborationMessage) +{ +
@collaborationMessage
+} + +@if (TempData["CollaborationError"] is string collaborationError) +{ +
@collaborationError
+} + +
+
+ +
+ + +
+
+ +
+
+
+ +
+

Current collaborators

+ @if (!Model.Collaborators.Any()) + { +

No collaborators have access to this project yet.

+ } + else + { +
+ + + + + + + + + + + @foreach (var collaborator in Model.Collaborators) + { + + + + + + + } + +
NameEmailAdded
@collaborator.DisplayName@collaborator.Email@collaborator.InvitedDateUTC?.ToString("dd MMM yyyy") +
+ + + +
+
+
+ } +
+ +@if (Model.PendingInvitations.Any()) +{ +
+

Pending invitations

+
+ + + + + + + + + @foreach (var invitation in Model.PendingInvitations) + { + + + + + } + +
EmailInvited
@invitation.EmailAddress@invitation.InvitedDateUTC.ToString("dd MMM yyyy")
+
+
+} diff --git a/PlotLine/Views/Projects/Details.cshtml b/PlotLine/Views/Projects/Details.cshtml index 1104960..1a8168c 100644 --- a/PlotLine/Views/Projects/Details.cshtml +++ b/PlotLine/Views/Projects/Details.cshtml @@ -44,6 +44,10 @@
Edit project + @if (Model.Project.IsOwner) + { + Collaborators + } Timeline Timeline Settings Scene Metrics @@ -61,13 +65,19 @@ Story dynamics Phase 1 Checklist Archived Items - Download Project Backup + @if (Model.Project.IsOwner) + { + Download Project Backup + } Restore Points Add book -
- - -
+ @if (Model.Project.IsOwner) + { +
+ + +
+ }
diff --git a/PlotLine/Views/Projects/Index.cshtml b/PlotLine/Views/Projects/Index.cshtml index 0b50911..4515c5c 100644 --- a/PlotLine/Views/Projects/Index.cshtml +++ b/PlotLine/Views/Projects/Index.cshtml @@ -29,14 +29,18 @@ else {

@project.ProjectName

+

@project.AccessRole

@(string.IsNullOrWhiteSpace(project.Description) ? "No description yet." : project.Description)

Open Edit -
- - -
+ @if (project.IsOwner) + { +
+ + +
+ }
}