using PlotLine.Data; using PlotLine.Models; using PlotLine.ViewModels; namespace PlotLine.Services; public interface IOnboardingService { Task GetOrCreateAsync(); Task GetWizardAsync(); Task GetScanReviewAsync(Guid? previewId = null); Task ShouldShowOnboardingAsync(); Task GetDashboardNudgeAsync(); Task ShouldShowWordCompanionHeaderAsync(); Task MoveToNextStepAsync(); Task SetCurrentStepAsync(string currentStep); Task SaveWritingJourneyAsync(string writingJourney); Task SaveWritingSoftwareAsync(string writingSoftware); Task SaveProjectAsync(ProjectOnboardingForm form); Task SaveBookAsync(BookOnboardingForm form); Task MarkCompletedAsync(); } public sealed class OnboardingService( IOnboardingRepository onboarding, IProjectRepository projects, IBookRepository books, IBookService bookService, IProjectActivityService activity, IWordCompanionPresenceService companionPresence, IManuscriptScanPreviewStore scanPreviews, ICurrentUserService currentUser) : IOnboardingService { private static readonly HashSet WritingJourneys = new(StringComparer.Ordinal) { WritingJourneyValues.PlanningNewStory, WritingJourneyValues.AlreadyStarted, WritingJourneyValues.ManuscriptMostlyComplete }; private static readonly HashSet WritingSoftware = new(StringComparer.Ordinal) { WritingSoftwareValues.MicrosoftWord, WritingSoftwareValues.GoogleDocs, WritingSoftwareValues.Scrivener, WritingSoftwareValues.LibreOfficeOpenOffice, WritingSoftwareValues.MarkdownObsidian, WritingSoftwareValues.Other }; public async Task GetOrCreateAsync() => await onboarding.GetOrCreateAsync(RequireUserId()); public async Task GetWizardAsync() { var state = await GetOrCreateAsync(); var step = ResolveStep(state); var projectOptions = await BuildProjectOptionsAsync(); var bookOptions = state.ProjectID.HasValue ? await BuildBookOptionsAsync(state.ProjectID.Value) : []; var selectedProject = state.ProjectID.HasValue ? await projects.GetForUserAsync(state.ProjectID.Value, RequireUserId()) : null; var selectedBook = state.BookID.HasValue ? await books.GetAsync(state.BookID.Value) : null; var presence = await companionPresence.GetStatusAsync(RequireUserId()); var scanState = await scanPreviews.GetStateAsync(RequireUserId(), state.UserOnboardingStateID); return new OnboardingWizardViewModel { CurrentStep = step, StepNumber = StepNumber(step), WritingJourney = state.WritingJourney, WritingSoftware = state.WritingSoftware, ProjectID = state.ProjectID, BookID = state.BookID, SelectedProjectName = selectedProject?.ProjectName, SelectedBookTitle = selectedBook is null ? null : BookOptionTitle(selectedBook), CompanionPresence = ToPresenceViewModel(presence), ScanState = ToScanStateViewModel(scanState), ProjectOptions = projectOptions, BookOptions = bookOptions, ProjectForm = new ProjectOnboardingForm { Mode = projectOptions.Count == 0 || !state.ProjectID.HasValue ? OnboardingSelectionModes.Create : OnboardingSelectionModes.Select, SelectedProjectID = state.ProjectID }, BookForm = new BookOnboardingForm { Mode = bookOptions.Count == 0 || !state.BookID.HasValue ? OnboardingSelectionModes.Create : OnboardingSelectionModes.Select, SelectedBookID = state.BookID }, Options = step switch { OnboardingSteps.WritingJourney => WritingJourneyOptions(), OnboardingSteps.WritingSoftware => WritingSoftwareOptions(), _ => [] } }; } public async Task GetScanReviewAsync(Guid? previewId = null) { var userId = RequireUserId(); var state = await onboarding.GetAsync(userId); if (state is null || !IsMicrosoftWordState(state)) { return null; } var preview = previewId.HasValue ? await scanPreviews.GetPreviewAsync(userId, previewId.Value) : await scanPreviews.GetLatestPreviewAsync(userId, state.UserOnboardingStateID); if (preview is null || preview.OnboardingID != state.UserOnboardingStateID || preview.ProjectID != state.ProjectID || preview.BookID != state.BookID) { return null; } var project = await projects.GetForUserAsync(preview.ProjectID, userId); var book = await books.GetAsync(preview.BookID); if (project is null || book is null || book.ProjectID != project.ProjectID) { return null; } return new ManuscriptScanReviewViewModel { PreviewID = preview.PreviewID, DocumentTitle = string.IsNullOrWhiteSpace(preview.DocumentTitle) ? "Word manuscript" : preview.DocumentTitle!, SelectedProjectName = project.ProjectName, SelectedBookTitle = BookOptionTitle(book), TotalWordCount = preview.TotalWordCount, ChapterCount = preview.ChapterCount, SceneCount = preview.SceneCount, CharacterCandidateCount = preview.CharacterCandidateCount, Chapters = preview.Chapters .OrderBy(chapter => chapter.ChapterNumber) .Select(chapter => new ManuscriptScanReviewChapterViewModel { TemporaryChapterKey = chapter.TemporaryChapterKey, ChapterNumber = chapter.ChapterNumber, Title = chapter.Title, WordCount = chapter.WordCount, Scenes = preview.Scenes .Where(scene => string.Equals(scene.TemporaryChapterKey, chapter.TemporaryChapterKey, StringComparison.Ordinal)) .OrderBy(scene => scene.SceneNumberWithinChapter) .ToList() }) .ToList(), CharacterCandidates = preview.CharacterCandidates .OrderByDescending(candidate => candidate.MentionCount) .ThenBy(candidate => candidate.Name) .ToList() }; } public async Task ShouldShowOnboardingAsync() { if (!currentUser.UserId.HasValue) { return false; } var state = await onboarding.GetAsync(currentUser.UserId.Value); return state is null || state.OnboardingCompletedUtc is null; } public async Task GetDashboardNudgeAsync() { if (!currentUser.UserId.HasValue) { return new OnboardingNudgeViewModel(); } var state = await onboarding.GetAsync(currentUser.UserId.Value); if (state is not null && IsMicrosoftWordState(state)) { var status = await companionPresence.GetStatusAsync(currentUser.UserId.Value); return new OnboardingNudgeViewModel { ShouldShow = true, IsCompanionPresence = true, CompanionConnected = status.IsConnected, Title = status.IsConnected ? "Word Companion Connected" : "Connect your Word Companion", Description = status.IsConnected ? "Your Word Companion is ready for this story setup." : "Open Word, start the Companion, and sign in with your PlotDirector account.", ButtonText = status.IsConnected ? "View setup" : "Connect Companion" }; } if (state?.OnboardingCompletedUtc is not null) { return new OnboardingNudgeViewModel { ShouldShow = false }; } var hasStarted = state is not null && (!string.IsNullOrWhiteSpace(state.WritingJourney) || !string.IsNullOrWhiteSpace(state.WritingSoftware) || state.ProjectID.HasValue || state.BookID.HasValue || state.CurrentStep != OnboardingSteps.Welcome); return hasStarted ? new OnboardingNudgeViewModel { ShouldShow = true, Title = "Continue your PlotDirector setup", Description = "Pick the project and book PlotDirector should use for your first guided setup.", ButtonText = "Continue setup" } : new OnboardingNudgeViewModel { ShouldShow = true }; } public async Task ShouldShowWordCompanionHeaderAsync() { if (!currentUser.UserId.HasValue) { return false; } var state = await onboarding.GetAsync(currentUser.UserId.Value); return state is not null && IsMicrosoftWordState(state); } public async Task MoveToNextStepAsync() { var state = await GetOrCreateAsync(); var nextStep = ResolveStep(state) switch { OnboardingSteps.Welcome => OnboardingSteps.WritingJourney, OnboardingSteps.WritingJourney => OnboardingSteps.WritingSoftware, OnboardingSteps.WritingSoftware => OnboardingSteps.Project, OnboardingSteps.Project => OnboardingSteps.Book, OnboardingSteps.Book => OnboardingSteps.NextPathPreview, _ => OnboardingSteps.NextPathPreview }; await onboarding.SetCurrentStepAsync(RequireUserId(), nextStep); } public async Task SetCurrentStepAsync(string currentStep) { if (currentStep is not (OnboardingSteps.Welcome or OnboardingSteps.WritingJourney or OnboardingSteps.WritingSoftware or OnboardingSteps.Project or OnboardingSteps.Book or OnboardingSteps.NextPathPreview or OnboardingSteps.Complete)) { throw new InvalidOperationException("Choose a valid onboarding step."); } await onboarding.SetCurrentStepAsync(RequireUserId(), currentStep); } public async Task SaveWritingJourneyAsync(string writingJourney) { if (!WritingJourneys.Contains(writingJourney)) { throw new InvalidOperationException("Choose a writing journey option."); } await onboarding.SaveWritingJourneyAsync(RequireUserId(), writingJourney); } public async Task SaveWritingSoftwareAsync(string writingSoftware) { if (!WritingSoftware.Contains(writingSoftware)) { throw new InvalidOperationException("Choose a writing software option."); } await onboarding.SaveWritingSoftwareAsync(RequireUserId(), writingSoftware); } public async Task SaveProjectAsync(ProjectOnboardingForm form) { var userId = RequireUserId(); if (string.Equals(form.Mode, OnboardingSelectionModes.Select, StringComparison.Ordinal)) { var projectId = form.SelectedProjectID.GetValueOrDefault(); var project = projectId > 0 ? await projects.GetForUserAsync(projectId, userId) : null; if (project is null || !project.IsOwner || project.IsArchived) { throw new InvalidOperationException("Choose one of your active projects."); } await onboarding.SaveProjectAsync(userId, project.ProjectID); return; } if (!string.Equals(form.Mode, OnboardingSelectionModes.Create, StringComparison.Ordinal)) { throw new InvalidOperationException("Choose whether to create or use an existing project."); } var projectName = Clean(form.ProjectName); if (string.IsNullOrWhiteSpace(projectName)) { throw new InvalidOperationException("Enter a project name."); } var projectIdCreated = await projects.SaveAsync(new Project { ProjectName = projectName, Description = CleanOptional(form.Description) }, userId, "Custom"); await activity.RecordAsync(projectIdCreated, "Created", "Project", projectIdCreated, projectName); await onboarding.SaveProjectAsync(userId, projectIdCreated); } public async Task SaveBookAsync(BookOnboardingForm form) { var userId = RequireUserId(); var state = await GetOrCreateAsync(); var projectId = state.ProjectID.GetValueOrDefault(); var project = projectId > 0 ? await projects.GetForUserAsync(projectId, userId) : null; if (project is null || !project.IsOwner || project.IsArchived) { throw new InvalidOperationException("Choose a project before choosing a book."); } if (string.Equals(form.Mode, OnboardingSelectionModes.Select, StringComparison.Ordinal)) { var bookId = form.SelectedBookID.GetValueOrDefault(); var book = bookId > 0 ? await books.GetAsync(bookId) : null; if (book is null || book.ProjectID != project.ProjectID || book.IsArchived) { throw new InvalidOperationException("Choose a book from the selected project."); } await onboarding.SaveBookAsync(userId, book.BookID); return; } if (!string.Equals(form.Mode, OnboardingSelectionModes.Create, StringComparison.Ordinal)) { throw new InvalidOperationException("Choose whether to create or use an existing book."); } var bookTitle = Clean(form.BookTitle); if (string.IsNullOrWhiteSpace(bookTitle)) { throw new InvalidOperationException("Enter a book title."); } var createModel = await bookService.GetCreateAsync(project.ProjectID) ?? throw new InvalidOperationException("The selected project could not be used."); createModel.BookTitle = bookTitle; createModel.Subtitle = CleanOptional(form.Subtitle); createModel.TargetWordCount = form.TargetWordCount; var result = await bookService.SaveAsync(createModel); if (!result.Succeeded) { throw new InvalidOperationException(result.ErrorMessage ?? "The book could not be created."); } await onboarding.SaveBookAsync(userId, result.BookID); } public async Task MarkCompletedAsync() => await onboarding.MarkCompletedAsync(RequireUserId()); private int RequireUserId() => currentUser.UserId ?? throw new InvalidOperationException("Sign in to use onboarding."); private static string ResolveStep(UserOnboardingState state) { if (state.OnboardingCompletedUtc.HasValue) { return OnboardingSteps.NextPathPreview; } if (state.CurrentStep is OnboardingSteps.Welcome or OnboardingSteps.WritingJourney or OnboardingSteps.WritingSoftware or OnboardingSteps.Project or OnboardingSteps.Book or OnboardingSteps.NextPathPreview) { return state.CurrentStep; } if (string.IsNullOrWhiteSpace(state.WritingJourney)) { return OnboardingSteps.WritingJourney; } if (string.IsNullOrWhiteSpace(state.WritingSoftware)) { return OnboardingSteps.WritingSoftware; } if (!state.ProjectID.HasValue) { return OnboardingSteps.Project; } if (!state.BookID.HasValue) { return OnboardingSteps.Book; } return OnboardingSteps.NextPathPreview; } private static int StepNumber(string step) => step switch { OnboardingSteps.Welcome => 1, OnboardingSteps.WritingJourney => 2, OnboardingSteps.WritingSoftware => 3, OnboardingSteps.Project => 4, OnboardingSteps.Book => 5, _ => 6 }; private async Task> BuildProjectOptionsAsync() { var userId = RequireUserId(); var rows = await projects.ListActiveForUserAsync(userId); return rows .Where(project => project.IsOwner && !project.IsArchived) .OrderBy(project => project.ProjectName) .Select(project => new OnboardingProjectOptionViewModel { ProjectID = project.ProjectID, ProjectName = project.ProjectName, Description = string.IsNullOrWhiteSpace(project.Description) ? $"Updated {project.UpdatedDate:yyyy-MM-dd}" : project.Description }) .ToList(); } private async Task> BuildBookOptionsAsync(int projectId) { var project = await projects.GetForUserAsync(projectId, RequireUserId()); if (project is null || !project.IsOwner || project.IsArchived) { return []; } var rows = await books.ListByProjectAsync(projectId); return rows .Where(book => !book.IsArchived) .OrderBy(book => book.SortOrder) .ThenBy(book => book.BookNumber) .ThenBy(book => book.BookTitle) .Select(book => new OnboardingBookOptionViewModel { BookID = book.BookID, DisplayTitle = BookOptionTitle(book), Detail = BookOptionDetail(book) }) .ToList(); } private static string BookOptionTitle(Book book) => $"Book {book.BookNumber}: {book.BookDisplayTitle}"; private static string BookOptionDetail(Book book) { var parts = new List { book.Status }; if (book.TargetWordCount is > 0) { parts.Add($"{book.TargetWordCount.Value:N0} target words"); } parts.Add($"Updated {book.UpdatedDate:yyyy-MM-dd}"); return string.Join(" / ", parts); } private static string Clean(string? value) => (value ?? string.Empty).Trim(); private static bool IsMicrosoftWordState(UserOnboardingState state) => string.Equals(state.WritingSoftware, WritingSoftwareValues.MicrosoftWord, StringComparison.Ordinal) || state.UsesWordCompanion == true; private static CompanionPresenceViewModel ToPresenceViewModel(WordCompanionPresenceStatus status) => new() { IsConnected = status.IsConnected, DocumentOpen = status.DocumentOpen, Status = status.Status, CompanionVersion = status.CompanionVersion, CurrentDocumentName = status.CurrentDocumentName, LastHeartbeatUtc = status.LastHeartbeatUtc }; private static ManuscriptScanStateViewModel ToScanStateViewModel(ManuscriptScanState state) => new() { Status = state.Status, Message = state.Message, PercentComplete = state.PercentComplete, ChapterCount = state.ChapterCount, SceneCount = state.SceneCount, CharacterCandidateCount = state.CharacterCandidateCount, TotalWordCount = state.TotalWordCount, PreviewID = state.PreviewID }; private static string? CleanOptional(string? value) { var cleaned = Clean(value); return string.IsNullOrWhiteSpace(cleaned) ? null : cleaned; } private static IReadOnlyList WritingJourneyOptions() => [ new() { Value = WritingJourneyValues.PlanningNewStory, Title = "I am planning a brand new story", Description = "You are shaping the idea, structure, characters, or world before drafting." }, new() { Value = WritingJourneyValues.AlreadyStarted, Title = "I have already started writing", Description = "You have draft material and want PlotDirector to help you organise what exists." }, new() { Value = WritingJourneyValues.ManuscriptMostlyComplete, Title = "My manuscript is largely complete", Description = "You are ready to review structure, continuity, and story detail across the manuscript." } ]; private static IReadOnlyList WritingSoftwareOptions() => [ new() { Value = WritingSoftwareValues.MicrosoftWord, Title = "Microsoft Word", Description = "Best fit for the PlotDirector Word Companion workflow.", Badge = "Recommended" }, new() { Value = WritingSoftwareValues.GoogleDocs, Title = "Google Docs", Description = "Use exports later when manuscript import is added." }, new() { Value = WritingSoftwareValues.Scrivener, Title = "Scrivener", Description = "Use a manuscript export later when import support arrives." }, new() { Value = WritingSoftwareValues.LibreOfficeOpenOffice, Title = "LibreOffice / OpenOffice", Description = "Good for document-based workflows outside Microsoft Word." }, new() { Value = WritingSoftwareValues.MarkdownObsidian, Title = "Markdown / Obsidian", Description = "For plain-text writing systems and linked-note workflows." }, new() { Value = WritingSoftwareValues.Other, Title = "Other", Description = "PlotDirector can still tailor future setup around your tools." } ]; }