From 955fcb9c0f231f9a43721c7b832586a338f5319e Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Tue, 23 Jun 2026 21:25:59 +0100 Subject: [PATCH] Phase 15A - Feature Request System Foundation and User Workflow --- PlotLine/Controllers/AdminController.cs | 92 ++++++- .../Controllers/FeatureRequestsController.cs | 94 +++++++ PlotLine/Data/FeatureRequestRepository.cs | 105 ++++++++ PlotLine/Models/FeatureRequestModels.cs | 84 ++++++ PlotLine/Program.cs | 2 + PlotLine/Services/FeatureRequestService.cs | 79 ++++++ PlotLine/Sql/090_Phase15A_FeatureRequests.sql | 251 ++++++++++++++++++ .../ViewModels/FeatureRequestViewModels.cs | 62 +++++ .../Views/Admin/FeatureRequestDetails.cshtml | 90 +++++++ PlotLine/Views/Admin/FeatureRequests.cshtml | 75 ++++++ PlotLine/Views/Admin/Index.cshtml | 38 +++ PlotLine/Views/FeatureRequests/Create.cshtml | 60 +++++ PlotLine/Views/FeatureRequests/Details.cshtml | 60 +++++ PlotLine/Views/FeatureRequests/Index.cshtml | 71 +++++ .../FeatureRequests/_Conversation.cshtml | 25 ++ PlotLine/Views/Shared/_Layout.cshtml | 15 ++ PlotLine/appsettings.Development.json | 2 +- 17 files changed, 1203 insertions(+), 2 deletions(-) create mode 100644 PlotLine/Controllers/FeatureRequestsController.cs create mode 100644 PlotLine/Data/FeatureRequestRepository.cs create mode 100644 PlotLine/Models/FeatureRequestModels.cs create mode 100644 PlotLine/Services/FeatureRequestService.cs create mode 100644 PlotLine/Sql/090_Phase15A_FeatureRequests.sql create mode 100644 PlotLine/ViewModels/FeatureRequestViewModels.cs create mode 100644 PlotLine/Views/Admin/FeatureRequestDetails.cshtml create mode 100644 PlotLine/Views/Admin/FeatureRequests.cshtml create mode 100644 PlotLine/Views/Admin/Index.cshtml create mode 100644 PlotLine/Views/FeatureRequests/Create.cshtml create mode 100644 PlotLine/Views/FeatureRequests/Details.cshtml create mode 100644 PlotLine/Views/FeatureRequests/Index.cshtml create mode 100644 PlotLine/Views/FeatureRequests/_Conversation.cshtml diff --git a/PlotLine/Controllers/AdminController.cs b/PlotLine/Controllers/AdminController.cs index 372a82e..3a48c92 100644 --- a/PlotLine/Controllers/AdminController.cs +++ b/PlotLine/Controllers/AdminController.cs @@ -1,15 +1,105 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using PlotLine.Models; +using PlotLine.Services; +using PlotLine.ViewModels; namespace PlotLine.Controllers; [Authorize(Policy = "AdminOnly")] [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")] public IActionResult ThemeAudit() { return View(); } + + [HttpGet("feature-requests")] + public async Task 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 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 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 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 }); + } } diff --git a/PlotLine/Controllers/FeatureRequestsController.cs b/PlotLine/Controllers/FeatureRequestsController.cs new file mode 100644 index 0000000..13e68d7 --- /dev/null +++ b/PlotLine/Controllers/FeatureRequestsController.cs @@ -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 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 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 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 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; + } +} diff --git a/PlotLine/Data/FeatureRequestRepository.cs b/PlotLine/Data/FeatureRequestRepository.cs new file mode 100644 index 0000000..5d8f99b --- /dev/null +++ b/PlotLine/Data/FeatureRequestRepository.cs @@ -0,0 +1,105 @@ +using System.Data; +using Dapper; +using PlotLine.Models; + +namespace PlotLine.Data; + +public interface IFeatureRequestRepository +{ + Task CreateAsync(int userId, string title, string description, string? appArea, string? importance); + Task GetForUserAsync(int featureRequestId, int userId); + Task GetForAdminAsync(int featureRequestId); + Task> ListForUserAsync(int userId); + Task> ListForAdminAsync(string? status); + Task UpdateStatusAsync(int featureRequestId, string status, bool isPublicCandidate); + Task UpdateAdminNotesAsync(int featureRequestId, string? adminNotes); + Task AddMessageAsync(int featureRequestId, int? userId, bool isAdmin, string messageBody); + Task> ListMessagesAsync(int featureRequestId); +} + +public sealed class FeatureRequestRepository(ISqlConnectionFactory connectionFactory) : IFeatureRequestRepository +{ + public async Task CreateAsync(int userId, string title, string description, string? appArea, string? importance) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.FeatureRequest_Create", + new { UserID = userId, Title = title, Description = description, AppArea = appArea, Importance = importance }, + commandType: CommandType.StoredProcedure); + } + + public async Task GetForUserAsync(int featureRequestId, int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.FeatureRequest_GetForUser", + new { FeatureRequestID = featureRequestId, UserID = userId }, + commandType: CommandType.StoredProcedure); + } + + public async Task GetForAdminAsync(int featureRequestId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.FeatureRequest_GetForAdmin", + new { FeatureRequestID = featureRequestId }, + commandType: CommandType.StoredProcedure); + } + + public async Task> ListForUserAsync(int userId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.FeatureRequest_ListForUser", + new { UserID = userId }, + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } + + public async Task> ListForAdminAsync(string? status) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.FeatureRequest_ListForAdmin", + new { Status = status }, + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } + + public async Task UpdateStatusAsync(int featureRequestId, string status, bool isPublicCandidate) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.FeatureRequest_UpdateStatus", + new { FeatureRequestID = featureRequestId, Status = status, IsPublicCandidate = isPublicCandidate }, + commandType: CommandType.StoredProcedure); + } + + public async Task UpdateAdminNotesAsync(int featureRequestId, string? adminNotes) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.FeatureRequest_UpdateAdminNotes", + new { FeatureRequestID = featureRequestId, AdminNotes = adminNotes }, + commandType: CommandType.StoredProcedure); + } + + public async Task AddMessageAsync(int featureRequestId, int? userId, bool isAdmin, string messageBody) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.FeatureRequestMessage_Add", + new { FeatureRequestID = featureRequestId, UserID = userId, IsAdmin = isAdmin, MessageBody = messageBody }, + commandType: CommandType.StoredProcedure); + } + + public async Task> ListMessagesAsync(int featureRequestId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.FeatureRequestMessage_List", + new { FeatureRequestID = featureRequestId }, + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } +} diff --git a/PlotLine/Models/FeatureRequestModels.cs b/PlotLine/Models/FeatureRequestModels.cs new file mode 100644 index 0000000..51a28a5 --- /dev/null +++ b/PlotLine/Models/FeatureRequestModels.cs @@ -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 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 AppAreas { get; } = + [ + "Timeline", + "Characters", + "Locations", + "Assets", + "Plot Lines", + "Continuity", + "Writing Schedule", + "Word Companion", + "Import / Export", + "Billing / Account", + "Other" + ]; + + public static IReadOnlyList 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; } +} diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 31373a4..ca929d9 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -93,6 +93,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -177,6 +178,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddHostedService(); var app = builder.Build(); diff --git a/PlotLine/Services/FeatureRequestService.cs b/PlotLine/Services/FeatureRequestService.cs new file mode 100644 index 0000000..31d9225 --- /dev/null +++ b/PlotLine/Services/FeatureRequestService.cs @@ -0,0 +1,79 @@ +using PlotLine.Data; +using PlotLine.Models; +using PlotLine.ViewModels; + +namespace PlotLine.Services; + +public interface IFeatureRequestService +{ + Task CreateAsync(int userId, FeatureRequestCreateViewModel model); + Task GetForUserAsync(int featureRequestId, int userId); + Task GetForAdminAsync(int featureRequestId); + Task> ListForUserAsync(int userId); + Task> ListForAdminAsync(string? status); + Task UpdateAdminFieldsAsync(AdminFeatureRequestUpdateViewModel model); + Task AddUserReplyAsync(int featureRequestId, int userId, string messageBody); + Task AddAdminReplyAsync(int featureRequestId, int adminUserId, string messageBody); + Task> ListMessagesAsync(int featureRequestId); +} + +public sealed class FeatureRequestService(IFeatureRequestRepository featureRequests) : IFeatureRequestService +{ + public Task CreateAsync(int userId, FeatureRequestCreateViewModel model) + { + return featureRequests.CreateAsync( + userId, + Clean(model.Title), + Clean(model.Description), + CleanNullable(model.AppArea), + CleanNullable(model.Importance)); + } + + public Task GetForUserAsync(int featureRequestId, int userId) => + featureRequests.GetForUserAsync(featureRequestId, userId); + + public Task GetForAdminAsync(int featureRequestId) => + featureRequests.GetForAdminAsync(featureRequestId); + + public Task> ListForUserAsync(int userId) => + featureRequests.ListForUserAsync(userId); + + public Task> ListForAdminAsync(string? status) => + featureRequests.ListForAdminAsync(FeatureRequestStatuses.IsValid(status) ? status : null); + + public async Task 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 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 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> 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(); +} diff --git a/PlotLine/Sql/090_Phase15A_FeatureRequests.sql b/PlotLine/Sql/090_Phase15A_FeatureRequests.sql new file mode 100644 index 0000000..a77c924 --- /dev/null +++ b/PlotLine/Sql/090_Phase15A_FeatureRequests.sql @@ -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 diff --git a/PlotLine/ViewModels/FeatureRequestViewModels.cs b/PlotLine/ViewModels/FeatureRequestViewModels.cs new file mode 100644 index 0000000..e06b6a1 --- /dev/null +++ b/PlotLine/ViewModels/FeatureRequestViewModels.cs @@ -0,0 +1,62 @@ +using System.ComponentModel.DataAnnotations; +using PlotLine.Models; + +namespace PlotLine.ViewModels; + +public sealed class FeatureRequestIndexViewModel +{ + public IReadOnlyList 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 AppAreas { get; set; } = FeatureRequestOptions.AppAreas; + public IReadOnlyList ImportanceOptions { get; set; } = FeatureRequestOptions.ImportanceOptions; +} + +public sealed class FeatureRequestDetailsViewModel +{ + public FeatureRequest Request { get; set; } = new(); + public IReadOnlyList Messages { get; set; } = []; + public FeatureRequestReplyViewModel Reply { get; set; } = new(); + public bool IsAdminView { get; set; } + public IReadOnlyList 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 Requests { get; set; } = []; + public string? Status { get; set; } + public IReadOnlyList 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; } +} diff --git a/PlotLine/Views/Admin/FeatureRequestDetails.cshtml b/PlotLine/Views/Admin/FeatureRequestDetails.cshtml new file mode 100644 index 0000000..3bcb002 --- /dev/null +++ b/PlotLine/Views/Admin/FeatureRequestDetails.cshtml @@ -0,0 +1,90 @@ +@model FeatureRequestDetailsViewModel +@{ + ViewData["Title"] = Model.Request.Title; + var request = Model.Request; +} + +
+
+

