Phase 15A - Feature Request System Foundation and User Workflow

This commit is contained in:
Nick Beckley 2026-06-23 21:25:59 +01:00
parent 43578df18a
commit 955fcb9c0f
17 changed files with 1203 additions and 2 deletions

View File

@ -1,15 +1,105 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using PlotLine.Models;
using PlotLine.Services;
using PlotLine.ViewModels;
namespace PlotLine.Controllers; namespace PlotLine.Controllers;
[Authorize(Policy = "AdminOnly")] [Authorize(Policy = "AdminOnly")]
[Route("admin")] [Route("admin")]
public sealed class AdminController : Controller public sealed class AdminController(
ICurrentUserService currentUser,
IFeatureRequestService featureRequests) : Controller
{ {
[HttpGet("")]
[HttpGet("index")]
public IActionResult Index()
{
return View();
}
[HttpGet("theme-audit")] [HttpGet("theme-audit")]
public IActionResult ThemeAudit() public IActionResult ThemeAudit()
{ {
return View(); return View();
} }
[HttpGet("feature-requests")]
public async Task<IActionResult> FeatureRequests(string? status)
{
var selectedStatus = FeatureRequestStatuses.IsValid(status) ? status : null;
return View(new AdminFeatureRequestIndexViewModel
{
Status = selectedStatus,
Requests = await featureRequests.ListForAdminAsync(selectedStatus)
});
}
[HttpGet("feature-requests/{id:int}")]
public async Task<IActionResult> FeatureRequestDetails(int id)
{
var request = await featureRequests.GetForAdminAsync(id);
if (request is null)
{
return NotFound();
}
return View(new FeatureRequestDetailsViewModel
{
IsAdminView = true,
Request = request,
Messages = await featureRequests.ListMessagesAsync(request.FeatureRequestID),
Reply = new FeatureRequestReplyViewModel { FeatureRequestID = request.FeatureRequestID }
});
}
[HttpPost("feature-requests/update")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UpdateFeatureRequest(AdminFeatureRequestUpdateViewModel model)
{
if (!FeatureRequestStatuses.IsValid(model.Status))
{
return BadRequest();
}
var updated = await featureRequests.UpdateAdminFieldsAsync(model);
if (!updated)
{
return NotFound();
}
TempData["AdminMessage"] = "Feature request updated.";
return RedirectToAction(nameof(FeatureRequestDetails), new { id = model.FeatureRequestID });
}
[HttpPost("feature-requests/reply")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> FeatureRequestReply([Bind(Prefix = "Reply")] FeatureRequestReplyViewModel model)
{
if (currentUser.UserId is not int userId)
{
return Forbid();
}
var request = await featureRequests.GetForAdminAsync(model.FeatureRequestID);
if (request is null)
{
return NotFound();
}
if (!ModelState.IsValid)
{
return View(nameof(FeatureRequestDetails), new FeatureRequestDetailsViewModel
{
IsAdminView = true,
Request = request,
Messages = await featureRequests.ListMessagesAsync(request.FeatureRequestID),
Reply = model
});
}
await featureRequests.AddAdminReplyAsync(model.FeatureRequestID, userId, model.MessageBody);
return RedirectToAction(nameof(FeatureRequestDetails), new { id = model.FeatureRequestID });
}
} }

View File

@ -0,0 +1,94 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PlotLine.Services;
using PlotLine.ViewModels;
namespace PlotLine.Controllers;
[Authorize]
public sealed class FeatureRequestsController(
ICurrentUserService currentUser,
IFeatureRequestService featureRequests) : Controller
{
[HttpGet]
public async Task<IActionResult> Index()
{
var userId = RequireUserId();
var requests = await featureRequests.ListForUserAsync(userId);
return View(new FeatureRequestIndexViewModel { Requests = requests });
}
[HttpGet]
public IActionResult Create()
{
return View(new FeatureRequestCreateViewModel());
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(FeatureRequestCreateViewModel model)
{
if (!ModelState.IsValid)
{
return View(WithOptions(model));
}
var created = await featureRequests.CreateAsync(RequireUserId(), model);
TempData["FeatureRequestMessage"] = "Your feature request has been submitted.";
return RedirectToAction(nameof(Details), new { id = created.FeatureRequestID });
}
[HttpGet]
public async Task<IActionResult> Details(int id)
{
var request = await featureRequests.GetForUserAsync(id, RequireUserId());
if (request is null)
{
return NotFound();
}
return View(new FeatureRequestDetailsViewModel
{
Request = request,
Messages = await featureRequests.ListMessagesAsync(request.FeatureRequestID),
Reply = new FeatureRequestReplyViewModel { FeatureRequestID = request.FeatureRequestID }
});
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Reply([Bind(Prefix = "Reply")] FeatureRequestReplyViewModel model)
{
var userId = RequireUserId();
var request = await featureRequests.GetForUserAsync(model.FeatureRequestID, userId);
if (request is null)
{
return NotFound();
}
if (!ModelState.IsValid)
{
return View(nameof(Details), new FeatureRequestDetailsViewModel
{
Request = request,
Messages = await featureRequests.ListMessagesAsync(request.FeatureRequestID),
Reply = model
});
}
await featureRequests.AddUserReplyAsync(model.FeatureRequestID, userId, model.MessageBody);
return RedirectToAction(nameof(Details), new { id = model.FeatureRequestID });
}
private int RequireUserId()
{
return currentUser.UserId ?? throw new InvalidOperationException("Authenticated user id was not available.");
}
private static FeatureRequestCreateViewModel WithOptions(FeatureRequestCreateViewModel model)
{
model.AppAreas = PlotLine.Models.FeatureRequestOptions.AppAreas;
model.ImportanceOptions = PlotLine.Models.FeatureRequestOptions.ImportanceOptions;
return model;
}
}

View File

@ -0,0 +1,105 @@
using System.Data;
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public interface IFeatureRequestRepository
{
Task<FeatureRequest> CreateAsync(int userId, string title, string description, string? appArea, string? importance);
Task<FeatureRequest?> GetForUserAsync(int featureRequestId, int userId);
Task<FeatureRequest?> GetForAdminAsync(int featureRequestId);
Task<IReadOnlyList<FeatureRequest>> ListForUserAsync(int userId);
Task<IReadOnlyList<FeatureRequest>> ListForAdminAsync(string? status);
Task<FeatureRequest?> UpdateStatusAsync(int featureRequestId, string status, 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);
}
public sealed class FeatureRequestRepository(ISqlConnectionFactory connectionFactory) : IFeatureRequestRepository
{
public async Task<FeatureRequest> CreateAsync(int userId, string title, string description, string? appArea, string? importance)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<FeatureRequest>(
"dbo.FeatureRequest_Create",
new { UserID = userId, Title = title, Description = description, AppArea = appArea, Importance = importance },
commandType: CommandType.StoredProcedure);
}
public async Task<FeatureRequest?> GetForUserAsync(int featureRequestId, int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<FeatureRequest>(
"dbo.FeatureRequest_GetForUser",
new { FeatureRequestID = featureRequestId, UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<FeatureRequest?> GetForAdminAsync(int featureRequestId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<FeatureRequest>(
"dbo.FeatureRequest_GetForAdmin",
new { FeatureRequestID = featureRequestId },
commandType: CommandType.StoredProcedure);
}
public async Task<IReadOnlyList<FeatureRequest>> ListForUserAsync(int userId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<FeatureRequest>(
"dbo.FeatureRequest_ListForUser",
new { UserID = userId },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<IReadOnlyList<FeatureRequest>> ListForAdminAsync(string? status)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<FeatureRequest>(
"dbo.FeatureRequest_ListForAdmin",
new { Status = status },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<FeatureRequest?> UpdateStatusAsync(int featureRequestId, string status, bool isPublicCandidate)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<FeatureRequest>(
"dbo.FeatureRequest_UpdateStatus",
new { FeatureRequestID = featureRequestId, Status = status, IsPublicCandidate = isPublicCandidate },
commandType: CommandType.StoredProcedure);
}
public async Task<FeatureRequest?> UpdateAdminNotesAsync(int featureRequestId, string? adminNotes)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<FeatureRequest>(
"dbo.FeatureRequest_UpdateAdminNotes",
new { FeatureRequestID = featureRequestId, AdminNotes = adminNotes },
commandType: CommandType.StoredProcedure);
}
public async Task<FeatureRequestMessage?> AddMessageAsync(int featureRequestId, int? userId, bool isAdmin, string messageBody)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<FeatureRequestMessage>(
"dbo.FeatureRequestMessage_Add",
new { FeatureRequestID = featureRequestId, UserID = userId, IsAdmin = isAdmin, MessageBody = messageBody },
commandType: CommandType.StoredProcedure);
}
public async Task<IReadOnlyList<FeatureRequestMessage>> ListMessagesAsync(int featureRequestId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<FeatureRequestMessage>(
"dbo.FeatureRequestMessage_List",
new { FeatureRequestID = featureRequestId },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
}

View File

@ -0,0 +1,84 @@
namespace PlotLine.Models;
public static class FeatureRequestStatuses
{
public const string New = "New";
public const string NeedsDetail = "Needs Detail";
public const string UnderReview = "Under Review";
public const string Candidate = "Candidate";
public const string Planned = "Planned";
public const string InProgress = "In Progress";
public const string Shipped = "Shipped";
public const string NotPlanned = "Not Planned";
public static IReadOnlyList<string> All { get; } =
[
New,
NeedsDetail,
UnderReview,
Candidate,
Planned,
InProgress,
Shipped,
NotPlanned
];
public static bool IsValid(string? status) => All.Contains(status ?? string.Empty, StringComparer.OrdinalIgnoreCase);
}
public static class FeatureRequestOptions
{
public static IReadOnlyList<string> AppAreas { get; } =
[
"Timeline",
"Characters",
"Locations",
"Assets",
"Plot Lines",
"Continuity",
"Writing Schedule",
"Word Companion",
"Import / Export",
"Billing / Account",
"Other"
];
public static IReadOnlyList<string> ImportanceOptions { get; } =
[
"Nice to have",
"Would save me time",
"Blocking my workflow"
];
}
public sealed class FeatureRequest
{
public int FeatureRequestID { get; set; }
public int UserID { get; set; }
public string? UserEmail { get; set; }
public string? UserDisplayName { get; set; }
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string? AppArea { get; set; }
public string? Importance { get; set; }
public string Status { get; set; } = FeatureRequestStatuses.New;
public bool IsPublicCandidate { get; set; }
public string? AdminNotes { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime UpdatedUtc { get; set; }
public DateTime? ClosedUtc { get; set; }
public DateTime? ShippedUtc { get; set; }
public DateTime? LatestActivityUtc { get; set; }
}
public sealed class FeatureRequestMessage
{
public int FeatureRequestMessageID { get; set; }
public int FeatureRequestID { get; set; }
public int? UserID { get; set; }
public string? UserEmail { get; set; }
public string? UserDisplayName { get; set; }
public bool IsAdmin { get; set; }
public string MessageBody { get; set; } = string.Empty;
public DateTime CreatedUtc { get; set; }
}

View File

@ -93,6 +93,7 @@ public class Program
builder.Services.AddScoped<IAuthenticationRepository, AuthenticationRepository>(); builder.Services.AddScoped<IAuthenticationRepository, AuthenticationRepository>();
builder.Services.AddScoped<IEmailQueueRepository, EmailQueueRepository>(); builder.Services.AddScoped<IEmailQueueRepository, EmailQueueRepository>();
builder.Services.AddScoped<IUserFileRepository, UserFileRepository>(); builder.Services.AddScoped<IUserFileRepository, UserFileRepository>();
builder.Services.AddScoped<IFeatureRequestRepository, FeatureRequestRepository>();
builder.Services.AddScoped<IProjectRepository, ProjectRepository>(); builder.Services.AddScoped<IProjectRepository, ProjectRepository>();
builder.Services.AddScoped<IBookRepository, BookRepository>(); builder.Services.AddScoped<IBookRepository, BookRepository>();
builder.Services.AddScoped<IChapterRepository, ChapterRepository>(); builder.Services.AddScoped<IChapterRepository, ChapterRepository>();
@ -177,6 +178,7 @@ public class Program
builder.Services.AddScoped<IEmailService, EmailService>(); builder.Services.AddScoped<IEmailService, EmailService>();
builder.Services.AddScoped<IQueuedEmailSender, QueuedEmailSender>(); builder.Services.AddScoped<IQueuedEmailSender, QueuedEmailSender>();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>(); builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
builder.Services.AddScoped<IFeatureRequestService, FeatureRequestService>();
builder.Services.AddHostedService<EmailQueueWorker>(); builder.Services.AddHostedService<EmailQueueWorker>();
var app = builder.Build(); var app = builder.Build();

View File

@ -0,0 +1,79 @@
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IFeatureRequestService
{
Task<FeatureRequest> CreateAsync(int userId, FeatureRequestCreateViewModel model);
Task<FeatureRequest?> GetForUserAsync(int featureRequestId, int userId);
Task<FeatureRequest?> GetForAdminAsync(int featureRequestId);
Task<IReadOnlyList<FeatureRequest>> ListForUserAsync(int userId);
Task<IReadOnlyList<FeatureRequest>> ListForAdminAsync(string? status);
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);
}
public sealed class FeatureRequestService(IFeatureRequestRepository featureRequests) : IFeatureRequestService
{
public Task<FeatureRequest> CreateAsync(int userId, FeatureRequestCreateViewModel model)
{
return featureRequests.CreateAsync(
userId,
Clean(model.Title),
Clean(model.Description),
CleanNullable(model.AppArea),
CleanNullable(model.Importance));
}
public Task<FeatureRequest?> GetForUserAsync(int featureRequestId, int userId) =>
featureRequests.GetForUserAsync(featureRequestId, userId);
public Task<FeatureRequest?> GetForAdminAsync(int featureRequestId) =>
featureRequests.GetForAdminAsync(featureRequestId);
public Task<IReadOnlyList<FeatureRequest>> ListForUserAsync(int userId) =>
featureRequests.ListForUserAsync(userId);
public Task<IReadOnlyList<FeatureRequest>> ListForAdminAsync(string? status) =>
featureRequests.ListForAdminAsync(FeatureRequestStatuses.IsValid(status) ? status : null);
public async Task<bool> UpdateAdminFieldsAsync(AdminFeatureRequestUpdateViewModel model)
{
if (!FeatureRequestStatuses.IsValid(model.Status))
{
return false;
}
var updated = await featureRequests.UpdateStatusAsync(model.FeatureRequestID, model.Status, model.IsPublicCandidate);
if (updated is null)
{
return false;
}
await featureRequests.UpdateAdminNotesAsync(model.FeatureRequestID, CleanNullable(model.AdminNotes));
return true;
}
public async Task<bool> AddUserReplyAsync(int featureRequestId, int userId, string messageBody)
{
var message = await featureRequests.AddMessageAsync(featureRequestId, userId, isAdmin: false, Clean(messageBody));
return message is not null;
}
public async Task<bool> AddAdminReplyAsync(int featureRequestId, int adminUserId, string messageBody)
{
var message = await featureRequests.AddMessageAsync(featureRequestId, adminUserId, isAdmin: true, Clean(messageBody));
return message is not null;
}
public Task<IReadOnlyList<FeatureRequestMessage>> ListMessagesAsync(int featureRequestId) =>
featureRequests.ListMessagesAsync(featureRequestId);
private static string Clean(string value) => value.Trim();
private static string? CleanNullable(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}

View File

@ -0,0 +1,251 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N'FeatureRequests' AND schema_id = SCHEMA_ID(N'dbo'))
BEGIN
CREATE TABLE dbo.FeatureRequests
(
FeatureRequestID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_FeatureRequests PRIMARY KEY,
UserID int NOT NULL,
Title nvarchar(200) NOT NULL,
Description nvarchar(max) NOT NULL,
AppArea nvarchar(100) NULL,
Importance nvarchar(50) NULL,
Status nvarchar(50) NOT NULL CONSTRAINT DF_FeatureRequests_Status DEFAULT N'New',
IsPublicCandidate bit NOT NULL CONSTRAINT DF_FeatureRequests_IsPublicCandidate DEFAULT 0,
AdminNotes nvarchar(max) NULL,
CreatedUtc datetime2 NOT NULL CONSTRAINT DF_FeatureRequests_CreatedUtc DEFAULT sysutcdatetime(),
UpdatedUtc datetime2 NOT NULL CONSTRAINT DF_FeatureRequests_UpdatedUtc DEFAULT sysutcdatetime(),
ClosedUtc datetime2 NULL,
ShippedUtc datetime2 NULL,
CONSTRAINT FK_FeatureRequests_AppUser FOREIGN KEY (UserID) REFERENCES dbo.AppUser(UserID)
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N'FeatureRequestMessages' AND schema_id = SCHEMA_ID(N'dbo'))
BEGIN
CREATE TABLE dbo.FeatureRequestMessages
(
FeatureRequestMessageID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_FeatureRequestMessages PRIMARY KEY,
FeatureRequestID int NOT NULL,
UserID int NULL,
IsAdmin bit NOT NULL CONSTRAINT DF_FeatureRequestMessages_IsAdmin DEFAULT 0,
MessageBody nvarchar(max) NOT NULL,
CreatedUtc datetime2 NOT NULL CONSTRAINT DF_FeatureRequestMessages_CreatedUtc DEFAULT sysutcdatetime(),
CONSTRAINT FK_FeatureRequestMessages_FeatureRequests FOREIGN KEY (FeatureRequestID) REFERENCES dbo.FeatureRequests(FeatureRequestID),
CONSTRAINT FK_FeatureRequestMessages_AppUser FOREIGN KEY (UserID) REFERENCES dbo.AppUser(UserID)
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_FeatureRequests_UserID' AND object_id = OBJECT_ID(N'dbo.FeatureRequests'))
CREATE INDEX IX_FeatureRequests_UserID ON dbo.FeatureRequests(UserID);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_FeatureRequests_Status' AND object_id = OBJECT_ID(N'dbo.FeatureRequests'))
CREATE INDEX IX_FeatureRequests_Status ON dbo.FeatureRequests(Status);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_FeatureRequests_CreatedUtc' AND object_id = OBJECT_ID(N'dbo.FeatureRequests'))
CREATE INDEX IX_FeatureRequests_CreatedUtc ON dbo.FeatureRequests(CreatedUtc);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_FeatureRequestMessages_FeatureRequestID' AND object_id = OBJECT_ID(N'dbo.FeatureRequestMessages'))
CREATE INDEX IX_FeatureRequestMessages_FeatureRequestID ON dbo.FeatureRequestMessages(FeatureRequestID);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_FeatureRequestMessages_CreatedUtc' AND object_id = OBJECT_ID(N'dbo.FeatureRequestMessages'))
CREATE INDEX IX_FeatureRequestMessages_CreatedUtc ON dbo.FeatureRequestMessages(CreatedUtc);
GO
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_Create
@UserID int,
@Title nvarchar(200),
@Description nvarchar(max),
@AppArea nvarchar(100) = NULL,
@Importance nvarchar(50) = NULL
AS
BEGIN
SET NOCOUNT ON;
INSERT INTO dbo.FeatureRequests (UserID, Title, Description, AppArea, Importance)
VALUES (@UserID, @Title, @Description, NULLIF(@AppArea, N''), NULLIF(@Importance, N''));
DECLARE @FeatureRequestID int = SCOPE_IDENTITY();
EXEC dbo.FeatureRequest_GetForUser @FeatureRequestID = @FeatureRequestID, @UserID = @UserID;
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,
latest.LatestActivityUtc
FROM dbo.FeatureRequests fr
INNER JOIN dbo.AppUser au ON au.UserID = fr.UserID
OUTER APPLY
(
SELECT MAX(frm.CreatedUtc) AS LatestActivityUtc
FROM dbo.FeatureRequestMessages frm
WHERE frm.FeatureRequestID = fr.FeatureRequestID
) latest
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,
latest.LatestActivityUtc
FROM dbo.FeatureRequests fr
INNER JOIN dbo.AppUser au ON au.UserID = fr.UserID
OUTER APPLY
(
SELECT MAX(frm.CreatedUtc) AS LatestActivityUtc
FROM dbo.FeatureRequestMessages frm
WHERE frm.FeatureRequestID = fr.FeatureRequestID
) latest
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,
latest.LatestActivityUtc
FROM dbo.FeatureRequests fr
INNER JOIN dbo.AppUser au ON au.UserID = fr.UserID
OUTER APPLY
(
SELECT MAX(frm.CreatedUtc) AS LatestActivityUtc
FROM dbo.FeatureRequestMessages frm
WHERE frm.FeatureRequestID = fr.FeatureRequestID
) latest
WHERE fr.UserID = @UserID
ORDER BY fr.UpdatedUtc DESC, fr.FeatureRequestID DESC;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_ListForAdmin
@Status nvarchar(50) = NULL
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,
latest.LatestActivityUtc
FROM dbo.FeatureRequests fr
INNER JOIN dbo.AppUser au ON au.UserID = fr.UserID
OUTER APPLY
(
SELECT MAX(frm.CreatedUtc) AS LatestActivityUtc
FROM dbo.FeatureRequestMessages frm
WHERE frm.FeatureRequestID = fr.FeatureRequestID
) latest
WHERE @Status IS NULL OR fr.Status = @Status
ORDER BY fr.UpdatedUtc 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(),
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_UpdateAdminNotes
@FeatureRequestID int,
@AdminNotes nvarchar(max) = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.FeatureRequests
SET AdminNotes = NULLIF(@AdminNotes, N''),
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()
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.FeatureRequestMessage_List
@FeatureRequestID int
AS
BEGIN
SET NOCOUNT ON;
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.FeatureRequestID = @FeatureRequestID
ORDER BY frm.CreatedUtc ASC, frm.FeatureRequestMessageID ASC;
END;
GO

View File

@ -0,0 +1,62 @@
using System.ComponentModel.DataAnnotations;
using PlotLine.Models;
namespace PlotLine.ViewModels;
public sealed class FeatureRequestIndexViewModel
{
public IReadOnlyList<FeatureRequest> Requests { get; set; } = [];
}
public sealed class FeatureRequestCreateViewModel
{
[Required(ErrorMessage = "Enter a title.")]
[StringLength(200, ErrorMessage = "Title must be 200 characters or fewer.")]
public string Title { get; set; } = string.Empty;
[Required(ErrorMessage = "Describe the problem or suggestion.")]
[Display(Name = "Description / Problem")]
public string Description { get; set; } = string.Empty;
[StringLength(100, ErrorMessage = "App area must be 100 characters or fewer.")]
[Display(Name = "App Area")]
public string? AppArea { get; set; }
[StringLength(50, ErrorMessage = "Importance must be 50 characters or fewer.")]
public string? Importance { get; set; }
public IReadOnlyList<string> AppAreas { get; set; } = FeatureRequestOptions.AppAreas;
public IReadOnlyList<string> ImportanceOptions { get; set; } = FeatureRequestOptions.ImportanceOptions;
}
public sealed class FeatureRequestDetailsViewModel
{
public FeatureRequest Request { get; set; } = new();
public IReadOnlyList<FeatureRequestMessage> Messages { get; set; } = [];
public FeatureRequestReplyViewModel Reply { get; set; } = new();
public bool IsAdminView { get; set; }
public IReadOnlyList<string> StatusOptions { get; set; } = FeatureRequestStatuses.All;
}
public sealed class FeatureRequestReplyViewModel
{
public int FeatureRequestID { get; set; }
[Required(ErrorMessage = "Enter a message.")]
public string MessageBody { get; set; } = string.Empty;
}
public sealed class AdminFeatureRequestIndexViewModel
{
public IReadOnlyList<FeatureRequest> Requests { get; set; } = [];
public string? Status { get; set; }
public IReadOnlyList<string> StatusOptions { get; set; } = FeatureRequestStatuses.All;
}
public sealed class AdminFeatureRequestUpdateViewModel
{
public int FeatureRequestID { get; set; }
public string Status { get; set; } = FeatureRequestStatuses.New;
public bool IsPublicCandidate { get; set; }
public string? AdminNotes { get; set; }
}

View File

@ -0,0 +1,90 @@
@model FeatureRequestDetailsViewModel
@{
ViewData["Title"] = Model.Request.Title;
var request = Model.Request;
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Admin / Feature Request</p>
<h1>@request.Title</h1>
<p class="lead-text">@request.UserDisplayName (@request.UserEmail)</p>
</div>
<div class="button-row">
<a class="btn btn-outline-secondary" asp-action="FeatureRequests">Back to list</a>
</div>
</div>
@if (TempData["AdminMessage"] is string adminMessage)
{
<div class="alert alert-info">@adminMessage</div>
}
<div class="row g-3">
<div class="col-lg-8">
<section class="edit-panel">
<h2>Request</h2>
<div class="row g-3 mb-3">
<div class="col-md-4">
<p class="eyebrow">Status</p>
<p>@request.Status</p>
</div>
<div class="col-md-4">
<p class="eyebrow">App area</p>
<p>@(request.AppArea ?? "Not set")</p>
</div>
<div class="col-md-4">
<p class="eyebrow">Importance</p>
<p>@(request.Importance ?? "Not set")</p>
</div>
</div>
<p style="white-space: pre-wrap">@request.Description</p>
</section>
<section class="edit-panel">
<h2>Conversation</h2>
@await Html.PartialAsync("~/Views/FeatureRequests/_Conversation.cshtml", Model)
<form asp-action="FeatureRequestReply" method="post" class="mt-3">
<input type="hidden" asp-for="Reply.FeatureRequestID" />
<div class="mb-3">
<label class="form-label" asp-for="Reply.MessageBody">Reply</label>
<textarea class="form-control" asp-for="Reply.MessageBody" rows="4"></textarea>
<span class="text-danger" asp-validation-for="Reply.MessageBody"></span>
</div>
<button class="btn btn-primary" type="submit">Send reply</button>
</form>
</section>
</div>
<div class="col-lg-4">
<section class="edit-panel">
<h2>Admin controls</h2>
<form asp-action="UpdateFeatureRequest" method="post">
<input type="hidden" name="FeatureRequestID" value="@request.FeatureRequestID" />
<div class="mb-3">
<label class="form-label" for="Status">Status</label>
<select class="form-select" id="Status" name="Status">
@foreach (var status in Model.StatusOptions)
{
<option value="@status" selected="@(string.Equals(request.Status, status, StringComparison.OrdinalIgnoreCase))">@status</option>
}
</select>
</div>
<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>
</div>
<div class="mb-3">
<label class="form-label" for="AdminNotes">Admin notes</label>
<textarea class="form-control" id="AdminNotes" name="AdminNotes" rows="8">@request.AdminNotes</textarea>
</div>
<button class="btn btn-primary" type="submit">Save</button>
</form>
</section>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -0,0 +1,75 @@
@model AdminFeatureRequestIndexViewModel
@{
ViewData["Title"] = "Feature Requests";
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Admin</p>
<h1>Feature Requests</h1>
</div>
<form method="get" class="d-flex gap-2 align-items-end">
<div>
<label class="form-label" for="status">Status</label>
<select class="form-select" id="status" name="status">
<option value="">All statuses</option>
@foreach (var status in Model.StatusOptions)
{
<option value="@status" selected="@(string.Equals(Model.Status, status, StringComparison.OrdinalIgnoreCase))">@status</option>
}
</select>
</div>
<button class="btn btn-outline-primary" type="submit">Filter</button>
</form>
</div>
@if (TempData["AdminMessage"] is string adminMessage)
{
<div class="alert alert-info">@adminMessage</div>
}
@if (!Model.Requests.Any())
{
<section class="empty-panel">
<h2>No feature requests found</h2>
<p class="muted mb-0">New suggestions will appear here once users submit them.</p>
</section>
}
else
{
<section class="edit-panel">
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Title</th>
<th>User</th>
<th>Status</th>
<th>Area</th>
<th>Updated</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var request in Model.Requests)
{
<tr>
<td>@request.Title</td>
<td>@(request.UserDisplayName ?? request.UserEmail)</td>
<td><span class="badge text-bg-secondary">@request.Status</span></td>
<td>@(request.AppArea ?? "Not set")</td>
<td>@FormatDate(request.UpdatedUtc)</td>
<td class="text-end">
<a class="btn btn-outline-primary btn-sm" asp-action="FeatureRequestDetails" asp-route-id="@request.FeatureRequestID">Open</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
@functions {
private static string FormatDate(DateTime value) => value.ToLocalTime().ToString("yyyy-MM-dd HH:mm");
}

View File

@ -0,0 +1,38 @@
@{
ViewData["Title"] = "Admin";
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Admin</p>
<h1>Dashboard</h1>
<p class="lead-text">Operational tools for PlotDirector administrators.</p>
</div>
</div>
<section class="project-card-grid">
<article class="project-card">
<div class="project-card__body">
<p class="eyebrow">User feedback</p>
<h2>Feature Requests</h2>
<p class="project-card__description">Review suggestions, update status and reply privately.</p>
</div>
<div class="project-card__actions">
<a class="btn btn-primary btn-sm" asp-action="FeatureRequests">Open</a>
</div>
</article>
<article class="project-card">
<div class="project-card__body">
<p class="eyebrow">Placeholder</p>
<h2>Users</h2>
<p class="project-card__description">User management tools will be added later.</p>
</div>
</article>
<article class="project-card">
<div class="project-card__body">
<p class="eyebrow">Placeholder</p>
<h2>Subscriptions</h2>
<p class="project-card__description">Subscription management tools will be added later.</p>
</div>
</article>
</section>

View File

@ -0,0 +1,60 @@
@model FeatureRequestCreateViewModel
@{
ViewData["Title"] = "Submit Feature Request";
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Feedback</p>
<h1>Submit Feature Request</h1>
<p class="lead-text">Tell us what would make PlotDirector more useful for the way you write. The most helpful suggestions explain the problem you're trying to solve, not just the button you want added.</p>
</div>
</div>
<section class="edit-panel">
<form asp-action="Create" method="post">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="mb-3">
<label class="form-label" asp-for="Title"></label>
<input class="form-control" asp-for="Title" />
<span class="text-danger" asp-validation-for="Title"></span>
</div>
<div class="mb-3">
<label class="form-label" asp-for="Description"></label>
<textarea class="form-control" asp-for="Description" rows="8"></textarea>
<span class="text-danger" asp-validation-for="Description"></span>
</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" asp-for="AppArea"></label>
<select class="form-select" asp-for="AppArea">
<option value="">Choose an area</option>
@foreach (var appArea in Model.AppAreas)
{
<option value="@appArea">@appArea</option>
}
</select>
<span class="text-danger" asp-validation-for="AppArea"></span>
</div>
<div class="col-md-6">
<label class="form-label" asp-for="Importance"></label>
<select class="form-select" asp-for="Importance">
<option value="">Choose importance</option>
@foreach (var importance in Model.ImportanceOptions)
{
<option value="@importance">@importance</option>
}
</select>
<span class="text-danger" asp-validation-for="Importance"></span>
</div>
</div>
<div class="button-row mt-4">
<button class="btn btn-primary" type="submit">Submit Feature Request</button>
<a class="btn btn-outline-secondary" asp-action="Index">Cancel</a>
</div>
</form>
</section>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -0,0 +1,60 @@
@model FeatureRequestDetailsViewModel
@{
ViewData["Title"] = Model.Request.Title;
var request = Model.Request;
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Feature Request</p>
<h1>@request.Title</h1>
</div>
<div class="button-row">
<a class="btn btn-outline-secondary" asp-action="Index">Back to requests</a>
</div>
</div>
<section class="edit-panel">
<h2>Request details</h2>
<div class="row g-3 mb-3">
<div class="col-md-3">
<p class="eyebrow">Status</p>
<p>@request.Status</p>
</div>
<div class="col-md-3">
<p class="eyebrow">App area</p>
<p>@(request.AppArea ?? "Not set")</p>
</div>
<div class="col-md-3">
<p class="eyebrow">Importance</p>
<p>@(request.Importance ?? "Not set")</p>
</div>
<div class="col-md-3">
<p class="eyebrow">Updated</p>
<p>@FormatDate(request.UpdatedUtc)</p>
</div>
</div>
<p style="white-space: pre-wrap">@request.Description</p>
</section>
<section class="edit-panel">
<h2>Conversation</h2>
@await Html.PartialAsync("_Conversation", Model)
<form asp-action="Reply" method="post" class="mt-3">
<input type="hidden" asp-for="Reply.FeatureRequestID" />
<div class="mb-3">
<label class="form-label" asp-for="Reply.MessageBody">Reply</label>
<textarea class="form-control" asp-for="Reply.MessageBody" rows="4"></textarea>
<span class="text-danger" asp-validation-for="Reply.MessageBody"></span>
</div>
<button class="btn btn-primary" type="submit">Send reply</button>
</form>
</section>
@functions {
private static string FormatDate(DateTime value) => value.ToLocalTime().ToString("yyyy-MM-dd HH:mm");
}
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -0,0 +1,71 @@
@model FeatureRequestIndexViewModel
@{
ViewData["Title"] = "Feature Requests";
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Feedback</p>
<h1>Feature Requests</h1>
<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-primary" asp-action="Create">Submit Feature Request</a>
</div>
</div>
@if (TempData["FeatureRequestMessage"] is string featureRequestMessage)
{
<div class="alert alert-info">@featureRequestMessage</div>
}
@if (!Model.Requests.Any())
{
<section class="empty-panel">
<h2>No suggestions yet</h2>
<p>No suggestions yet. If something keeps getting in your way, or you have an idea that would make PlotDirector better, send it in.</p>
<a class="btn btn-primary" asp-action="Create">Submit Feature Request</a>
</section>
}
else
{
<section class="edit-panel">
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Title</th>
<th>App Area</th>
<th>Importance</th>
<th>Status</th>
<th>Created</th>
<th>Updated</th>
<th>Latest Activity</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var request in Model.Requests)
{
<tr>
<td>@request.Title</td>
<td>@(request.AppArea ?? "Not set")</td>
<td>@(request.Importance ?? "Not set")</td>
<td><span class="badge text-bg-secondary">@request.Status</span></td>
<td>@FormatDate(request.CreatedUtc)</td>
<td>@FormatDate(request.UpdatedUtc)</td>
<td>@(request.LatestActivityUtc.HasValue ? FormatDate(request.LatestActivityUtc.Value) : "None yet")</td>
<td class="text-end">
<a class="btn btn-outline-primary btn-sm" asp-action="Details" asp-route-id="@request.FeatureRequestID">Open</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
@functions {
private static string FormatDate(DateTime value) => value.ToLocalTime().ToString("yyyy-MM-dd");
}

View File

@ -0,0 +1,25 @@
@model FeatureRequestDetailsViewModel
@if (!Model.Messages.Any())
{
<p class="muted">No replies yet.</p>
}
else
{
<div class="d-flex flex-column gap-3">
@foreach (var message in Model.Messages)
{
<article class="border rounded p-3">
<div class="d-flex justify-content-between gap-3 flex-wrap mb-2">
<strong>@(message.IsAdmin ? "PlotDirector team" : message.UserDisplayName ?? "You")</strong>
<span class="muted">@FormatDate(message.CreatedUtc)</span>
</div>
<p class="mb-0" style="white-space: pre-wrap">@message.MessageBody</p>
</article>
}
</div>
}
@functions {
private static string FormatDate(DateTime value) => value.ToLocalTime().ToString("yyyy-MM-dd HH:mm");
}

View File

@ -1,8 +1,14 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-theme="light" data-bs-theme="light"> <html lang="en" data-theme="light" data-bs-theme="light">
@using System.Security.Claims
@inject IConfiguration Configuration
@{ @{
var shellClass = ViewData["ShellClass"] as string ?? string.Empty; var shellClass = ViewData["ShellClass"] as string ?? string.Empty;
var isMarketingPage = shellClass.Contains("marketing-shell", StringComparison.OrdinalIgnoreCase); var isMarketingPage = shellClass.Contains("marketing-shell", StringComparison.OrdinalIgnoreCase);
var userEmail = User.FindFirstValue(ClaimTypes.Email);
var adminEmails = Configuration.GetSection("Admin:AllowedEmails").Get<string[]>() ?? [];
var isAdmin = !string.IsNullOrWhiteSpace(userEmail)
&& adminEmails.Any(email => string.Equals(email, userEmail, StringComparison.OrdinalIgnoreCase));
var defaultTitle = $"{ViewData["Title"]} - PlotDirector"; var defaultTitle = $"{ViewData["Title"]} - PlotDirector";
var seoTitle = isMarketingPage && ViewData["SeoTitle"] is string seoTitleValue ? seoTitleValue : defaultTitle; var seoTitle = isMarketingPage && ViewData["SeoTitle"] is string seoTitleValue ? seoTitleValue : defaultTitle;
var seoDescription = isMarketingPage && ViewData["SeoDescription"] is string seoDescriptionValue ? seoDescriptionValue : null; var seoDescription = isMarketingPage && ViewData["SeoDescription"] is string seoDescriptionValue ? seoDescriptionValue : null;
@ -121,6 +127,15 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Help" asp-action="Index">Help</a> <a class="nav-link text-dark" asp-area="" asp-controller="Help" asp-action="Index">Help</a>
</li> </li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="FeatureRequests" asp-action="Index">Feature Requests</a>
</li>
@if (isAdmin)
{
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Admin" asp-action="Index">Admin</a>
</li>
}
} }
</ul> </ul>
<div class="d-flex align-items-center gap-2 flex-wrap"> <div class="d-flex align-items-center gap-2 flex-wrap">

View File

@ -1,6 +1,6 @@
{ {
"Admin": { "Admin": {
"AllowedEmails": [] "AllowedEmails": ["nickbeckley5@gmail.com"]
}, },
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {