PlotDirector/PlotLine/Controllers/AdminController.cs

471 lines
20 KiB
C#

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,
IStoryIntelligenceImportCommitService storyIntelligenceImportCommit,
IIllustrationLibraryService illustrationLibrary) : Controller
{
[HttpGet("")]
[HttpGet("index")]
public async Task<IActionResult> Index()
{
return View(new AdminDashboardViewModel
{
FeatureRequestSummary = await featureRequests.GetAdminSummaryAsync()
});
}
[HttpGet("theme-audit")]
public IActionResult ThemeAudit()
{
return View();
}
[HttpGet("story-intelligence/illustration-library")]
public async Task<IActionResult> StoryIntelligenceIllustrationLibrary([FromQuery] IllustrationLibraryFilter filter)
{
return View(await illustrationLibrary.BuildAdminViewModelAsync(filter));
}
[HttpPost("story-intelligence/illustration-library/starter-plan")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CreateIllustrationStarterPlan()
{
var result = await illustrationLibrary.EnsureStarterPlanAsync();
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary));
}
[HttpPost("story-intelligence/illustration-library/generate-starter")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> GenerateIllustrationStarterLibrary(IllustrationStarterGenerationForm form)
{
var result = await illustrationLibrary.GenerateStarterLibraryAsync(form.Confirmed);
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary));
}
[HttpPost("story-intelligence/illustration-library/queue")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> QueueIllustrationLibraryItems(IllustrationLibraryQueueForm form)
{
var result = await illustrationLibrary.QueueAsync(null, form.Category, form.Confirmed);
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), new { category = form.Category });
}
[HttpPost("story-intelligence/illustration-library/requeue-failed")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RequeueFailedIllustrationLibraryItems(IllustrationLibraryBulkRetryForm form)
{
var result = await illustrationLibrary.RequeueAllFailedAsync();
TempData[result.QueuedCount > 0 ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), new { category = form.Category, status = form.Status, search = form.Search });
}
[HttpPost("story-intelligence/illustration-library/{id:int}/queue")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> QueueIllustrationLibraryItem(int id, IllustrationLibraryQueueForm form)
{
var result = await illustrationLibrary.QueueAsync(id, null, form.Confirmed);
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), PreserveIllustrationLibraryFilter(form));
}
[HttpPost("story-intelligence/illustration-library/{id:int}/approve")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ApproveIllustrationLibraryItem(int id, IllustrationLibraryQueueForm form)
{
if (currentUser.UserId is not int userId)
{
return Forbid();
}
var result = await illustrationLibrary.ApproveAsync(id, userId);
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), PreserveIllustrationLibraryFilter(form));
}
[HttpPost("story-intelligence/illustration-library/{id:int}/reject")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RejectIllustrationLibraryItem(int id, IllustrationLibraryRejectForm form)
{
if (currentUser.UserId is not int userId)
{
return Forbid();
}
var result = await illustrationLibrary.RejectAsync(id, userId, form.Reason);
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), PreserveIllustrationLibraryFilter(form));
}
[HttpPost("story-intelligence/illustration-library/{id:int}/regenerate")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RegenerateIllustrationLibraryItem(int id, IllustrationLibraryQueueForm form)
{
var result = await illustrationLibrary.RegenerateAsync(id, form.Confirmed);
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), PreserveIllustrationLibraryFilter(form));
}
[HttpPost("story-intelligence/illustration-library/{id:int}/disable")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DisableIllustrationLibraryItem(int id, IllustrationLibraryQueueForm form)
{
var result = await illustrationLibrary.DisableAsync(id);
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), PreserveIllustrationLibraryFilter(form));
}
[HttpPost("story-intelligence/illustration-library/{id:int}/metadata")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UpdateIllustrationLibraryMetadata(int id, IllustrationLibraryMetadataForm form)
{
var result = await illustrationLibrary.UpdateMetadataAsync(id, form);
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), PreserveIllustrationLibraryFilter(form));
}
private static object PreserveIllustrationLibraryFilter(IllustrationLibraryQueueForm form)
=> new { category = form.Category, status = form.Status, search = form.Search };
private static object PreserveIllustrationLibraryFilter(IllustrationLibraryRejectForm form)
=> new { category = form.Category, status = form.Status, search = form.Search };
private static object PreserveIllustrationLibraryFilter(IllustrationLibraryMetadataForm form)
=> new { category = form.Category, status = form.Status, search = form.Search };
[HttpGet("story-intelligence-diagnostics")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> StoryIntelligenceExistingChapter([FromQuery] StoryIntelligenceExistingChapterQueueForm form)
{
return View(await existingChapterQueue.BuildViewModelAsync(form));
}
[HttpPost("story-intelligence-existing-chapter")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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<IActionResult> StoryIntelligenceFullChapterTest(int? sourceRunId)
{
return View(await fullChapterTest.GetDefaultAsync(sourceRunId));
}
[HttpPost("story-intelligence-full-chapter-test")]
[ValidateAntiForgeryToken]
[RequestSizeLimit(5 * 1024 * 1024)]
public async Task<IActionResult> 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<IActionResult> StoryIntelligenceRuns()
{
return View(await storyIntelligenceResults.ListRunsAsync());
}
[HttpGet("story-intelligence-runs/{id:int}")]
public async Task<IActionResult> 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<IActionResult> CancelStoryIntelligenceRun(int id)
{
await storyIntelligenceResults.CancelRunAsync(id);
TempData["AdminMessage"] = $"Cancellation requested for Story Intelligence run {id:N0}.";
return RedirectToAction(nameof(StoryIntelligenceRunDetails), new { id });
}
[HttpGet("story-intelligence-runs/{id:int}/commit")]
public async Task<IActionResult> ConfirmStoryIntelligenceImportCommit(int id)
{
var model = await storyIntelligenceImportCommit.BuildConfirmationAsync(id);
return model is null ? NotFound() : View(model);
}
[HttpPost("story-intelligence-runs/{id:int}/commit")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CommitStoryIntelligenceImport(int id)
{
if (currentUser.UserId is not int userId)
{
return Forbid();
}
try
{
var result = await storyIntelligenceImportCommit.CommitAsync(id, userId);
TempData[result.Success ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(result.Success ? nameof(StoryIntelligenceRunDetails) : nameof(ConfirmStoryIntelligenceImportCommit), new { id });
}
catch (InvalidOperationException ex)
{
TempData["AdminError"] = ex.Message;
return RedirectToAction(nameof(ConfirmStoryIntelligenceImportCommit), new { id });
}
}
[HttpGet("feature-requests")]
public async Task<IActionResult> 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<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 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<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);
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]}...";
}
}