Admin / Feature Request

+

@request.Title

+

@request.UserDisplayName (@request.UserEmail)

+
+ +
+ +@if (TempData["AdminMessage"] is string adminMessage) +{ +
@adminMessage
+} + +
+
+
+

Request

+
+
+

Status

+

@request.Status

+
+
+

App area

+

@(request.AppArea ?? "Not set")

+
+
+

Importance

+

@(request.Importance ?? "Not set")

+
+
+

@request.Description

+
+ +
+

Conversation

+ @await Html.PartialAsync("~/Views/FeatureRequests/_Conversation.cshtml", Model) +
+ +
+ + + +
+ +
+
+
+ +
+
+

Admin controls

+
+ +
+ + +
+
+ + + +
+
+ + +
+ +
+
+
+
+ +@section Scripts { + +} diff --git a/PlotLine/Views/Admin/FeatureRequests.cshtml b/PlotLine/Views/Admin/FeatureRequests.cshtml new file mode 100644 index 0000000..f005b1a --- /dev/null +++ b/PlotLine/Views/Admin/FeatureRequests.cshtml @@ -0,0 +1,75 @@ +@model AdminFeatureRequestIndexViewModel +@{ + ViewData["Title"] = "Feature Requests"; +} + +
+
+

Admin

+

Feature Requests

