diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index de663bc..17747b1 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -270,6 +270,7 @@ var tests = new (string Name, Action Test)[] ("Story Intelligence Review Centre uses independent tile grid", StoryIntelligenceReviewCentreUsesIndependentTileGrid), ("Story Intelligence Review Centre opens with character gate", StoryIntelligenceReviewCentreOpensWithCharacterGate), ("Story Intelligence review modules are independent after character gate", StoryIntelligenceReviewModulesAreIndependentAfterCharacterGate), + ("Story Intelligence review module completion is durable and independent", StoryIntelligenceReviewModuleCompletionIsDurableAndIndependent), ("Story Intelligence review module pages omit wizard chrome", StoryIntelligenceReviewModulePagesOmitWizardChrome), ("Story Intelligence downstream review uses completed scene analysis", StoryIntelligenceDownstreamReviewUsesCompletedSceneAnalysis), ("Story Intelligence review posts do not use success interstitials", StoryIntelligenceReviewPostsDoNotUseSuccessInterstitials), @@ -802,6 +803,77 @@ static void StoryIntelligenceReviewModulesAreIndependentAfterCharacterGate() Assert(locationsAction.Contains("RedirectToReviewCentreWithCharacterGate", StringComparison.Ordinal), "Direct dependent module URLs should return to Review Centre when the character gate is closed."); } +static void StoryIntelligenceReviewModuleCompletionIsDurableAndIndependent() +{ + var root = PlotLineRoot(); + var model = File.ReadAllText(Path.Combine(root, "Models/StoryIntelligencePersistenceModels.cs")); + var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligencePipelineRepository.cs")); + var pipeline = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligencePipelineStateService.cs")); + var onboardingStory = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs")); + var storyService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceService.cs")); + var coreServices = File.ReadAllText(Path.Combine(root, "Services/CoreServices.cs")); + var characterService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs")); + var locationService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceLocationImportService.cs")); + var assetService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceAssetImportService.cs")); + var relationshipService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceRelationshipImportService.cs")); + var knowledgeService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceKnowledgeImportService.cs")); + var narrativeService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceNarrativeReviewService.cs")); + var migration = File.ReadAllText(Path.Combine(root, "Sql/175_Phase27B_StoryIntelligenceIndependentReviewState.sql")); + var synthesisSql = File.ReadAllText(Path.Combine(root, "Sql/174_Phase27_WholeBookPlotIntelligence.sql")); + + foreach (var column in new[] + { + "CharacterReviewCompletedUtc", + "LocationReviewCompletedUtc", + "AssetReviewCompletedUtc", + "RelationshipReviewCompletedUtc", + "KnowledgeReviewCompletedUtc", + "NarrativeReviewCompletedUtc" + }) + { + Assert(model.Contains($"public DateTime? {column}", StringComparison.Ordinal), $"{column} should be exposed on persisted pipeline state."); + Assert(migration.Contains($"ADD {column} datetime2 NULL", StringComparison.Ordinal), $"{column} should be added by migration."); + Assert(migration.Contains($"p.{column}", StringComparison.Ordinal), $"{column} should be returned by pipeline read procedures."); + } + + Assert(repository.Contains("request.MarkCharacterReviewComplete", StringComparison.Ordinal), "Pipeline upsert should pass durable character completion."); + Assert(repository.Contains("request.MarkNarrativeReviewComplete", StringComparison.Ordinal), "Pipeline upsert should pass durable narrative completion."); + Assert(pipeline.Contains("MarkCharacterReviewComplete = true", StringComparison.Ordinal), "Character import should mark only character review complete."); + Assert(pipeline.Contains("MarkNarrativeReviewComplete = true", StringComparison.Ordinal), "Narrative import should mark only narrative review complete."); + Assert(pipeline.Contains("MarkLocationReviewComplete = true", StringComparison.Ordinal), "Location import should mark durable location completion."); + Assert(pipeline.Contains("MarkAssetReviewComplete = true", StringComparison.Ordinal), "Asset import should mark durable asset completion."); + Assert(pipeline.Contains("MarkRelationshipReviewComplete = true", StringComparison.Ordinal), "Relationship import should mark durable relationship completion."); + Assert(pipeline.Contains("MarkKnowledgeReviewComplete = true", StringComparison.Ordinal), "Knowledge import should mark durable knowledge completion."); + Assert(migration.Contains("CharacterReviewCompletedUtc = CASE WHEN @MarkCharacterReviewComplete = 1 THEN COALESCE(CharacterReviewCompletedUtc", StringComparison.Ordinal), "Pipeline upsert must preserve character completion when other modules update."); + Assert(migration.Contains("NarrativeReviewCompletedUtc = CASE WHEN @MarkNarrativeReviewComplete = 1 THEN COALESCE(NarrativeReviewCompletedUtc", StringComparison.Ordinal), "Pipeline upsert must preserve narrative completion when other modules update."); + Assert(migration.Contains("WHERE NarrativeReviewCompletedUtc IS NULL", StringComparison.Ordinal), "Migration repair should be non-destructive."); + + Assert(onboardingStory.Contains("pipeline?.CharacterReviewCompletedUtc is not null", StringComparison.Ordinal), "Review Centre should hydrate character completion from durable state."); + Assert(onboardingStory.Contains("pipeline?.LocationReviewCompletedUtc is not null", StringComparison.Ordinal), "Review Centre should hydrate location completion independently."); + Assert(onboardingStory.Contains("pipeline?.AssetReviewCompletedUtc is not null", StringComparison.Ordinal), "Review Centre should hydrate asset completion independently."); + Assert(onboardingStory.Contains("pipeline?.RelationshipReviewCompletedUtc is not null", StringComparison.Ordinal), "Review Centre should hydrate relationship completion independently."); + Assert(onboardingStory.Contains("pipeline?.KnowledgeReviewCompletedUtc is not null", StringComparison.Ordinal), "Review Centre should hydrate knowledge completion independently."); + Assert(onboardingStory.Contains("pipeline?.NarrativeReviewCompletedUtc is not null", StringComparison.Ordinal), "Review Centre should hydrate narrative completion independently."); + Assert(onboardingStory.Contains("batch.CharacterStageComplete = state.CharacterReviewCompletedUtc is not null", StringComparison.Ordinal), "Resume should hydrate character completion from durable state only."); + Assert(!onboardingStory.Contains("state.CurrentStage is StoryIntelligencePipelineStages.LocationReview", StringComparison.Ordinal), "Resume should not infer character completion from post-character stage ordinals."); + + Assert(characterService.Contains("pipeline?.CharacterReviewCompletedUtc is not null", StringComparison.Ordinal) && characterService.Contains("Candidates = []", StringComparison.Ordinal), "Completed character review should not resurrect stale scene candidates."); + Assert(locationService.Contains("pipeline?.LocationReviewCompletedUtc is not null", StringComparison.Ordinal), "Completed location review should not resurrect stale scene candidates."); + Assert(assetService.Contains("pipeline?.AssetReviewCompletedUtc is not null", StringComparison.Ordinal), "Completed asset review should not resurrect stale scene candidates."); + Assert(relationshipService.Contains("pipeline?.RelationshipReviewCompletedUtc is not null", StringComparison.Ordinal), "Completed relationship review should not resurrect stale scene candidates."); + Assert(knowledgeService.Contains("pipeline?.KnowledgeReviewCompletedUtc is not null", StringComparison.Ordinal), "Completed knowledge review should not resurrect stale scene candidates."); + Assert(narrativeService.Contains("pipeline?.NarrativeReviewCompletedUtc is not null", StringComparison.Ordinal), "Completed plot review should not resurrect stale plot candidates."); + + Assert(!storyService.Contains("StoryIntelligenceReadyForLocations", StringComparison.Ordinal), "Dashboard should not use old post-character location readiness ordinals."); + Assert(!storyService.Contains("StoryIntelligenceReadyForAssets", StringComparison.Ordinal), "Dashboard should not use old post-character asset readiness ordinals."); + Assert(!storyService.Contains("StoryIntelligenceReadyForRelationships", StringComparison.Ordinal), "Dashboard should not use old post-character relationship readiness ordinals."); + Assert(!storyService.Contains("StoryIntelligenceReadyForKnowledge", StringComparison.Ordinal), "Dashboard should not use old post-character knowledge readiness ordinals."); + Assert(coreServices.Contains("pipeline.CharacterReviewCompletedUtc is null", StringComparison.Ordinal), "Book action should not show character review required once durable character completion exists."); + + Assert(synthesisSql.Contains("AND Status = N'Pending'", StringComparison.Ordinal), "Whole-book candidate regeneration should preserve reviewed plot decisions."); + Assert(synthesisSql.Contains("Status IN (N'Queued', N'Running', N'Completed')", StringComparison.Ordinal), "Synthesis queue should preserve current completed runs rather than replacing them."); +} + static void StoryIntelligenceReviewModulePagesOmitWizardChrome() { var root = PlotLineRoot(); @@ -3566,16 +3638,16 @@ static void StoryIntelligenceReviewCentreCompletionIsCategoryScoped() var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs")); var reviewView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceReviewCentre.cshtml")); - Assert(onboardingStory.Contains("LocationStageComplete = locationReview.IsComplete", StringComparison.Ordinal), "Location tile completion should come from the Location review service."); - Assert(onboardingStory.Contains("AssetStageComplete = assetReview.IsComplete", StringComparison.Ordinal), "Asset tile completion should come from the Asset review service."); - Assert(onboardingStory.Contains("RelationshipStageComplete = relationshipReview.IsComplete", StringComparison.Ordinal), "Relationship tile completion should come from the Relationship review service."); - Assert(onboardingStory.Contains("KnowledgeStageComplete = knowledgeReview.IsComplete", StringComparison.Ordinal), "Knowledge tile completion should come from the Knowledge review service."); - Assert(onboardingStory.Contains("NarrativeStageComplete = narrative.IsComplete", StringComparison.Ordinal), "Narrative tile completion should come from the Narrative review service."); - Assert(onboardingStory.Contains("batch.LocationStageComplete = false;", StringComparison.Ordinal), "Resume should not hydrate Location completion from the single pipeline stage marker."); - Assert(onboardingStory.Contains("batch.AssetStageComplete = false;", StringComparison.Ordinal), "Resume should not hydrate Asset completion from the single pipeline stage marker."); - Assert(onboardingStory.Contains("batch.RelationshipStageComplete = false;", StringComparison.Ordinal), "Resume should not hydrate Relationship completion from the single pipeline stage marker."); - Assert(onboardingStory.Contains("batch.KnowledgeStageComplete = false;", StringComparison.Ordinal), "Resume should not hydrate Knowledge completion from the single pipeline stage marker."); - Assert(onboardingStory.Contains("batch.NarrativeStageComplete = false;", StringComparison.Ordinal), "Resume should not hydrate Narrative completion from the single pipeline stage marker."); + Assert(onboardingStory.Contains("LocationStageComplete = locationComplete", StringComparison.Ordinal), "Location tile completion should come from scoped durable/review state."); + Assert(onboardingStory.Contains("AssetStageComplete = assetComplete", StringComparison.Ordinal), "Asset tile completion should come from scoped durable/review state."); + Assert(onboardingStory.Contains("RelationshipStageComplete = relationshipComplete", StringComparison.Ordinal), "Relationship tile completion should come from scoped durable/review state."); + Assert(onboardingStory.Contains("KnowledgeStageComplete = knowledgeComplete", StringComparison.Ordinal), "Knowledge tile completion should come from scoped durable/review state."); + Assert(onboardingStory.Contains("NarrativeStageComplete = narrativeComplete", StringComparison.Ordinal), "Narrative tile completion should come from scoped durable/review state."); + Assert(onboardingStory.Contains("batch.LocationStageComplete = state.LocationReviewCompletedUtc is not null", StringComparison.Ordinal), "Resume should hydrate Location completion from durable state."); + Assert(onboardingStory.Contains("batch.AssetStageComplete = state.AssetReviewCompletedUtc is not null", StringComparison.Ordinal), "Resume should hydrate Asset completion from durable state."); + Assert(onboardingStory.Contains("batch.RelationshipStageComplete = state.RelationshipReviewCompletedUtc is not null", StringComparison.Ordinal), "Resume should hydrate Relationship completion from durable state."); + Assert(onboardingStory.Contains("batch.KnowledgeStageComplete = state.KnowledgeReviewCompletedUtc is not null", StringComparison.Ordinal), "Resume should hydrate Knowledge completion from durable state."); + Assert(onboardingStory.Contains("batch.NarrativeStageComplete = state.NarrativeReviewCompletedUtc is not null", StringComparison.Ordinal), "Resume should hydrate Narrative completion from durable state."); Assert(reviewView.Contains("Model.PipelineDashboard.RelationshipStageComplete", StringComparison.Ordinal), "Review Centre should render the scoped dashboard completion flags."); var locationGet = ExtractMethod(controller, "StoryIntelligenceLocations"); diff --git a/PlotLine/Data/StoryIntelligencePipelineRepository.cs b/PlotLine/Data/StoryIntelligencePipelineRepository.cs index f7237c2..bb65ffa 100644 --- a/PlotLine/Data/StoryIntelligencePipelineRepository.cs +++ b/PlotLine/Data/StoryIntelligencePipelineRepository.cs @@ -70,7 +70,13 @@ public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory co request.CurrentReviewStage, request.Status, request.CompletedUtc, - request.LastRunID + request.LastRunID, + request.MarkCharacterReviewComplete, + request.MarkLocationReviewComplete, + request.MarkAssetReviewComplete, + request.MarkRelationshipReviewComplete, + request.MarkKnowledgeReviewComplete, + request.MarkNarrativeReviewComplete }, commandType: CommandType.StoredProcedure); } diff --git a/PlotLine/Models/StoryIntelligencePersistenceModels.cs b/PlotLine/Models/StoryIntelligencePersistenceModels.cs index ac5a6d3..b9249af 100644 --- a/PlotLine/Models/StoryIntelligencePersistenceModels.cs +++ b/PlotLine/Models/StoryIntelligencePersistenceModels.cs @@ -62,6 +62,12 @@ public sealed class StoryIntelligenceBookPipelineState public string? CurrentReviewStage { get; init; } public string Status { get; init; } = StoryIntelligencePipelineStatuses.NeedsReview; public DateTime? CompletedUtc { get; init; } + public DateTime? CharacterReviewCompletedUtc { get; init; } + public DateTime? LocationReviewCompletedUtc { get; init; } + public DateTime? AssetReviewCompletedUtc { get; init; } + public DateTime? RelationshipReviewCompletedUtc { get; init; } + public DateTime? KnowledgeReviewCompletedUtc { get; init; } + public DateTime? NarrativeReviewCompletedUtc { get; init; } public int? LastRunID { get; init; } public DateTime CreatedUtc { get; init; } public DateTime UpdatedUtc { get; init; } @@ -130,6 +136,12 @@ public sealed class StoryIntelligenceBookPipelineSaveRequest public string Status { get; init; } = StoryIntelligencePipelineStatuses.NeedsReview; public DateTime? CompletedUtc { get; init; } public int? LastRunID { get; init; } + public bool MarkCharacterReviewComplete { get; init; } + public bool MarkLocationReviewComplete { get; init; } + public bool MarkAssetReviewComplete { get; init; } + public bool MarkRelationshipReviewComplete { get; init; } + public bool MarkKnowledgeReviewComplete { get; init; } + public bool MarkNarrativeReviewComplete { get; init; } } public sealed class StoryIntelligenceCommittedRunSummary diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index aa4f342..82cf85a 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -1382,8 +1382,9 @@ public sealed class BookService( } var isInProgress = string.Equals(pipeline.Status, StoryIntelligencePipelineStatuses.InProgress, StringComparison.OrdinalIgnoreCase); - var needsCharacterReview = string.Equals(pipeline.CurrentReviewStage, StoryIntelligencePipelineStages.CharacterReview, StringComparison.OrdinalIgnoreCase) - || string.Equals(pipeline.CurrentStage, StoryIntelligencePipelineStages.CharacterReview, StringComparison.OrdinalIgnoreCase); + var needsCharacterReview = pipeline.CharacterReviewCompletedUtc is null + && (string.Equals(pipeline.CurrentReviewStage, StoryIntelligencePipelineStages.CharacterReview, StringComparison.OrdinalIgnoreCase) + || string.Equals(pipeline.CurrentStage, StoryIntelligencePipelineStages.CharacterReview, StringComparison.OrdinalIgnoreCase)); return new StoryIntelligenceBookActionViewModel { HasCanonicalSceneSources = hasCanonicalSceneSources, diff --git a/PlotLine/Services/OnboardingStoryIntelligenceService.cs b/PlotLine/Services/OnboardingStoryIntelligenceService.cs index be8e4f6..fc57600 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -647,7 +647,13 @@ public sealed class OnboardingStoryIntelligenceService( var narrative = await narrativeReview.GetReviewAsync(batch); var plotSynthesisRun = await wholeBookPlotIntelligence.GetCurrentAsync(batch.BookID); var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID); - var narrativePendingCount = narrative.Candidates.Count; + var characterComplete = batch.CharacterStageComplete || pipeline?.CharacterReviewCompletedUtc is not null || characterReview.IsComplete; + var locationComplete = batch.LocationStageComplete || pipeline?.LocationReviewCompletedUtc is not null || locationReview.IsComplete; + var assetComplete = batch.AssetStageComplete || pipeline?.AssetReviewCompletedUtc is not null || assetReview.IsComplete; + var relationshipComplete = batch.RelationshipStageComplete || pipeline?.RelationshipReviewCompletedUtc is not null || relationshipReview.IsComplete; + var knowledgeComplete = batch.KnowledgeStageComplete || pipeline?.KnowledgeReviewCompletedUtc is not null || knowledgeReview.IsComplete; + var narrativeComplete = batch.NarrativeStageComplete || pipeline?.NarrativeReviewCompletedUtc is not null || narrative.IsComplete; + var narrativePendingCount = narrativeComplete ? 0 : narrative.Candidates.Count; var narrativeThreadCount = narrative.Candidates.Count(candidate => string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.Thread, StringComparison.OrdinalIgnoreCase)); var narrativeEventCount = narrative.Candidates.Sum(candidate => candidate.Events.Count); @@ -691,24 +697,24 @@ public sealed class OnboardingStoryIntelligenceService( SceneAnalysisComplete = pipeline is not null && SceneAnalysisComplete(pipeline), CharactersIdentified = characterReview.Candidates.Count + batch.CharacterDecisions.Count, CharactersCreatedOrLinked = batch.CharacterDecisions.Count(decision => decision.CreatedOrLinked), - CharacterStageComplete = characterReview.IsComplete, + CharacterStageComplete = characterComplete, LocationsIdentified = locationReview.Candidates.Count + batch.LocationDecisions.Count, LocationsCreatedOrLinked = batch.LocationDecisions.Count(decision => decision.CreatedOrLinked), - LocationStageComplete = locationReview.IsComplete, + LocationStageComplete = locationComplete, AssetsIdentified = assetReview.Candidates.Count + batch.AssetDecisions.Count, AssetsCreatedOrLinked = batch.AssetDecisions.Count(decision => decision.CreatedOrLinked), - AssetStageComplete = assetReview.IsComplete, + AssetStageComplete = assetComplete, RelationshipsIdentified = relationshipReview.Candidates.Count + batch.RelationshipDecisions.Count, RelationshipsCreatedOrLinked = batch.RelationshipDecisions.Count(decision => decision.CreatedOrLinked), - RelationshipStageComplete = relationshipReview.IsComplete, + RelationshipStageComplete = relationshipComplete, KnowledgeIdentified = knowledgeReview.Candidates.Count + batch.KnowledgeDecisions.Count, KnowledgeCreatedOrLinked = batch.KnowledgeDecisions.Count(decision => decision.CreatedOrLinked), - KnowledgeStageComplete = knowledgeReview.IsComplete, + KnowledgeStageComplete = knowledgeComplete, NarrativeCandidatesIdentified = narrative.Candidates.Count + (batch.LastNarrativeImportResult?.TotalReviewed ?? 0), NarrativeCandidatesApplied = (batch.LastNarrativeImportResult?.PlotLinesCreated ?? 0) + (batch.LastNarrativeImportResult?.PlotLinesMerged ?? 0) + (batch.LastNarrativeImportResult?.ThreadsCreated ?? 0), - NarrativeStageComplete = narrative.IsComplete + NarrativeStageComplete = narrativeComplete } }; } @@ -1045,24 +1051,12 @@ public sealed class OnboardingStoryIntelligenceService( Items = batchItems }; - var characterStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.CharacterImport) - || state.CurrentStage is StoryIntelligencePipelineStages.LocationReview - or StoryIntelligencePipelineStages.LocationImport - or StoryIntelligencePipelineStages.AssetReview - or StoryIntelligencePipelineStages.AssetImport - or StoryIntelligencePipelineStages.RelationshipReview - or StoryIntelligencePipelineStages.RelationshipImport - or StoryIntelligencePipelineStages.KnowledgeReview - or StoryIntelligencePipelineStages.KnowledgeImport - or StoryIntelligencePipelineStages.NarrativeReview - or StoryIntelligencePipelineStages.NarrativeImport - or StoryIntelligencePipelineStages.Complete; - batch.CharacterStageComplete = characterStageComplete; - batch.LocationStageComplete = false; - batch.AssetStageComplete = false; - batch.RelationshipStageComplete = false; - batch.KnowledgeStageComplete = false; - batch.NarrativeStageComplete = false; + batch.CharacterStageComplete = state.CharacterReviewCompletedUtc is not null; + batch.LocationStageComplete = state.LocationReviewCompletedUtc is not null; + batch.AssetStageComplete = state.AssetReviewCompletedUtc is not null; + batch.RelationshipStageComplete = state.RelationshipReviewCompletedUtc is not null; + batch.KnowledgeStageComplete = state.KnowledgeReviewCompletedUtc is not null; + batch.NarrativeStageComplete = state.NarrativeReviewCompletedUtc is not null; await batchStore.SaveAsync(batch); diff --git a/PlotLine/Services/StoryIntelligenceAssetImportService.cs b/PlotLine/Services/StoryIntelligenceAssetImportService.cs index 2f05cf4..dc17219 100644 --- a/PlotLine/Services/StoryIntelligenceAssetImportService.cs +++ b/PlotLine/Services/StoryIntelligenceAssetImportService.cs @@ -56,6 +56,19 @@ public sealed class StoryIntelligenceAssetImportService( public async Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) { var data = await BuildCandidateDataAsync(batch); + var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID); + if (batch.AssetStageComplete || pipeline?.AssetReviewCompletedUtc is not null) + { + return new StoryIntelligenceAssetReviewViewModel + { + HasCommittedScenes = data.HasCommittedScenes, + CanImport = false, + AlreadyLinkedCount = data.AlreadyLinkedCount, + IsComplete = data.HasCommittedScenes, + Candidates = [] + }; + } + var decidedKeys = batch.AssetDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList(); return new StoryIntelligenceAssetReviewViewModel diff --git a/PlotLine/Services/StoryIntelligenceCharacterImportService.cs b/PlotLine/Services/StoryIntelligenceCharacterImportService.cs index 61dae74..65be69d 100644 --- a/PlotLine/Services/StoryIntelligenceCharacterImportService.cs +++ b/PlotLine/Services/StoryIntelligenceCharacterImportService.cs @@ -72,6 +72,19 @@ public sealed class StoryIntelligenceCharacterImportService( public async Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) { var data = await BuildCandidateDataAsync(batch); + var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID); + if (batch.CharacterStageComplete || pipeline?.CharacterReviewCompletedUtc is not null) + { + return new StoryIntelligenceCharacterReviewViewModel + { + HasCommittedScenes = data.HasCommittedScenes, + CanImport = false, + AlreadyLinkedCount = data.AlreadyLinkedCount, + IsComplete = data.HasCommittedScenes, + Candidates = [] + }; + } + var decidedKeys = batch.CharacterDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList(); var existingIndex = await BuildCharacterIndexAsync(batch.ProjectID); diff --git a/PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs b/PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs index 3131ff8..2ef4415 100644 --- a/PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs +++ b/PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs @@ -34,13 +34,18 @@ public sealed class StoryIntelligenceKnowledgeImportService( var decidedKeys = batch.KnowledgeDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); var selectedStateIds = batch.KnowledgeSelections.ToDictionary(selection => selection.Key, selection => selection.KnowledgeStateID, StringComparer.OrdinalIgnoreCase); var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList(); + var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID); + if (batch.KnowledgeStageComplete || pipeline?.KnowledgeReviewCompletedUtc is not null) + { + visibleCandidates = []; + } return new StoryIntelligenceKnowledgeReviewViewModel { HasCommittedScenes = data.HasCommittedScenes, CanImport = visibleCandidates.Count > 0, AlreadyLinkedCount = data.AlreadyLinkedCount, - IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0, + IsComplete = data.HasCommittedScenes && (visibleCandidates.Count == 0 || batch.KnowledgeStageComplete || pipeline?.KnowledgeReviewCompletedUtc is not null), KnowledgeStateOptions = lookupData.KnowledgeStates .OrderBy(state => state.SortOrder) .ThenBy(state => state.StateName) diff --git a/PlotLine/Services/StoryIntelligenceLocationImportService.cs b/PlotLine/Services/StoryIntelligenceLocationImportService.cs index be40729..cea57e1 100644 --- a/PlotLine/Services/StoryIntelligenceLocationImportService.cs +++ b/PlotLine/Services/StoryIntelligenceLocationImportService.cs @@ -70,6 +70,19 @@ public sealed class StoryIntelligenceLocationImportService( public async Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) { var data = await BuildCandidateDataAsync(batch); + var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID); + if (batch.LocationStageComplete || pipeline?.LocationReviewCompletedUtc is not null) + { + return new StoryIntelligenceLocationReviewViewModel + { + HasCommittedScenes = data.HasCommittedScenes, + CanImport = false, + AlreadyLinkedCount = data.AlreadyLinkedCount, + IsComplete = data.HasCommittedScenes, + Candidates = [] + }; + } + var decidedKeys = batch.LocationDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList(); return new StoryIntelligenceLocationReviewViewModel diff --git a/PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs b/PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs index b349094..51befe7 100644 --- a/PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs +++ b/PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs @@ -36,8 +36,11 @@ public sealed class StoryIntelligenceNarrativeReviewService( var rows = (await candidates.ListByBookAsync(batch.BookID)) .Where(IsWholeBookCandidate) .ToList(); + var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID); + var reviewComplete = batch.NarrativeStageComplete || pipeline?.NarrativeReviewCompletedUtc is not null; var pending = rows - .Where(candidate => string.Equals(candidate.Status, StoryIntelligenceNarrativeCandidateStatuses.Pending, StringComparison.OrdinalIgnoreCase)) + .Where(candidate => !reviewComplete + && string.Equals(candidate.Status, StoryIntelligenceNarrativeCandidateStatuses.Pending, StringComparison.OrdinalIgnoreCase)) .ToList(); var plotLines = await plots.ListPlotLinesAsync(batch.ProjectID); @@ -45,7 +48,7 @@ public sealed class StoryIntelligenceNarrativeReviewService( { HasCommittedScenes = await HasCommittedScenesAsync(batch), CanImport = pending.Count > 0, - IsComplete = rows.Count > 0 && pending.Count == 0, + IsComplete = reviewComplete || (rows.Count > 0 && pending.Count == 0), PlotLineOptions = plotLines .OrderBy(plotLine => plotLine.SortOrder) .ThenBy(plotLine => plotLine.PlotLineName) diff --git a/PlotLine/Services/StoryIntelligencePipelineStateService.cs b/PlotLine/Services/StoryIntelligencePipelineStateService.cs index 81893c8..36824e2 100644 --- a/PlotLine/Services/StoryIntelligencePipelineStateService.cs +++ b/PlotLine/Services/StoryIntelligencePipelineStateService.cs @@ -108,7 +108,8 @@ public sealed class StoryIntelligencePipelineStateService( CurrentReviewStage = StoryIntelligencePipelineStages.LocationReview, Status = StoryIntelligencePipelineStatuses.NeedsReview, CompletedUtc = null, - LastRunID = null + LastRunID = null, + MarkCharacterReviewComplete = true }); } @@ -123,7 +124,8 @@ public sealed class StoryIntelligencePipelineStateService( CurrentReviewStage = StoryIntelligencePipelineStages.AssetReview, Status = StoryIntelligencePipelineStatuses.NeedsReview, CompletedUtc = null, - LastRunID = null + LastRunID = null, + MarkLocationReviewComplete = true }); } @@ -138,7 +140,8 @@ public sealed class StoryIntelligencePipelineStateService( CurrentReviewStage = StoryIntelligencePipelineStages.RelationshipReview, Status = StoryIntelligencePipelineStatuses.NeedsReview, CompletedUtc = null, - LastRunID = null + LastRunID = null, + MarkAssetReviewComplete = true }); } @@ -153,7 +156,8 @@ public sealed class StoryIntelligencePipelineStateService( CurrentReviewStage = StoryIntelligencePipelineStages.KnowledgeReview, Status = StoryIntelligencePipelineStatuses.NeedsReview, CompletedUtc = null, - LastRunID = null + LastRunID = null, + MarkRelationshipReviewComplete = true }); } @@ -168,7 +172,8 @@ public sealed class StoryIntelligencePipelineStateService( CurrentReviewStage = StoryIntelligencePipelineStages.NarrativeReview, Status = StoryIntelligencePipelineStatuses.NeedsReview, CompletedUtc = null, - LastRunID = null + LastRunID = null, + MarkKnowledgeReviewComplete = true }); } @@ -183,7 +188,8 @@ public sealed class StoryIntelligencePipelineStateService( CurrentReviewStage = null, Status = StoryIntelligencePipelineStatuses.Complete, CompletedUtc = DateTime.UtcNow, - LastRunID = null + LastRunID = null, + MarkNarrativeReviewComplete = true }); } diff --git a/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs b/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs index 4976236..e19b8e8 100644 --- a/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs +++ b/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs @@ -34,12 +34,18 @@ public sealed class StoryIntelligenceRelationshipImportService( var selectedTypeIds = batch.RelationshipSelections.ToDictionary(selection => selection.Key, selection => selection.RelationshipTypeID, StringComparer.OrdinalIgnoreCase); var decidedKeys = batch.RelationshipDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList(); + var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID); + if (batch.RelationshipStageComplete || pipeline?.RelationshipReviewCompletedUtc is not null) + { + visibleCandidates = []; + } + return new StoryIntelligenceRelationshipReviewViewModel { HasCommittedScenes = data.HasCommittedScenes, CanImport = visibleCandidates.Count > 0, AlreadyLinkedCount = data.AlreadyLinkedCount, - IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0, + IsComplete = data.HasCommittedScenes && (visibleCandidates.Count == 0 || batch.RelationshipStageComplete || pipeline?.RelationshipReviewCompletedUtc is not null), RelationshipTypeOptions = typeOptions, Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceRelationshipReviewCandidateViewModel { diff --git a/PlotLine/Services/StoryIntelligenceService.cs b/PlotLine/Services/StoryIntelligenceService.cs index 2d2d707..9966f3d 100644 --- a/PlotLine/Services/StoryIntelligenceService.cs +++ b/PlotLine/Services/StoryIntelligenceService.cs @@ -281,10 +281,7 @@ public sealed class StoryIntelligenceService( StoryIntelligencePipelineStages.LocationReview => "Review Centre", StoryIntelligencePipelineStages.CharacterReview => "Character Resolution Needed", StoryIntelligencePipelineStages.SceneReview => "Needs Scene Review", - _ when StoryIntelligenceReadyForKnowledge(state) => "Review Centre", - _ when StoryIntelligenceReadyForRelationships(state) => "Review Centre", - _ when StoryIntelligenceReadyForAssets(state) => "Review Centre", - _ when StoryIntelligenceReadyForLocations(state) => "Review Centre", + _ when StoryIntelligenceCharacterReviewComplete(state) => "Review Centre", _ => string.Equals(state.Status, StoryIntelligencePipelineStatuses.InProgress, StringComparison.OrdinalIgnoreCase) ? "Story Intelligence In Progress" : "Story Intelligence Needs Review" @@ -304,36 +301,12 @@ public sealed class StoryIntelligenceService( StoryIntelligencePipelineStages.LocationReview => "Open the Review Centre and choose which saved category to handle.", StoryIntelligencePipelineStages.CharacterReview => "Resolve character identities before reviewing dependent story intelligence.", StoryIntelligencePipelineStages.SceneReview => "Story Intelligence analysis has not finished for this book.", - _ when StoryIntelligenceReadyForKnowledge(state) => "Open the Review Centre and choose which saved category to handle.", - _ when StoryIntelligenceReadyForRelationships(state) => "Open the Review Centre and choose which saved category to handle.", - _ when StoryIntelligenceReadyForAssets(state) => "Open the Review Centre and choose which saved category to handle.", - _ when StoryIntelligenceReadyForLocations(state) => "Open the Review Centre and choose which saved category to handle.", + _ when StoryIntelligenceCharacterReviewComplete(state) => "Open the Review Centre and choose which saved category to handle.", _ => "Open the Review Centre and choose which saved category to handle." }; - private static bool StoryIntelligenceReadyForKnowledge(StoryIntelligenceBookPipelineState state) - => string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.RelationshipImport, StringComparison.OrdinalIgnoreCase) - || state.CurrentStage is StoryIntelligencePipelineStages.KnowledgeReview or StoryIntelligencePipelineStages.KnowledgeImport; - - private static bool StoryIntelligenceReadyForRelationships(StoryIntelligenceBookPipelineState state) - => string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.AssetImport, StringComparison.OrdinalIgnoreCase) - || state.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport - or StoryIntelligencePipelineStages.KnowledgeReview or StoryIntelligencePipelineStages.KnowledgeImport; - - private static bool StoryIntelligenceReadyForAssets(StoryIntelligenceBookPipelineState state) - => string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.LocationImport, StringComparison.OrdinalIgnoreCase) - || state.CurrentStage is StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport - or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport - or StoryIntelligencePipelineStages.KnowledgeReview or StoryIntelligencePipelineStages.KnowledgeImport - || (string.Equals(state.CurrentStage, StoryIntelligencePipelineStages.Complete, StringComparison.OrdinalIgnoreCase) - && !string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.KnowledgeImport, StringComparison.OrdinalIgnoreCase)); - - private static bool StoryIntelligenceReadyForLocations(StoryIntelligenceBookPipelineState state) - => string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.CharacterImport, StringComparison.OrdinalIgnoreCase) - || state.CurrentStage is StoryIntelligencePipelineStages.LocationReview or StoryIntelligencePipelineStages.LocationImport - or StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport - or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport - or StoryIntelligencePipelineStages.KnowledgeReview or StoryIntelligencePipelineStages.KnowledgeImport; + private static bool StoryIntelligenceCharacterReviewComplete(StoryIntelligenceBookPipelineState state) + => state.CharacterReviewCompletedUtc is not null; private static string FormatElapsed(TimeSpan elapsed) => elapsed.TotalMinutes < 1 diff --git a/PlotLine/Sql/175_Phase27B_StoryIntelligenceIndependentReviewState.sql b/PlotLine/Sql/175_Phase27B_StoryIntelligenceIndependentReviewState.sql new file mode 100644 index 0000000..ce4b3d8 --- /dev/null +++ b/PlotLine/Sql/175_Phase27B_StoryIntelligenceIndependentReviewState.sql @@ -0,0 +1,255 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF COL_LENGTH(N'dbo.StoryIntelligenceBookPipelines', N'CharacterReviewCompletedUtc') IS NULL +BEGIN + ALTER TABLE dbo.StoryIntelligenceBookPipelines ADD CharacterReviewCompletedUtc datetime2 NULL; +END; +GO + +IF COL_LENGTH(N'dbo.StoryIntelligenceBookPipelines', N'LocationReviewCompletedUtc') IS NULL +BEGIN + ALTER TABLE dbo.StoryIntelligenceBookPipelines ADD LocationReviewCompletedUtc datetime2 NULL; +END; +GO + +IF COL_LENGTH(N'dbo.StoryIntelligenceBookPipelines', N'AssetReviewCompletedUtc') IS NULL +BEGIN + ALTER TABLE dbo.StoryIntelligenceBookPipelines ADD AssetReviewCompletedUtc datetime2 NULL; +END; +GO + +IF COL_LENGTH(N'dbo.StoryIntelligenceBookPipelines', N'RelationshipReviewCompletedUtc') IS NULL +BEGIN + ALTER TABLE dbo.StoryIntelligenceBookPipelines ADD RelationshipReviewCompletedUtc datetime2 NULL; +END; +GO + +IF COL_LENGTH(N'dbo.StoryIntelligenceBookPipelines', N'KnowledgeReviewCompletedUtc') IS NULL +BEGIN + ALTER TABLE dbo.StoryIntelligenceBookPipelines ADD KnowledgeReviewCompletedUtc datetime2 NULL; +END; +GO + +IF COL_LENGTH(N'dbo.StoryIntelligenceBookPipelines', N'NarrativeReviewCompletedUtc') IS NULL +BEGIN + ALTER TABLE dbo.StoryIntelligenceBookPipelines ADD NarrativeReviewCompletedUtc datetime2 NULL; +END; +GO + +UPDATE dbo.StoryIntelligenceBookPipelines +SET CharacterReviewCompletedUtc = COALESCE(CharacterReviewCompletedUtc, UpdatedUtc, SYSUTCDATETIME()) +WHERE CharacterReviewCompletedUtc IS NULL + AND + ( + LastCompletedStage IN + ( + N'CharacterImport', + N'LocationImport', + N'AssetImport', + N'RelationshipImport', + N'KnowledgeImport', + N'NarrativeImport' + ) + OR CurrentStage IN + ( + N'LocationReview', + N'LocationImport', + N'AssetReview', + N'AssetImport', + N'RelationshipReview', + N'RelationshipImport', + N'KnowledgeReview', + N'KnowledgeImport', + N'NarrativeReview', + N'NarrativeImport', + N'Complete' + ) + ); +GO + +UPDATE dbo.StoryIntelligenceBookPipelines +SET NarrativeReviewCompletedUtc = COALESCE(NarrativeReviewCompletedUtc, CompletedUtc, UpdatedUtc, SYSUTCDATETIME()) +WHERE NarrativeReviewCompletedUtc IS NULL + AND LastCompletedStage = N'NarrativeImport' + AND Status = N'Complete'; +GO + +UPDATE dbo.StoryIntelligenceBookPipelines +SET LocationReviewCompletedUtc = COALESCE(LocationReviewCompletedUtc, UpdatedUtc, SYSUTCDATETIME()) +WHERE LocationReviewCompletedUtc IS NULL + AND LastCompletedStage = N'LocationImport'; +GO + +UPDATE dbo.StoryIntelligenceBookPipelines +SET AssetReviewCompletedUtc = COALESCE(AssetReviewCompletedUtc, UpdatedUtc, SYSUTCDATETIME()) +WHERE AssetReviewCompletedUtc IS NULL + AND LastCompletedStage = N'AssetImport'; +GO + +UPDATE dbo.StoryIntelligenceBookPipelines +SET RelationshipReviewCompletedUtc = COALESCE(RelationshipReviewCompletedUtc, UpdatedUtc, SYSUTCDATETIME()) +WHERE RelationshipReviewCompletedUtc IS NULL + AND LastCompletedStage = N'RelationshipImport'; +GO + +UPDATE dbo.StoryIntelligenceBookPipelines +SET KnowledgeReviewCompletedUtc = COALESCE(KnowledgeReviewCompletedUtc, UpdatedUtc, SYSUTCDATETIME()) +WHERE KnowledgeReviewCompletedUtc IS NULL + AND LastCompletedStage = N'KnowledgeImport'; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceBookPipeline_GetByBook + @BookID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT p.StoryIntelligenceBookPipelineID, p.ProjectID, project.ProjectName, + p.BookID, book.BookTitle, book.Subtitle AS BookSubtitle, + p.CurrentStage, p.LastCompletedStage, p.CurrentReviewStage, p.Status, + p.CompletedUtc, p.CharacterReviewCompletedUtc, p.LocationReviewCompletedUtc, + p.AssetReviewCompletedUtc, p.RelationshipReviewCompletedUtc, p.KnowledgeReviewCompletedUtc, + p.NarrativeReviewCompletedUtc, p.LastRunID, p.CreatedUtc, p.UpdatedUtc + FROM dbo.StoryIntelligenceBookPipelines p + INNER JOIN dbo.Projects project ON project.ProjectID = p.ProjectID + INNER JOIN dbo.Books book ON book.BookID = p.BookID + WHERE p.BookID = @BookID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceBookPipeline_Upsert + @ProjectID int, + @BookID int, + @CurrentStage nvarchar(80), + @LastCompletedStage nvarchar(80) = NULL, + @CurrentReviewStage nvarchar(80) = NULL, + @Status nvarchar(40), + @CompletedUtc datetime2 = NULL, + @LastRunID int = NULL, + @MarkCharacterReviewComplete bit = 0, + @MarkLocationReviewComplete bit = 0, + @MarkAssetReviewComplete bit = 0, + @MarkRelationshipReviewComplete bit = 0, + @MarkKnowledgeReviewComplete bit = 0, + @MarkNarrativeReviewComplete bit = 0 +AS +BEGIN + SET NOCOUNT ON; + + IF EXISTS (SELECT 1 FROM dbo.StoryIntelligenceBookPipelines WHERE BookID = @BookID) + BEGIN + UPDATE dbo.StoryIntelligenceBookPipelines + SET ProjectID = @ProjectID, + CurrentStage = @CurrentStage, + LastCompletedStage = @LastCompletedStage, + CurrentReviewStage = @CurrentReviewStage, + Status = @Status, + CompletedUtc = @CompletedUtc, + LastRunID = COALESCE(@LastRunID, LastRunID), + CharacterReviewCompletedUtc = CASE WHEN @MarkCharacterReviewComplete = 1 THEN COALESCE(CharacterReviewCompletedUtc, SYSUTCDATETIME()) ELSE CharacterReviewCompletedUtc END, + LocationReviewCompletedUtc = CASE WHEN @MarkLocationReviewComplete = 1 THEN COALESCE(LocationReviewCompletedUtc, SYSUTCDATETIME()) ELSE LocationReviewCompletedUtc END, + AssetReviewCompletedUtc = CASE WHEN @MarkAssetReviewComplete = 1 THEN COALESCE(AssetReviewCompletedUtc, SYSUTCDATETIME()) ELSE AssetReviewCompletedUtc END, + RelationshipReviewCompletedUtc = CASE WHEN @MarkRelationshipReviewComplete = 1 THEN COALESCE(RelationshipReviewCompletedUtc, SYSUTCDATETIME()) ELSE RelationshipReviewCompletedUtc END, + KnowledgeReviewCompletedUtc = CASE WHEN @MarkKnowledgeReviewComplete = 1 THEN COALESCE(KnowledgeReviewCompletedUtc, SYSUTCDATETIME()) ELSE KnowledgeReviewCompletedUtc END, + NarrativeReviewCompletedUtc = CASE WHEN @MarkNarrativeReviewComplete = 1 THEN COALESCE(NarrativeReviewCompletedUtc, SYSUTCDATETIME()) ELSE NarrativeReviewCompletedUtc END, + UpdatedUtc = SYSUTCDATETIME() + WHERE BookID = @BookID; + END + ELSE + BEGIN + INSERT dbo.StoryIntelligenceBookPipelines + (ProjectID, BookID, CurrentStage, LastCompletedStage, CurrentReviewStage, Status, CompletedUtc, LastRunID, + CharacterReviewCompletedUtc, LocationReviewCompletedUtc, AssetReviewCompletedUtc, + RelationshipReviewCompletedUtc, KnowledgeReviewCompletedUtc, NarrativeReviewCompletedUtc) + VALUES + (@ProjectID, @BookID, @CurrentStage, @LastCompletedStage, @CurrentReviewStage, @Status, @CompletedUtc, @LastRunID, + CASE WHEN @MarkCharacterReviewComplete = 1 THEN SYSUTCDATETIME() ELSE NULL END, + CASE WHEN @MarkLocationReviewComplete = 1 THEN SYSUTCDATETIME() ELSE NULL END, + CASE WHEN @MarkAssetReviewComplete = 1 THEN SYSUTCDATETIME() ELSE NULL END, + CASE WHEN @MarkRelationshipReviewComplete = 1 THEN SYSUTCDATETIME() ELSE NULL END, + CASE WHEN @MarkKnowledgeReviewComplete = 1 THEN SYSUTCDATETIME() ELSE NULL END, + CASE WHEN @MarkNarrativeReviewComplete = 1 THEN SYSUTCDATETIME() ELSE NULL END); + END; + + EXEC dbo.StoryIntelligenceBookPipeline_GetByBook @BookID = @BookID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceBookPipeline_GetByBookForUser + @BookID int, + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT p.StoryIntelligenceBookPipelineID, p.ProjectID, project.ProjectName, + p.BookID, book.BookTitle, book.Subtitle AS BookSubtitle, + p.CurrentStage, p.LastCompletedStage, p.CurrentReviewStage, p.Status, + p.CompletedUtc, p.CharacterReviewCompletedUtc, p.LocationReviewCompletedUtc, + p.AssetReviewCompletedUtc, p.RelationshipReviewCompletedUtc, p.KnowledgeReviewCompletedUtc, + p.NarrativeReviewCompletedUtc, p.LastRunID, p.CreatedUtc, p.UpdatedUtc + FROM dbo.StoryIntelligenceBookPipelines p + INNER JOIN dbo.Projects project ON project.ProjectID = p.ProjectID + INNER JOIN dbo.Books book ON book.BookID = p.BookID + INNER JOIN dbo.ProjectUserAccess access ON access.ProjectID = p.ProjectID + AND access.UserID = @UserID + AND access.IsActive = 1 + WHERE p.BookID = @BookID + AND book.IsArchived = 0 + AND project.IsArchived = 0; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceBookPipeline_GetByIDForUser + @StoryIntelligenceBookPipelineID int, + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT p.StoryIntelligenceBookPipelineID, p.ProjectID, project.ProjectName, + p.BookID, book.BookTitle, book.Subtitle AS BookSubtitle, + p.CurrentStage, p.LastCompletedStage, p.CurrentReviewStage, p.Status, + p.CompletedUtc, p.CharacterReviewCompletedUtc, p.LocationReviewCompletedUtc, + p.AssetReviewCompletedUtc, p.RelationshipReviewCompletedUtc, p.KnowledgeReviewCompletedUtc, + p.NarrativeReviewCompletedUtc, p.LastRunID, p.CreatedUtc, p.UpdatedUtc + FROM dbo.StoryIntelligenceBookPipelines p + INNER JOIN dbo.Projects project ON project.ProjectID = p.ProjectID + INNER JOIN dbo.Books book ON book.BookID = p.BookID + INNER JOIN dbo.ProjectUserAccess access ON access.ProjectID = p.ProjectID + AND access.UserID = @UserID + AND access.IsActive = 1 + WHERE p.StoryIntelligenceBookPipelineID = @StoryIntelligenceBookPipelineID + AND book.IsArchived = 0 + AND project.IsArchived = 0; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceBookPipeline_ListForUser + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT p.StoryIntelligenceBookPipelineID, p.ProjectID, project.ProjectName, + p.BookID, book.BookTitle, book.Subtitle AS BookSubtitle, + p.CurrentStage, p.LastCompletedStage, p.CurrentReviewStage, p.Status, + p.CompletedUtc, p.CharacterReviewCompletedUtc, p.LocationReviewCompletedUtc, + p.AssetReviewCompletedUtc, p.RelationshipReviewCompletedUtc, p.KnowledgeReviewCompletedUtc, + p.NarrativeReviewCompletedUtc, p.LastRunID, p.CreatedUtc, p.UpdatedUtc + FROM dbo.StoryIntelligenceBookPipelines p + INNER JOIN dbo.Projects project ON project.ProjectID = p.ProjectID + INNER JOIN dbo.Books book ON book.BookID = p.BookID + INNER JOIN dbo.ProjectUserAccess access ON access.ProjectID = p.ProjectID + AND access.UserID = @UserID + AND access.IsActive = 1 + WHERE book.IsArchived = 0 + AND project.IsArchived = 0 + ORDER BY + CASE p.Status WHEN N'NeedsReview' THEN 0 WHEN N'InProgress' THEN 1 ELSE 2 END, + p.UpdatedUtc DESC; +END; +GO