Phase 15C - Feature Requests: Layout Polish, Notifications and Public Roadmap Foundation

This commit is contained in:
Nick Beckley 2026-06-23 21:51:10 +01:00
parent ac14776e45
commit 6896629ed0
16 changed files with 787 additions and 17 deletions

View File

@ -10,7 +10,8 @@ namespace PlotLine.Controllers;
[Route("admin")]
public sealed class AdminController(
ICurrentUserService currentUser,
IFeatureRequestService featureRequests) : Controller
IFeatureRequestService featureRequests,
IEmailService emails) : Controller
{
[HttpGet("")]
[HttpGet("index")]
@ -78,12 +79,27 @@ public sealed class AdminController(
return BadRequest();
}
var before = await featureRequests.GetForAdminAsync(model.FeatureRequestID);
if (before is null)
{
return NotFound();
}
var updated = await featureRequests.UpdateAdminFieldsAsync(model);
if (!updated)
{
return NotFound();
}
var after = await featureRequests.GetForAdminAsync(model.FeatureRequestID);
if (after is not null && !string.Equals(before.Status, after.Status, StringComparison.OrdinalIgnoreCase))
{
await emails.SendFeatureRequestStatusChangedAsync(
after,
BuildAbsoluteUrl("Details", "FeatureRequests", new { id = after.FeatureRequestID }),
currentUser.Email ?? string.Empty);
}
TempData["AdminMessage"] = "Feature request updated.";
return RedirectToAction(nameof(FeatureRequestDetails), new { id = model.FeatureRequestID });
}
@ -115,6 +131,23 @@ public sealed class AdminController(
}
await featureRequests.AddAdminReplyAsync(model.FeatureRequestID, userId, model.MessageBody);
await emails.SendFeatureRequestAdminReplyAsync(
request,
Preview(model.MessageBody),
BuildAbsoluteUrl("Details", "FeatureRequests", new { id = request.FeatureRequestID }),
currentUser.Email ?? string.Empty);
return RedirectToAction(nameof(FeatureRequestDetails), new { id = model.FeatureRequestID });
}
private string BuildAbsoluteUrl(string action, string controller, object routeValues)
{
return Url.Action(action, controller, routeValues, Request.Scheme)
?? $"{Request.Scheme}://{Request.Host}";
}
private static string Preview(string value)
{
var cleaned = value.ReplaceLineEndings(" ").Trim();
return cleaned.Length <= 240 ? cleaned : $"{cleaned[..240]}...";
}
}

View File