+
+
+
+ + +
+ +
+
+ +@if (TempData["AdminMessage"] is string adminMessage) +{ +
@adminMessage
+} + +@if (!Model.Requests.Any()) +{ +
+

No feature requests found

+

New suggestions will appear here once users submit them.

+
+} +else +{ +
+
+ + + + + + + + + + + + + @foreach (var request in Model.Requests) + { + + + + + + + + + } + +
TitleUserStatusAreaUpdated
@request.Title@(request.UserDisplayName ?? request.UserEmail)@request.Status@(request.AppArea ?? "Not set")@FormatDate(request.UpdatedUtc) + Open +
+
+
+} + +@functions { + private static string FormatDate(DateTime value) => value.ToLocalTime().ToString("yyyy-MM-dd HH:mm"); +} diff --git a/PlotLine/Views/Admin/Index.cshtml b/PlotLine/Views/Admin/Index.cshtml new file mode 100644 index 0000000..544e535 --- /dev/null +++ b/PlotLine/Views/Admin/Index.cshtml @@ -0,0 +1,38 @@ +@{ + ViewData["Title"] = "Admin"; +} + +
+
+

Admin

+

Dashboard

+

Operational tools for PlotDirector administrators.

+
+
+ +
+
+
+

User feedback

+

Feature Requests

