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("scan-characters")] public async Task ScanCharacters(Guid? previewId, int page = 1) { var model = await onboarding.GetScanReviewAsync(previewId) ?? (previewId.HasValue ? await onboarding.GetScanReviewAsync() : null); if (model is null) { TempData["ArchiveError"] = "The character review could not be opened. Please scan the manuscript again."; await onboarding.SetCurrentStepAsync(OnboardingSteps.NextPathPreview); return RedirectToAction(nameof(Index)); } var build = await onboarding.GetBuildResultAsync(model.PreviewID); if (build is null || build.SceneMappings.Count == 0) { TempData["ArchiveError"] = "Import the approved chapters and scenes before reviewing characters."; await onboarding.SetCurrentStepAsync(OnboardingSteps.NextPathPreview); return RedirectToAction(nameof(Index)); } ViewData["ScanReviewStep"] = "characters"; ViewData["CharacterReviewPage"] = Math.Max(1, page); return View("ScanReview", 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(); if (model.ExistingBatchID.HasValue) { return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId = model.ExistingBatchID.Value }); } if (model.PreviewID == Guid.Empty) { return RedirectToAction(nameof(Index)); } return RedirectToAction(nameof(BuildComplete), new { previewId = model.PreviewID }); } [HttpGet("story-intelligence/context")] public async Task StoryIntelligenceContext() { return await StoryIntelligence(); } [HttpGet("story-intelligence/book/{bookId:int}/continue")] public async Task ContinueStoryIntelligence(int bookId) => await StoryIntelligenceBookReviewCentre(bookId); [HttpGet("story-intelligence/book/{bookId:int}/review-centre")] public async Task StoryIntelligenceBookReviewCentre(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.ReviewCentre => await StoryIntelligenceReviewCentre(target.BatchID), StoryIntelligenceResumeRoutes.Complete => await StoryIntelligenceReviewCentre(target.BatchID), StoryIntelligenceResumeRoutes.Narrative => await StoryIntelligenceReviewCentre(target.BatchID), StoryIntelligenceResumeRoutes.Knowledge => await StoryIntelligenceReviewCentre(target.BatchID), StoryIntelligenceResumeRoutes.Relationships => await StoryIntelligenceReviewCentre(target.BatchID), StoryIntelligenceResumeRoutes.Assets => await StoryIntelligenceReviewCentre(target.BatchID), StoryIntelligenceResumeRoutes.Locations => await StoryIntelligenceReviewCentre(target.BatchID), StoryIntelligenceResumeRoutes.Characters => await StoryIntelligenceReviewCentre(target.BatchID), _ => RedirectToAction("Details", "Books", new { id = bookId }) }; } [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(Index)); } return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId = job.BatchID }); } [HttpPost("story-intelligence/book/{bookId:int}/start")] [ValidateAntiForgeryToken] public async Task StartBookStoryIntelligence(int bookId) { var existing = await storyIntelligence.ResumeBookAsync(bookId); if (existing is not null) { TempData["ArchiveMessage"] = "Story Intelligence is already analysing this book. Progress is shown in the page header."; return RedirectToAction("Details", "Books", new { id = bookId }); } try { var job = await storyIntelligence.StartBookAsync(bookId); if (job is null) { TempData["ArchiveError"] = "Story Intelligence could not find that book."; return RedirectToAction("Details", "Books", new { id = bookId }); } TempData["ArchiveMessage"] = "Story Intelligence analysis has started. You can keep working while PlotDirector analyses the book in the background."; return RedirectToAction("Details", "Books", new { id = bookId }); } catch (InvalidOperationException ex) { TempData["ArchiveError"] = ex.Message; return RedirectToAction("Details", "Books", new { id = bookId }); } } [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); if (model is null) { return NotFound(); } return model.SceneAnalysisComplete ? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }) : RedirectToBook(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 RedirectToBook(model); } return model.SceneAnalysisComplete ? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }) : RedirectToBook(model); } [HttpGet("story-intelligence/review-centre")] public async Task StoryIntelligenceReviewCentre(Guid batchId) { var model = await storyIntelligence.GetProgressAsync(batchId); if (model is null) { return NotFound(); } if (model.HasActiveRuns) { return RedirectToBook(model); } if (!model.SceneAnalysisComplete) { return RedirectToBook(model); } return View(nameof(StoryIntelligenceReviewCentre), 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 RedirectToBook(model); } if (!model.SceneAnalysisComplete) { return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } return model.CharacterGateSatisfied && model.CharacterReview.Candidates.Count == 0 ? RedirectToAction(nameof(StoryIntelligenceReviewCentre), 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 RedirectToBook(model); } if (!model.SceneAnalysisComplete) { return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } return !model.CharacterGateSatisfied ? RedirectToReviewCentreWithCharacterGate(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 RedirectToBook(model); } if (!model.SceneAnalysisComplete) { return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } if (!model.CharacterGateSatisfied) { return RedirectToReviewCentreWithCharacterGate(batchId); } return 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 RedirectToBook(model); } if (!model.SceneAnalysisComplete) { return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } if (!model.CharacterGateSatisfied) { return RedirectToReviewCentreWithCharacterGate(batchId); } return 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 RedirectToBook(progress); } [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; return RedirectToAction(nameof(StoryIntelligenceReviewCentre), 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 RedirectToAction(nameof(StoryIntelligenceReviewCentre), 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(StoryIntelligenceReviewCentre), new { batchId = form.BatchID }) : RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId = form.BatchID }); } [HttpGet("story-intelligence/characters/complete")] public IActionResult StoryIntelligenceCharacterComplete(Guid batchId) { return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } [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(StoryIntelligenceReviewCentre), new { batchId = form.BatchID }) : RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID }); } [HttpGet("story-intelligence/locations/complete")] public IActionResult StoryIntelligenceLocationComplete(Guid batchId) { return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } [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(StoryIntelligenceReviewCentre), new { batchId = form.BatchID }) : RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = form.BatchID }); } [HttpGet("story-intelligence/assets/complete")] public IActionResult StoryIntelligenceAssetComplete(Guid batchId) { return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } [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(StoryIntelligenceReviewCentre), new { batchId = form.BatchID }) : RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = form.BatchID }); } [HttpGet("story-intelligence/relationships/complete")] public IActionResult StoryIntelligenceRelationshipComplete(Guid batchId) { return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } [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 RedirectToBook(model); } if (!model.SceneAnalysisComplete) { return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } if (!model.CharacterGateSatisfied) { return RedirectToReviewCentreWithCharacterGate(batchId); } return 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(StoryIntelligenceReviewCentre), new { batchId = form.BatchID }) : RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId = form.BatchID }); } [HttpGet("story-intelligence/knowledge/complete")] public IActionResult StoryIntelligenceKnowledgeComplete(Guid batchId) { return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } [HttpGet("story-intelligence/narrative")] public async Task StoryIntelligenceNarrative(Guid batchId) { var model = await storyIntelligence.GetProgressAsync(batchId); if (model is null) { return NotFound(); } if (model.HasActiveRuns) { return RedirectToBook(model); } if (!model.SceneAnalysisComplete) { return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } if (!model.CharacterGateSatisfied) { return RedirectToReviewCentreWithCharacterGate(batchId); } return View(model); } [HttpPost("story-intelligence/narrative")] [ValidateAntiForgeryToken] public async Task ImportStoryIntelligenceNarrative(StoryIntelligenceNarrativeImportForm form) { var (progress, result) = await storyIntelligence.ImportNarrativeAsync(form.BatchID, form); if (progress is null) { return NotFound(); } TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; return result.Success ? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = form.BatchID }) : RedirectToAction(nameof(StoryIntelligenceNarrative), new { batchId = form.BatchID }); } [HttpGet("story-intelligence/narrative/complete")] public IActionResult StoryIntelligenceNarrativeComplete(Guid batchId) { return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } [HttpGet("story-intelligence/complete")] public async Task StoryIntelligenceComplete(Guid batchId) { var progress = await storyIntelligence.GetProgressAsync(batchId); if (progress is null) { return NotFound(); } if (!progress.CharacterGateSatisfied) { return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } var model = await storyIntelligence.GetCompletionAsync(batchId); return model is null ? NotFound() : View(model); } private IActionResult RedirectToBook(StoryIntelligenceProgressViewModel model) => RedirectToAction("Details", "Books", new { id = model.BookID }); private IActionResult RedirectToReviewCentreWithCharacterGate(Guid batchId) { TempData["OnboardingStoryIntelligenceMessage"] = "Resolve Characters before reviewing this category."; return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } [HttpPost("scan-review")] [RequestSizeLimit(25 * 1024 * 1024)] [RequestFormLimits(ValueCountLimit = 20000, KeyLengthLimit = 4096, ValueLengthLimit = 1024 * 1024)] [ValidateAntiForgeryToken] public async Task SaveScanReview(ManuscriptScanReviewForm form, string intent = "save", int characterPage = 1) { var continueToCharacters = string.Equals(intent, "characters", StringComparison.OrdinalIgnoreCase); var continueCharacterReview = string.Equals(intent, "character-next", StringComparison.OrdinalIgnoreCase); var backCharacterReview = string.Equals(intent, "character-back", StringComparison.OrdinalIgnoreCase); var readyToImport = string.Equals(intent, "structure", StringComparison.OrdinalIgnoreCase); var completeCoreImport = string.Equals(intent, "continue", StringComparison.OrdinalIgnoreCase); var nextCharacterPage = Math.Max(1, characterPage + (backCharacterReview ? -1 : 1)); try { if (completeCoreImport) { var result = await onboarding.FinaliseCharacterReviewAsync(form); if (result is null) { return NotFound(); } TempData["OnboardingReviewMessage"] = "Characters saved. Core manuscript import is complete."; return RedirectToAction(nameof(BuildComplete), new { previewId = form.PreviewID }); } var model = await onboarding.SaveScanReviewAsync(form, readyToImport); if (model is null) { return NotFound(); } if (continueToCharacters) { var build = await onboarding.GetBuildResultAsync(form.PreviewID); if (build is null || build.SceneMappings.Count == 0) { TempData["ArchiveError"] = "Import the approved chapters and scenes before reviewing characters."; await onboarding.SetCurrentStepAsync(OnboardingSteps.NextPathPreview); return RedirectToAction(nameof(Index)); } } TempData["OnboardingReviewMessage"] = readyToImport ? "Chapter review saved. Next: import scenes and update Word." : continueToCharacters ? "Chapter and scene review saved. Next: review detected characters." : continueCharacterReview || backCharacterReview ? "Character review choices saved." : "Review choices saved."; return readyToImport ? RedirectToAction(nameof(Index)) : continueCharacterReview || backCharacterReview ? RedirectToAction(nameof(ScanCharacters), new { previewId = form.PreviewID, page = nextCharacterPage }) : continueToCharacters ? RedirectToAction(nameof(ScanCharacters), new { previewId = form.PreviewID }) : 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(); } if (continueToCharacters || continueCharacterReview || backCharacterReview || completeCoreImport) { ViewData["ScanReviewStep"] = "characters"; ViewData["CharacterReviewPage"] = readyToImport ? FirstInvalidCharacterPage(model) : Math.Max(1, characterPage); } return View("ScanReview", model); } } private static int FirstInvalidCharacterPage(ManuscriptScanReviewViewModel model) { const int pageSize = 10; var characters = model.CharacterCandidates .Where(character => !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase)) .OrderBy(character => character.ReviewName) .ThenBy(character => character.Name) .ToList(); var canonicalNames = characters .Where(IsIncludedCanonicalCharacter) .Select(character => character.ReviewName.Trim()) .Where(name => !string.IsNullOrWhiteSpace(name)) .GroupBy(name => name, StringComparer.OrdinalIgnoreCase) .Where(group => group.Count() > 1) .SelectMany(group => group) .ToHashSet(StringComparer.OrdinalIgnoreCase); for (var index = 0; index < characters.Count; index++) { var character = characters[index]; if (character.Include && string.IsNullOrWhiteSpace(character.ReviewName)) { return (index / pageSize) + 1; } if (IsIncludedCanonicalCharacter(character) && canonicalNames.Contains(character.ReviewName.Trim())) { return (index / pageSize) + 1; } if (character.Include && !string.IsNullOrWhiteSpace(character.AliasTargetTemporaryCharacterKey)) { var target = characters.FirstOrDefault(candidate => string.Equals(candidate.TemporaryCharacterKey, character.AliasTargetTemporaryCharacterKey, StringComparison.Ordinal)); if (target is null || !IsIncludedCanonicalCharacter(target)) { return (index / pageSize) + 1; } } } return 1; } private static bool IsIncludedCanonicalCharacter(ManuscriptScanReviewCharacterViewModel character) => character.Include && string.IsNullOrWhiteSpace(character.AliasTargetTemporaryCharacterKey) && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase); [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)); } }