PlotDirector/PlotLine/Controllers/AdminController.cs

535 lines
23 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,
IStoryIntelligenceDevelopmentResetService storyIntelligenceDevelopmentReset,
IStoryIntelligenceDryRunService storyIntelligenceDryRun,
IChapterStructureDryRunService chapterStructureDryRun,
IChapterStoryIntelligenceDryRunService chapterStoryIntelligenceDryRun,
IStoryIntelligenceResultPersistenceService storyIntelligenceResults,
IStoryIntelligenceExistingChapterQueueService existingChapterQueue,
IStoryIntelligenceFullChapterTestService fullChapterTest,
IStoryIntelligenceImportCommitService storyIntelligenceImportCommit,
INameIntelligenceService nameIntelligence,
IIllustrationLibraryService illustrationLibrary,
ILogger<AdminController> logger) : 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();
}
[HttpPost("name-intelligence/validate")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ValidateNameIntelligence(CancellationToken cancellationToken)
{
var result = await nameIntelligence.ValidatePackageAsync(cancellationToken);
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = $"{result.Message} Locales={result.LocaleRows:N0}, names={result.GivenNameRows:N0}, relationships={result.RelationshipRows:N0}, usages={result.UsageRows:N0}.";
if (result.Errors.Count > 0)
{
TempData["AdminError"] = string.Join(" ", result.Errors.Take(5));
}
return RedirectToAction(nameof(Index));
}
[HttpPost("name-intelligence/import")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ImportNameIntelligence(bool force, CancellationToken cancellationToken)
{
var result = await nameIntelligence.EnsureReferenceDataImportedAsync(force, cancellationToken);
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = $"{result.Message} Package {result.PackageId} {result.Version}: locales={result.LocaleRows:N0}, names={result.GivenNameRows:N0}, relationships={result.RelationshipRows:N0}, usages={result.UsageRows:N0}.";
if (result.Errors.Count > 0)
{
TempData["AdminError"] = string.Join(" ", result.Errors.Take(5));
}
return RedirectToAction(nameof(Index));
}
[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));
}
[HttpPost("story-intelligence-diagnostics/reset-imports")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ResetStoryIntelligenceImports(CancellationToken cancellationToken)
{
if (currentUser.UserId is not int userId)
{
return Forbid();
}
logger.LogWarning("Story Intelligence development reset requested by admin user {UserId}.", userId);
try
{
var result = await storyIntelligenceDevelopmentReset.ResetOwnedProjectsAndImportsAsync(userId, cancellationToken);
TempData[result.Success ? "AdminMessage" : "AdminError"] = result.Message;
logger.LogWarning(
"Story Intelligence development reset finished for admin user {UserId}. Success={Success} DeletedProjects={DeletedProjects} RemainingProjects={RemainingProjects} RemainingImports={RemainingImports} RemainingRuns={RemainingRuns}.",
userId,
result.Success,
result.DeletedProjects,
result.RemainingProjects,
result.RemainingImportSessions,
result.RemainingRuns);
}
catch (Exception ex)
{
logger.LogError(ex, "Story Intelligence development reset failed for admin user {UserId}.", userId);
TempData["AdminError"] = "Story Intelligence reset failed. No files were removed; check the application logs for the blocking dependency.";
}
return RedirectToAction(nameof(StoryIntelligenceDiagnostics));
}
[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]}...";
}
}