+

Review suggestions, update status and reply privately.

+
+
+ Open +
+
+
+
+

Placeholder

+

Users

+

User management tools will be added later.

+
+
+
+
+

Placeholder

+

Subscriptions

+

Subscription management tools will be added later.

+
+
+
diff --git a/PlotLine/Views/FeatureRequests/Create.cshtml b/PlotLine/Views/FeatureRequests/Create.cshtml new file mode 100644 index 0000000..7b0cbff --- /dev/null +++ b/PlotLine/Views/FeatureRequests/Create.cshtml @@ -0,0 +1,60 @@ +@model FeatureRequestCreateViewModel +@{ + ViewData["Title"] = "Submit Feature Request"; +} + +
+
+

Feedback

+

Submit Feature Request

+

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.

+
+
+ +
+
+
+
+ + + +
+
+ + + +
+
+
+ + + +
+
+ + + +
+
+
+ + Cancel +
+
+
+ +@section Scripts { + +} diff --git a/PlotLine/Views/FeatureRequests/Details.cshtml b/PlotLine/Views/FeatureRequests/Details.cshtml new file mode 100644 index 0000000..5bdb6fa --- /dev/null +++ b/PlotLine/Views/FeatureRequests/Details.cshtml @@ -0,0 +1,60 @@ +@model FeatureRequestDetailsViewModel +@{ + ViewData["Title"] = Model.Request.Title; + var request = Model.Request; +} + +
+
+

Feature Request

+

@request.Title

+
+ +
+ +
+

Request details

+
+
+

Status

+

@request.Status

+
+
+

App area

+

@(request.AppArea ?? "Not set")

+
+
+

Importance

+

@(request.Importance ?? "Not set")

+
+
+

Updated

+

@FormatDate(request.UpdatedUtc)

+
+
+

@request.Description

+
+ +
+

Conversation

