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);
|
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
|
public interface IBookRepository
|
||||||
{
|
{
|
||||||
Task<IReadOnlyList<Book>> ListByProjectAsync(int projectId);
|
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 sealed class BookRepository(ISqlConnectionFactory connectionFactory) : IBookRepository
|
||||||
{
|
{
|
||||||
public async Task<IReadOnlyList<Book>> ListByProjectAsync(int projectId)
|
public async Task<IReadOnlyList<Book>> ListByProjectAsync(int projectId)
|
||||||
|
|||||||
@ -10,6 +10,40 @@ public sealed class Project
|
|||||||
public bool IsArchived { get; set; }
|
public bool IsArchived { get; set; }
|
||||||
public DateTime? ArchivedDate { get; set; }
|
public DateTime? ArchivedDate { get; set; }
|
||||||
public string? ArchivedReason { 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
|
public sealed class GenreMetricPreset
|
||||||
|
|||||||
@ -103,8 +103,10 @@ public class Program
|
|||||||
builder.Services.AddScoped<IAcceptanceRepository, AcceptanceRepository>();
|
builder.Services.AddScoped<IAcceptanceRepository, AcceptanceRepository>();
|
||||||
builder.Services.AddScoped<IImportRepository, ImportRepository>();
|
builder.Services.AddScoped<IImportRepository, ImportRepository>();
|
||||||
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
|
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
|
||||||
|
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
|
||||||
builder.Services.AddScoped<ISubscriptionRepository, SubscriptionRepository>();
|
builder.Services.AddScoped<ISubscriptionRepository, SubscriptionRepository>();
|
||||||
builder.Services.AddScoped<IProjectService, ProjectService>();
|
builder.Services.AddScoped<IProjectService, ProjectService>();
|
||||||
|
builder.Services.AddScoped<IProjectCollaborationService, ProjectCollaborationService>();
|
||||||
builder.Services.AddScoped<IBookService, BookService>();
|
builder.Services.AddScoped<IBookService, BookService>();
|
||||||
builder.Services.AddScoped<IChapterService, ChapterService>();
|
builder.Services.AddScoped<IChapterService, ChapterService>();
|
||||||
builder.Services.AddScoped<ISceneService, SceneService>();
|
builder.Services.AddScoped<ISceneService, SceneService>();
|
||||||
|
|||||||
@ -2,6 +2,7 @@ using System.Diagnostics;
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using PlotLine.Data;
|
using PlotLine.Data;
|
||||||
using PlotLine.Models;
|
using PlotLine.Models;
|
||||||
using PlotLine.ViewModels;
|
using PlotLine.ViewModels;
|
||||||
@ -19,6 +20,24 @@ public interface IProjectService
|
|||||||
Task ArchiveAsync(int projectId);
|
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
|
public interface IBookService
|
||||||
{
|
{
|
||||||
Task<BookDetailViewModel?> GetDetailAsync(int bookId);
|
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;
|
=> 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
|
internal static class SubscriptionLimitGuards
|
||||||
{
|
{
|
||||||
public static async Task EnsureAllowedAsync(Func<int, Task<SubscriptionLimitResult>> validateAsync, int? userId)
|
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)
|
private static readonly Dictionary<string, Dictionary<string, string>> ControllerParameterEntities = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
["Projects"] = new(StringComparer.OrdinalIgnoreCase) { ["id"] = "Project", ["projectId"] = "Project" },
|
["Projects"] = new(StringComparer.OrdinalIgnoreCase) { ["id"] = "Project", ["projectId"] = "Project" },
|
||||||
|
["ProjectCollaborators"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project" },
|
||||||
["Books"] = new(StringComparer.OrdinalIgnoreCase) { ["id"] = "Book", ["bookId"] = "Book", ["projectId"] = "Project" },
|
["Books"] = new(StringComparer.OrdinalIgnoreCase) { ["id"] = "Book", ["bookId"] = "Book", ["projectId"] = "Project" },
|
||||||
["Chapters"] = new(StringComparer.OrdinalIgnoreCase) { ["id"] = "Chapter", ["chapterId"] = "Chapter", ["bookId"] = "Book" },
|
["Chapters"] = new(StringComparer.OrdinalIgnoreCase) { ["id"] = "Chapter", ["chapterId"] = "Chapter", ["bookId"] = "Book" },
|
||||||
["Characters"] = new(StringComparer.OrdinalIgnoreCase) { ["id"] = "Character", ["characterId"] = "Character", ["projectId"] = "Project" },
|
["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" }
|
["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)
|
private static readonly Dictionary<string, Dictionary<string, string>> ActionIdEntities = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
["Characters"] = new(StringComparer.OrdinalIgnoreCase) { ["ArchiveRelationship"] = "CharacterRelationship" },
|
["Characters"] = new(StringComparer.OrdinalIgnoreCase) { ["ArchiveRelationship"] = "CharacterRelationship" },
|
||||||
@ -146,7 +154,7 @@ public sealed class ProjectAccessFilter(IProjectAccessService access, ICurrentUs
|
|||||||
foreach (var requirement in requirements)
|
foreach (var requirement in requirements)
|
||||||
{
|
{
|
||||||
var allowed = requirement.EntityType == "Project"
|
var allowed = requirement.EntityType == "Project"
|
||||||
? await access.CanAccessProjectAsync(requirement.EntityId)
|
? await CheckProjectRequirementAsync(controllerName, descriptor.ActionName, requirement.EntityId)
|
||||||
: await access.CanAccessEntityAsync(requirement.EntityType, requirement.EntityId);
|
: await access.CanAccessEntityAsync(requirement.EntityType, requirement.EntityId);
|
||||||
|
|
||||||
if (!allowed)
|
if (!allowed)
|
||||||
@ -159,6 +167,13 @@ public sealed class ProjectAccessFilter(IProjectAccessService access, ICurrentUs
|
|||||||
await next();
|
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)
|
private static HashSet<AccessRequirement> CollectRequirements(string controllerName, string actionName, IDictionary<string, object?> arguments)
|
||||||
{
|
{
|
||||||
var requirements = new HashSet<AccessRequirement>();
|
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 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 sealed class BookEditViewModel
|
||||||
{
|
{
|
||||||
public int BookID { get; set; }
|
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>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<a class="btn btn-outline-secondary" asp-action="Edit" asp-route-id="@Model.Project.ProjectID">Edit project</a>
|
<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-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="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>
|
<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-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="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="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-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>
|
<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? This will hide it from active lists and timelines, but the data will be kept and can be restored later.">
|
@if (Model.Project.IsOwner)
|
||||||
<input type="hidden" name="id" value="@Model.Project.ProjectID" />
|
{
|
||||||
<button class="btn btn-outline-danger" type="submit">Archive Project</button>
|
<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.">
|
||||||
</form>
|
<input type="hidden" name="id" value="@Model.Project.ProjectID" />
|
||||||
|
<button class="btn btn-outline-danger" type="submit">Archive Project</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -29,14 +29,18 @@ else
|
|||||||
{
|
{
|
||||||
<article class="plain-card">
|
<article class="plain-card">
|
||||||
<h2>@project.ProjectName</h2>
|
<h2>@project.ProjectName</h2>
|
||||||
|
<p class="eyebrow">@project.AccessRole</p>
|
||||||
<p>@(string.IsNullOrWhiteSpace(project.Description) ? "No description yet." : project.Description)</p>
|
<p>@(string.IsNullOrWhiteSpace(project.Description) ? "No description yet." : project.Description)</p>
|
||||||
<div class="button-row">
|
<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-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>
|
<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? This will hide it from active lists and timelines, but the data will be kept and can be restored later.">
|
@if (project.IsOwner)
|
||||||
<input type="hidden" name="id" value="@project.ProjectID" />
|
{
|
||||||
<button class="btn btn-outline-danger btn-sm" type="submit">Archive</button>
|
<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.">
|
||||||
</form>
|
<input type="hidden" name="id" value="@project.ProjectID" />
|
||||||
|
<button class="btn btn-outline-danger btn-sm" type="submit">Archive</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user