using System.Data; using Dapper; using PlotLine.Models; namespace PlotLine.Data; public interface IProjectRepository { Task> ListAsync(); Task> ListForUserAsync(int userId); Task> ListActiveForUserAsync(int userId); Task> ListArchivedForUserAsync(int userId); Task> ListOwnedForAccountDeletionAsync(int userId); Task GetAsync(int projectId); Task GetForUserAsync(int projectId, int userId); Task GetArchivedForOwnerAsync(int projectId, int userId); Task GetDashboardMetricsAsync(int projectId); Task> ListGenreMetricPresetsAsync(); Task SaveAsync(Project project); Task SaveAsync(Project project, int userId, string? genreMetricPresetKey); Task SaveAsync(Project project, string? genreMetricPresetKey); Task ArchiveAsync(int projectId); Task ArchiveForUserAsync(int projectId, int userId); Task ArchiveForOwnerAsync(int projectId, int userId); Task RestoreForOwnerAsync(int projectId, int userId); Task> ListHardDeleteFilePathsForOwnerAsync(int projectId, int userId); Task HardDeleteForOwnerAsync(int projectId, int userId); Task> ListHardDeleteFilePathsForAccountDeletionAsync(int projectId, int userId); Task HardDeleteForAccountDeletionAsync(int projectId, int userId); } public interface IProjectCollaborationRepository { Task GetOwnerUserIdAsync(int projectId); Task> ListCollaboratorsAsync(int projectId); Task> ListPendingInvitationsAsync(int projectId); Task CollaboratorCountsForOwnerAsync(int ownerUserId, int collaboratorUserId); Task AddCollaboratorAsync(int projectId, int userId, int invitedByUserId); Task RemoveCollaboratorAsync(int projectId, int userId); Task CreatePendingInvitationAsync(int projectId, string emailAddress, int invitedByUserId); } public interface IProjectActivityRepository { Task> ListForProjectAsync(int projectId); Task RecordAsync(int projectId, int? userId, string activityType, string entityType, int? entityId, string? entityName, string? description = null); } public interface IBookRepository { Task> ListByProjectAsync(int projectId); Task GetAsync(int bookId); Task GetCoverPathsAsync(int bookId); Task SaveAsync(Book book); Task UpdateCoverAsync(int bookId, string? thumbnailPath, string? standardPath); Task ArchiveAsync(int bookId); } public interface IChapterRepository { Task> ListByBookAsync(int bookId); Task GetAsync(int chapterId); Task SaveAsync(Chapter chapter); Task ArchiveAsync(int chapterId); } public interface ISceneRepository { Task> ListByChapterAsync(int chapterId); Task GetAsync(int sceneId); Task SaveAsync(Scene scene); Task SavePurposesAsync(int sceneId, IEnumerable purposeIds); Task SaveMetricValuesAsync(int sceneId, IEnumerable metricValues); Task SaveMetricValueAsync(int sceneId, int metricTypeId, int value); Task ArchiveAsync(int sceneId); Task MoveAsync(int sceneId, string direction); Task> ListChapterOptionsAsync(int projectId); Task GetMovePreviewAsync(int sceneId, int targetChapterId, string position); Task MoveToChapterAsync(int sceneId, int targetChapterId, string position, bool renumber); Task MoveRelativeAsync(int sceneId, int anchorSceneId, string position); Task RenumberChapterAsync(int chapterId); } public interface ILookupRepository { Task GetAllAsync(); } public interface ITimelineRepository { Task GetByProjectAsync(int projectId, int? bookId, bool includeMetrics, IEnumerable metricTypeIds, bool includePlotLines); } public interface ITimelinePresetRepository { Task> ListAsync(int projectId, int? bookId); Task GetAsync(int timelineViewPresetId); Task SaveAsync(TimelineViewPreset preset); Task ArchiveAsync(int timelineViewPresetId); } public interface ITimelineSettingsRepository { Task GetAsync(int projectId); Task> ListMetricTypesAsync(int projectId); Task> ListMetricSettingsAsync(int projectId); Task SaveAsync(ProjectTimelineSettings settings, IEnumerable metricTypeIds); } public interface ISceneMetricTypeRepository { Task> ListForManagementAsync(int projectId); Task GetAsync(int metricTypeId, int projectId); Task SaveAsync(SceneMetricType metric); Task SetActiveAsync(int metricTypeId, int projectId, bool isActive); Task MoveAsync(int metricTypeId, int projectId, string direction); Task AddMissingDefaultsAsync(int projectId); Task RemoveUnusedAsync(int metricTypeId, int projectId); Task HasProjectMetricsAsync(int projectId); } public interface IPlotRepository { Task GetLookupsAsync(); Task> ListPlotLinesAsync(int projectId); Task GetPlotLineAsync(int plotLineId); Task SavePlotLineAsync(PlotLineItem plotLine); Task ArchivePlotLineAsync(int plotLineId); Task> ListPlotThreadsByProjectAsync(int projectId); Task> ListPlotThreadsByPlotLineAsync(int plotLineId); Task GetPlotThreadAsync(int plotThreadId); Task SavePlotThreadAsync(PlotThread plotThread); Task ArchivePlotThreadAsync(int plotThreadId); Task> ListThreadEventsBySceneAsync(int sceneId); Task> ListThreadEventsByPlotThreadAsync(int plotThreadId); Task SaveThreadEventAsync(ThreadEvent threadEvent); Task SaveThreadEventTargetsAsync(int threadEventId, IEnumerable plotLineIds); Task DeleteThreadEventAsync(int threadEventId); Task> ListSceneOptionsAsync(int projectId); } public interface IAssetRepository { Task GetLookupsAsync(); Task> ListAssetsAsync(int projectId); Task GetAssetAsync(int storyAssetId); Task SaveAssetAsync(StoryAsset asset); Task ArchiveAssetAsync(int storyAssetId); Task> ListEventsBySceneAsync(int sceneId); Task> ListEventsByAssetAsync(int storyAssetId); Task SaveEventAsync(AssetEvent assetEvent); Task DeleteEventAsync(int assetEventId); Task> ListDependenciesByAssetAsync(int storyAssetId); Task SaveDependencyAsync(AssetDependency dependency); Task DeleteDependencyAsync(int assetDependencyId); Task> ListCustodyBySceneAsync(int sceneId); Task> ListCustodyByAssetAsync(int storyAssetId); Task> ListCustodyCharactersByProjectAsync(int projectId); Task SaveCustodyEventAsync(AssetCustodyEvent custodyEvent, string custodianNames, IEnumerable custodianCharacterIds, int custodyRoleId); Task DeleteCustodyEventAsync(int assetCustodyEventId); Task<(IReadOnlyList Assets, IReadOnlyList Events)> GetTimelineAsync(int projectId, int? bookId); Task> CheckDependencyIssuesAsync(int storyAssetId, int sceneId, int? toStateId); } public interface ICharacterRepository { Task GetLookupsAsync(); Task> ListCharactersAsync(int projectId); Task GetCharacterAsync(int characterId); Task SaveCharacterAsync(Character character); Task ArchiveCharacterAsync(int characterId); Task> ListSceneCharactersAsync(int sceneId); Task SaveSceneCharacterAsync(SceneCharacter sceneCharacter); Task DeleteSceneCharacterAsync(int sceneCharacterId); Task> ListAttributeEventsByCharacterAsync(int characterId); Task SaveAttributeEventAsync(CharacterAttributeEvent attributeEvent); Task> ListKnowledgeBySceneAsync(int sceneId); Task> ListKnowledgeByCharacterAsync(int characterId); Task SaveKnowledgeAsync(CharacterKnowledgeItem knowledge); Task DeleteKnowledgeAsync(int characterKnowledgeId); Task> ListRelationshipsByProjectAsync(int projectId); Task> ListRelationshipsByCharacterAsync(int characterId); Task> ListInitialRelationshipsByCharacterAsync(int characterId); Task GetRelationshipAsync(int characterRelationshipId); Task SaveRelationshipAsync(CharacterRelationship relationship); Task ArchiveRelationshipAsync(int characterRelationshipId); Task> ListRelationshipEventsBySceneAsync(int sceneId); Task> ListRelationshipEventsByCharacterAsync(int characterId); Task SaveRelationshipEventAsync(RelationshipEvent relationshipEvent); Task DeleteRelationshipEventAsync(int relationshipEventId); Task<(IReadOnlyList Characters, IReadOnlyList Appearances)> GetTimelineAsync(int projectId, int? bookId); Task GetRelationshipMapAsync(int projectId); } public interface ILocationRepository { Task GetLookupsAsync(); Task> ListByProjectAsync(int projectId); Task GetAsync(int locationId); Task SaveAsync(LocationItem location); Task ArchiveAsync(int locationId); Task> ListRelationshipsByProjectAsync(int projectId); Task> ListRelationshipsByLocationAsync(int locationId); Task SaveRelationshipAsync(LocationRelationship relationship); Task ArchiveRelationshipAsync(int locationRelationshipId); Task> ListSceneAssetLocationsAsync(int sceneId); Task SaveSceneAssetLocationAsync(SceneAssetLocation sceneAssetLocation, bool updateCurrentLocation); Task DeleteSceneAssetLocationAsync(int sceneAssetLocationId); Task> ListScenesByLocationAsync(int locationId); Task> ListCharactersByLocationAsync(int locationId); Task> ListAssetsByLocationAsync(int locationId); } public interface IWarningRepository { Task GetLookupsAsync(); Task ValidateProjectAsync(int projectId); Task ValidateBookAsync(int bookId); Task ValidateSceneAsync(int sceneId); Task> ListAsync(int projectId, int? bookId, int? sceneId, int? warningTypeId, int? warningSeverityId, string? entityType, bool includeDismissed, bool includeIntentional); Task> ListBySceneAsync(int sceneId); Task> GetSceneCountsAsync(int projectId, int? bookId); Task DismissAsync(int continuityWarningId); Task MarkIntentionalAsync(int continuityWarningId); Task RestoreAsync(int continuityWarningId); Task FinaliseSceneDependencyWarningsAsync(int projectId, int? bookId); } public interface IContinuityWarningAcknowledgementRepository { Task> ListByProjectAsync(int projectId); Task GetAsync(int projectId, string warningType, int? sceneId, int? characterId, int? assetId); Task SaveAsync(ContinuityWarningAcknowledgement acknowledgement); Task DeleteAsync(int projectId, string warningType, int? sceneId, int? characterId, int? assetId); } public interface ISceneDependencyRepository { Task GetLookupsAsync(); Task> ListBySceneAsync(int sceneId); Task> ListByProjectAsync(int projectId, int? bookId); Task> GetCountsAsync(int projectId, int? bookId); Task SaveAsync(SceneDependency dependency); Task ArchiveAsync(int sceneDependencyId); } public interface IAnalyticsRepository { Task GetStoryHealthAsync(int projectId, int? bookId, decimal? startChapterNumber, decimal? endChapterNumber, decimal? startSceneNumber, decimal? endSceneNumber); } public interface IStoryBibleRepository { Task> GetCharacterSummariesAsync(int projectId); Task> GetThreadSummariesAsync(int projectId); Task> GetAssetSummariesAsync(int projectId); Task> GetRelationshipSummariesAsync(int projectId); Task> GetLocationSummariesAsync(int projectId); Task> GetTimelineSummaryAsync(int projectId); Task> GetOpenQuestionsAsync(int projectId); Task> GetRevisionProgressAsync(int projectId); Task> SearchAsync(int projectId, string? query); } public interface IWriterWorkspaceRepository { Task> ListNoteTypesAsync(); Task> ListAttachmentTypesAsync(); Task GetWorkflowAsync(int sceneId); Task SaveWorkflowAsync(SceneWorkflow workflow); Task> ListNotesAsync(int sceneId); Task SaveNoteAsync(SceneNote note); Task DeleteNoteAsync(int sceneNoteId); Task> ListChecklistItemsAsync(int sceneId); Task SaveChecklistItemAsync(SceneChecklistItem item); Task DeleteChecklistItemAsync(int sceneChecklistItemId); Task> ListAttachmentsAsync(int sceneId); Task SaveAttachmentAsync(SceneAttachment attachment); Task DeleteAttachmentAsync(int sceneAttachmentId); Task GetChapterGoalAsync(int chapterId); Task SaveChapterGoalAsync(ChapterGoal goal); Task> GetChapterWorkflowAsync(int chapterId); Task> GetWriterDashboardScenesAsync(int? projectId, string queue); Task> GetWriterDashboardRemindersAsync(int? projectId); } public interface IWritingScheduleRepository { Task> GetWritingPlansByUserAsync(int userId); Task GetWritingPlanByIDAsync(int writingPlanId); Task CreateWritingPlanAsync(WritingPlan plan); Task UpdateWritingPlanAsync(WritingPlan plan); Task DeleteWritingPlanAsync(int writingPlanId); Task SetWritingPlanActiveAsync(int writingPlanId, bool isActive); Task> GetScheduleItemsByPlanAsync(int writingPlanId); Task> GetScheduleItemsByDateRangeAsync(int writingPlanId, DateTime startDate, DateTime endDate); Task CreateScheduleItemAsync(WritingScheduleItem item); Task UpdateScheduleItemAsync(WritingScheduleItem item); Task DeleteScheduleItemAsync(int writingScheduleItemId); Task UpdateScheduleItemStatusAsync(int writingScheduleItemId, string scheduleStatus, DateTime? completedDateUtc); Task> GetWritingPlansForManagementAsync(int userId); Task> GetSchedulingScenesAsync(int projectId, int? bookId, bool includeBlockedScenes); Task ReplaceScheduleItemsAsync(int writingPlanId, IReadOnlyList items); Task> GetWritingPlanSummariesByUserAsync(int userId, int? projectId = null); Task> GetSchedulePreviewItemsAsync(int writingPlanId); Task DeleteScheduleItemsByPlanAsync(int writingPlanId); Task> GetActiveScheduleDashboardItemsAsync(int userId, int? projectId = null); Task UpdatePlannedScheduleItemStatusForUserAsync(int writingScheduleItemId, int userId, string scheduleStatus, DateTime? completedDateUtc); Task> GetPlansRequiringRebalanceAsync(int userId, DateTime today, int? projectId = null); Task UpdatePlannedScheduleItemDatesAsync(int writingPlanId, IReadOnlyList changes); } public interface IExportRepository { Task GetStoryBibleAsync(int projectId); Task GetNarrativeTimelineAsync(int projectId); Task GetCharacterReferenceAsync(int characterId); Task GetSceneBriefAsync(int sceneId); Task GetChapterBriefAsync(int chapterId); Task GetWriterSessionBriefAsync(int sceneId); Task GetResearchPackAsync(int projectId); Task GetSearchResultsAsync(int projectId, string query); } public interface IDynamicsRepository { Task<(Character? Character, IReadOnlyList ArcPoints, IReadOnlyList RelationshipEvents, IReadOnlyList KnowledgeEvents, IReadOnlyList Issues)> GetCharacterArcAsync(int characterId); Task<(CharacterRelationship? Relationship, IReadOnlyList Events, IReadOnlyList Issues)> GetRelationshipEvolutionAsync(int characterRelationshipId); Task<(Character? Character, IReadOnlyList KnowledgeEvents, IReadOnlyList Issues)> GetKnowledgeTimelineAsync(int characterId); Task<(IReadOnlyList Pairings, IReadOnlyList Issues)> GetStoryDynamicsAsync(int projectId); Task RunContinuityChecksAsync(int projectId, int? bookId, int? sceneId); } public interface IScenarioRepository { Task> ListAsync(int projectId); Task GetAsync(int scenarioId); Task CreateAsync(int projectId, int? bookId, string scenarioName, string? description); Task<(IReadOnlyList Books, IReadOnlyList Chapters, IReadOnlyList Scenes, IReadOnlyList Warnings)> GetTimelineAsync(int scenarioId); Task MoveSceneAsync(int scenarioId, int sceneId, int? anchorSceneId, int? targetChapterId, string position); Task ValidateAsync(int scenarioId); Task<(IReadOnlyList MovedScenes, IReadOnlyList Warnings)> CompareAsync(int scenarioId); Task ApplyAsync(int scenarioId); Task ArchiveAsync(int scenarioId); } public interface IArchiveRepository { Task> ListAsync(int? projectId, string? entityType); Task> ListForUserAsync(int? projectId, string? entityType, int userId); Task> ListProjectFiltersAsync(); Task> ListProjectFiltersForUserAsync(int userId); Task ArchiveAsync(string entityType, int entityId, string? reason); Task RestoreAsync(string entityType, int entityId); Task DeleteForeverAsync(string entityType, int entityId, string confirmationText); } public interface IAcceptanceRepository { Task> GetPhase1ChecklistAsync(int? projectId); } public sealed class TimelineData { public Project? Project { get; init; } public IReadOnlyList Books { get; init; } = []; public IReadOnlyList Chapters { get; init; } = []; public IReadOnlyList Scenes { get; init; } = []; public IReadOnlyList PurposeLabels { get; init; } = []; public IReadOnlyList MetricTypes { get; init; } = []; public IReadOnlyList MetricPoints { get; init; } = []; public IReadOnlyList PlotLines { get; init; } = []; public IReadOnlyList ThreadEvents { get; init; } = []; } public sealed class LocationRepository(ISqlConnectionFactory connectionFactory) : ILocationRepository { public async Task GetLookupsAsync() { using var connection = connectionFactory.CreateConnection(); using var result = await connection.QueryMultipleAsync("dbo.LocationLookup_All", commandType: CommandType.StoredProcedure); return new LocationLookupData { LocationTypes = (await result.ReadAsync()).ToList(), RelationshipTypes = (await result.ReadAsync()).ToList() }; } public async Task> ListByProjectAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.Location_ListByProject", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task GetAsync(int locationId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync("dbo.Location_Get", new { LocationID = locationId }, commandType: CommandType.StoredProcedure); } public async Task SaveAsync(LocationItem location) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.Location_Save", new { location.LocationID, location.ProjectID, location.ParentLocationID, location.LocationName, location.LocationTypeID, location.Description, location.ShowInQuickAddBar }, commandType: CommandType.StoredProcedure); } public async Task ArchiveAsync(int locationId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.Location_Archive", new { LocationID = locationId }, commandType: CommandType.StoredProcedure); } public async Task> ListRelationshipsByProjectAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.LocationRelationship_ListByProject", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task> ListRelationshipsByLocationAsync(int locationId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.LocationRelationship_ListByLocation", new { LocationID = locationId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task SaveRelationshipAsync(LocationRelationship relationship) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.LocationRelationship_Save", new { relationship.LocationRelationshipID, relationship.FromLocationID, relationship.ToLocationID, relationship.LocationRelationshipTypeID, relationship.IsBidirectional, relationship.Strength, relationship.CanHearNormalSpeech, relationship.CanHearLoudNoise, relationship.CanSeeMovement, relationship.CanSeeClearly, relationship.CanTravelDirectly, relationship.Notes }, commandType: CommandType.StoredProcedure); } public async Task ArchiveRelationshipAsync(int locationRelationshipId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.LocationRelationship_Archive", new { LocationRelationshipID = locationRelationshipId }, commandType: CommandType.StoredProcedure); } public async Task> ListSceneAssetLocationsAsync(int sceneId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.SceneAssetLocation_ListByScene", new { SceneID = sceneId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task SaveSceneAssetLocationAsync(SceneAssetLocation sceneAssetLocation, bool updateCurrentLocation) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.SceneAssetLocation_Save", new { sceneAssetLocation.SceneAssetLocationID, sceneAssetLocation.SceneID, sceneAssetLocation.StoryAssetID, sceneAssetLocation.LocationID, sceneAssetLocation.Description, UpdateCurrentLocation = updateCurrentLocation }, commandType: CommandType.StoredProcedure); } public async Task DeleteSceneAssetLocationAsync(int sceneAssetLocationId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.SceneAssetLocation_Delete", new { SceneAssetLocationID = sceneAssetLocationId }, commandType: CommandType.StoredProcedure); } public async Task> ListScenesByLocationAsync(int locationId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.LocationScene_ListByLocation", new { LocationID = locationId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task> ListCharactersByLocationAsync(int locationId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.LocationCharacter_ListByLocation", new { LocationID = locationId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task> ListAssetsByLocationAsync(int locationId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.SceneAssetLocation_ListByLocation", new { LocationID = locationId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } } public sealed class WarningRepository(ISqlConnectionFactory connectionFactory) : IWarningRepository { public async Task GetLookupsAsync() { using var connection = connectionFactory.CreateConnection(); using var result = await connection.QueryMultipleAsync("dbo.WarningLookup_All", commandType: CommandType.StoredProcedure); return new WarningLookupData { WarningTypes = (await result.ReadAsync()).ToList(), Severities = (await result.ReadAsync()).ToList() }; } public async Task ValidateProjectAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.ContinuityValidation_ValidateProject", new { ProjectID = projectId, TriggeredBy = "User" }, commandType: CommandType.StoredProcedure); } public async Task ValidateBookAsync(int bookId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.ContinuityValidation_ValidateBook", new { BookID = bookId, TriggeredBy = "User" }, commandType: CommandType.StoredProcedure); } public async Task ValidateSceneAsync(int sceneId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.ContinuityValidation_ValidateScene", new { SceneID = sceneId, TriggeredBy = "User" }, commandType: CommandType.StoredProcedure); } public async Task> ListAsync(int projectId, int? bookId, int? sceneId, int? warningTypeId, int? warningSeverityId, string? entityType, bool includeDismissed, bool includeIntentional) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( "dbo.ContinuityWarning_List", new { ProjectID = projectId, BookID = bookId, SceneID = sceneId, WarningTypeID = warningTypeId, WarningSeverityID = warningSeverityId, EntityType = string.IsNullOrWhiteSpace(entityType) ? null : entityType, IncludeDismissed = includeDismissed, IncludeIntentional = includeIntentional }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task> ListBySceneAsync(int sceneId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.ContinuityWarning_ListByScene", new { SceneID = sceneId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task> GetSceneCountsAsync(int projectId, int? bookId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.ContinuityWarning_SceneCounts", new { ProjectID = projectId, BookID = bookId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task DismissAsync(int continuityWarningId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.ContinuityWarning_Dismiss", new { ContinuityWarningID = continuityWarningId }, commandType: CommandType.StoredProcedure); } public async Task MarkIntentionalAsync(int continuityWarningId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.ContinuityWarning_MarkIntentional", new { ContinuityWarningID = continuityWarningId }, commandType: CommandType.StoredProcedure); } public async Task RestoreAsync(int continuityWarningId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.ContinuityWarning_Restore", new { ContinuityWarningID = continuityWarningId }, commandType: CommandType.StoredProcedure); } public async Task FinaliseSceneDependencyWarningsAsync(int projectId, int? bookId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.SceneDependency_FinaliseWarnings", new { ProjectID = projectId, BookID = bookId }, commandType: CommandType.StoredProcedure); } } public sealed class ContinuityWarningAcknowledgementRepository(ISqlConnectionFactory connectionFactory) : IContinuityWarningAcknowledgementRepository { public async Task> ListByProjectAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( "dbo.ContinuityWarningAcknowledgement_ListByProject", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task GetAsync(int projectId, string warningType, int? sceneId, int? characterId, int? assetId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync( "dbo.ContinuityWarningAcknowledgement_Get", new { ProjectID = projectId, WarningType = warningType, SceneID = sceneId, CharacterID = characterId, AssetID = assetId }, commandType: CommandType.StoredProcedure); } public async Task SaveAsync(ContinuityWarningAcknowledgement acknowledgement) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.ContinuityWarningAcknowledgement_Save", new { acknowledgement.ProjectID, acknowledgement.WarningType, acknowledgement.SceneID, acknowledgement.CharacterID, acknowledgement.AssetID, acknowledgement.AcknowledgedByUserID, acknowledgement.Notes }, commandType: CommandType.StoredProcedure); } public async Task DeleteAsync(int projectId, string warningType, int? sceneId, int? characterId, int? assetId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.ContinuityWarningAcknowledgement_Delete", new { ProjectID = projectId, WarningType = warningType, SceneID = sceneId, CharacterID = characterId, AssetID = assetId }, commandType: CommandType.StoredProcedure); } } public sealed class AnalyticsData { public IReadOnlyList MetricPoints { get; init; } = []; public IReadOnlyList RevisionSummary { get; init; } = []; public IReadOnlyList RevisionAttentionScenes { get; init; } = []; public IReadOnlyList PurposeSummary { get; init; } = []; public IReadOnlyList SceneGaps { get; init; } = []; public IReadOnlyList ThreadHealth { get; init; } = []; public IReadOnlyList AssetHealth { get; init; } = []; public IReadOnlyList WarningSeverityCounts { get; init; } = []; public IReadOnlyList WarningTypeCounts { get; init; } = []; public IReadOnlyList WarningHotspots { get; init; } = []; public IReadOnlyList PovSummary { get; init; } = []; public IReadOnlyList MultiPovChapters { get; init; } = []; public IReadOnlyList CharacterAppearances { get; init; } = []; public IReadOnlyList BusyScenes { get; init; } = []; public IReadOnlyList LocationUsage { get; init; } = []; } public sealed class AnalyticsRepository(ISqlConnectionFactory connectionFactory) : IAnalyticsRepository { public async Task GetStoryHealthAsync(int projectId, int? bookId, decimal? startChapterNumber, decimal? endChapterNumber, decimal? startSceneNumber, decimal? endSceneNumber) { using var connection = connectionFactory.CreateConnection(); using var result = await connection.QueryMultipleAsync( "dbo.Analytics_StoryHealth", new { ProjectID = projectId, BookID = bookId, StartChapterNumber = startChapterNumber, EndChapterNumber = endChapterNumber, StartSceneNumber = startSceneNumber, EndSceneNumber = endSceneNumber }, commandType: CommandType.StoredProcedure); return new AnalyticsData { MetricPoints = (await result.ReadAsync()).ToList(), RevisionSummary = (await result.ReadAsync()).ToList(), RevisionAttentionScenes = (await result.ReadAsync()).ToList(), PurposeSummary = (await result.ReadAsync()).ToList(), SceneGaps = (await result.ReadAsync()).ToList(), ThreadHealth = (await result.ReadAsync()).ToList(), AssetHealth = (await result.ReadAsync()).ToList(), WarningSeverityCounts = (await result.ReadAsync()).ToList(), WarningTypeCounts = (await result.ReadAsync()).ToList(), WarningHotspots = (await result.ReadAsync()).ToList(), PovSummary = (await result.ReadAsync()).ToList(), MultiPovChapters = (await result.ReadAsync()).ToList(), CharacterAppearances = (await result.ReadAsync()).ToList(), BusyScenes = (await result.ReadAsync()).ToList(), LocationUsage = (await result.ReadAsync()).ToList() }; } } public sealed class StoryBibleRepository(ISqlConnectionFactory connectionFactory) : IStoryBibleRepository { public async Task> GetCharacterSummariesAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); return (await connection.QueryAsync("dbo.StoryBible_CharacterSummaries", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure)).ToList(); } public async Task> GetThreadSummariesAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); return (await connection.QueryAsync("dbo.StoryBible_ThreadSummaries", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure)).ToList(); } public async Task> GetAssetSummariesAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); return (await connection.QueryAsync("dbo.StoryBible_AssetSummaries", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure)).ToList(); } public async Task> GetRelationshipSummariesAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); return (await connection.QueryAsync("dbo.StoryBible_RelationshipSummaries", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure)).ToList(); } public async Task> GetLocationSummariesAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); return (await connection.QueryAsync("dbo.StoryBible_LocationSummaries", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure)).ToList(); } public async Task> GetTimelineSummaryAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); return (await connection.QueryAsync("dbo.StoryBible_TimelineSummary", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure)).ToList(); } public async Task> GetOpenQuestionsAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); return (await connection.QueryAsync("dbo.StoryBible_OpenQuestions", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure)).ToList(); } public async Task> GetRevisionProgressAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); return (await connection.QueryAsync("dbo.StoryBible_RevisionProgress", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure)).ToList(); } public async Task> SearchAsync(int projectId, string? query) { using var connection = connectionFactory.CreateConnection(); return (await connection.QueryAsync("dbo.StoryBible_Search", new { ProjectID = projectId, Query = query }, commandType: CommandType.StoredProcedure)).ToList(); } } public sealed class WriterWorkspaceRepository(ISqlConnectionFactory connectionFactory) : IWriterWorkspaceRepository { public async Task> ListNoteTypesAsync() { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.SceneNoteType_List", commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task> ListAttachmentTypesAsync() { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.SceneAttachmentType_List", commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task GetWorkflowAsync(int sceneId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync("dbo.SceneWorkflow_Get", new { SceneID = sceneId }, commandType: CommandType.StoredProcedure); } public async Task SaveWorkflowAsync(SceneWorkflow workflow) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.SceneWorkflow_Save", new { workflow.SceneID, workflow.DraftStatus, workflow.EstimatedWordCount, workflow.ActualWordCount, workflow.DraftedDate, workflow.LastWorkedOn, workflow.ReadyForDraft, workflow.ReadyForRevision, workflow.ReadyForPolish, workflow.IsBlocked, workflow.BlockedReason, workflow.Priority }, commandType: CommandType.StoredProcedure); } public async Task> ListNotesAsync(int sceneId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.SceneNote_ListByScene", new { SceneID = sceneId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task SaveNoteAsync(SceneNote note) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.SceneNote_Save", new { note.SceneNoteID, note.SceneID, note.SceneNoteTypeID, note.NoteTitle, note.NoteText, note.SortOrder, note.IsPinned, note.IsResolved }, commandType: CommandType.StoredProcedure); } public async Task DeleteNoteAsync(int sceneNoteId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.SceneNote_Delete", new { SceneNoteID = sceneNoteId }, commandType: CommandType.StoredProcedure); } public async Task> ListChecklistItemsAsync(int sceneId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.SceneChecklist_ListByScene", new { SceneID = sceneId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task SaveChecklistItemAsync(SceneChecklistItem item) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.SceneChecklist_Save", new { item.SceneChecklistItemID, item.SceneID, item.ItemText, item.IsCompleted, item.SortOrder }, commandType: CommandType.StoredProcedure); } public async Task DeleteChecklistItemAsync(int sceneChecklistItemId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.SceneChecklist_Delete", new { SceneChecklistItemID = sceneChecklistItemId }, commandType: CommandType.StoredProcedure); } public async Task> ListAttachmentsAsync(int sceneId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.SceneAttachment_ListByScene", new { SceneID = sceneId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task SaveAttachmentAsync(SceneAttachment attachment) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.SceneAttachment_Save", new { attachment.SceneAttachmentID, attachment.SceneID, attachment.SceneAttachmentTypeID, attachment.Title, attachment.FilePath, attachment.ExternalUrl, attachment.Notes }, commandType: CommandType.StoredProcedure); } public async Task DeleteAttachmentAsync(int sceneAttachmentId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.SceneAttachment_Delete", new { SceneAttachmentID = sceneAttachmentId }, commandType: CommandType.StoredProcedure); } public async Task GetChapterGoalAsync(int chapterId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync("dbo.ChapterGoal_Get", new { ChapterID = chapterId }, commandType: CommandType.StoredProcedure); } public async Task SaveChapterGoalAsync(ChapterGoal goal) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.ChapterGoal_Save", new { goal.ChapterID, goal.GoalSummary, goal.EmotionalGoal, goal.ReaderTakeaway, goal.MustInclude, goal.RisksOrConcerns }, commandType: CommandType.StoredProcedure); } public async Task> GetChapterWorkflowAsync(int chapterId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.ChapterWorkflow_Summary", new { ChapterID = chapterId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task> GetWriterDashboardScenesAsync(int? projectId, string queue) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.WriterDashboard_Scenes", new { ProjectID = projectId, Queue = queue }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task> GetWriterDashboardRemindersAsync(int? projectId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.WriterDashboard_Reminders", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } } public sealed class WritingScheduleRepository(ISqlConnectionFactory connectionFactory) : IWritingScheduleRepository { public async Task> GetWritingPlansByUserAsync(int userId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.GetWritingPlansByUser", new { UserID = userId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task GetWritingPlanByIDAsync(int writingPlanId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync("dbo.GetWritingPlanByID", new { WritingPlanID = writingPlanId }, commandType: CommandType.StoredProcedure); } public async Task CreateWritingPlanAsync(WritingPlan plan) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.CreateWritingPlan", new { plan.UserID, plan.ProjectID, plan.BookID, plan.PlanName, plan.GoalType, plan.StartDate, plan.DeadlineDate, plan.AvailableDaysJson, plan.SessionLengthMinutes, plan.IncludeBlockedScenes, plan.RebalanceMode, plan.IsActive }, commandType: CommandType.StoredProcedure); } public async Task UpdateWritingPlanAsync(WritingPlan plan) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.UpdateWritingPlan", new { plan.WritingPlanID, plan.UserID, plan.ProjectID, plan.BookID, plan.PlanName, plan.GoalType, plan.StartDate, plan.DeadlineDate, plan.AvailableDaysJson, plan.SessionLengthMinutes, plan.IncludeBlockedScenes, plan.RebalanceMode, plan.IsActive }, commandType: CommandType.StoredProcedure); } public async Task DeleteWritingPlanAsync(int writingPlanId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.DeleteWritingPlan", new { WritingPlanID = writingPlanId }, commandType: CommandType.StoredProcedure); } public async Task SetWritingPlanActiveAsync(int writingPlanId, bool isActive) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.SetWritingPlanActive", new { WritingPlanID = writingPlanId, IsActive = isActive }, commandType: CommandType.StoredProcedure); } public async Task> GetScheduleItemsByPlanAsync(int writingPlanId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.GetScheduleItemsByPlan", new { WritingPlanID = writingPlanId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task> GetScheduleItemsByDateRangeAsync(int writingPlanId, DateTime startDate, DateTime endDate) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( "dbo.GetScheduleItemsByDateRange", new { WritingPlanID = writingPlanId, StartDate = startDate, EndDate = endDate }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task CreateScheduleItemAsync(WritingScheduleItem item) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.CreateScheduleItem", new { item.WritingPlanID, item.SceneID, item.ScheduledDate, item.TaskType, item.EstimatedMinutes, item.TargetWords, item.ScheduleStatus, item.CompletedDateUtc, item.Notes }, commandType: CommandType.StoredProcedure); } public async Task UpdateScheduleItemAsync(WritingScheduleItem item) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.UpdateScheduleItem", new { item.WritingScheduleItemID, item.WritingPlanID, item.SceneID, item.ScheduledDate, item.TaskType, item.EstimatedMinutes, item.TargetWords, item.ScheduleStatus, item.CompletedDateUtc, item.Notes }, commandType: CommandType.StoredProcedure); } public async Task DeleteScheduleItemAsync(int writingScheduleItemId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.DeleteScheduleItem", new { WritingScheduleItemID = writingScheduleItemId }, commandType: CommandType.StoredProcedure); } public async Task UpdateScheduleItemStatusAsync(int writingScheduleItemId, string scheduleStatus, DateTime? completedDateUtc) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.UpdateScheduleItemStatus", new { WritingScheduleItemID = writingScheduleItemId, ScheduleStatus = scheduleStatus, CompletedDateUtc = completedDateUtc }, commandType: CommandType.StoredProcedure); } public async Task> GetWritingPlansForManagementAsync(int userId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( """ SELECT wp.WritingPlanID, wp.UserID, wp.ProjectID, p.ProjectName, wp.BookID, b.BookTitle, wp.PlanName, wp.GoalType, wp.StartDate, wp.DeadlineDate, wp.AvailableDaysJson, wp.SessionLengthMinutes, wp.IsActive, SUM(CASE WHEN wsi.ScheduleStatus = N'Planned' THEN 1 ELSE 0 END) AS PlannedTaskCount, MAX(CASE WHEN wsi.ScheduleStatus = N'Planned' THEN wsi.ScheduledDate ELSE NULL END) AS ProjectedFinishDate FROM dbo.WritingPlans wp INNER JOIN dbo.Projects p ON p.ProjectID = wp.ProjectID LEFT JOIN dbo.Books b ON b.BookID = wp.BookID LEFT JOIN dbo.WritingScheduleItems wsi ON wsi.WritingPlanID = wp.WritingPlanID WHERE wp.UserID = @UserID GROUP BY wp.WritingPlanID, wp.UserID, wp.ProjectID, p.ProjectName, wp.BookID, b.BookTitle, wp.PlanName, wp.GoalType, wp.StartDate, wp.DeadlineDate, wp.AvailableDaysJson, wp.SessionLengthMinutes, wp.IsActive ORDER BY wp.IsActive DESC, wp.DeadlineDate, wp.PlanName, wp.WritingPlanID; """, new { UserID = userId }); return rows.ToList(); } public async Task> GetSchedulingScenesAsync(int projectId, int? bookId, bool includeBlockedScenes) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( """ SELECT s.SceneID, rs.StatusName AS RevisionStatusName, sw.EstimatedWordCount, sw.Priority, COALESCE(sw.IsBlocked, 0) AS IsBlocked, b.SortOrder AS BookSortOrder, c.SortOrder AS ChapterSortOrder, s.SortOrder AS SceneSortOrder FROM dbo.Projects p INNER JOIN dbo.Books b ON b.ProjectID = p.ProjectID AND b.IsArchived = 0 INNER JOIN dbo.Chapters c ON c.BookID = b.BookID AND c.IsArchived = 0 INNER JOIN dbo.Scenes s ON s.ChapterID = c.ChapterID AND s.IsArchived = 0 INNER JOIN dbo.RevisionStatuses rs ON rs.RevisionStatusID = s.RevisionStatusID LEFT JOIN dbo.SceneWorkflow sw ON sw.SceneID = s.SceneID WHERE p.ProjectID = @ProjectID AND p.IsArchived = 0 AND (@BookID IS NULL OR b.BookID = @BookID) AND rs.StatusName IN (N'Outlined', N'Needs Work', N'Revised') AND (@IncludeBlockedScenes = 1 OR COALESCE(sw.IsBlocked, 0) = 0) ORDER BY COALESCE(sw.Priority, 0) DESC, b.SortOrder, c.SortOrder, s.SortOrder, s.SceneID; """, new { ProjectID = projectId, BookID = bookId, IncludeBlockedScenes = includeBlockedScenes }); return rows.ToList(); } public async Task ReplaceScheduleItemsAsync(int writingPlanId, IReadOnlyList items) { using var connection = connectionFactory.CreateConnection(); connection.Open(); using var transaction = connection.BeginTransaction(); try { await connection.ExecuteAsync( "DELETE FROM dbo.WritingScheduleItems WHERE WritingPlanID = @WritingPlanID;", new { WritingPlanID = writingPlanId }, transaction); foreach (var item in items) { await connection.ExecuteAsync( """ INSERT dbo.WritingScheduleItems ( WritingPlanID, SceneID, ScheduledDate, TaskType, EstimatedMinutes, TargetWords, ScheduleStatus, CompletedDateUtc, Notes ) VALUES ( @WritingPlanID, @SceneID, @ScheduledDate, @TaskType, @EstimatedMinutes, @TargetWords, @ScheduleStatus, @CompletedDateUtc, @Notes ); """, new { item.WritingPlanID, item.SceneID, item.ScheduledDate, item.TaskType, item.EstimatedMinutes, item.TargetWords, item.ScheduleStatus, item.CompletedDateUtc, item.Notes }, transaction); } transaction.Commit(); } catch { transaction.Rollback(); throw; } } public async Task> GetWritingPlanSummariesByUserAsync(int userId, int? projectId = null) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( """ SELECT wp.WritingPlanID, wp.UserID, wp.ProjectID, p.ProjectName, wp.BookID, b.BookTitle, wp.PlanName, wp.DeadlineDate, wp.IsActive FROM dbo.WritingPlans wp INNER JOIN dbo.Projects p ON p.ProjectID = wp.ProjectID LEFT JOIN dbo.Books b ON b.BookID = wp.BookID WHERE wp.UserID = @UserID AND (@ProjectID IS NULL OR wp.ProjectID = @ProjectID) ORDER BY wp.IsActive DESC, wp.DeadlineDate, wp.PlanName, wp.WritingPlanID; """, new { UserID = userId, ProjectID = projectId }); return rows.ToList(); } public async Task> GetSchedulePreviewItemsAsync(int writingPlanId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( """ SELECT wsi.WritingScheduleItemID, wsi.WritingPlanID, wsi.SceneID, wsi.ScheduledDate, wsi.TaskType, b.BookTitle, c.ChapterNumber, c.ChapterTitle, s.SceneNumber, s.SceneTitle, sw.Priority, wsi.EstimatedMinutes, wsi.TargetWords, wsi.ScheduleStatus, wsi.Notes, COALESCE(sw.IsBlocked, 0) AS IsBlocked FROM dbo.WritingScheduleItems wsi INNER JOIN dbo.Scenes s ON s.SceneID = wsi.SceneID INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID INNER JOIN dbo.Books b ON b.BookID = c.BookID LEFT JOIN dbo.SceneWorkflow sw ON sw.SceneID = s.SceneID WHERE wsi.WritingPlanID = @WritingPlanID ORDER BY wsi.ScheduledDate, wsi.WritingScheduleItemID; """, new { WritingPlanID = writingPlanId }); return rows.ToList(); } public async Task DeleteScheduleItemsByPlanAsync(int writingPlanId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "DELETE FROM dbo.WritingScheduleItems WHERE WritingPlanID = @WritingPlanID;", new { WritingPlanID = writingPlanId }); } public async Task> GetActiveScheduleDashboardItemsAsync(int userId, int? projectId = null) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( """ SELECT wsi.WritingScheduleItemID, wsi.WritingPlanID, wp.PlanName, wsi.SceneID, wsi.ScheduledDate, wsi.TaskType, b.BookTitle, c.ChapterNumber, c.ChapterTitle, c.SortOrder AS ChapterSortOrder, s.SceneNumber, s.SceneTitle, s.SortOrder AS SceneSortOrder, sw.Priority, wsi.EstimatedMinutes, wsi.TargetWords, wsi.ScheduleStatus, COALESCE(sw.IsBlocked, 0) AS IsBlocked FROM dbo.WritingPlans wp INNER JOIN dbo.WritingScheduleItems wsi ON wsi.WritingPlanID = wp.WritingPlanID INNER JOIN dbo.Scenes s ON s.SceneID = wsi.SceneID INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID INNER JOIN dbo.Books b ON b.BookID = c.BookID LEFT JOIN dbo.SceneWorkflow sw ON sw.SceneID = s.SceneID WHERE wp.UserID = @UserID AND wp.IsActive = 1 AND (@ProjectID IS NULL OR wp.ProjectID = @ProjectID) ORDER BY wsi.ScheduledDate, wsi.WritingScheduleItemID; """, new { UserID = userId, ProjectID = projectId }); return rows.ToList(); } public async Task UpdatePlannedScheduleItemStatusForUserAsync(int writingScheduleItemId, int userId, string scheduleStatus, DateTime? completedDateUtc) { using var connection = connectionFactory.CreateConnection(); return await connection.ExecuteAsync( """ UPDATE wsi SET ScheduleStatus = @ScheduleStatus, CompletedDateUtc = @CompletedDateUtc, ModifiedDateUtc = SYSUTCDATETIME() FROM dbo.WritingScheduleItems wsi INNER JOIN dbo.WritingPlans wp ON wp.WritingPlanID = wsi.WritingPlanID WHERE wsi.WritingScheduleItemID = @WritingScheduleItemID AND wp.UserID = @UserID AND wp.IsActive = 1 AND wsi.ScheduleStatus = N'Planned'; """, new { WritingScheduleItemID = writingScheduleItemId, UserID = userId, ScheduleStatus = scheduleStatus, CompletedDateUtc = completedDateUtc }); } public async Task> GetPlansRequiringRebalanceAsync(int userId, DateTime today, int? projectId = null) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( """ SELECT wp.WritingPlanID, wp.UserID, wp.ProjectID, p.ProjectName, wp.BookID, b.BookTitle, wp.PlanName, wp.DeadlineDate, wp.IsActive FROM dbo.WritingPlans wp INNER JOIN dbo.Projects p ON p.ProjectID = wp.ProjectID LEFT JOIN dbo.Books b ON b.BookID = wp.BookID WHERE wp.UserID = @UserID AND wp.IsActive = 1 AND (@ProjectID IS NULL OR wp.ProjectID = @ProjectID) AND EXISTS ( SELECT 1 FROM dbo.WritingScheduleItems wsi WHERE wsi.WritingPlanID = wp.WritingPlanID AND wsi.ScheduleStatus = N'Planned' AND wsi.ScheduledDate < @Today ) ORDER BY wp.DeadlineDate, wp.PlanName, wp.WritingPlanID; """, new { UserID = userId, Today = today.Date, ProjectID = projectId }); return rows.ToList(); } public async Task UpdatePlannedScheduleItemDatesAsync(int writingPlanId, IReadOnlyList changes) { if (changes.Count == 0) { return; } using var connection = connectionFactory.CreateConnection(); connection.Open(); using var transaction = connection.BeginTransaction(); try { foreach (var change in changes) { await connection.ExecuteAsync( """ UPDATE dbo.WritingScheduleItems SET ScheduledDate = @ScheduledDate, ModifiedDateUtc = SYSUTCDATETIME() WHERE WritingPlanID = @WritingPlanID AND WritingScheduleItemID = @WritingScheduleItemID AND ScheduleStatus = N'Planned'; """, new { WritingPlanID = writingPlanId, change.WritingScheduleItemID, ScheduledDate = change.ScheduledDate.Date }, transaction); } transaction.Commit(); } catch { transaction.Rollback(); throw; } } } public sealed class ExportRepository(ISqlConnectionFactory connectionFactory) : IExportRepository { public async Task GetStoryBibleAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); using var result = await connection.QueryMultipleAsync("dbo.Export_StoryBible", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); return new ExportPacket { Title = "Story Bible", Characters = (await result.ReadAsync()).ToList(), PlotThreads = (await result.ReadAsync()).ToList(), Assets = (await result.ReadAsync()).ToList(), Relationships = (await result.ReadAsync()).ToList(), Locations = (await result.ReadAsync()).ToList(), Timeline = (await result.ReadAsync()).ToList(), OpenQuestions = (await result.ReadAsync()).ToList(), RevisionItems = (await result.ReadAsync()).ToList() }; } public async Task GetNarrativeTimelineAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.Export_NarrativeTimeline", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); return new ExportPacket { Title = "Narrative Timeline Summary", Timeline = rows.ToList() }; } public async Task GetCharacterReferenceAsync(int characterId) { using var connection = connectionFactory.CreateConnection(); using var result = await connection.QueryMultipleAsync("dbo.Export_CharacterReference", new { CharacterID = characterId }, commandType: CommandType.StoredProcedure); return new ExportPacket { Title = "Character Reference Pack", Character = await result.ReadSingleOrDefaultAsync(), CharacterRelationships = (await result.ReadAsync()).ToList(), CharacterAppearances = (await result.ReadAsync()).ToList(), CharacterKnowledge = (await result.ReadAsync()).ToList(), CharacterCustody = (await result.ReadAsync()).ToList(), Items = (await result.ReadAsync()).ToList() }; } public async Task GetSceneBriefAsync(int sceneId) { using var connection = connectionFactory.CreateConnection(); using var result = await connection.QueryMultipleAsync("dbo.Export_SceneBrief", new { SceneID = sceneId }, commandType: CommandType.StoredProcedure); return new ExportPacket { Title = "Scene Brief", SceneHeader = await result.ReadSingleOrDefaultAsync(), Items = (await result.ReadAsync()).ToList() }; } public async Task GetChapterBriefAsync(int chapterId) { using var connection = connectionFactory.CreateConnection(); using var result = await connection.QueryMultipleAsync("dbo.Export_ChapterBrief", new { ChapterID = chapterId }, commandType: CommandType.StoredProcedure); return new ExportPacket { Title = "Chapter Brief", ChapterHeader = await result.ReadSingleOrDefaultAsync(), Timeline = (await result.ReadAsync()).ToList(), Items = (await result.ReadAsync()).ToList() }; } public async Task GetWriterSessionBriefAsync(int sceneId) { using var connection = connectionFactory.CreateConnection(); using var result = await connection.QueryMultipleAsync("dbo.Export_WriterSessionBrief", new { SceneID = sceneId }, commandType: CommandType.StoredProcedure); return new ExportPacket { Title = "Writing Session Brief", SceneHeader = await result.ReadSingleOrDefaultAsync(), Items = (await result.ReadAsync()).ToList() }; } public async Task GetResearchPackAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.Export_ResearchPack", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); return new ExportPacket { Title = "Research Pack", ResearchItems = rows.ToList() }; } public async Task GetSearchResultsAsync(int projectId, string query) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.Export_SearchResults", new { ProjectID = projectId, Query = query }, commandType: CommandType.StoredProcedure); return new ExportPacket { Title = $"Search Results: {query}", SearchResults = rows.ToList() }; } } public sealed class DynamicsRepository(ISqlConnectionFactory connectionFactory) : IDynamicsRepository { public async Task<(Character? Character, IReadOnlyList ArcPoints, IReadOnlyList RelationshipEvents, IReadOnlyList KnowledgeEvents, IReadOnlyList Issues)> GetCharacterArcAsync(int characterId) { using var connection = connectionFactory.CreateConnection(); using var result = await connection.QueryMultipleAsync("dbo.Dynamics_CharacterArc", new { CharacterID = characterId }, commandType: CommandType.StoredProcedure); return ( await result.ReadSingleOrDefaultAsync(), (await result.ReadAsync()).ToList(), (await result.ReadAsync()).ToList(), (await result.ReadAsync()).ToList(), (await result.ReadAsync()).ToList()); } public async Task<(CharacterRelationship? Relationship, IReadOnlyList Events, IReadOnlyList Issues)> GetRelationshipEvolutionAsync(int characterRelationshipId) { using var connection = connectionFactory.CreateConnection(); using var result = await connection.QueryMultipleAsync("dbo.Dynamics_RelationshipEvolution", new { CharacterRelationshipID = characterRelationshipId }, commandType: CommandType.StoredProcedure); return ( await result.ReadSingleOrDefaultAsync(), (await result.ReadAsync()).ToList(), (await result.ReadAsync()).ToList()); } public async Task<(Character? Character, IReadOnlyList KnowledgeEvents, IReadOnlyList Issues)> GetKnowledgeTimelineAsync(int characterId) { using var connection = connectionFactory.CreateConnection(); using var result = await connection.QueryMultipleAsync("dbo.Dynamics_KnowledgeTimeline", new { CharacterID = characterId }, commandType: CommandType.StoredProcedure); return ( await result.ReadSingleOrDefaultAsync(), (await result.ReadAsync()).ToList(), (await result.ReadAsync()).ToList()); } public async Task<(IReadOnlyList Pairings, IReadOnlyList Issues)> GetStoryDynamicsAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); using var result = await connection.QueryMultipleAsync("dbo.Dynamics_StoryDashboard", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); return ((await result.ReadAsync()).ToList(), (await result.ReadAsync()).ToList()); } public async Task RunContinuityChecksAsync(int projectId, int? bookId, int? sceneId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.Dynamics_RunContinuityChecks", new { ProjectID = projectId, BookID = bookId, SceneID = sceneId }, commandType: CommandType.StoredProcedure); } } public sealed class ScenarioRepository(ISqlConnectionFactory connectionFactory) : IScenarioRepository { public async Task> ListAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.Scenario_List", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task GetAsync(int scenarioId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync("dbo.Scenario_Get", new { ScenarioID = scenarioId }, commandType: CommandType.StoredProcedure); } public async Task CreateAsync(int projectId, int? bookId, string scenarioName, string? description) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.Scenario_Create", new { ProjectID = projectId, BookID = bookId, ScenarioName = scenarioName, Description = description }, commandType: CommandType.StoredProcedure); } public async Task<(IReadOnlyList Books, IReadOnlyList Chapters, IReadOnlyList Scenes, IReadOnlyList Warnings)> GetTimelineAsync(int scenarioId) { using var connection = connectionFactory.CreateConnection(); using var result = await connection.QueryMultipleAsync("dbo.Scenario_Timeline", new { ScenarioID = scenarioId }, commandType: CommandType.StoredProcedure); return ( (await result.ReadAsync()).ToList(), (await result.ReadAsync()).ToList(), (await result.ReadAsync()).ToList(), (await result.ReadAsync()).ToList()); } public async Task MoveSceneAsync(int scenarioId, int sceneId, int? anchorSceneId, int? targetChapterId, string position) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.Scenario_MoveScene", new { ScenarioID = scenarioId, SceneID = sceneId, AnchorSceneID = anchorSceneId, TargetChapterID = targetChapterId, Position = position }, commandType: CommandType.StoredProcedure); } public async Task ValidateAsync(int scenarioId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync("dbo.Scenario_Validate", new { ScenarioID = scenarioId }, commandType: CommandType.StoredProcedure); } public async Task<(IReadOnlyList MovedScenes, IReadOnlyList Warnings)> CompareAsync(int scenarioId) { using var connection = connectionFactory.CreateConnection(); using var result = await connection.QueryMultipleAsync("dbo.Scenario_Compare", new { ScenarioID = scenarioId }, commandType: CommandType.StoredProcedure); return ((await result.ReadAsync()).ToList(), (await result.ReadAsync()).ToList()); } public async Task ApplyAsync(int scenarioId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.Scenario_ApplyToMain", new { ScenarioID = scenarioId }, commandType: CommandType.StoredProcedure); } public async Task ArchiveAsync(int scenarioId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.Scenario_Archive", new { ScenarioID = scenarioId }, commandType: CommandType.StoredProcedure); } } public sealed class ArchiveRepository(ISqlConnectionFactory connectionFactory) : IArchiveRepository { public async Task> ListAsync(int? projectId, string? entityType) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( "dbo.Archive_List", new { ProjectID = projectId, EntityType = entityType }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task> ListForUserAsync(int? projectId, string? entityType, int userId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( "dbo.Archive_ListForUser", new { ProjectID = projectId, EntityType = entityType, UserID = userId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task> ListProjectFiltersAsync() { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( "dbo.Archive_ProjectFilterList", commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task> ListProjectFiltersForUserAsync(int userId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( "dbo.Archive_ProjectFilterListForUser", new { UserID = userId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task ArchiveAsync(string entityType, int entityId, string? reason) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.Archive_Entity", new { EntityType = entityType, EntityID = entityId, ArchivedReason = reason }, commandType: CommandType.StoredProcedure); } public async Task RestoreAsync(string entityType, int entityId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.Archive_Restore", new { EntityType = entityType, EntityID = entityId }, commandType: CommandType.StoredProcedure); } public async Task DeleteForeverAsync(string entityType, int entityId, string confirmationText) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.Archive_DeleteForever", new { EntityType = entityType, EntityID = entityId, ConfirmationText = confirmationText }, commandType: CommandType.StoredProcedure); } } public sealed class AcceptanceRepository(ISqlConnectionFactory connectionFactory) : IAcceptanceRepository { public async Task> GetPhase1ChecklistAsync(int? projectId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( "dbo.Acceptance_Phase1Checklist", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } } public sealed class SceneDependencyRepository(ISqlConnectionFactory connectionFactory) : ISceneDependencyRepository { public async Task GetLookupsAsync() { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.SceneDependencyLookup_All", commandType: CommandType.StoredProcedure); return new SceneDependencyLookupData { DependencyTypes = rows.ToList() }; } public async Task> ListBySceneAsync(int sceneId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.SceneDependency_ListByScene", new { SceneID = sceneId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task> ListByProjectAsync(int projectId, int? bookId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.SceneDependency_ListByProject", new { ProjectID = projectId, BookID = bookId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task> GetCountsAsync(int projectId, int? bookId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.SceneDependency_Counts", new { ProjectID = projectId, BookID = bookId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task SaveAsync(SceneDependency dependency) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.SceneDependency_Save", new { dependency.SceneDependencyID, dependency.ProjectID, dependency.SourceSceneID, dependency.TargetSceneID, dependency.SceneDependencyTypeID, dependency.Description, dependency.IsHardDependency }, commandType: CommandType.StoredProcedure); } public async Task ArchiveAsync(int sceneDependencyId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.SceneDependency_Archive", new { SceneDependencyID = sceneDependencyId }, commandType: CommandType.StoredProcedure); } } public sealed class ProjectRepository(ISqlConnectionFactory connectionFactory) : IProjectRepository { public async Task> ListAsync() { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.Project_List", commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task> ListForUserAsync(int userId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.Project_ListForUser", new { UserID = userId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task> ListActiveForUserAsync(int userId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.Project_ListActiveForUser", new { UserID = userId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task> ListArchivedForUserAsync(int userId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.Project_ListArchivedForUser", new { UserID = userId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task> ListOwnedForAccountDeletionAsync(int userId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( "dbo.Project_ListOwnedForAccountDeletion", new { UserID = userId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task GetAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync("dbo.Project_Get", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); } public async Task GetForUserAsync(int projectId, int userId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync("dbo.Project_GetForUser", new { ProjectID = projectId, UserID = userId }, commandType: CommandType.StoredProcedure); } public async Task GetArchivedForOwnerAsync(int projectId, int userId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync("dbo.Project_GetArchivedForOwner", new { ProjectID = projectId, UserID = userId }, commandType: CommandType.StoredProcedure); } public async Task GetDashboardMetricsAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( """ SELECT (SELECT COUNT(1) FROM dbo.Characters WHERE ProjectID = @ProjectID AND IsArchived = 0) AS CharacterCount, (SELECT COUNT(1) FROM dbo.Locations WHERE ProjectID = @ProjectID AND IsArchived = 0) AS LocationCount, ( SELECT COUNT(1) FROM dbo.PlotThreads pt INNER JOIN dbo.PlotLines pl ON pl.PlotLineID = pt.PlotLineID INNER JOIN dbo.ThreadStatuses ts ON ts.ThreadStatusID = pt.ThreadStatusID WHERE pl.ProjectID = @ProjectID AND pl.IsArchived = 0 AND pt.IsArchived = 0 AND ts.IsOpenStatus = 1 ) AS ActivePlotThreadCount, ( SELECT COUNT(1) FROM dbo.ContinuityWarnings WHERE ProjectID = @ProjectID AND IsDismissed = 0 AND IsIntentional = 0 ) AS ContinuityWarningCount; """, new { ProjectID = projectId }); } public async Task> ListGenreMetricPresetsAsync() { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.GenreMetricPreset_List", commandType: CommandType.StoredProcedure); return rows.ToList(); } public Task SaveAsync(Project project) => SaveAsync(project, null); public Task SaveAsync(Project project, int userId, string? genreMetricPresetKey) => SaveInternalAsync(project, userId, genreMetricPresetKey); public async Task SaveAsync(Project project, string? genreMetricPresetKey) => await SaveInternalAsync(project, null, genreMetricPresetKey); private async Task SaveInternalAsync(Project project, int? userId, string? genreMetricPresetKey) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.Project_Save", new { project.ProjectID, project.ProjectName, project.Description, GenreMetricPresetKey = genreMetricPresetKey, UserID = userId }, commandType: CommandType.StoredProcedure); } public async Task ArchiveAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.Project_Archive", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); } public async Task ArchiveForUserAsync(int projectId, int userId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.Project_ArchiveForUser", new { ProjectID = projectId, UserID = userId }, commandType: CommandType.StoredProcedure); } public async Task ArchiveForOwnerAsync(int projectId, int userId) { using var connection = connectionFactory.CreateConnection(); var rowsAffected = await connection.QuerySingleAsync( "dbo.Project_ArchiveForOwner", new { ProjectID = projectId, UserID = userId }, commandType: CommandType.StoredProcedure); return rowsAffected > 0; } public async Task RestoreForOwnerAsync(int projectId, int userId) { using var connection = connectionFactory.CreateConnection(); var rowsAffected = await connection.QuerySingleAsync( "dbo.Project_RestoreForOwner", new { ProjectID = projectId, UserID = userId }, commandType: CommandType.StoredProcedure); return rowsAffected > 0; } public async Task> ListHardDeleteFilePathsForOwnerAsync(int projectId, int userId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( "dbo.ProjectHardDelete_ListFilePathsForOwner", new { ProjectID = projectId, UserID = userId }, commandType: CommandType.StoredProcedure); return rows.Where(path => !string.IsNullOrWhiteSpace(path)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); } public async Task HardDeleteForOwnerAsync(int projectId, int userId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.ProjectHardDelete_ForOwner", new { ProjectID = projectId, UserID = userId }, commandType: CommandType.StoredProcedure); } public async Task> ListHardDeleteFilePathsForAccountDeletionAsync(int projectId, int userId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( "dbo.ProjectHardDelete_ListFilePathsForAccountDeletion", new { ProjectID = projectId, UserID = userId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task HardDeleteForAccountDeletionAsync(int projectId, int userId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.ProjectHardDelete_ForAccountDeletion", new { ProjectID = projectId, UserID = userId }, commandType: CommandType.StoredProcedure); } } public sealed class ProjectCollaborationRepository(ISqlConnectionFactory connectionFactory) : IProjectCollaborationRepository { public async Task GetOwnerUserIdAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync( "dbo.ProjectCollaboration_GetOwner", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); } public async Task> ListCollaboratorsAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( "dbo.ProjectCollaboration_ListCollaborators", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task> ListPendingInvitationsAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( "dbo.ProjectCollaboration_ListPendingInvitations", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task CollaboratorCountsForOwnerAsync(int ownerUserId, int collaboratorUserId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.ProjectCollaboration_CollaboratorCountsForOwner", new { OwnerUserID = ownerUserId, CollaboratorUserID = collaboratorUserId }, commandType: CommandType.StoredProcedure); } public async Task AddCollaboratorAsync(int projectId, int userId, int invitedByUserId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.ProjectCollaboration_AddCollaborator", new { ProjectID = projectId, UserID = userId, InvitedByUserID = invitedByUserId }, commandType: CommandType.StoredProcedure); } public async Task RemoveCollaboratorAsync(int projectId, int userId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.ProjectCollaboration_RemoveCollaborator", new { ProjectID = projectId, UserID = userId }, commandType: CommandType.StoredProcedure); } public async Task CreatePendingInvitationAsync(int projectId, string emailAddress, int invitedByUserId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.ProjectCollaboration_CreatePendingInvitation", new { ProjectID = projectId, EmailAddress = emailAddress, InvitedByUserID = invitedByUserId }, commandType: CommandType.StoredProcedure); } } public sealed class ProjectActivityRepository(ISqlConnectionFactory connectionFactory) : IProjectActivityRepository { public async Task> ListForProjectAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( "dbo.ProjectActivity_ListForProject", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task RecordAsync(int projectId, int? userId, string activityType, string entityType, int? entityId, string? entityName, string? description = null) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.ProjectAudit_Record", new { ProjectID = projectId, UserID = userId, ActivityType = activityType, EntityType = entityType, EntityID = entityId, EntityName = entityName, Description = description }, commandType: CommandType.StoredProcedure); } } public sealed class BookRepository(ISqlConnectionFactory connectionFactory) : IBookRepository { public async Task> ListByProjectAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.Book_ListByProject", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task GetAsync(int bookId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync("dbo.Book_Get", new { BookID = bookId }, commandType: CommandType.StoredProcedure); } public async Task GetCoverPathsAsync(int bookId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync( """ SELECT BookID, ProjectID, CoverThumbnailPath, CoverStandardPath FROM dbo.Books WHERE BookID = @BookID; """, new { BookID = bookId }); } public async Task SaveAsync(Book book) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.Book_Save", new { book.BookID, book.ProjectID, book.BookTitle, book.Subtitle, book.Tagline, book.ShortDescription, book.Status, book.BookNumber, book.Description, book.TargetWordCount, book.WritingStartDate, book.TargetCompletionDate, book.PublicationDate }, commandType: CommandType.StoredProcedure); } public async Task UpdateCoverAsync(int bookId, string? thumbnailPath, string? standardPath) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.Book_UpdateCover", new { BookID = bookId, CoverThumbnailPath = thumbnailPath, CoverStandardPath = standardPath }, commandType: CommandType.StoredProcedure); } public async Task ArchiveAsync(int bookId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.Book_Archive", new { BookID = bookId }, commandType: CommandType.StoredProcedure); } } public sealed class ChapterRepository(ISqlConnectionFactory connectionFactory) : IChapterRepository { public async Task> ListByBookAsync(int bookId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.Chapter_ListByBook", new { BookID = bookId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task GetAsync(int chapterId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync("dbo.Chapter_Get", new { ChapterID = chapterId }, commandType: CommandType.StoredProcedure); } public async Task SaveAsync(Chapter chapter) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.Chapter_Save", new { chapter.ChapterID, chapter.BookID, chapter.ChapterNumber, chapter.ChapterTitle, chapter.Summary, chapter.RevisionStatusID }, commandType: CommandType.StoredProcedure); } public async Task ArchiveAsync(int chapterId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.Chapter_Archive", new { ChapterID = chapterId }, commandType: CommandType.StoredProcedure); } } public sealed class SceneRepository(ISqlConnectionFactory connectionFactory) : ISceneRepository { public async Task> ListByChapterAsync(int chapterId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.Scene_ListByChapter", new { ChapterID = chapterId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task GetAsync(int sceneId) { using var connection = connectionFactory.CreateConnection(); using var result = await connection.QueryMultipleAsync("dbo.Scene_Get", new { SceneID = sceneId }, commandType: CommandType.StoredProcedure); var scene = await result.ReadSingleOrDefaultAsync(); if (scene is null) { return null; } scene.SelectedPurposeTypeIds = (await result.ReadAsync()).ToList(); scene.Metrics = (await result.ReadAsync()).ToList(); return scene; } public async Task SaveAsync(Scene scene) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.Scene_Save", new { scene.SceneID, scene.ChapterID, scene.SceneNumber, scene.SceneTitle, scene.Summary, scene.TimeModeID, scene.StartDateTime, scene.EndDateTime, scene.DurationAmount, scene.DurationUnitID, scene.RelativeTimeText, scene.TimeConfidenceID, scene.ScenePurposeNotes, scene.SceneOutcomeNotes, scene.RevisionStatusID, scene.PrimaryLocationID }, commandType: CommandType.StoredProcedure); } public async Task SavePurposesAsync(int sceneId, IEnumerable purposeIds) { using var connection = connectionFactory.CreateConnection(); var cleanPurposeIds = purposeIds .Where(id => id > 0) .Distinct() .ToList(); await connection.ExecuteAsync( "dbo.Scene_SavePurposes", new { SceneID = sceneId, PurposeIds = string.Join(',', cleanPurposeIds) }, commandType: CommandType.StoredProcedure); } public async Task SaveMetricValuesAsync(int sceneId, IEnumerable metricValues) { using var connection = connectionFactory.CreateConnection(); foreach (var metric in metricValues) { await connection.ExecuteAsync( "dbo.SceneMetric_SaveValue", new { SceneID = sceneId, metric.MetricTypeID, metric.Value, metric.Notes }, commandType: CommandType.StoredProcedure); } } public async Task SaveMetricValueAsync(int sceneId, int metricTypeId, int value) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( """ MERGE dbo.SceneMetricValues AS target USING (SELECT @SceneID AS SceneID, @MetricTypeID AS MetricTypeID) AS source ON target.SceneID = source.SceneID AND target.MetricTypeID = source.MetricTypeID WHEN MATCHED THEN UPDATE SET Value = @Value WHEN NOT MATCHED THEN INSERT (SceneID, MetricTypeID, Value, Notes) VALUES (@SceneID, @MetricTypeID, @Value, NULL); """, new { SceneID = sceneId, MetricTypeID = metricTypeId, Value = value }); } public async Task ArchiveAsync(int sceneId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.Scene_Archive", new { SceneID = sceneId }, commandType: CommandType.StoredProcedure); } public async Task MoveAsync(int sceneId, string direction) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.Scene_Move", new { SceneID = sceneId, Direction = direction }, commandType: CommandType.StoredProcedure); } public async Task> ListChapterOptionsAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.ChapterOption_ListByProject", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task GetMovePreviewAsync(int sceneId, int targetChapterId, string position) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync( "dbo.SceneMovePreview_Get", new { SceneID = sceneId, TargetChapterID = targetChapterId, Position = position }, commandType: CommandType.StoredProcedure); } public async Task MoveToChapterAsync(int sceneId, int targetChapterId, string position, bool renumber) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.Scene_MoveToChapter", new { SceneID = sceneId, TargetChapterID = targetChapterId, Position = position, Renumber = renumber }, commandType: CommandType.StoredProcedure); } public async Task MoveRelativeAsync(int sceneId, int anchorSceneId, string position) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.Scene_MoveRelative", new { SceneID = sceneId, AnchorSceneID = anchorSceneId, Position = position }, commandType: CommandType.StoredProcedure); } public async Task RenumberChapterAsync(int chapterId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.Scene_RenumberChapter", new { ChapterID = chapterId }, commandType: CommandType.StoredProcedure); } } public sealed class LookupRepository(ISqlConnectionFactory connectionFactory) : ILookupRepository { public async Task GetAllAsync() { using var connection = connectionFactory.CreateConnection(); using var result = await connection.QueryMultipleAsync("dbo.Lookup_All", commandType: CommandType.StoredProcedure); return new LookupData { RevisionStatuses = (await result.ReadAsync()).ToList(), TimeModes = (await result.ReadAsync()).ToList(), DurationUnits = (await result.ReadAsync()).ToList(), TimeConfidences = (await result.ReadAsync()).ToList(), ScenePurposeTypes = (await result.ReadAsync()).ToList() }; } } public sealed class TimelineRepository(ISqlConnectionFactory connectionFactory) : ITimelineRepository { public async Task GetByProjectAsync(int projectId, int? bookId, bool includeMetrics, IEnumerable metricTypeIds, bool includePlotLines) { using var connection = connectionFactory.CreateConnection(); var selectedMetricIds = string.Join(',', metricTypeIds.Where(id => id > 0).Distinct()); using var result = await connection.QueryMultipleAsync( "dbo.Timeline_GetByProject", new { ProjectID = projectId, BookID = bookId, IncludeMetrics = includeMetrics, SelectedMetricTypeIDs = selectedMetricIds, IncludePlotLines = includePlotLines }, commandType: CommandType.StoredProcedure); var project = await result.ReadSingleOrDefaultAsync(); var books = (await result.ReadAsync()).ToList(); var chapters = (await result.ReadAsync()).ToList(); var scenes = (await result.ReadAsync()).ToList(); var purposeLabels = (await result.ReadAsync()).ToList(); var metricTypes = (await result.ReadAsync()).ToList(); var metricPoints = (await result.ReadAsync()).ToList(); var plotLines = (await result.ReadAsync()).ToList(); var threadEvents = (await result.ReadAsync()).ToList(); await PopulateThreadEventTargetsAsync(connection, threadEvents); return new TimelineData { Project = project, Books = books, Chapters = chapters, Scenes = scenes, PurposeLabels = purposeLabels, MetricTypes = metricTypes, MetricPoints = metricPoints, PlotLines = plotLines, ThreadEvents = threadEvents }; } private static async Task PopulateThreadEventTargetsAsync(IDbConnection connection, IReadOnlyList threadEvents) { var threadEventIds = threadEvents.Select(x => x.ThreadEventID).ToList(); if (threadEventIds.Count == 0) { return; } var targetRows = await connection.QueryAsync( "SELECT PlotEventID, PlotLineID FROM dbo.PlotEventTargetPlotLines WHERE PlotEventID IN @ThreadEventIDs;", new { ThreadEventIDs = threadEventIds }); var targetsByEvent = targetRows .GroupBy(x => x.PlotEventID) .ToDictionary(x => x.Key, x => (IReadOnlyList)x.Select(row => row.PlotLineID).ToList()); foreach (var threadEvent in threadEvents) { if (targetsByEvent.TryGetValue(threadEvent.ThreadEventID, out var targets)) { threadEvent.TargetPlotLineIDs = targets; } } } private sealed class ThreadEventTargetRow { public int PlotEventID { get; set; } public int PlotLineID { get; set; } } } public sealed class TimelineSettingsRepository(ISqlConnectionFactory connectionFactory) : ITimelineSettingsRepository { public async Task GetAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync( "dbo.ProjectTimelineSettings_Get", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); } public async Task> ListMetricTypesAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( "dbo.ProjectTimelineSettings_MetricTypes", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task> ListMetricSettingsAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( "dbo.ProjectTimelineMetricSettings_List", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task SaveAsync(ProjectTimelineSettings settings, IEnumerable metricTypeIds) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.ProjectTimelineSettings_Save", new { settings.ProjectID, settings.ShowSceneCards, settings.ShowMetricShape, settings.ShowPlotLines, settings.ShowStoryAssets, settings.ShowCharacterAppearances, settings.ShowWarnings, MetricTypeIDs = string.Join(',', metricTypeIds.Where(id => id > 0).Distinct()) }, commandType: CommandType.StoredProcedure); } } public sealed class SceneMetricTypeRepository(ISqlConnectionFactory connectionFactory) : ISceneMetricTypeRepository { public async Task> ListForManagementAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( "dbo.SceneMetricType_ListForProjectManagement", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task GetAsync(int metricTypeId, int projectId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync( "dbo.SceneMetricType_GetForProject", new { MetricTypeID = metricTypeId, ProjectID = projectId }, commandType: CommandType.StoredProcedure); } public async Task SaveAsync(SceneMetricType metric) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.SceneMetricType_SaveForProject", new { metric.MetricTypeID, metric.ProjectID, metric.MetricName, metric.Description, metric.MinValue, metric.MaxValue, metric.DefaultValue, metric.SortOrder, metric.IsActive }, commandType: CommandType.StoredProcedure); } public async Task SetActiveAsync(int metricTypeId, int projectId, bool isActive) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.SceneMetricType_SetActiveForProject", new { MetricTypeID = metricTypeId, ProjectID = projectId, IsActive = isActive }, commandType: CommandType.StoredProcedure); } public async Task MoveAsync(int metricTypeId, int projectId, string direction) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.SceneMetricType_MoveForProject", new { MetricTypeID = metricTypeId, ProjectID = projectId, Direction = direction }, commandType: CommandType.StoredProcedure); } public async Task AddMissingDefaultsAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.SceneMetricType_AddMissingDefaultsForProject", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); } public async Task RemoveUnusedAsync(int metricTypeId, int projectId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.SceneMetricType_RemoveUnusedForProject", new { MetricTypeID = metricTypeId, ProjectID = projectId }, commandType: CommandType.StoredProcedure); } public async Task HasProjectMetricsAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync("dbo.SceneMetricType_ProjectHasMetrics", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); } } public sealed class TimelinePresetRepository(ISqlConnectionFactory connectionFactory) : ITimelinePresetRepository { public async Task> ListAsync(int projectId, int? bookId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( "dbo.TimelineViewPreset_List", new { ProjectID = projectId, BookID = bookId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task GetAsync(int timelineViewPresetId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync( "dbo.TimelineViewPreset_Get", new { TimelineViewPresetID = timelineViewPresetId }, commandType: CommandType.StoredProcedure); } public async Task SaveAsync(TimelineViewPreset preset) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.TimelineViewPreset_Save", new { preset.TimelineViewPresetID, preset.ProjectID, preset.BookID, preset.PresetName, preset.FilterJson, preset.VisibilityJson, preset.FocusType, preset.FocusID }, commandType: CommandType.StoredProcedure); } public async Task ArchiveAsync(int timelineViewPresetId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.TimelineViewPreset_Archive", new { TimelineViewPresetID = timelineViewPresetId }, commandType: CommandType.StoredProcedure); } } public sealed class PlotRepository(ISqlConnectionFactory connectionFactory) : IPlotRepository { public async Task GetLookupsAsync() { using var connection = connectionFactory.CreateConnection(); using var result = await connection.QueryMultipleAsync("dbo.PlotLookup_All", commandType: CommandType.StoredProcedure); return new PlotLookupData { PlotLineTypes = (await result.ReadAsync()).ToList(), PlotImportance = (await result.ReadAsync()).ToList(), ThreadTypes = (await result.ReadAsync()).ToList(), ThreadStatuses = (await result.ReadAsync()).ToList(), ThreadEventTypes = (await result.ReadAsync()).ToList(), PlotEventTypes = (await result.ReadAsync()).ToList() }; } public async Task> ListPlotLinesAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.PlotLine_ListByProject", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task GetPlotLineAsync(int plotLineId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync("dbo.PlotLine_Get", new { PlotLineID = plotLineId }, commandType: CommandType.StoredProcedure); } public async Task SavePlotLineAsync(PlotLineItem plotLine) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.PlotLine_Save", new { plotLine.PlotLineID, plotLine.ProjectID, plotLine.BookID, plotLine.PlotLineName, plotLine.PlotLineTypeID, plotLine.PlotImportanceID, plotLine.Description, plotLine.ParentPlotLineID, plotLine.EmergesFromPlotLineID, plotLine.SortOrder, plotLine.Colour, plotLine.IsVisibleOnTimeline }, commandType: CommandType.StoredProcedure); } public async Task ArchivePlotLineAsync(int plotLineId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.PlotLine_Archive", new { PlotLineID = plotLineId }, commandType: CommandType.StoredProcedure); } public async Task> ListPlotThreadsByProjectAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.PlotThread_ListByProject", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task> ListPlotThreadsByPlotLineAsync(int plotLineId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.PlotThread_ListByPlotLine", new { PlotLineID = plotLineId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task GetPlotThreadAsync(int plotThreadId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync("dbo.PlotThread_Get", new { PlotThreadID = plotThreadId }, commandType: CommandType.StoredProcedure); } public async Task SavePlotThreadAsync(PlotThread plotThread) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.PlotThread_Save", new { plotThread.PlotThreadID, plotThread.PlotLineID, plotThread.ThreadTitle, plotThread.ThreadTypeID, plotThread.ThreadStatusID, plotThread.Importance, plotThread.Summary, plotThread.IntroducedSceneID, plotThread.PlannedResolutionSceneID, plotThread.ActualResolutionSceneID }, commandType: CommandType.StoredProcedure); } public async Task ArchivePlotThreadAsync(int plotThreadId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.PlotThread_Archive", new { PlotThreadID = plotThreadId }, commandType: CommandType.StoredProcedure); } public async Task> ListThreadEventsBySceneAsync(int sceneId) { using var connection = connectionFactory.CreateConnection(); var rows = (await connection.QueryAsync("dbo.ThreadEvent_ListByScene", new { SceneID = sceneId }, commandType: CommandType.StoredProcedure)).ToList(); await PopulateThreadEventTargetsAsync(connection, rows); return rows; } public async Task> ListThreadEventsByPlotThreadAsync(int plotThreadId) { using var connection = connectionFactory.CreateConnection(); var rows = (await connection.QueryAsync("dbo.ThreadEvent_ListByPlotThread", new { PlotThreadID = plotThreadId }, commandType: CommandType.StoredProcedure)).ToList(); await PopulateThreadEventTargetsAsync(connection, rows); return rows; } private static async Task PopulateThreadEventTargetsAsync(IDbConnection connection, IReadOnlyList threadEvents) { var threadEventIds = threadEvents.Select(x => x.ThreadEventID).ToList(); if (threadEventIds.Count == 0) { return; } var targetRows = await connection.QueryAsync( "SELECT PlotEventID, PlotLineID FROM dbo.PlotEventTargetPlotLines WHERE PlotEventID IN @ThreadEventIDs;", new { ThreadEventIDs = threadEventIds }); var targetsByEvent = targetRows .GroupBy(x => x.PlotEventID) .ToDictionary(x => x.Key, x => (IReadOnlyList)x.Select(row => row.PlotLineID).ToList()); foreach (var threadEvent in threadEvents) { if (targetsByEvent.TryGetValue(threadEvent.ThreadEventID, out var targets)) { threadEvent.TargetPlotLineIDs = targets; } } } private sealed class ThreadEventTargetRow { public int PlotEventID { get; set; } public int PlotLineID { get; set; } } public async Task SaveThreadEventAsync(ThreadEvent threadEvent) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.ThreadEvent_Save", new { threadEvent.ThreadEventID, threadEvent.PlotThreadID, threadEvent.SceneID, threadEvent.EventTypeID, threadEvent.PlotEventTypeID, threadEvent.TargetPlotLineID, threadEvent.EventTitle, threadEvent.EventDescription }, commandType: CommandType.StoredProcedure); } public async Task SaveThreadEventTargetsAsync(int threadEventId, IEnumerable plotLineIds) { using var connection = connectionFactory.CreateConnection(); var targetIds = plotLineIds.Where(id => id > 0).Distinct().ToList(); await connection.ExecuteAsync( "DELETE FROM dbo.PlotEventTargetPlotLines WHERE PlotEventID = @ThreadEventID;", new { ThreadEventID = threadEventId }); if (targetIds.Count == 0) { return; } await connection.ExecuteAsync( "INSERT dbo.PlotEventTargetPlotLines (PlotEventID, PlotLineID) VALUES (@ThreadEventID, @PlotLineID);", targetIds.Select(plotLineId => new { ThreadEventID = threadEventId, PlotLineID = plotLineId })); } public async Task DeleteThreadEventAsync(int threadEventId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.ThreadEvent_Delete", new { ThreadEventID = threadEventId }, commandType: CommandType.StoredProcedure); } public async Task> ListSceneOptionsAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.SceneOption_ListByProject", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } } public sealed class AssetRepository(ISqlConnectionFactory connectionFactory) : IAssetRepository { public async Task GetLookupsAsync() { using var connection = connectionFactory.CreateConnection(); using var result = await connection.QueryMultipleAsync("dbo.AssetLookup_All", commandType: CommandType.StoredProcedure); return new AssetLookupData { AssetKinds = (await result.ReadAsync()).ToList(), AssetStates = (await result.ReadAsync()).ToList(), AssetEventTypes = (await result.ReadAsync()).ToList(), AssetDependencyTypes = (await result.ReadAsync()).ToList(), AssetCustodyEventTypes = (await result.ReadAsync()).ToList(), CustodyRoles = (await result.ReadAsync()).ToList() }; } public async Task> ListAssetsAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.StoryAsset_ListByProject", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task GetAssetAsync(int storyAssetId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync("dbo.StoryAsset_Get", new { StoryAssetID = storyAssetId }, commandType: CommandType.StoredProcedure); } public async Task SaveAssetAsync(StoryAsset asset) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.StoryAsset_Save", new { asset.StoryAssetID, asset.ProjectID, asset.AssetName, asset.AssetKindID, asset.Description, asset.Importance, asset.CurrentStateID, asset.CurrentLocationID, asset.IsResolved, asset.ShowInQuickAddBar }, commandType: CommandType.StoredProcedure); } public async Task ArchiveAssetAsync(int storyAssetId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.StoryAsset_Archive", new { StoryAssetID = storyAssetId }, commandType: CommandType.StoredProcedure); } public async Task> ListEventsBySceneAsync(int sceneId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.AssetEvent_ListByScene", new { SceneID = sceneId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task> ListEventsByAssetAsync(int storyAssetId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.AssetEvent_ListByAsset", new { StoryAssetID = storyAssetId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task SaveEventAsync(AssetEvent assetEvent) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.AssetEvent_Save", new { assetEvent.AssetEventID, assetEvent.StoryAssetID, assetEvent.SceneID, assetEvent.AssetEventTypeID, assetEvent.FromStateID, assetEvent.ToStateID, assetEvent.EventTitle, assetEvent.EventDescription }, commandType: CommandType.StoredProcedure); } public async Task DeleteEventAsync(int assetEventId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.AssetEvent_Delete", new { AssetEventID = assetEventId }, commandType: CommandType.StoredProcedure); } public async Task> ListDependenciesByAssetAsync(int storyAssetId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.AssetDependency_ListByAsset", new { StoryAssetID = storyAssetId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task SaveDependencyAsync(AssetDependency dependency) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.AssetDependency_Save", new { dependency.AssetDependencyID, dependency.SourceAssetID, dependency.TargetAssetID, dependency.AssetDependencyTypeID, dependency.SourceRequiredStateID, dependency.TargetBlockedStateID, dependency.Description }, commandType: CommandType.StoredProcedure); } public async Task DeleteDependencyAsync(int assetDependencyId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.AssetDependency_Delete", new { AssetDependencyID = assetDependencyId }, commandType: CommandType.StoredProcedure); } public async Task> ListCustodyBySceneAsync(int sceneId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.AssetCustodyEvent_ListByScene", new { SceneID = sceneId }, commandType: CommandType.StoredProcedure); return await WithValidCustodianSummariesAsync(connection, rows.ToList()); } public async Task> ListCustodyByAssetAsync(int storyAssetId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.AssetCustodyEvent_ListByAsset", new { StoryAssetID = storyAssetId }, commandType: CommandType.StoredProcedure); return await WithValidCustodianSummariesAsync(connection, rows.ToList()); } public async Task> ListCustodyCharactersByProjectAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( """ SELECT acec.AssetCustodyEventID, ace.StoryAssetID, ace.SceneID, acec.CharacterID, c.CharacterName, acec.CharacterNameText, cr.RoleName, ace.UpdatedDate FROM dbo.AssetCustodyEventCharacters acec INNER JOIN dbo.AssetCustodyEvents ace ON ace.AssetCustodyEventID = acec.AssetCustodyEventID INNER JOIN dbo.StoryAssets sa ON sa.StoryAssetID = ace.StoryAssetID LEFT JOIN dbo.Characters c ON c.CharacterID = acec.CharacterID LEFT JOIN dbo.CustodyRoles cr ON cr.CustodyRoleID = acec.CustodyRoleID WHERE sa.ProjectID = @ProjectID AND sa.IsArchived = 0 AND acec.CharacterID IS NOT NULL AND NULLIF(LTRIM(RTRIM(c.CharacterName)), N'') IS NOT NULL; """, new { ProjectID = projectId }); return rows.ToList(); } private static async Task> WithValidCustodianSummariesAsync(IDbConnection connection, List rows) { if (rows.Count == 0) { return rows; } var eventIds = rows.Select(x => x.AssetCustodyEventID).Distinct().ToList(); var validCustodians = await connection.QueryAsync( """ SELECT acec.AssetCustodyEventID, c.CharacterName, cr.RoleName FROM dbo.AssetCustodyEventCharacters acec INNER JOIN dbo.Characters c ON c.CharacterID = acec.CharacterID LEFT JOIN dbo.CustodyRoles cr ON cr.CustodyRoleID = acec.CustodyRoleID WHERE acec.AssetCustodyEventID IN @AssetCustodyEventIDs AND acec.CharacterID IS NOT NULL AND NULLIF(LTRIM(RTRIM(c.CharacterName)), N'') IS NOT NULL; """, new { AssetCustodyEventIDs = eventIds }); var summaries = validCustodians .GroupBy(x => x.AssetCustodyEventID) .ToDictionary( x => x.Key, x => string.Join(", ", x .Select(row => string.IsNullOrWhiteSpace(row.RoleName) ? row.CharacterName : $"{row.CharacterName} ({row.RoleName})") .Where(label => !string.IsNullOrWhiteSpace(label)) .Distinct(StringComparer.OrdinalIgnoreCase))); foreach (var row in rows) { row.CustodianSummary = summaries.GetValueOrDefault(row.AssetCustodyEventID); } return rows.Where(x => !string.IsNullOrWhiteSpace(x.CustodianSummary)).ToList(); } private sealed class AssetCustodySummaryRow { public int AssetCustodyEventID { get; set; } public string CharacterName { get; set; } = string.Empty; public string? RoleName { get; set; } } public async Task SaveCustodyEventAsync(AssetCustodyEvent custodyEvent, string custodianNames, IEnumerable custodianCharacterIds, int custodyRoleId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.AssetCustodyEvent_Save", new { custodyEvent.AssetCustodyEventID, custodyEvent.StoryAssetID, custodyEvent.SceneID, custodyEvent.AssetCustodyEventTypeID, custodyEvent.Description, CustodianNames = custodianNames, CustodianCharacterIds = string.Join(',', custodianCharacterIds.Where(id => id > 0).Distinct()), CustodyRoleID = custodyRoleId }, commandType: CommandType.StoredProcedure); } public async Task DeleteCustodyEventAsync(int assetCustodyEventId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.AssetCustodyEvent_Delete", new { AssetCustodyEventID = assetCustodyEventId }, commandType: CommandType.StoredProcedure); } public async Task<(IReadOnlyList Assets, IReadOnlyList Events)> GetTimelineAsync(int projectId, int? bookId) { using var connection = connectionFactory.CreateConnection(); using var result = await connection.QueryMultipleAsync("dbo.AssetTimeline_GetByProject", new { ProjectID = projectId, BookID = bookId }, commandType: CommandType.StoredProcedure); var assets = (await result.ReadAsync()).ToList(); var events = (await result.ReadAsync()).ToList(); return (assets, events); } public async Task> CheckDependencyIssuesAsync(int storyAssetId, int sceneId, int? toStateId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( "dbo.AssetDependency_CheckForEvent", new { StoryAssetID = storyAssetId, SceneID = sceneId, ToStateID = toStateId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } } public sealed class CharacterRepository(ISqlConnectionFactory connectionFactory) : ICharacterRepository { public async Task GetLookupsAsync() { using var connection = connectionFactory.CreateConnection(); using var result = await connection.QueryMultipleAsync("dbo.CharacterLookup_All", commandType: CommandType.StoredProcedure); return new CharacterLookupData { RoleTypes = (await result.ReadAsync()).ToList(), PresenceTypes = (await result.ReadAsync()).ToList(), AttributeTypes = (await result.ReadAsync()).ToList(), KnowledgeStates = (await result.ReadAsync()).ToList(), RelationshipTypes = (await result.ReadAsync()).ToList(), RelationshipStates = (await result.ReadAsync()).ToList(), RelationshipCategories = (await result.ReadAsync()).ToList() }; } public async Task> ListCharactersAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.Character_ListByProject", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task GetCharacterAsync(int characterId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync("dbo.Character_Get", new { CharacterID = characterId }, commandType: CommandType.StoredProcedure); } public async Task SaveCharacterAsync(Character character) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.Character_Save", new { character.CharacterID, character.ProjectID, character.CharacterName, character.ShortName, character.Sex, character.BirthDate, character.AgeAtSeriesStart, character.AgeReferenceSceneID, character.Height, character.EyeColour, character.CharacterImportance, character.ShowInQuickAddBar, character.DefaultDescription }, commandType: CommandType.StoredProcedure); } public async Task ArchiveCharacterAsync(int characterId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.Character_Archive", new { CharacterID = characterId }, commandType: CommandType.StoredProcedure); } public async Task> ListSceneCharactersAsync(int sceneId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.SceneCharacter_ListByScene", new { SceneID = sceneId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task SaveSceneCharacterAsync(SceneCharacter sceneCharacter) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.SceneCharacter_Save", new { sceneCharacter.SceneCharacterID, sceneCharacter.SceneID, sceneCharacter.CharacterID, sceneCharacter.RoleInSceneTypeID, sceneCharacter.PresenceTypeID, sceneCharacter.LocationID, sceneCharacter.EntryLocationID, sceneCharacter.ExitLocationID, sceneCharacter.AppearanceNotes, sceneCharacter.OutfitDescription, sceneCharacter.PhysicalCondition, sceneCharacter.EmotionalState, sceneCharacter.KnowledgeNotes }, commandType: CommandType.StoredProcedure); } public async Task DeleteSceneCharacterAsync(int sceneCharacterId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.SceneCharacter_Delete", new { SceneCharacterID = sceneCharacterId }, commandType: CommandType.StoredProcedure); } public async Task> ListAttributeEventsByCharacterAsync(int characterId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.CharacterAttributeEvent_ListByCharacter", new { CharacterID = characterId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task SaveAttributeEventAsync(CharacterAttributeEvent attributeEvent) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.CharacterAttributeEvent_Save", new { attributeEvent.CharacterAttributeEventID, attributeEvent.CharacterID, attributeEvent.CharacterAttributeTypeID, attributeEvent.SceneID, attributeEvent.AttributeValue, attributeEvent.Description }, commandType: CommandType.StoredProcedure); } public async Task> ListKnowledgeBySceneAsync(int sceneId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.CharacterKnowledge_ListByScene", new { SceneID = sceneId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task> ListKnowledgeByCharacterAsync(int characterId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.CharacterKnowledge_ListByCharacter", new { CharacterID = characterId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task SaveKnowledgeAsync(CharacterKnowledgeItem knowledge) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.CharacterKnowledge_Save", new { knowledge.CharacterKnowledgeID, knowledge.CharacterID, knowledge.StoryAssetID, knowledge.PlotThreadID, knowledge.SceneID, knowledge.KnowledgeStateID, knowledge.SourceEventID, knowledge.Description }, commandType: CommandType.StoredProcedure); } public async Task DeleteKnowledgeAsync(int characterKnowledgeId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.CharacterKnowledge_Delete", new { CharacterKnowledgeID = characterKnowledgeId }, commandType: CommandType.StoredProcedure); } public async Task> ListRelationshipsByProjectAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.Relationship_ListByProject", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task> ListRelationshipsByCharacterAsync(int characterId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.Relationship_ListByCharacter", new { CharacterID = characterId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task GetRelationshipAsync(int characterRelationshipId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync( "dbo.Relationship_Get", new { CharacterRelationshipID = characterRelationshipId }, commandType: CommandType.StoredProcedure); } public async Task SaveRelationshipAsync(CharacterRelationship relationship) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.Relationship_Save", new { relationship.CharacterRelationshipID, relationship.ProjectID, relationship.CharacterAID, relationship.CharacterBID, relationship.RelationshipTypeID, relationship.IsPermanent, relationship.StartSceneID, relationship.EndSceneID, relationship.IsKnownToReader, relationship.Notes, relationship.IsInitialRelationship, relationship.InitialBookID, relationship.ReaderInitiallyKnows, relationship.InitialRelationshipStateID, relationship.InitialIntensity, relationship.IsReciprocal }, commandType: CommandType.StoredProcedure); } public async Task> ListInitialRelationshipsByCharacterAsync(int characterId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.Relationship_ListInitialByCharacter", new { CharacterID = characterId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task ArchiveRelationshipAsync(int characterRelationshipId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.Relationship_Archive", new { CharacterRelationshipID = characterRelationshipId }, commandType: CommandType.StoredProcedure); } public async Task> ListRelationshipEventsBySceneAsync(int sceneId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.RelationshipEvent_ListByScene", new { SceneID = sceneId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task> ListRelationshipEventsByCharacterAsync(int characterId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync("dbo.RelationshipEvent_ListByCharacter", new { CharacterID = characterId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task SaveRelationshipEventAsync(RelationshipEvent relationshipEvent) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.RelationshipEvent_Save", new { relationshipEvent.RelationshipEventID, relationshipEvent.CharacterRelationshipID, relationshipEvent.SceneID, relationshipEvent.RelationshipStateID, relationshipEvent.Intensity, relationshipEvent.IsKnownToOtherCharacter, relationshipEvent.Description }, commandType: CommandType.StoredProcedure); } public async Task DeleteRelationshipEventAsync(int relationshipEventId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync("dbo.RelationshipEvent_Delete", new { RelationshipEventID = relationshipEventId }, commandType: CommandType.StoredProcedure); } public async Task<(IReadOnlyList Characters, IReadOnlyList Appearances)> GetTimelineAsync(int projectId, int? bookId) { using var connection = connectionFactory.CreateConnection(); using var result = await connection.QueryMultipleAsync("dbo.CharacterTimeline_GetByProject", new { ProjectID = projectId, BookID = bookId }, commandType: CommandType.StoredProcedure); var characters = (await result.ReadAsync()).ToList(); var appearances = (await result.ReadAsync()).ToList(); return (characters, appearances); } public async Task GetRelationshipMapAsync(int projectId) { using var connection = connectionFactory.CreateConnection(); using var result = await connection.QueryMultipleAsync("dbo.RelationshipMap_GetByProject", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); return new RelationshipMapData { Characters = (await result.ReadAsync()).ToList(), Books = (await result.ReadAsync()).ToList(), Chapters = (await result.ReadAsync()).ToList(), Scenes = (await result.ReadAsync()).ToList(), Relationships = (await result.ReadAsync()).ToList(), RelationshipEvents = (await result.ReadAsync()).ToList(), RelationshipCategories = (await result.ReadAsync()).ToList() }; } }