+ @await Html.PartialAsync("_Conversation", Model) +
+ +
+ + + +
+ +
+
+ +@functions { + private static string FormatDate(DateTime value) => value.ToLocalTime().ToString("yyyy-MM-dd HH:mm"); +} + +@section Scripts { + +} diff --git a/PlotLine/Views/FeatureRequests/Index.cshtml b/PlotLine/Views/FeatureRequests/Index.cshtml new file mode 100644 index 0000000..59961e9 --- /dev/null +++ b/PlotLine/Views/FeatureRequests/Index.cshtml @@ -0,0 +1,71 @@ +@model FeatureRequestIndexViewModel +@{ + ViewData["Title"] = "Feature Requests"; +} + +
+
+

Feedback

+

Feature Requests

+

Send suggestions and keep track of your conversations with the PlotDirector team.

+
+ +
+ +@if (TempData["FeatureRequestMessage"] is string featureRequestMessage) +{ +
@featureRequestMessage
+} + +@if (!Model.Requests.Any()) +{ +
+

No suggestions yet

+

No suggestions yet. If something keeps getting in your way, or you have an idea that would make PlotDirector better, send it in.

+ Submit Feature Request +
+} +else +{ +
+
+ + + + + + + + + + + + + + + @foreach (var request in Model.Requests) + { + + + + + + + + + + + } + +
TitleApp AreaImportanceStatusCreatedUpdatedLatest Activity
@request.Title@(request.AppArea ?? "Not set")@(request.Importance ?? "Not set")@request.Status@FormatDate(request.CreatedUtc)@FormatDate(request.UpdatedUtc)@(request.LatestActivityUtc.HasValue ? FormatDate(request.LatestActivityUtc.Value) : "None yet") + Open +
+
+
+} + +@functions { + private static string FormatDate(DateTime value) => value.ToLocalTime().ToString("yyyy-MM-dd"); +} diff --git a/PlotLine/Views/FeatureRequests/_Conversation.cshtml b/PlotLine/Views/FeatureRequests/_Conversation.cshtml new file mode 100644 index 0000000..dc8d7c4 --- /dev/null +++ b/PlotLine/Views/FeatureRequests/_Conversation.cshtml @@ -0,0 +1,25 @@ +@model FeatureRequestDetailsViewModel + +@if (!Model.Messages.Any()) +{ +

No replies yet.

+} +else +{ +
+ @foreach (var message in Model.Messages) + { +
+
+ @(message.IsAdmin ? "PlotDirector team" : message.UserDisplayName ?? "You") + @FormatDate(message.CreatedUtc) +
+

@message.MessageBody

+
+ } +
+} + +@functions { + private static string FormatDate(DateTime value) => value.ToLocalTime().ToString("yyyy-MM-dd HH:mm"); +} diff --git a/PlotLine/Views/Shared/_Layout.cshtml b/PlotLine/Views/Shared/_Layout.cshtml index f5e207a..b2ca7fa 100644 --- a/PlotLine/Views/Shared/_Layout.cshtml +++ b/PlotLine/Views/Shared/_Layout.cshtml @@ -1,8 +1,14 @@ +@using System.Security.Claims +@inject IConfiguration Configuration @{ var shellClass = ViewData["ShellClass"] as string ?? string.Empty; var isMarketingPage = shellClass.Contains("marketing-shell", StringComparison.OrdinalIgnoreCase); + var userEmail = User.FindFirstValue(ClaimTypes.Email); + var adminEmails = Configuration.GetSection("Admin:AllowedEmails").Get() ?? []; + var isAdmin = !string.IsNullOrWhiteSpace(userEmail) + && adminEmails.Any(email => string.Equals(email, userEmail, StringComparison.OrdinalIgnoreCase)); 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; @@ -121,6 +127,15 @@ + + @if (isAdmin) + { + + } }
diff --git a/PlotLine/appsettings.Development.json b/PlotLine/appsettings.Development.json index bc88cf9..11e0493 100644 --- a/PlotLine/appsettings.Development.json +++ b/PlotLine/appsettings.Development.json @@ -1,6 +1,6 @@ { "Admin": { - "AllowedEmails": [] + "AllowedEmails": ["nickbeckley5@gmail.com"] }, "Logging": { "LogLevel": {