@ -8,7 +8,8 @@ namespace PlotLine.Controllers;
[Authorize]
public sealed class FeatureRequestsController(
ICurrentUserService currentUser,
IFeatureRequestService featureRequests) : Controller
IFeatureRequestService featureRequests,
IEmailService emails) : Controller
{
[HttpGet]
public async Task<IActionResult> Index()
@ -24,6 +25,15 @@ public sealed class FeatureRequestsController(
return View(new FeatureRequestCreateViewModel());
}
[HttpGet]
public async Task<IActionResult> Roadmap()
{
return View(new FeatureRequestRoadmapViewModel
{
Sections = await featureRequests.ListRoadmapAsync()
});
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(FeatureRequestCreateViewModel model)
@ -47,6 +57,10 @@ public sealed class FeatureRequestsController(
return NotFound();
}
await featureRequests.MarkViewedForUserAsync(request.FeatureRequestID, request.UserID);
request.UserNeedsAttention = false;
request.UserAttentionUtc = null;
return View(new FeatureRequestDetailsViewModel
{
Request = request,
@ -77,6 +91,11 @@ public sealed class FeatureRequestsController(
}
await featureRequests.AddUserReplyAsync(model.FeatureRequestID, userId, model.MessageBody);
await emails.SendFeatureRequestUserReplyAsync(
request,
Preview(model.MessageBody),
BuildAbsoluteUrl("FeatureRequestDetails", "Admin", new { id = request.FeatureRequestID }),
currentUser.Email ?? string.Empty);
return RedirectToAction(nameof(Details), new { id = model.FeatureRequestID });
}
@ -91,4 +110,16 @@ public sealed class FeatureRequestsController(
model.ImportanceOptions = PlotLine.Models.FeatureRequestOptions.ImportanceOptions;
return model;
}
private string BuildAbsoluteUrl(string action, string controller, object routeValues)
{
return Url.Action(action, controller, routeValues, Request.Scheme)
?? $"{Request.Scheme}://{Request.Host}";
}
private static string Preview(string value)
{
var cleaned = value.ReplaceLineEndings(" ").Trim();
return cleaned.Length <= 240 ? cleaned : $"{cleaned[..240]}...";
}
}

View File

@ -11,10 +11,15 @@ public interface IFeatureRequestRepository
Task<FeatureRequest?> GetForAdminAsync(int featureRequestId);
Task<IReadOnlyList<FeatureRequest>> ListForUserAsync(int userId);
Task<IReadOnlyList<FeatureRequest>> ListForAdminAsync(string? status, string? appArea, string? importance, string? search, string sort);
Task<IReadOnlyList<FeatureRequest>> ListRoadmapAsync();
Task<FeatureRequest?> UpdateStatusAsync(int featureRequestId, string status, bool isPublicCandidate);
Task<FeatureRequest?> UpdateRoadmapVisibilityAsync(int featureRequestId, bool isPublicCandidate);
Task<FeatureRequest?> UpdateAdminNotesAsync(int featureRequestId, string? adminNotes);
Task<FeatureRequestMessage?> AddMessageAsync(int featureRequestId, int? userId, bool isAdmin, string messageBody);
Task<IReadOnlyList<FeatureRequestMessage>> ListMessagesAsync(int featureRequestId);
Task<bool> MarkViewedForUserAsync(int featureRequestId, int userId);
Task<int> CountAttentionForUserAsync(int userId);
Task<int> CountWaitingForAdminAsync();
}
public sealed class FeatureRequestRepository(ISqlConnectionFactory connectionFactory) : IFeatureRequestRepository
@ -66,6 +71,15 @@ public sealed class FeatureRequestRepository(ISqlConnectionFactory connectionFac
return rows.ToList();
}
public async Task<IReadOnlyList<FeatureRequest>> ListRoadmapAsync()
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<FeatureRequest>(
"dbo.FeatureRequest_ListRoadmap",
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<FeatureRequest?> UpdateStatusAsync(int featureRequestId, string status, bool isPublicCandidate)
{
using var connection = connectionFactory.CreateConnection();
@ -75,6 +89,15 @@ public sealed class FeatureRequestRepository(ISqlConnectionFactory connectionFac
commandType: CommandType.StoredProcedure);
}
public async Task<FeatureRequest?> UpdateRoadmapVisibilityAsync(int featureRequestId, bool isPublicCandidate)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<FeatureRequest>(
"dbo.FeatureRequest_UpdateRoadmapVisibility",
new { FeatureRequestID = featureRequestId, IsPublicCandidate = isPublicCandidate },
commandType: CommandType.StoredProcedure);
}
public async Task<FeatureRequest?> UpdateAdminNotesAsync(int featureRequestId, string? adminNotes)
{
using var connection = connectionFactory.CreateConnection();
@ -102,4 +125,30 @@ public sealed class FeatureRequestRepository(ISqlConnectionFactory connectionFac
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<bool> MarkViewedForUserAsync(int featureRequestId, int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<bool>(
"dbo.FeatureRequest_MarkViewedForUser",
new { FeatureRequestID = featureRequestId, UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<int> CountAttentionForUserAsync(int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.FeatureRequest_CountAttentionForUser",
new { UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<int> CountWaitingForAdminAsync()
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.FeatureRequest_CountWaitingForAdmin",
commandType: CommandType.StoredProcedure);
}
}

View File

@ -104,6 +104,9 @@ public sealed class FeatureRequest
public bool? LatestMessageIsAdmin { get; set; }
public string? LatestMessagePreview { get; set; }
public int MessageCount { get; set; }
public DateTime? UserLastViewedUtc { get; set; }
public DateTime? UserAttentionUtc { get; set; }
public bool UserNeedsAttention { get; set; }
public bool IsOpen => FeatureRequestStatuses.IsOpen(Status);
public string StatusExplanation => FeatureRequestStatuses.Explanation(Status);
@ -126,6 +129,13 @@ public sealed class FeatureRequest
}
}
public sealed class FeatureRequestRoadmapSection
{
public string Heading { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public IReadOnlyList<FeatureRequest> Requests { get; set; } = [];
}
public static class FeatureRequestConversationStates
{
public const string WaitingForAdminReply = "Waiting for Admin Reply";

View File

@ -47,6 +47,9 @@ public interface IEmailService
{
Task SendVerificationEmailAsync(string email, string displayName, Guid token);
Task SendPasswordResetEmailAsync(string email, string displayName, Guid token);
Task SendFeatureRequestAdminReplyAsync(FeatureRequest request, string messagePreview, string link, string actorEmail);
Task SendFeatureRequestStatusChangedAsync(FeatureRequest request, string link, string actorEmail);
Task SendFeatureRequestUserReplyAsync(FeatureRequest request, string messagePreview, string link, string actorEmail);
}
public interface ICurrentUserService

View File

@ -51,6 +51,7 @@ public sealed class CurrentUserService(IHttpContextAccessor httpContextAccessor)
public sealed class EmailService(
IOptions<EmailSettings> emailOptions,
IConfiguration configuration,
IEmailQueueRepository emailQueue,
IHttpContextAccessor httpContextAccessor,
ILogger<EmailService> logger) : IEmailService
@ -85,8 +86,107 @@ public sealed class EmailService(
return QueueAsync(email, displayName, "Reset your PlotDirector password", plainText, html);
}
private async Task QueueAsync(string toEmail, string displayName, string subject, string plainText, string html)
public Task SendFeatureRequestAdminReplyAsync(FeatureRequest request, string messagePreview, string link, string actorEmail)
{
if (string.Equals(request.UserEmail, actorEmail, StringComparison.OrdinalIgnoreCase))
{
return Task.CompletedTask;
}
var body = $"""
Hi {DisplayName(request.UserDisplayName)},
There is a new reply on your PlotDirector feature request:
"{request.Title}"
{messagePreview}
You can view and reply here:
{link}
Thanks,
The PlotDirector team
""";
return QueueAsync(
request.UserEmail ?? string.Empty,
DisplayName(request.UserDisplayName),
$"PlotDirector feature request update: {request.Title}",
body,
html: null);
}
public Task SendFeatureRequestStatusChangedAsync(FeatureRequest request, string link, string actorEmail)
{
if (string.Equals(request.UserEmail, actorEmail, StringComparison.OrdinalIgnoreCase))
{
return Task.CompletedTask;
}
var body = $"""
Hi {DisplayName(request.UserDisplayName)},
The status of your feature request has changed:
"{request.Title}"
New status: {request.Status}
{request.StatusExplanation}
You can view the request here:
{link}
Thanks,
The PlotDirector team
""";
return QueueAsync(
request.UserEmail ?? string.Empty,
DisplayName(request.UserDisplayName),
$"PlotDirector feature request status changed: {request.Title}",
body,
html: null);
}
public async Task SendFeatureRequestUserReplyAsync(FeatureRequest request, string messagePreview, string link, string actorEmail)
{
var adminEmails = configuration.GetSection("Admin:AllowedEmails").Get<string[]>() ?? [];
foreach (var adminEmail in adminEmails
.Where(email => !string.IsNullOrWhiteSpace(email))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Where(email => !string.Equals(email, actorEmail, StringComparison.OrdinalIgnoreCase)))
{
var body = $"""
A user replied to a PlotDirector feature request:
"{request.Title}"
User: {request.UserDisplayName} ({request.UserEmail})
{messagePreview}
View the request here:
{link}
""";
await QueueAsync(
adminEmail,
"PlotDirector admin",
$"PlotDirector feature request reply: {request.Title}",
body,
html: null);
}
}
private async Task QueueAsync(string toEmail, string displayName, string subject, string plainText, string? html)
{
if (string.IsNullOrWhiteSpace(toEmail))
{
return;
}
var emailQueueId = await emailQueue.CreateAsync(new EmailQueueItem
{
ToEmail = toEmail,
@ -163,6 +263,11 @@ public sealed class EmailService(
return string.IsNullOrWhiteSpace(value) ? "PlotDirector" : value.Trim();
}
private static string DisplayName(string? value)
{
return string.IsNullOrWhiteSpace(value) ? "there" : value.Trim();
}
private string GetFromAddress()
{
return string.IsNullOrWhiteSpace(_settings.FromAddress)

View File

@ -11,11 +11,15 @@ public interface IFeatureRequestService
Task<FeatureRequest?> GetForAdminAsync(int featureRequestId);
Task<IReadOnlyList<FeatureRequest>> ListForUserAsync(int userId);
Task<IReadOnlyList<FeatureRequest>> ListForAdminAsync(AdminFeatureRequestFilter filter);
Task<IReadOnlyList<FeatureRequestRoadmapSection>> ListRoadmapAsync();
Task<FeatureRequestAdminSummary> GetAdminSummaryAsync();
Task<bool> UpdateAdminFieldsAsync(AdminFeatureRequestUpdateViewModel model);
Task<bool> AddUserReplyAsync(int featureRequestId, int userId, string messageBody);
Task<bool> AddAdminReplyAsync(int featureRequestId, int adminUserId, string messageBody);
Task<IReadOnlyList<FeatureRequestMessage>> ListMessagesAsync(int featureRequestId);
Task<bool> MarkViewedForUserAsync(int featureRequestId, int userId);
Task<int> CountAttentionForUserAsync(int userId);
Task<int> CountWaitingForAdminAsync();
}
public sealed class FeatureRequestService(IFeatureRequestRepository featureRequests) : IFeatureRequestService
@ -50,6 +54,20 @@ public sealed class FeatureRequestService(IFeatureRequestRepository featureReque
sanitized.Sort);
}
public async Task<IReadOnlyList<FeatureRequestRoadmapSection>> ListRoadmapAsync()
{
var requests = await featureRequests.ListRoadmapAsync();
return
[
BuildRoadmapSection("Under Consideration", FeatureRequestStatuses.Candidate, requests),
BuildRoadmapSection("Planned", FeatureRequestStatuses.Planned, requests),
BuildRoadmapSection("In Progress", FeatureRequestStatuses.InProgress, requests),
BuildRoadmapSection("Recently Shipped", FeatureRequestStatuses.Shipped, requests
.OrderByDescending(request => request.ShippedUtc ?? request.UpdatedUtc)
.ToList())
];
}
public async Task<FeatureRequestAdminSummary> GetAdminSummaryAsync()
{
var requests = await featureRequests.ListForAdminAsync(
@ -81,12 +99,24 @@ public sealed class FeatureRequestService(IFeatureRequestRepository featureReque
return false;
}
var updated = await featureRequests.UpdateStatusAsync(model.FeatureRequestID, model.Status, model.IsPublicCandidate);
if (updated is null)
var current = await featureRequests.GetForAdminAsync(model.FeatureRequestID);
if (current is null)
{
return false;
}
var statusChanged = !string.Equals(current.Status, model.Status, StringComparison.OrdinalIgnoreCase);
var roadmapVisibilityChanged = current.IsPublicCandidate != model.IsPublicCandidate;
if (statusChanged)
{
await featureRequests.UpdateStatusAsync(model.FeatureRequestID, model.Status, model.IsPublicCandidate);
}
else if (roadmapVisibilityChanged)
{
await featureRequests.UpdateRoadmapVisibilityAsync(model.FeatureRequestID, model.IsPublicCandidate);
}
await featureRequests.UpdateAdminNotesAsync(model.FeatureRequestID, CleanNullable(model.AdminNotes));
return true;
}
@ -106,6 +136,15 @@ public sealed class FeatureRequestService(IFeatureRequestRepository featureReque
public Task<IReadOnlyList<FeatureRequestMessage>> ListMessagesAsync(int featureRequestId) =>
featureRequests.ListMessagesAsync(featureRequestId);
public Task<bool> MarkViewedForUserAsync(int featureRequestId, int userId) =>
featureRequests.MarkViewedForUserAsync(featureRequestId, userId);
public Task<int> CountAttentionForUserAsync(int userId) =>
featureRequests.CountAttentionForUserAsync(userId);
public Task<int> CountWaitingForAdminAsync() =>
featureRequests.CountWaitingForAdminAsync();
private static string Clean(string value) => value.Trim();
private static string? CleanNullable(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
@ -121,4 +160,16 @@ public sealed class FeatureRequestService(IFeatureRequestRepository featureReque
Sort = FeatureRequestSortOptions.IsValid(filter.Sort) ? filter.Sort : FeatureRequestSortOptions.LatestActivity
};
}
private static FeatureRequestRoadmapSection BuildRoadmapSection(string heading, string status, IEnumerable<FeatureRequest> requests)
{
return new FeatureRequestRoadmapSection
{
Heading = heading,
Status = status,
Requests = requests
.Where(request => string.Equals(request.Status, status, StringComparison.OrdinalIgnoreCase))
.ToList()
};
}
}

View File

@ -0,0 +1,331 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF COL_LENGTH(N'dbo.FeatureRequests', N'UserLastViewedUtc') IS NULL
BEGIN
ALTER TABLE dbo.FeatureRequests ADD UserLastViewedUtc datetime2 NULL;
END;
GO
IF COL_LENGTH(N'dbo.FeatureRequests', N'UserAttentionUtc') IS NULL
BEGIN
ALTER TABLE dbo.FeatureRequests ADD UserAttentionUtc datetime2 NULL;
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_FeatureRequests_UserAttention' AND object_id = OBJECT_ID(N'dbo.FeatureRequests'))
BEGIN
CREATE INDEX IX_FeatureRequests_UserAttention
ON dbo.FeatureRequests(UserID, UserAttentionUtc)
WHERE UserAttentionUtc IS NOT NULL;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_GetForUser
@FeatureRequestID int,
@UserID int
AS
BEGIN
SET NOCOUNT ON;
SELECT fr.FeatureRequestID, fr.UserID, au.Email AS UserEmail, au.DisplayName AS UserDisplayName,
fr.Title, fr.Description, fr.AppArea, fr.Importance, fr.Status, fr.IsPublicCandidate,
fr.AdminNotes, fr.CreatedUtc, fr.UpdatedUtc, fr.ClosedUtc, fr.ShippedUtc,
COALESCE(latest.CreatedUtc, fr.UpdatedUtc, fr.CreatedUtc) AS LatestActivityUtc,
latest.IsAdmin AS LatestMessageIsAdmin,
latest.MessageBody AS LatestMessagePreview,
messageCounts.MessageCount,
fr.UserLastViewedUtc,
fr.UserAttentionUtc,
CAST(CASE WHEN fr.UserAttentionUtc IS NOT NULL THEN 1 ELSE 0 END AS bit) AS UserNeedsAttention
FROM dbo.FeatureRequests fr
INNER JOIN dbo.AppUser au ON au.UserID = fr.UserID
OUTER APPLY
(
SELECT TOP (1) frm.IsAdmin, frm.MessageBody, frm.CreatedUtc
FROM dbo.FeatureRequestMessages frm
WHERE frm.FeatureRequestID = fr.FeatureRequestID
ORDER BY frm.CreatedUtc DESC, frm.FeatureRequestMessageID DESC
) latest
OUTER APPLY
(
SELECT COUNT(1) AS MessageCount
FROM dbo.FeatureRequestMessages frm
WHERE frm.FeatureRequestID = fr.FeatureRequestID
) messageCounts
WHERE fr.FeatureRequestID = @FeatureRequestID
AND fr.UserID = @UserID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_GetForAdmin
@FeatureRequestID int
AS
BEGIN
SET NOCOUNT ON;
SELECT fr.FeatureRequestID, fr.UserID, au.Email AS UserEmail, au.DisplayName AS UserDisplayName,
fr.Title, fr.Description, fr.AppArea, fr.Importance, fr.Status, fr.IsPublicCandidate,
fr.AdminNotes, fr.CreatedUtc, fr.UpdatedUtc, fr.ClosedUtc, fr.ShippedUtc,
COALESCE(latest.CreatedUtc, fr.UpdatedUtc, fr.CreatedUtc) AS LatestActivityUtc,
latest.IsAdmin AS LatestMessageIsAdmin,
latest.MessageBody AS LatestMessagePreview,
messageCounts.MessageCount,
fr.UserLastViewedUtc,
fr.UserAttentionUtc,
CAST(CASE WHEN fr.UserAttentionUtc IS NOT NULL THEN 1 ELSE 0 END AS bit) AS UserNeedsAttention
FROM dbo.FeatureRequests fr
INNER JOIN dbo.AppUser au ON au.UserID = fr.UserID
OUTER APPLY
(
SELECT TOP (1) frm.IsAdmin, frm.MessageBody, frm.CreatedUtc
FROM dbo.FeatureRequestMessages frm
WHERE frm.FeatureRequestID = fr.FeatureRequestID
ORDER BY frm.CreatedUtc DESC, frm.FeatureRequestMessageID DESC
) latest
OUTER APPLY
(
SELECT COUNT(1) AS MessageCount
FROM dbo.FeatureRequestMessages frm
WHERE frm.FeatureRequestID = fr.FeatureRequestID
) messageCounts
WHERE fr.FeatureRequestID = @FeatureRequestID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_ListForUser
@UserID int
AS
BEGIN
SET NOCOUNT ON;
SELECT fr.FeatureRequestID, fr.UserID, au.Email AS UserEmail, au.DisplayName AS UserDisplayName,
fr.Title, fr.Description, fr.AppArea, fr.Importance, fr.Status, fr.IsPublicCandidate,
CAST(NULL AS nvarchar(max)) AS AdminNotes, fr.CreatedUtc, fr.UpdatedUtc, fr.ClosedUtc, fr.ShippedUtc,
COALESCE(latest.CreatedUtc, fr.UpdatedUtc, fr.CreatedUtc) AS LatestActivityUtc,
latest.IsAdmin AS LatestMessageIsAdmin,
latest.MessageBody AS LatestMessagePreview,
messageCounts.MessageCount,
fr.UserLastViewedUtc,
fr.UserAttentionUtc,
CAST(CASE WHEN fr.UserAttentionUtc IS NOT NULL THEN 1 ELSE 0 END AS bit) AS UserNeedsAttention
FROM dbo.FeatureRequests fr
INNER JOIN dbo.AppUser au ON au.UserID = fr.UserID
OUTER APPLY
(
SELECT TOP (1) frm.IsAdmin, frm.MessageBody, frm.CreatedUtc
FROM dbo.FeatureRequestMessages frm
WHERE frm.FeatureRequestID = fr.FeatureRequestID
ORDER BY frm.CreatedUtc DESC, frm.FeatureRequestMessageID DESC
) latest
OUTER APPLY
(
SELECT COUNT(1) AS MessageCount
FROM dbo.FeatureRequestMessages frm
WHERE frm.FeatureRequestID = fr.FeatureRequestID
) messageCounts
WHERE fr.UserID = @UserID
ORDER BY COALESCE(fr.UserAttentionUtc, latest.CreatedUtc, fr.UpdatedUtc, fr.CreatedUtc) DESC, fr.FeatureRequestID DESC;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_ListForAdmin
@Status nvarchar(50) = NULL,
@AppArea nvarchar(100) = NULL,
@Importance nvarchar(50) = NULL,
@Search nvarchar(200) = NULL,
@Sort nvarchar(50) = N'LatestActivity'
AS
BEGIN
SET NOCOUNT ON;
DECLARE @CleanSearch nvarchar(200) = NULLIF(LTRIM(RTRIM(@Search)), N'');
SELECT fr.FeatureRequestID, fr.UserID, au.Email AS UserEmail, au.DisplayName AS UserDisplayName,
fr.Title, fr.Description, fr.AppArea, fr.Importance, fr.Status, fr.IsPublicCandidate,
fr.AdminNotes, fr.CreatedUtc, fr.UpdatedUtc, fr.ClosedUtc, fr.ShippedUtc,
COALESCE(latest.CreatedUtc, fr.UpdatedUtc, fr.CreatedUtc) AS LatestActivityUtc,
latest.IsAdmin AS LatestMessageIsAdmin,
latest.MessageBody AS LatestMessagePreview,
messageCounts.MessageCount,
fr.UserLastViewedUtc,
fr.UserAttentionUtc,
CAST(CASE WHEN fr.UserAttentionUtc IS NOT NULL THEN 1 ELSE 0 END AS bit) AS UserNeedsAttention
FROM dbo.FeatureRequests fr
INNER JOIN dbo.AppUser au ON au.UserID = fr.UserID
OUTER APPLY
(
SELECT TOP (1) frm.IsAdmin, frm.MessageBody, frm.CreatedUtc
FROM dbo.FeatureRequestMessages frm
WHERE frm.FeatureRequestID = fr.FeatureRequestID
ORDER BY frm.CreatedUtc DESC, frm.FeatureRequestMessageID DESC
) latest
OUTER APPLY
(
SELECT COUNT(1) AS MessageCount
FROM dbo.FeatureRequestMessages frm
WHERE frm.FeatureRequestID = fr.FeatureRequestID
) messageCounts
WHERE (@Status IS NULL OR fr.Status = @Status)
AND (@AppArea IS NULL OR fr.AppArea = @AppArea)
AND (@Importance IS NULL OR fr.Importance = @Importance)
AND
(
@CleanSearch IS NULL
OR fr.Title LIKE N'%' + @CleanSearch + N'%'
OR fr.Description LIKE N'%' + @CleanSearch + N'%'
OR au.DisplayName LIKE N'%' + @CleanSearch + N'%'
OR au.Email LIKE N'%' + @CleanSearch + N'%'
)
ORDER BY
CASE WHEN @Sort = N'OldestFirst' THEN fr.CreatedUtc END ASC,
CASE WHEN @Sort = N'NewestFirst' THEN fr.CreatedUtc END DESC,
CASE WHEN @Sort IS NULL OR @Sort = N'LatestActivity' THEN COALESCE(latest.CreatedUtc, fr.UpdatedUtc, fr.CreatedUtc) END DESC,
fr.FeatureRequestID DESC;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_UpdateStatus
@FeatureRequestID int,
@Status nvarchar(50),
@IsPublicCandidate bit = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.FeatureRequests
SET Status = @Status,
IsPublicCandidate = COALESCE(@IsPublicCandidate, IsPublicCandidate),
UpdatedUtc = sysutcdatetime(),
UserAttentionUtc = sysutcdatetime(),
ClosedUtc = CASE WHEN @Status IN (N'Shipped', N'Not Planned') THEN COALESCE(ClosedUtc, sysutcdatetime()) ELSE NULL END,
ShippedUtc = CASE WHEN @Status = N'Shipped' THEN COALESCE(ShippedUtc, sysutcdatetime()) ELSE NULL END
WHERE FeatureRequestID = @FeatureRequestID;
EXEC dbo.FeatureRequest_GetForAdmin @FeatureRequestID = @FeatureRequestID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_UpdateRoadmapVisibility
@FeatureRequestID int,
@IsPublicCandidate bit
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.FeatureRequests
SET IsPublicCandidate = @IsPublicCandidate,
UpdatedUtc = sysutcdatetime()
WHERE FeatureRequestID = @FeatureRequestID;
EXEC dbo.FeatureRequest_GetForAdmin @FeatureRequestID = @FeatureRequestID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FeatureRequestMessage_Add
@FeatureRequestID int,
@UserID int = NULL,
@IsAdmin bit = 0,
@MessageBody nvarchar(max)
AS
BEGIN
SET NOCOUNT ON;
IF NOT EXISTS (SELECT 1 FROM dbo.FeatureRequests WHERE FeatureRequestID = @FeatureRequestID)
RETURN;
INSERT INTO dbo.FeatureRequestMessages (FeatureRequestID, UserID, IsAdmin, MessageBody)
VALUES (@FeatureRequestID, @UserID, @IsAdmin, @MessageBody);
UPDATE dbo.FeatureRequests
SET UpdatedUtc = sysutcdatetime(),
UserAttentionUtc = CASE WHEN @IsAdmin = 1 THEN sysutcdatetime() ELSE UserAttentionUtc END
WHERE FeatureRequestID = @FeatureRequestID;
SELECT frm.FeatureRequestMessageID, frm.FeatureRequestID, frm.UserID, au.Email AS UserEmail,
au.DisplayName AS UserDisplayName, frm.IsAdmin, frm.MessageBody, frm.CreatedUtc
FROM dbo.FeatureRequestMessages frm
LEFT JOIN dbo.AppUser au ON au.UserID = frm.UserID
WHERE frm.FeatureRequestMessageID = SCOPE_IDENTITY();
END;
GO
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_MarkViewedForUser
@FeatureRequestID int,
@UserID int
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.FeatureRequests
SET UserLastViewedUtc = sysutcdatetime(),
UserAttentionUtc = NULL
WHERE FeatureRequestID = @FeatureRequestID
AND UserID = @UserID;
SELECT CAST(@@ROWCOUNT AS bit) AS Succeeded;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_CountAttentionForUser
@UserID int
AS
BEGIN
SET NOCOUNT ON;
SELECT COUNT(1)
FROM dbo.FeatureRequests
WHERE UserID = @UserID
AND UserAttentionUtc IS NOT NULL;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_CountWaitingForAdmin
AS
BEGIN
SET NOCOUNT ON;
SELECT COUNT(1)
FROM dbo.FeatureRequests fr
OUTER APPLY
(
SELECT TOP (1) frm.IsAdmin
FROM dbo.FeatureRequestMessages frm
WHERE frm.FeatureRequestID = fr.FeatureRequestID
ORDER BY frm.CreatedUtc DESC, frm.FeatureRequestMessageID DESC
) latest
WHERE fr.Status NOT IN (N'Shipped', N'Not Planned')
AND (latest.IsAdmin IS NULL OR latest.IsAdmin = 0);
END;
GO
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_ListRoadmap
AS
BEGIN
SET NOCOUNT ON;
SELECT FeatureRequestID, Title, CAST(NULL AS int) AS UserID, CAST(NULL AS nvarchar(256)) AS UserEmail,
CAST(NULL AS nvarchar(200)) AS UserDisplayName, Description, AppArea, Importance, Status,
IsPublicCandidate, CAST(NULL AS nvarchar(max)) AS AdminNotes, CreatedUtc, UpdatedUtc,
ClosedUtc, ShippedUtc, UpdatedUtc AS LatestActivityUtc,
CAST(NULL AS bit) AS LatestMessageIsAdmin, CAST(NULL AS nvarchar(max)) AS LatestMessagePreview,
0 AS MessageCount, CAST(NULL AS datetime2) AS UserLastViewedUtc, CAST(NULL AS datetime2) AS UserAttentionUtc,
CAST(0 AS bit) AS UserNeedsAttention
FROM dbo.FeatureRequests
WHERE IsPublicCandidate = 1
AND Status IN (N'Candidate', N'Planned', N'In Progress', N'Shipped')
ORDER BY
CASE Status
WHEN N'Candidate' THEN 1
WHEN N'Planned' THEN 2
WHEN N'In Progress' THEN 3
WHEN N'Shipped' THEN 4
ELSE 5
END,
CASE WHEN Status = N'Shipped' THEN ShippedUtc END DESC,
UpdatedUtc DESC,
FeatureRequestID DESC;
END;
GO

View File

@ -8,6 +8,11 @@ public sealed class FeatureRequestIndexViewModel
public IReadOnlyList<FeatureRequest> Requests { get; set; } = [];
}
public sealed class FeatureRequestRoadmapViewModel
{
public IReadOnlyList<FeatureRequestRoadmapSection> Sections { get; set; } = [];
}
public sealed class FeatureRequestCreateViewModel
{
[Required(ErrorMessage = "Enter a title.")]

View File

@ -94,7 +94,8 @@
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="IsPublicCandidate" name="IsPublicCandidate" value="true" checked="@request.IsPublicCandidate" />
<input type="hidden" name="IsPublicCandidate" value="false" />
<label class="form-check-label" for="IsPublicCandidate">Public candidate</label>
<label class="form-check-label" for="IsPublicCandidate">Show on curated roadmap</label>
<div class="form-text">Only Candidate, Planned, In Progress and Shipped requests appear on the roadmap.</div>
</div>
<div class="mb-3">
<label class="form-label" for="AdminNotes">Admin notes</label>

View File

@ -3,6 +3,7 @@
@using System.Text.Encodings.Web
@{
ViewData["Title"] = "Feature Requests";
ViewData["ShellClass"] = "plotline-shell-wide feature-request-admin-page";
}
<div class="page-heading compact">
@ -88,7 +89,7 @@ else
{
<section class="edit-panel">
<div class="table-responsive">
<table class="table table-sm align-middle">
<table class="table table-sm align-middle feature-request-admin-table">
<thead>
<tr>
<th>Title</th>
@ -100,9 +101,8 @@ else
<th>Latest Activity</th>
<th>Conversation</th>
<th>Messages</th>
<th>Latest message</th>
<th>Updated</th>
<th></th>
<th class="feature-request-admin-table__preview">Latest message</th>
<th class="feature-request-admin-table__action"></th>
</tr>
</thead>
<tbody>
@ -118,8 +118,7 @@ else
<td>@FormatNullableDate(request.LatestActivityUtc)</td>
<td>@ConversationBadge(request.ConversationState)</td>
<td>@request.MessageCount.ToString("N0")</td>
<td class="text-truncate" style="max-width: 260px;">@Preview(request.LatestMessagePreview)</td>
<td>@FormatDate(request.UpdatedUtc)</td>
<td class="feature-request-admin-table__preview">@Preview(request.LatestMessagePreview)</td>
<td class="text-end">
<a class="btn btn-outline-primary btn-sm" asp-action="FeatureRequestDetails" asp-route-id="@request.FeatureRequestID">Open</a>
</td>
@ -144,7 +143,7 @@ else
}
var cleaned = value.ReplaceLineEndings(" ").Trim();
return cleaned.Length <= 120 ? cleaned : $"{cleaned[..120]}...";
return cleaned.Length <= 90 ? cleaned : $"{cleaned[..90]}...";
}
private static IHtmlContent StatusBadge(string status)

View File

@ -12,6 +12,7 @@
<p class="lead-text">Send suggestions and keep track of your conversations with the PlotDirector team.</p>
</div>
<div class="button-row">
<a class="btn btn-outline-primary" asp-action="Roadmap">View Roadmap</a>
<a class="btn btn-primary" asp-action="Create">Submit Feature Request</a>
</div>
</div>
@ -54,7 +55,13 @@ else
@foreach (var request in Model.Requests)
{
<tr>
<td>@request.Title</td>
<td>
@if (request.UserNeedsAttention)
{
<span class="badge text-bg-warning me-1">New</span>
}
@request.Title
</td>
<td>@StatusBadge(request.Status)</td>
<td>@FormatNullableDate(request.LatestActivityUtc)</td>
<td>@ConversationBadge(request.ConversationState)</td>

View File

@ -0,0 +1,93 @@
@model FeatureRequestRoadmapViewModel
@using Microsoft.AspNetCore.Html
@using System.Text.Encodings.Web
@{
ViewData["Title"] = "Feature Roadmap";
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Feedback</p>
<h1>Feature Roadmap</h1>
<p class="lead-text">Here are some improvements currently being considered, planned, in progress, or recently shipped. This is not a complete list of every suggestion, but it shows where PlotDirector is actively evolving.</p>
</div>
<div class="button-row">
<a class="btn btn-outline-secondary" asp-action="Index">My Requests</a>
<a class="btn btn-primary" asp-action="Create">Submit Feature Request</a>
</div>
</div>
<nav class="mb-3" aria-label="Breadcrumb">
<a asp-action="Index">Feature Requests</a>
<span class="muted"> / Roadmap</span>
</nav>
@foreach (var roadmapSection in Model.Sections)
{
<section class="edit-panel">
<h2>@roadmapSection.Heading</h2>
@if (!roadmapSection.Requests.Any())
{
<p class="muted mb-0">@EmptyMessage(roadmapSection.Status)</p>
}
else
{
<div class="d-flex flex-column gap-3">
@foreach (var request in roadmapSection.Requests)
{
<article class="border rounded p-3">
<div class="d-flex justify-content-between gap-3 flex-wrap mb-2">
<div>
<h3 class="fs-5 mb-1">@request.Title</h3>
<div class="d-flex flex-wrap gap-2">
@StatusBadge(request.Status)
@if (!string.IsNullOrWhiteSpace(request.AppArea))
{
<span class="badge text-bg-secondary">@request.AppArea</span>
}
</div>
</div>
@if (string.Equals(request.Status, FeatureRequestStatuses.Shipped, StringComparison.OrdinalIgnoreCase) && request.ShippedUtc.HasValue)
{
<span class="muted">Shipped @FormatDate(request.ShippedUtc.Value)</span>
}
</div>
<p class="mb-0">@Excerpt(request.Description)</p>
</article>
}
</div>
}
</section>
}
@functions {
private static string FormatDate(DateTime value) => value.ToLocalTime().ToString("yyyy-MM-dd");
private static string Excerpt(string value)
{
var cleaned = value.ReplaceLineEndings(" ").Trim();
return cleaned.Length <= 220 ? cleaned : $"{cleaned[..220]}...";
}
private static string EmptyMessage(string status) => status switch
{
FeatureRequestStatuses.Shipped => "No shipped roadmap items yet.",
FeatureRequestStatuses.InProgress => "No roadmap items are currently in progress.",
FeatureRequestStatuses.Planned => "No planned roadmap items yet.",
_ => "No roadmap items in this section yet."
};
private static IHtmlContent StatusBadge(string status)
{
var css = status switch
{
FeatureRequestStatuses.Candidate => "text-bg-secondary",
FeatureRequestStatuses.Planned => "text-bg-success",
FeatureRequestStatuses.InProgress => "text-bg-success",
FeatureRequestStatuses.Shipped => "text-bg-dark",
_ => "text-bg-secondary"
};
return new HtmlString($"<span class=\"badge {css}\">{HtmlEncoder.Default.Encode(status)}</span>");
}
}

View File

@ -2,13 +2,18 @@
<html lang="en" data-theme="light" data-bs-theme="light">
@using System.Security.Claims
@inject IConfiguration Configuration
@inject IFeatureRequestService FeatureRequestService
@{
var shellClass = ViewData["ShellClass"] as string ?? string.Empty;
var isMarketingPage = shellClass.Contains("marketing-shell", StringComparison.OrdinalIgnoreCase);
var userEmail = User.FindFirstValue(ClaimTypes.Email);
var userIdValue = User.FindFirstValue(ClaimTypes.NameIdentifier);
var userId = int.TryParse(userIdValue, out var parsedUserId) ? parsedUserId : (int?)null;
var adminEmails = Configuration.GetSection("Admin:AllowedEmails").Get<string[]>() ?? [];
var isAdmin = !string.IsNullOrWhiteSpace(userEmail)
&& adminEmails.Any(email => string.Equals(email, userEmail, StringComparison.OrdinalIgnoreCase));
var featureRequestAttentionCount = userId.HasValue ? await FeatureRequestService.CountAttentionForUserAsync(userId.Value) : 0;
var adminFeatureRequestAttentionCount = isAdmin ? await FeatureRequestService.CountWaitingForAdminAsync() : 0;
var defaultTitle = $"{ViewData["Title"]} - PlotDirector";
var seoTitle = isMarketingPage && ViewData["SeoTitle"] is string seoTitleValue ? seoTitleValue : defaultTitle;
var seoDescription = isMarketingPage && ViewData["SeoDescription"] is string seoDescriptionValue ? seoDescriptionValue : null;
@ -128,12 +133,24 @@
<a class="nav-link text-dark" asp-area="" asp-controller="Help" asp-action="Index">Help</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="FeatureRequests" asp-action="Index">Feature Requests</a>
<a class="nav-link text-dark" asp-area="" asp-controller="FeatureRequests" asp-action="Index">
Feature Requests
@if (featureRequestAttentionCount > 0)
{
<span class="badge text-bg-warning ms-1">@featureRequestAttentionCount</span>
}
</a>
</li>
@if (isAdmin)
{
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Admin" asp-action="Index">Admin</a>
<a class="nav-link text-dark" asp-area="" asp-controller="Admin" asp-action="Index">
Admin
@if (adminFeatureRequestAttentionCount > 0)
{
<span class="badge text-bg-danger ms-1">@adminFeatureRequestAttentionCount</span>
}
</a>
</li>
}
}

View File

@ -169,6 +169,41 @@ td.text-end {
max-width: 980px;
}
.feature-request-admin-page .page-heading.compact,
.feature-request-admin-page .edit-panel {
max-width: none;
}
.feature-request-admin-table {
table-layout: fixed;
}
.feature-request-admin-table th,
.feature-request-admin-table td {
vertical-align: middle;
}
.feature-request-admin-table__preview {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.feature-request-admin-table__action {
width: 78px;
}
@media (max-width: 1100px) {
.feature-request-admin-table {
table-layout: auto;
}
.feature-request-admin-table__preview {
max-width: 160px;
}
}
.trial-status-panel,
.trial-dashboard-banner {
border: 1px solid var(--plotline-line);

File diff suppressed because one or more lines are too long