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( ICurrentUserService currentUser, IFeatureRequestService featureRequests, IEmailService emails, IStoryIntelligenceDiagnosticsService storyIntelligenceDiagnostics, IStoryIntelligenceDryRunService storyIntelligenceDryRun, IChapterStructureDryRunService chapterStructureDryRun, IChapterStoryIntelligenceDryRunService chapterStoryIntelligenceDryRun, IStoryIntelligenceResultPersistenceService storyIntelligenceResults, IStoryIntelligenceExistingChapterQueueService existingChapterQueue, IStoryIntelligenceFullChapterTestService fullChapterTest) : Controller { [HttpGet("")] [HttpGet("index")] public async Task Index() { return View(new AdminDashboardViewModel { FeatureRequestSummary = await featureRequests.GetAdminSummaryAsync() }); } [HttpGet("theme-audit")] public IActionResult ThemeAudit() { return View(); } [HttpGet("story-intelligence-diagnostics")] public async Task StoryIntelligenceDiagnostics(CancellationToken cancellationToken) { return View(await storyIntelligenceDiagnostics.GetDiagnosticsAsync(cancellationToken)); } [HttpGet("story-intelligence-test")] public IActionResult StoryIntelligenceTest() { return View(storyIntelligenceDryRun.GetDefault()); } [HttpPost("story-intelligence-test")] [ValidateAntiForgeryToken] public async Task StoryIntelligenceTest([Bind(Prefix = "Form")] StoryIntelligenceDryRunForm form, CancellationToken cancellationToken) { return View(await storyIntelligenceDryRun.ExecuteAsync(form, cancellationToken)); } [HttpGet("chapter-structure-test")] public IActionResult ChapterStructureTest() { return View(chapterStructureDryRun.GetDefault()); } [HttpPost("chapter-structure-test")] [ValidateAntiForgeryToken] public async Task ChapterStructureTest([Bind(Prefix = "Form")] ChapterStructureDryRunForm form, CancellationToken cancellationToken) { return View(await chapterStructureDryRun.ExecuteAsync(form, cancellationToken)); } [HttpGet("chapter-story-intelligence-test")] public IActionResult ChapterStoryIntelligenceTest() { return View(chapterStoryIntelligenceDryRun.GetDefault()); } [HttpPost("chapter-story-intelligence-test")] [ValidateAntiForgeryToken] public async Task ChapterStoryIntelligenceTest([Bind(Prefix = "Form")] ChapterStoryIntelligenceDryRunForm form, CancellationToken cancellationToken) { return View(await chapterStoryIntelligenceDryRun.ExecuteAsync(form, cancellationToken)); } [HttpPost("chapter-story-intelligence-test/save")] [ValidateAntiForgeryToken] public async Task SaveChapterStoryIntelligenceRun(StoryIntelligenceSaveRunForm form) { if (currentUser.UserId is not int userId) { return Forbid(); } var runId = await storyIntelligenceResults.SaveDisplayedRunAsync(userId, form.Payload); TempData["AdminMessage"] = $"Story Intelligence run {runId:N0} saved. Saving the same displayed dry run again creates a separate audit record."; return RedirectToAction(nameof(StoryIntelligenceRunDetails), new { id = runId }); } [HttpPost("chapter-story-intelligence-test/queue")] [ValidateAntiForgeryToken] public async Task QueueChapterStoryIntelligenceRun([Bind(Prefix = "Form")] ChapterStoryIntelligenceDryRunForm form) { if (currentUser.UserId is not int userId) { return Forbid(); } var runId = await storyIntelligenceResults.QueueAdminTextRunAsync(userId, form); TempData["AdminMessage"] = $"Story Intelligence run {runId:N0} queued. The background worker will process it progressively."; return RedirectToAction(nameof(StoryIntelligenceRunDetails), new { id = runId }); } [HttpGet("story-intelligence-existing-chapter")] public async Task StoryIntelligenceExistingChapter([FromQuery] StoryIntelligenceExistingChapterQueueForm form) { return View(await existingChapterQueue.BuildViewModelAsync(form)); } [HttpPost("story-intelligence-existing-chapter")] [ValidateAntiForgeryToken] public async Task QueueStoryIntelligenceExistingChapter(StoryIntelligenceExistingChapterQueueForm form) { if (currentUser.UserId is not int userId) { return Forbid(); } var model = await existingChapterQueue.QueueAsync(userId, form); if (model.QueuedRunID is int runId) { TempData["AdminMessage"] = $"Story Intelligence run {runId:N0} queued from existing chapter. The background worker will process it progressively."; return RedirectToAction(nameof(StoryIntelligenceRunDetails), new { id = runId }); } return View(nameof(StoryIntelligenceExistingChapter), model); } [HttpGet("story-intelligence-full-chapter-test")] public async Task StoryIntelligenceFullChapterTest(int? sourceRunId) { return View(await fullChapterTest.GetDefaultAsync(sourceRunId)); } [HttpPost("story-intelligence-full-chapter-test")] [ValidateAntiForgeryToken] [RequestSizeLimit(5 * 1024 * 1024)] public async Task StoryIntelligenceFullChapterTest( [Bind(Prefix = "Form")] StoryIntelligenceFullChapterTestForm form, IFormFile? textFile, string submitAction) { if (string.Equals(submitAction, "Queue", StringComparison.OrdinalIgnoreCase)) { if (currentUser.UserId is not int userId) { return Forbid(); } try { var runId = await fullChapterTest.QueueAsync(userId, form, textFile); TempData["AdminMessage"] = $"Full chapter Story Intelligence run {runId:N0} queued. The browser does not need to remain open."; return RedirectToAction(nameof(StoryIntelligenceRunDetails), new { id = runId }); } catch (InvalidOperationException ex) { var model = await fullChapterTest.AnalyzeAsync(form, textFile); return View(new StoryIntelligenceFullChapterTestViewModel { Form = model.Form, Preflight = model.Preflight, SourceFileName = model.SourceFileName, SourceFileSizeBytes = model.SourceFileSizeBytes, ErrorMessage = ex.Message }); } } return View(await fullChapterTest.AnalyzeAsync(form, textFile)); } [HttpGet("story-intelligence-runs")] public async Task StoryIntelligenceRuns() { return View(await storyIntelligenceResults.ListRunsAsync()); } [HttpGet("story-intelligence-runs/{id:int}")] public async Task StoryIntelligenceRunDetails(int id) { var model = await storyIntelligenceResults.GetRunDetailAsync(id); return model is null ? NotFound() : View(model); } [HttpPost("story-intelligence-runs/{id:int}/cancel")] [ValidateAntiForgeryToken] public async Task CancelStoryIntelligenceRun(int id) { await storyIntelligenceResults.CancelRunAsync(id); TempData["AdminMessage"] = $"Cancellation requested for Story Intelligence run {id:N0}."; return RedirectToAction(nameof(StoryIntelligenceRunDetails), new { id }); } [HttpGet("feature-requests")] public async Task FeatureRequests(string? status, string? appArea, string? importance, string? search, string? sort) { var filter = new AdminFeatureRequestFilter { Status = FeatureRequestStatuses.IsValid(status) ? status : null, AppArea = FeatureRequestOptions.AppAreas.Contains(appArea ?? string.Empty, StringComparer.OrdinalIgnoreCase) ? appArea : null, Importance = FeatureRequestOptions.ImportanceOptions.Contains(importance ?? string.Empty, StringComparer.OrdinalIgnoreCase) ? importance : null, Search = string.IsNullOrWhiteSpace(search) ? null : search.Trim(), Sort = FeatureRequestSortOptions.IsValid(sort) ? sort! : FeatureRequestSortOptions.LatestActivity }; return View(new AdminFeatureRequestIndexViewModel { Status = filter.Status, AppArea = filter.AppArea, Importance = filter.Importance, Search = filter.Search, Sort = filter.Sort, Requests = await featureRequests.ListForAdminAsync(filter) }); } [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 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 }); } [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); 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]}..."; } }