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(AdminFeatureRequestFilter filter); Task> ListRoadmapAsync(); Task GetAdminSummaryAsync(); Task UpdateAdminFieldsAsync(AdminFeatureRequestUpdateViewModel model); Task AddUserReplyAsync(int featureRequestId, int userId, string messageBody); Task AddAdminReplyAsync(int featureRequestId, int adminUserId, string messageBody); Task> ListMessagesAsync(int featureRequestId); Task MarkViewedForUserAsync(int featureRequestId, int userId); Task CountAttentionForUserAsync(int userId); Task CountWaitingForAdminAsync(); } 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(AdminFeatureRequestFilter filter) { var sanitized = SanitizeFilter(filter); return featureRequests.ListForAdminAsync( sanitized.Status, sanitized.AppArea, sanitized.Importance, sanitized.Search, sanitized.Sort); } public async Task> 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 GetAdminSummaryAsync() { var requests = await featureRequests.ListForAdminAsync( status: null, appArea: null, importance: null, search: null, sort: FeatureRequestSortOptions.LatestActivity); return new FeatureRequestAdminSummary { Statuses = FeatureRequestStatuses.All .Select(status => new FeatureRequestStatusSummary { Status = status, Count = requests.Count(request => string.Equals(request.Status, status, StringComparison.OrdinalIgnoreCase)) }) .ToList(), TotalOpenRequests = requests.Count(request => request.IsOpen), AwaitingAdminReply = requests.Count(request => request.ConversationState == FeatureRequestConversationStates.WaitingForAdminReply), AwaitingUserReply = requests.Count(request => request.ConversationState == FeatureRequestConversationStates.WaitingForUserReply) }; } public async Task UpdateAdminFieldsAsync(AdminFeatureRequestUpdateViewModel model) { if (!FeatureRequestStatuses.IsValid(model.Status)) { return false; } 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; } 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); public Task MarkViewedForUserAsync(int featureRequestId, int userId) => featureRequests.MarkViewedForUserAsync(featureRequestId, userId); public Task CountAttentionForUserAsync(int userId) => featureRequests.CountAttentionForUserAsync(userId); public Task CountWaitingForAdminAsync() => featureRequests.CountWaitingForAdminAsync(); private static string Clean(string value) => value.Trim(); private static string? CleanNullable(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); private static AdminFeatureRequestFilter SanitizeFilter(AdminFeatureRequestFilter filter) { return new AdminFeatureRequestFilter { Status = FeatureRequestStatuses.IsValid(filter.Status) ? filter.Status : null, AppArea = FeatureRequestOptions.AppAreas.Contains(filter.AppArea ?? string.Empty, StringComparer.OrdinalIgnoreCase) ? filter.AppArea : null, Importance = FeatureRequestOptions.ImportanceOptions.Contains(filter.Importance ?? string.Empty, StringComparer.OrdinalIgnoreCase) ? filter.Importance : null, Search = CleanNullable(filter.Search), Sort = FeatureRequestSortOptions.IsValid(filter.Sort) ? filter.Sort : FeatureRequestSortOptions.LatestActivity }; } private static FeatureRequestRoadmapSection BuildRoadmapSection(string heading, string status, IEnumerable requests) { return new FeatureRequestRoadmapSection { Heading = heading, Status = status, Requests = requests .Where(request => string.Equals(request.Status, status, StringComparison.OrdinalIgnoreCase)) .ToList() }; } }