using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using PlotLine.Models; using PlotLine.Services; using PlotLine.ViewModels; namespace PlotLine.Controllers; [Authorize] [Route("onboarding")] public sealed class OnboardingController(IOnboardingService onboarding, IOnboardingStoryIntelligenceService storyIntelligence) : Controller { [HttpGet("")] public async Task Index() { var model = await onboarding.GetWizardAsync(); return View(model); } [HttpPost("start-manual")] [ValidateAntiForgeryToken] public async Task StartManual() { await onboarding.StartManualSetupAsync(); return RedirectToAction(nameof(Index)); } [HttpPost("start-import")] [ValidateAntiForgeryToken] public async Task StartImport() { await onboarding.StartManuscriptImportSetupAsync(); return RedirectToAction(nameof(Index)); } [HttpGet("scan-review")] public async Task ScanReview(Guid? previewId) { var model = await onboarding.GetScanReviewAsync(previewId) ?? (previewId.HasValue ? await onboarding.GetScanReviewAsync() : null); if (model is null) { TempData["ArchiveError"] = "The scan review could not be opened. Please scan the manuscript again."; await onboarding.SetCurrentStepAsync(OnboardingSteps.NextPathPreview); return RedirectToAction(nameof(Index)); } return View(model); } [HttpGet("build-complete")] public async Task BuildComplete(Guid previewId) { var model = await onboarding.GetBuildResultAsync(previewId); return model is null ? NotFound() : View(model); } [HttpGet("story-intelligence")] public async Task StoryIntelligence() { var model = await storyIntelligence.GetOverviewAsync(); return View(model); } [HttpGet("story-intelligence/book/{bookId:int}/continue")] public async Task ContinueStoryIntelligence(int bookId) { var target = await storyIntelligence.ResumeBookAsync(bookId); if (target is null) { TempData["ArchiveError"] = "Story Intelligence could not find saved analysis for that book."; return RedirectToAction("Details", "Books", new { id = bookId }); } return target.Route switch { StoryIntelligenceResumeRoutes.Complete => RedirectToAction(nameof(StoryIntelligenceComplete), new { batchId = target.BatchID }), StoryIntelligenceResumeRoutes.Knowledge => RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId = target.BatchID }), StoryIntelligenceResumeRoutes.Relationships => RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = target.BatchID }), StoryIntelligenceResumeRoutes.Assets => RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = target.BatchID }), StoryIntelligenceResumeRoutes.Locations => RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = target.BatchID }), StoryIntelligenceResumeRoutes.Characters => RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId = target.BatchID }), _ => RedirectToAction(nameof(StoryIntelligenceReview), new { batchId = target.BatchID }) }; } [HttpPost("story-intelligence/start")] [ValidateAntiForgeryToken] public async Task StartStoryIntelligence() { var job = await storyIntelligence.StartAsync(); if (job is null) { TempData["OnboardingStoryIntelligenceError"] = "Review the manuscript scan before starting Story Intelligence."; return RedirectToAction(nameof(StoryIntelligence)); } return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId = job.BatchID }); } [HttpPost("story-intelligence/skip")] [ValidateAntiForgeryToken] public async Task SkipStoryIntelligence() { await onboarding.MarkCompletedAsync(); TempData["ArchiveMessage"] = "Story Intelligence skipped for now. You can return to it from your dashboard."; return RedirectToAction("Index", "Projects"); } [HttpPost("scan-manuscript")] [ValidateAntiForgeryToken] public async Task ScanManuscript() { await onboarding.SetCurrentStepAsync(OnboardingSteps.NextPathPreview); return RedirectToAction(nameof(Index)); } [HttpGet("story-intelligence/progress")] public async Task StoryIntelligenceProgress(Guid batchId) { var model = await storyIntelligence.GetProgressAsync(batchId); return model is null ? NotFound() : View(model); } [HttpGet("story-intelligence/review")] public async Task StoryIntelligenceReview(Guid batchId) { var model = await storyIntelligence.GetProgressAsync(batchId); if (model is null) { return NotFound(); } if (model.HasActiveRuns) { return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId }); } return model.AllCommitted ? RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }) : View(model); } [HttpGet("story-intelligence/characters")] public async Task StoryIntelligenceCharacters(Guid batchId) { var model = await storyIntelligence.GetProgressAsync(batchId); if (model is null) { return NotFound(); } if (model.HasActiveRuns) { return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId }); } return !model.AllCommitted ? RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }) : View(model); } [HttpGet("story-intelligence/locations")] public async Task StoryIntelligenceLocations(Guid batchId) { var model = await storyIntelligence.GetProgressAsync(batchId); if (model is null) { return NotFound(); } if (model.HasActiveRuns) { return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId }); } if (!model.AllCommitted) { return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }); } return !model.PipelineDashboard.CharacterStageComplete ? RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }) : View(model); } [HttpGet("story-intelligence/assets")] public async Task StoryIntelligenceAssets(Guid batchId) { var model = await storyIntelligence.GetProgressAsync(batchId); if (model is null) { return NotFound(); } if (model.HasActiveRuns) { return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId }); } if (!model.AllCommitted) { return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }); } if (!model.PipelineDashboard.CharacterStageComplete) { return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }); } return !model.PipelineDashboard.LocationStageComplete ? RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId }) : View(model); } [HttpGet("story-intelligence/relationships")] public async Task StoryIntelligenceRelationships(Guid batchId) { var model = await storyIntelligence.GetProgressAsync(batchId); if (model is null) { return NotFound(); } if (model.HasActiveRuns) { return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId }); } if (!model.AllCommitted) { return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }); } if (!model.PipelineDashboard.CharacterStageComplete) { return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }); } if (!model.PipelineDashboard.LocationStageComplete) { return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId }); } return !model.PipelineDashboard.AssetStageComplete ? RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId }) : View(model); } [HttpPost("story-intelligence/cancel")] [ValidateAntiForgeryToken] public async Task CancelStoryIntelligence(Guid batchId) { var progress = await storyIntelligence.CancelAsync(batchId); if (progress is null) { return NotFound(); } TempData["OnboardingStoryIntelligenceMessage"] = "Analysis cancellation requested."; return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }); } [HttpPost("story-intelligence/commit")] [ValidateAntiForgeryToken] public async Task CommitStoryIntelligence(Guid batchId, int runId) { var (progress, result) = await storyIntelligence.CommitAsync(batchId, runId); if (progress is null) { return NotFound(); } TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; if (result.Success && progress.AllCommitted) { TempData["WriterWorkspaceMessage"] = $"{progress.CommittedChapterCount} chapter(s) and {progress.Chapters.Sum(chapter => chapter.ScenesCreated)} scene(s) created from your manuscript."; return RedirectToAction("Index", "Writer", new { projectId = progress.ProjectID }); } return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }); } [HttpPost("story-intelligence/commit-all")] [ValidateAntiForgeryToken] public async Task CommitAllStoryIntelligence(Guid batchId) { var (progress, result) = await storyIntelligence.CommitAllAsync(batchId); if (progress is null) { return NotFound(); } TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; return result.Success && progress.AllCommitted ? RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }) : RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }); } [HttpPost("story-intelligence/characters")] [ValidateAntiForgeryToken] public async Task ImportStoryIntelligenceCharacters(StoryIntelligenceCharacterImportForm form) { var (progress, result) = await storyIntelligence.ImportCharactersAsync(form.BatchID, form); if (progress is null) { return NotFound(); } TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; return result.Success ? RedirectToAction(nameof(StoryIntelligenceCharacterComplete), new { batchId = form.BatchID }) : RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId = form.BatchID }); } [HttpGet("story-intelligence/characters/complete")] public async Task StoryIntelligenceCharacterComplete(Guid batchId) { var model = await storyIntelligence.GetCharacterImportResultAsync(batchId); return model is null ? NotFound() : View(model); } [HttpPost("story-intelligence/locations")] [ValidateAntiForgeryToken] public async Task ImportStoryIntelligenceLocations(StoryIntelligenceLocationImportForm form) { var (progress, result) = await storyIntelligence.ImportLocationsAsync(form.BatchID, form); if (progress is null) { return NotFound(); } TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; return result.Success ? RedirectToAction(nameof(StoryIntelligenceLocationComplete), new { batchId = form.BatchID }) : RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID }); } [HttpGet("story-intelligence/locations/complete")] public async Task StoryIntelligenceLocationComplete(Guid batchId) { var model = await storyIntelligence.GetLocationImportResultAsync(batchId); return model is null ? NotFound() : View(model); } [HttpPost("story-intelligence/assets")] [ValidateAntiForgeryToken] public async Task ImportStoryIntelligenceAssets(StoryIntelligenceAssetImportForm form) { var (progress, result) = await storyIntelligence.ImportAssetsAsync(form.BatchID, form); if (progress is null) { return NotFound(); } TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; return result.Success ? RedirectToAction(nameof(StoryIntelligenceAssetComplete), new { batchId = form.BatchID }) : RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = form.BatchID }); } [HttpGet("story-intelligence/assets/complete")] public async Task StoryIntelligenceAssetComplete(Guid batchId) { var model = await storyIntelligence.GetAssetImportResultAsync(batchId); return model is null ? NotFound() : View(model); } [HttpPost("story-intelligence/relationships")] [ValidateAntiForgeryToken] public async Task ImportStoryIntelligenceRelationships(StoryIntelligenceRelationshipImportForm form) { var (progress, result) = await storyIntelligence.ImportRelationshipsAsync(form.BatchID, form); if (progress is null) { return NotFound(); } TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; return result.Success ? RedirectToAction(nameof(StoryIntelligenceRelationshipComplete), new { batchId = form.BatchID }) : RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = form.BatchID }); } [HttpGet("story-intelligence/relationships/complete")] public async Task StoryIntelligenceRelationshipComplete(Guid batchId) { var model = await storyIntelligence.GetRelationshipImportResultAsync(batchId); return model is null ? NotFound() : View(model); } [HttpGet("story-intelligence/knowledge")] public async Task StoryIntelligenceKnowledge(Guid batchId) { var model = await storyIntelligence.GetProgressAsync(batchId); if (model is null) { return NotFound(); } if (model.HasActiveRuns) { return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId }); } if (!model.AllCommitted) { return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }); } if (!model.PipelineDashboard.CharacterStageComplete) { return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }); } if (!model.PipelineDashboard.LocationStageComplete) { return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId }); } if (!model.PipelineDashboard.AssetStageComplete) { return RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId }); } return !model.PipelineDashboard.RelationshipStageComplete ? RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId }) : View(model); } [HttpPost("story-intelligence/knowledge")] [ValidateAntiForgeryToken] public async Task ImportStoryIntelligenceKnowledge(StoryIntelligenceKnowledgeImportForm form) { var (progress, result) = await storyIntelligence.ImportKnowledgeAsync(form.BatchID, form); if (progress is null) { return NotFound(); } TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; return result.Success ? RedirectToAction(nameof(StoryIntelligenceKnowledgeComplete), new { batchId = form.BatchID }) : RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId = form.BatchID }); } [HttpGet("story-intelligence/knowledge/complete")] public async Task StoryIntelligenceKnowledgeComplete(Guid batchId) { var model = await storyIntelligence.GetKnowledgeImportResultAsync(batchId); return model is null ? NotFound() : View(model); } [HttpGet("story-intelligence/complete")] public async Task StoryIntelligenceComplete(Guid batchId) { var progress = await storyIntelligence.GetProgressAsync(batchId); if (progress is null) { return NotFound(); } if (!progress.PipelineDashboard.CharacterStageComplete) { return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }); } if (!progress.PipelineDashboard.LocationStageComplete) { return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId }); } if (!progress.PipelineDashboard.AssetStageComplete) { return RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId }); } if (!progress.PipelineDashboard.RelationshipStageComplete) { return RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId }); } if (!progress.PipelineDashboard.KnowledgeStageComplete) { return RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId }); } var model = await storyIntelligence.GetCompletionAsync(batchId); return model is null ? NotFound() : View(model); } [HttpPost("scan-review")] [ValidateAntiForgeryToken] public async Task SaveScanReview(ManuscriptScanReviewForm form, string intent = "save") { var readyToImport = string.Equals(intent, "continue", StringComparison.OrdinalIgnoreCase); try { var model = await onboarding.SaveScanReviewAsync(form, readyToImport); if (model is null) { return NotFound(); } TempData["OnboardingReviewMessage"] = readyToImport ? "Review saved. Next: prepare chapters and analyse manuscript." : "Review choices saved."; return readyToImport ? RedirectToAction(nameof(StoryIntelligence)) : RedirectToAction(nameof(ScanReview), new { previewId = form.PreviewID }); } catch (InvalidOperationException ex) { ModelState.AddModelError(string.Empty, ex.Message); var model = await onboarding.GetScanReviewAsync(form.PreviewID); if (model is null) { return NotFound(); } return View("ScanReview", model); } } [HttpPost("welcome")] [ValidateAntiForgeryToken] public async Task Welcome() { await onboarding.MoveToNextStepAsync(); return RedirectToAction(nameof(Index)); } [HttpPost("writing-journey")] [ValidateAntiForgeryToken] public async Task WritingJourney(WritingJourneyOnboardingForm form) { if (!ModelState.IsValid || string.IsNullOrWhiteSpace(form.WritingJourney)) { var model = await onboarding.GetWizardAsync(); model.CurrentStep = OnboardingSteps.WritingJourney; model.StepNumber = 2; return View(nameof(Index), model); } await onboarding.SaveWritingJourneyAsync(form.WritingJourney); return RedirectToAction(nameof(Index)); } [HttpPost("writing-software")] [ValidateAntiForgeryToken] public async Task WritingSoftware(WritingSoftwareOnboardingForm form) { if (!ModelState.IsValid || string.IsNullOrWhiteSpace(form.WritingSoftware)) { var model = await onboarding.GetWizardAsync(); model.CurrentStep = OnboardingSteps.WritingSoftware; model.StepNumber = 3; return View(nameof(Index), model); } await onboarding.SaveWritingSoftwareAsync(form.WritingSoftware); return RedirectToAction(nameof(Index)); } [HttpPost("project")] [ValidateAntiForgeryToken] public async Task Project(ProjectOnboardingForm form) { if (string.Equals(form.Mode, OnboardingSelectionModes.Create, StringComparison.Ordinal) && string.IsNullOrWhiteSpace(form.ProjectName)) { ModelState.AddModelError(nameof(form.ProjectName), "Enter a project name."); } if (string.Equals(form.Mode, OnboardingSelectionModes.Select, StringComparison.Ordinal) && !form.SelectedProjectID.HasValue) { ModelState.AddModelError(nameof(form.SelectedProjectID), "Choose one of your active projects."); } if (!ModelState.IsValid) { var invalidModel = await onboarding.GetWizardAsync(); invalidModel.CurrentStep = OnboardingSteps.Project; invalidModel.StepNumber = 4; invalidModel.ProjectForm = form; return View(nameof(Index), invalidModel); } try { await onboarding.SaveProjectAsync(form); } catch (InvalidOperationException ex) { ModelState.AddModelError(string.Equals(form.Mode, OnboardingSelectionModes.Select, StringComparison.Ordinal) ? nameof(form.SelectedProjectID) : nameof(form.ProjectName), ex.Message); var invalidModel = await onboarding.GetWizardAsync(); invalidModel.CurrentStep = OnboardingSteps.Project; invalidModel.StepNumber = 4; invalidModel.ProjectForm = form; return View(nameof(Index), invalidModel); } return RedirectToAction(nameof(Index)); } [HttpPost("book")] [ValidateAntiForgeryToken] public async Task Book(BookOnboardingForm form) { if (string.Equals(form.Mode, OnboardingSelectionModes.Create, StringComparison.Ordinal) && string.IsNullOrWhiteSpace(form.BookTitle)) { ModelState.AddModelError(nameof(form.BookTitle), "Enter a book title."); } if (string.Equals(form.Mode, OnboardingSelectionModes.Select, StringComparison.Ordinal) && !form.SelectedBookID.HasValue) { ModelState.AddModelError(nameof(form.SelectedBookID), "Choose a book from the selected project."); } if (!ModelState.IsValid) { var invalidModel = await onboarding.GetWizardAsync(); invalidModel.CurrentStep = OnboardingSteps.Book; invalidModel.StepNumber = 5; invalidModel.BookForm = form; return View(nameof(Index), invalidModel); } try { await onboarding.SaveBookAsync(form); } catch (SubscriptionLimitException ex) { ModelState.AddModelError(nameof(form.BookTitle), ex.Message); var invalidModel = await onboarding.GetWizardAsync(); invalidModel.CurrentStep = OnboardingSteps.Book; invalidModel.StepNumber = 5; invalidModel.BookForm = form; return View(nameof(Index), invalidModel); } catch (InvalidOperationException ex) { ModelState.AddModelError(string.Equals(form.Mode, OnboardingSelectionModes.Select, StringComparison.Ordinal) ? nameof(form.SelectedBookID) : nameof(form.BookTitle), ex.Message); var invalidModel = await onboarding.GetWizardAsync(); invalidModel.CurrentStep = OnboardingSteps.Book; invalidModel.StepNumber = 5; invalidModel.BookForm = form; return View(nameof(Index), invalidModel); } return RedirectToAction(nameof(Index)); } [HttpPost("finish")] [ValidateAntiForgeryToken] public async Task Finish() { await onboarding.MarkCompletedAsync(); TempData["ArchiveMessage"] = "Setup saved. You can return to setup preferences from your account menu."; return RedirectToAction("Index", "Projects"); } [HttpPost("back")] [ValidateAntiForgeryToken] public async Task Back(string currentStep) { var targetStep = currentStep switch { OnboardingSteps.WritingJourney => OnboardingSteps.Welcome, OnboardingSteps.WritingSoftware => OnboardingSteps.WritingJourney, OnboardingSteps.Project => OnboardingSteps.WritingSoftware, OnboardingSteps.Book => OnboardingSteps.Project, OnboardingSteps.NextPathPreview => OnboardingSteps.Book, _ => OnboardingSteps.Welcome }; await onboarding.SetCurrentStepAsync(targetStep); return RedirectToAction(nameof(Index)); } }