Phase 5D Collaboration Foundation

This commit is contained in:
Nick Beckley 2026-06-07 10:47:27 +01:00
parent b94ca66918
commit 9824b364b7
11 changed files with 780 additions and 10 deletions

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View 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>
}

View File

@ -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>
<a class="btn btn-outline-secondary" asp-controller="Exports" asp-action="ProjectBackup" asp-route-projectId="@Model.Project.ProjectID">Download Project Backup</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>
<form asp-action="Archive" method="post" class="archive-button-form" data-confirm-message="Archive this project?&#10;&#10;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>
@if (Model.Project.IsOwner)
{
<form asp-action="Archive" method="post" class="archive-button-form" data-confirm-message="Archive this project?&#10;&#10;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>

View File

@ -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>
<form asp-action="Archive" method="post" class="archive-button-form" data-confirm-message="Archive this project?&#10;&#10;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>
@if (project.IsOwner)
{
<form asp-action="Archive" method="post" class="archive-button-form" data-confirm-message="Archive this project?&#10;&#10;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>
}