From fa401e1056571b272abbc8b4a40b3ae8d0b40e69 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 6 May 2026 11:48:37 +0200 Subject: [PATCH 1/4] chore: remove non-essential modules and AI/RAG framework projects Strip the template down to core framework + auth/admin essentials. Removes demo/showcase modules (Datasets, Map, PageBuilder, Orders, Products, Marketplace) and AI/agentic modules (Rag, Agents, Chat) along with their framework dependencies (SimpleModule.Rag*, SimpleModule.Agents, SimpleModule.AI.*). Kept: Admin, AuditLogs, BackgroundJobs, Dashboard, Email, FeatureFlags, FileStorage, Localization, OpenIddict, Permissions, RateLimiting, Settings, Tenants, Users. Also removes the agent UI components from @simplemodule/ui, the sm new agent CLI subcommand, and corresponding test fixtures, benchmarks, and load-test scenarios. Updates Host/Worker Program.cs and csprojs and the Tests.Shared factory to drop references to deleted contexts and seed services. --- SimpleModule.slnx | 56 +- .../Commands/New/NewAgentCommand.cs | 173 - .../Commands/New/NewAgentSettings.cs | 56 - cli/SimpleModule.Cli/Program.cs | 11 +- .../Templates/HostTemplates.cs | 9 - .../AnthropicExtensions.cs | 28 - .../AnthropicOptions.cs | 7 - .../SimpleModule.AI.Anthropic.csproj | 11 - .../AzureOpenAIExtensions.cs | 43 - .../AzureOpenAIOptions.cs | 9 - .../SimpleModule.AI.AzureOpenAI.csproj | 12 - .../OllamaExtensions.cs | 31 - .../SimpleModule.AI.Ollama/OllamaOptions.cs | 8 - .../SimpleModule.AI.Ollama.csproj | 11 - .../OpenAIExtensions.cs | 40 - .../SimpleModule.AI.OpenAI/OpenAIOptions.cs | 8 - .../SimpleModule.AI.OpenAI.csproj | 12 - framework/SimpleModule.Agents/AgentBuilder.cs | 23 - .../SimpleModule.Agents/AgentChatService.cs | 160 - framework/SimpleModule.Agents/AgentOptions.cs | 18 - .../SimpleModule.Agents/AgentRegistration.cs | 9 - .../SimpleModule.Agents/AgentRegistry.cs | 19 - .../Dtos/AgentChatRequest.cs | 14 - .../Dtos/AgentChatResponse.cs | 3 - .../SimpleModule.Agents/Dtos/AgentInfo.cs | 3 - .../Guardrails/GuardrailDirection.cs | 7 - .../Guardrails/GuardrailResult.cs | 15 - .../Guardrails/IAgentGuardrail.cs | 10 - .../SimpleModule.Agents/IAgentRegistry.cs | 7 - .../Middleware/AgentContext.cs | 16 - .../Middleware/AgentMiddlewarePipeline.cs | 25 - .../Middleware/IAgentMiddleware.cs | 10 - .../SimpleModule.Agents.csproj | 14 - .../SimpleModuleAgentExtensions.cs | 22 - .../ContentHasher.cs | 16 - .../Data/CachedStructuredKnowledge.cs | 21 - .../Data/IStructuredKnowledgeCache.cs | 28 - .../IKnowledgeStructurizer.cs | 13 - .../IStructureRouter.cs | 12 - .../IStructuredKnowledgeUtilizer.cs | 10 - .../LlmKnowledgeStructurizer.cs | 36 - .../LlmStructureRouter.cs | 45 - .../LlmStructuredKnowledgeUtilizer.cs | 32 - .../Preprocessing/LlmKnowledgePreprocessor.cs | 123 - .../SimpleModule.Rag.StructuredRag.csproj | 12 - .../StructurePrompts.cs | 63 - .../StructuredKnowledge.cs | 5 - .../StructuredRagExtensions.cs | 31 - .../StructuredRagOptions.cs | 17 - .../StructuredRagPipeline.cs | 117 - .../InMemoryVectorStoreExtensions.cs | 16 - ...mpleModule.Rag.VectorStore.InMemory.csproj | 13 - .../PostgresVectorStoreExtensions.cs | 35 - .../PostgresVectorStoreOptions.cs | 6 - ...mpleModule.Rag.VectorStore.Postgres.csproj | 13 - framework/SimpleModule.Rag/IKnowledgeStore.cs | 23 - framework/SimpleModule.Rag/IRagPipeline.cs | 10 - .../KnowledgeIndexingHostedService.cs | 121 - framework/SimpleModule.Rag/KnowledgeRecord.cs | 24 - .../SimpleModule.Rag/KnowledgeSearchResult.cs | 8 - framework/SimpleModule.Rag/RagOptions.cs | 9 - framework/SimpleModule.Rag/RagQueryOptions.cs | 12 - framework/SimpleModule.Rag/RagResult.cs | 22 - .../RagSettingsDefinitions.cs | 74 - .../SimpleModule.Rag/SimpleModule.Rag.csproj | 13 - .../SimpleModuleRagExtensions.cs | 23 - .../SimpleModule.Rag/VectorKnowledgeStore.cs | 102 - .../AgentMessage.cs | 14 - .../AgentMessageDto.cs | 14 - .../AgentMessageId.cs | 6 - .../AgentSession.cs | 15 - .../AgentSessionDto.cs | 13 - .../AgentSessionId.cs | 6 - .../Events/AgentMessageReceivedEvent.cs | 12 - .../Events/AgentResponseGeneratedEvent.cs | 13 - .../Events/AgentToolCalledEvent.cs | 11 - .../IAgentsContracts.cs | 21 - .../SimpleModule.Agents.Contracts.csproj | 13 - .../AgentEndpoints.cs | 76 - .../AgentSettingsDefinitions.cs | 95 - .../AgentsConstants.cs | 6 - .../AgentsDbContext.cs | 51 - .../AgentsModule.cs | 56 - .../AgentsService.cs | 62 - .../DevTools/AgentPlaygroundEndpoints.cs | 57 - .../EfAgentSessionStore.cs | 68 - .../AgentMessageConfiguration.cs | 21 - .../AgentSessionConfiguration.cs | 23 - .../Files/AgentFileService.cs | 49 - .../Guardrails/ContentLengthGuardrail.cs | 32 - .../Guardrails/PiiRedactionGuardrail.cs | 33 - .../Guardrails/PromptInjectionGuardrail.cs | 46 - .../Middleware/LoggingMiddleware.cs | 34 - .../Middleware/RateLimitingMiddleware.cs | 50 - .../Middleware/RetryMiddleware.cs | 43 - .../Middleware/TokenTrackingMiddleware.cs | 33 - .../src/SimpleModule.Agents.Module/README.md | 29 - .../Sessions/IAgentSessionStore.cs | 26 - .../Sessions/InMemoryAgentSessionStore.cs | 70 - .../SimpleModule.Agents.Module.csproj | 15 - .../Telemetry/AgentActivitySource.cs | 30 - .../Agents/src/SimpleModule.Agents/types.ts | 28 - .../SimpleModule.Agents.Tests/GlobalUsings.cs | 1 - .../IntegrationTestCollection.cs | 6 - .../SimpleModule.Agents.Tests.csproj | 21 - .../ChatConstants.cs | 22 - .../ChatMessage.cs | 29 - .../ChatMessageId.cs | 6 - .../Conversation.cs | 13 - .../ConversationId.cs | 6 - .../CreateConversationRequest.cs | 12 - .../IChatContracts.cs | 16 - .../SimpleModule.Chat.Contracts.csproj | 12 - .../src/SimpleModule.Chat/ChatDbContext.cs | 39 - .../Chat/src/SimpleModule.Chat/ChatModule.cs | 44 - .../src/SimpleModule.Chat/ChatPermissions.cs | 10 - .../Chat/src/SimpleModule.Chat/ChatService.cs | 152 - .../SimpleModule.Chat/Dtos/TanStackDtos.cs | 56 - .../Chat/CreateConversationEndpoint.cs | 44 - .../Chat/DeleteConversationEndpoint.cs | 26 - .../Endpoints/Chat/GetConversationEndpoint.cs | 37 - .../Endpoints/Chat/GetMessagesEndpoint.cs | 30 - .../Chat/ListConversationsEndpoint.cs | 26 - .../Chat/RenameConversationEndpoint.cs | 42 - .../Chat/SendMessageStreamEndpoint.cs | 179 - .../ConversationConfiguration.cs | 44 - .../src/SimpleModule.Chat/Pages/Browse.tsx | 98 - .../SimpleModule.Chat/Pages/BrowseEndpoint.cs | 38 - .../SimpleModule.Chat/Pages/Conversation.tsx | 102 - .../Pages/ConversationEndpoint.cs | 54 - .../Chat/src/SimpleModule.Chat/Pages/index.ts | 4 - modules/Chat/src/SimpleModule.Chat/README.md | 83 - .../SimpleModule.Chat.csproj | 13 - .../Chat/src/SimpleModule.Chat/package.json | 18 - .../Chat/src/SimpleModule.Chat/tsconfig.json | 8 - modules/Chat/src/SimpleModule.Chat/types.ts | 32 - .../Chat/src/SimpleModule.Chat/vite.config.ts | 3 - .../SimpleModule.Chat.Tests/GlobalUsings.cs | 1 - .../ChatEndpointTests.Authorization.cs | 81 - .../Integration/ChatEndpointTests.Create.cs | 52 - .../Integration/ChatEndpointTests.Helpers.cs | 39 - .../Integration/ChatEndpointTests.Mutate.cs | 85 - .../Integration/ChatEndpointTests.Query.cs | 80 - ...hatStreamingEndpointTests.ErrorHandling.cs | 84 - .../ChatStreamingEndpointTests.HappyPath.cs | 119 - .../ChatStreamingEndpointTests.Helpers.cs | 190 - .../ChatStreamingEndpointTests.Validation.cs | 94 - .../IntegrationTestCollection.cs | 6 - .../SimpleModule.Chat.Tests.csproj | 23 - .../Unit/AgentHistoryReplayTests.cs | 200 - .../Unit/ChatServiceTests.Conversation.cs | 250 - .../Unit/ChatServiceTests.Helpers.cs | 35 - .../Unit/ChatServiceTests.Messages.cs | 103 - .../Unit/ConversationIdTests.cs | 82 - .../Unit/TanStackDtoSerializationTests.cs | 141 - .../BoundingBoxDto.cs | 12 - .../Dataset.cs | 27 - .../DatasetDto.cs | 30 - .../DatasetFormat.cs | 57 - .../DatasetId.cs | 6 - .../DatasetMetadata.cs | 86 - .../DatasetStatus.cs | 9 - .../DatasetsConstants.cs | 40 - .../DatasetsPermissions.cs | 11 - .../Events/DatasetProcessed.cs | 7 - .../IDatasetsContracts.cs | 43 - .../SimpleModule.Datasets.Contracts.csproj | 12 - .../Agents/DatasetsToolProvider.cs | 63 - .../Converters/IDatasetConverter.cs | 24 - .../Converters/RasterToCogConverter.cs | 44 - .../Converters/VectorToGeoJsonConverter.cs | 33 - .../Converters/VectorToPmTilesConverter.cs | 55 - .../DatasetsContractsService.Features.cs | 101 - .../DatasetsContractsService.cs | 245 - .../DatasetsDbContext.cs | 47 - .../SimpleModule.Datasets/DatasetsModule.cs | 177 - .../Datasets/ConvertDatasetEndpoint.cs | 62 - .../Datasets/DeleteDatasetEndpoint.cs | 25 - .../Datasets/DownloadDatasetEndpoint.cs | 59 - .../Endpoints/Datasets/GetDatasetEndpoint.cs | 25 - .../Datasets/GetDatasetFeaturesEndpoint.cs | 85 - .../Datasets/ListDatasetsEndpoint.cs | 21 - .../Datasets/UploadDatasetEndpoint.cs | 44 - .../DatasetConfiguration.cs | 34 - .../Infrastructure/CliRunner.cs | 50 - .../Infrastructure/TempDirectory.cs | 28 - .../Jobs/ConvertDatasetJob.cs | 141 - .../Jobs/ProcessDatasetJob.cs | 143 - .../Jobs/PurgeDatasetJob.cs | 83 - .../SimpleModule.Datasets/Pages/Browse.tsx | 93 - .../SimpleModule.Datasets/Pages/Detail.tsx | 140 - .../SimpleModule.Datasets/Pages/Upload.tsx | 65 - .../src/SimpleModule.Datasets/Pages/index.ts | 5 - .../src/SimpleModule.Datasets/Pages/labels.ts | 33 - .../Processing/CogProcessor.cs | 142 - .../Processing/DatasetProcessorRegistry.cs | 14 - .../Processing/GeoJsonBboxWalker.cs | 55 - .../Processing/GeoJsonProcessor.cs | 138 - .../Processing/IDatasetProcessor.cs | 27 - .../Processing/OgrVectorProcessor.cs | 234 - .../Processing/PmTilesProcessor.cs | 174 - .../SimpleModule.Datasets.csproj | 20 - .../Views/DatasetsBrowseView.cs | 24 - .../Views/DatasetsDetailView.cs | 29 - .../Views/DatasetsUploadView.cs | 17 - .../src/SimpleModule.Datasets/package.json | 14 - .../src/SimpleModule.Datasets/tsconfig.json | 8 - .../src/SimpleModule.Datasets/types.ts | 100 - .../src/SimpleModule.Datasets/vite.config.ts | 3 - .../Fixtures/sample.geojson | 15 - .../GeoJsonProcessorTests.cs | 27 - .../GlobalUsings.cs | 1 - .../PurgeDatasetJobTests.cs | 241 - .../SimpleModule.Datasets.Tests.csproj | 24 - .../src/SimpleModule.Map.Contracts/Basemap.cs | 35 - .../SimpleModule.Map.Contracts/BasemapId.cs | 6 - .../CreateBasemapRequest.cs | 27 - .../CreateLayerSourceFromDatasetRequest.cs | 11 - .../CreateLayerSourceRequest.cs | 32 - .../IMapContracts.cs | 37 - .../SimpleModule.Map.Contracts/LayerSource.cs | 61 - .../LayerSourceId.cs | 6 - .../LayerSourceType.cs | 18 - .../SimpleModule.Map.Contracts/MapBasemap.cs | 17 - .../MapConstants.cs | 55 - .../SimpleModule.Map.Contracts/MapLayer.cs | 24 - .../SimpleModule.Map.Contracts/SavedMap.cs | 53 - .../SimpleModule.Map.Contracts/SavedMapId.cs | 6 - .../SimpleModule.Map.Contracts.csproj | 13 - .../UpdateBasemapRequest.cs | 27 - .../UpdateDefaultMapRequest.cs | 29 - .../UpdateLayerSourceRequest.cs | 32 - .../Basemaps/CreateBasemapEndpoint.cs | 41 - .../Basemaps/CreateBasemapRequestValidator.cs | 17 - .../Basemaps/DeleteBasemapEndpoint.cs | 22 - .../Basemaps/GetAllBasemapsEndpoint.cs | 17 - .../Basemaps/GetBasemapByIdEndpoint.cs | 21 - .../Basemaps/UpdateBasemapEndpoint.cs | 39 - .../Basemaps/UpdateBasemapRequestValidator.cs | 17 - .../DefaultMap/GetDefaultMapEndpoint.cs | 20 - .../DefaultMap/UpdateDefaultMapEndpoint.cs | 38 - .../UpdateDefaultMapRequestValidator.cs | 24 - .../Endpoints/LayerSources/CreateEndpoint.cs | 41 - .../LayerSources/CreateFromDatasetEndpoint.cs | 29 - .../CreateLayerSourceRequestValidator.cs | 20 - .../Endpoints/LayerSources/DeleteEndpoint.cs | 22 - .../Endpoints/LayerSources/GetAllEndpoint.cs | 17 - .../Endpoints/LayerSources/GetByIdEndpoint.cs | 21 - .../Endpoints/LayerSources/UpdateEndpoint.cs | 41 - .../UpdateLayerSourceRequestValidator.cs | 20 - .../BasemapConfiguration.cs | 120 - .../JsonDictionaryConverter.cs | 41 - .../LayerSourceConfiguration.cs | 247 - .../SavedMapConfiguration.cs | 71 - .../Map/src/SimpleModule.Map/MapDbContext.cs | 41 - modules/Map/src/SimpleModule.Map/MapModule.cs | 106 - .../src/SimpleModule.Map/MapModuleOptions.cs | 54 - .../src/SimpleModule.Map/MapPermissions.cs | 16 - .../SimpleModule.Map/MapService.Basemaps.cs | 67 - .../SimpleModule.Map/MapService.Logging.cs | 48 - .../Map/src/SimpleModule.Map/MapService.cs | 279 - .../Map/src/SimpleModule.Map/Pages/Browse.tsx | 294 - .../SimpleModule.Map/Pages/BrowseEndpoint.cs | 71 - .../Map/src/SimpleModule.Map/Pages/Layers.tsx | 202 - .../SimpleModule.Map/Pages/LayersEndpoint.cs | 29 - .../Pages/components/AddBasemapDialog.tsx | 91 - .../Pages/components/AddLayerSourceDialog.tsx | 134 - .../Pages/components/BasemapsPanel.tsx | 125 - .../Pages/components/LayersPanel.tsx | 168 - .../Pages/components/MapBottomControls.tsx | 51 - .../Pages/components/MapCanvas.tsx | 137 - .../Pages/components/MapTopControls.tsx | 68 - .../Pages/components/css.d.ts | 1 - .../Pages/components/layer-utils.ts | 11 - .../Pages/components/useViewportInsets.ts | 36 - .../Map/src/SimpleModule.Map/Pages/index.ts | 4 - .../Pages/lib/layer-builders.ts | 204 - .../SimpleModule.Map/Pages/lib/protocols.ts | 20 - .../SimpleModule.Map/SimpleModule.Map.csproj | 15 - modules/Map/src/SimpleModule.Map/package.json | 20 - .../Map/src/SimpleModule.Map/tsconfig.json | 8 - modules/Map/src/SimpleModule.Map/types.ts | 122 - .../Map/src/SimpleModule.Map/vite.config.ts | 3 - .../SimpleModule.Map.Tests/GlobalUsings.cs | 1 - .../Integration/BasemapsEndpointTests.cs | 107 - .../CreateFromDatasetEndpointTests.cs | 191 - .../Integration/LayerSourcesEndpointTests.cs | 149 - .../Integration/MapsEndpointTests.cs | 168 - .../IntegrationTestCollection.cs | 6 - .../SimpleModule.Map.Tests.csproj | 22 - .../IMarketplaceContracts.cs | 8 - .../MarketplaceCategory.cs | 14 - .../MarketplaceConstants.cs | 17 - .../MarketplacePackage.cs | 20 - .../MarketplacePackageDetail.cs | 19 - .../MarketplaceSearchRequest.cs | 10 - .../MarketplaceSearchResult.cs | 10 - .../MarketplaceSortOption.cs | 9 - .../SimpleModule.Marketplace.Contracts.csproj | 9 - .../CategoryMapper.cs | 54 - .../Endpoints/Marketplace/GetByIdEndpoint.cs | 22 - .../Endpoints/Marketplace/SearchEndpoint.cs | 37 - .../InstalledPackageDetector.cs | 77 - .../SimpleModule.Marketplace/Locales/en.json | 33 - .../SimpleModule.Marketplace/Locales/keys.ts | 37 - .../MarketplaceConstants.cs | 18 - .../MarketplaceModule.cs | 52 - .../MarketplaceModuleOptions.cs | 15 - .../MarketplacePermissions.cs | 10 - .../src/SimpleModule.Marketplace/NuGetDtos.cs | 73 - .../NuGetMarketplaceService.cs | 253 - .../SimpleModule.Marketplace/Pages/Browse.tsx | 220 - .../Pages/BrowseEndpoint.cs | 61 - .../SimpleModule.Marketplace/Pages/Detail.tsx | 255 - .../Pages/DetailEndpoint.cs | 32 - .../Pages/components/DetailHelpers.tsx | 67 - .../SimpleModule.Marketplace/Pages/index.ts | 4 - .../SimpleModule.Marketplace/Pages/utils.ts | 26 - .../src/SimpleModule.Marketplace/README.md | 32 - .../SimpleModule.Marketplace.csproj | 19 - .../src/SimpleModule.Marketplace/package.json | 17 - .../SimpleModule.Marketplace/tsconfig.json | 8 - .../src/SimpleModule.Marketplace/types.ts | 53 - .../SimpleModule.Marketplace/vite.config.ts | 3 - .../GlobalUsings.cs | 1 - .../MarketplaceBrowseEndpointTests.cs | 29 - .../Integration/MarketplaceEndpointTests.cs | 59 - .../IntegrationTestCollection.cs | 6 - .../SimpleModule.Marketplace.Tests.csproj | 22 - .../Unit/CategoryMapperTests.cs | 49 - .../CreateOrderRequest.cs | 7 - .../Events/OrderCreatedEvent.cs | 5 - .../IOrderContracts.cs | 10 - .../SimpleModule.Orders.Contracts/Order.cs | 10 - .../SimpleModule.Orders.Contracts/OrderId.cs | 6 - .../OrderItem.cs | 7 - .../OrdersConstants.cs | 35 - .../SimpleModule.Orders.Contracts.csproj | 12 - .../UpdateOrderRequest.cs | 7 - .../Endpoints/Orders/CreateEndpoint.cs | 42 - .../Orders/CreateRequestValidator.cs | 54 - .../Endpoints/Orders/DeleteEndpoint.cs | 23 - .../Endpoints/Orders/GetAllEndpoint.cs | 22 - .../Endpoints/Orders/GetByIdEndpoint.cs | 22 - .../Endpoints/Orders/UpdateEndpoint.cs | 47 - .../OrderConfiguration.cs | 17 - .../OrderItemConfiguration.cs | 13 - .../src/SimpleModule.Orders/Locales/en.json | 43 - .../src/SimpleModule.Orders/Locales/keys.ts | 53 - .../src/SimpleModule.Orders/OrderService.cs | 160 - .../SimpleModule.Orders/OrdersConstants.cs | 44 - .../SimpleModule.Orders/OrdersDbContext.cs | 43 - .../src/SimpleModule.Orders/OrdersModule.cs | 39 - .../OrdersModuleOptions.cs | 19 - .../SimpleModule.Orders/OrdersPermissions.cs | 11 - .../src/SimpleModule.Orders/Pages/Create.tsx | 176 - .../Pages/CreateEndpoint.cs | 62 - .../src/SimpleModule.Orders/Pages/Edit.tsx | 223 - .../SimpleModule.Orders/Pages/EditEndpoint.cs | 92 - .../src/SimpleModule.Orders/Pages/List.tsx | 120 - .../SimpleModule.Orders/Pages/ListEndpoint.cs | 22 - .../src/SimpleModule.Orders/Pages/index.ts | 5 - .../Orders/src/SimpleModule.Orders/README.md | 31 - .../Services/OrderSeedService.cs | 115 - .../SimpleModule.Orders.csproj | 18 - .../src/SimpleModule.Orders/package.json | 14 - .../src/SimpleModule.Orders/tsconfig.json | 8 - .../Orders/src/SimpleModule.Orders/types.ts | 28 - .../src/SimpleModule.Orders/vite.config.ts | 3 - .../SimpleModule.Orders.Tests/GlobalUsings.cs | 1 - .../Integration/OrdersEndpointTests.cs | 82 - .../IntegrationTestCollection.cs | 6 - .../SimpleModule.Orders.Tests.csproj | 25 - .../Unit/CreateOrderRequestValidatorTests.cs | 77 - .../Unit/OrderIdTests.cs | 49 - .../Unit/OrderServiceTests.cs | 258 - .../AddTagRequest.cs | 6 - .../CreatePageRequest.cs | 7 - .../CreatePageTemplateRequest.cs | 7 - .../Events/PageCreatedEvent.cs | 5 - .../Events/PageDeletedEvent.cs | 5 - .../Events/PagePublishedEvent.cs | 5 - .../Events/PageUnpublishedEvent.cs | 5 - .../IPageBuilderContracts.cs | 18 - .../IPageBuilderTagContracts.cs | 9 - .../IPageBuilderTemplateContracts.cs | 8 - .../Page.cs | 17 - .../PageBuilderConstants.cs | 42 - .../PageId.cs | 6 - .../PageSummary.cs | 15 - .../PageTag.cs | 7 - .../PageTagId.cs | 6 - .../PageTemplate.cs | 9 - .../PageTemplateId.cs | 6 - .../SimpleModule.PageBuilder.Contracts.csproj | 12 - .../UpdatePageContentRequest.cs | 6 - .../UpdatePageRequest.cs | 12 - .../Endpoints/Pages/CreateEndpoint.cs | 41 - .../Pages/CreatePageRequestValidator.cs | 12 - .../Endpoints/Pages/DeleteEndpoint.cs | 22 - .../Endpoints/Pages/GetAllEndpoint.cs | 21 - .../Endpoints/Pages/GetByIdEndpoint.cs | 21 - .../Pages/PermanentDeleteEndpoint.cs | 23 - .../Endpoints/Pages/PublishEndpoint.cs | 25 - .../Endpoints/Pages/RestoreEndpoint.cs | 25 - .../Endpoints/Pages/TrashEndpoint.cs | 22 - .../Endpoints/Pages/UnpublishEndpoint.cs | 25 - .../Endpoints/Pages/UpdateContentEndpoint.cs | 22 - .../Endpoints/Pages/UpdateEndpoint.cs | 41 - .../Pages/UpdatePageRequestValidator.cs | 13 - .../Endpoints/Tags/AddTagToPageEndpoint.cs | 28 - .../Endpoints/Tags/GetAllTagsEndpoint.cs | 21 - .../Tags/RemoveTagFromPageEndpoint.cs | 25 - .../Templates/CreateTemplateEndpoint.cs | 34 - .../Templates/DeleteTemplateEndpoint.cs | 23 - .../Templates/GetAllTemplatesEndpoint.cs | 22 - .../EntityConfigurations/PageConfiguration.cs | 33 - .../PageTagConfiguration.cs | 16 - .../PageTemplateConfiguration.cs | 18 - .../SimpleModule.PageBuilder/Locales/en.json | 47 - .../SimpleModule.PageBuilder/Locales/keys.ts | 69 - .../PageBuilderDbContext.cs | 55 - .../PageBuilderModule.cs | 42 - .../PageBuilderModuleOptions.cs | 19 - .../PageBuilderPermissions.cs | 12 - .../PageBuilderService.Logging.cs | 37 - .../PageBuilderService.Templates.cs | 75 - .../PageBuilderService.cs | 297 - .../SimpleModule.PageBuilder/Pages/Editor.tsx | 201 - .../Pages/EditorEndpoint.cs | 60 - .../SimpleModule.PageBuilder/Pages/Manage.tsx | 261 - .../Pages/ManageEndpoint.cs | 26 - .../Pages/PagesList.tsx | 53 - .../Pages/PagesListEndpoint.cs | 26 - .../SimpleModule.PageBuilder/Pages/Viewer.tsx | 38 - .../Pages/ViewerDraftEndpoint.cs | 47 - .../Pages/ViewerEndpoint.cs | 32 - .../Pages/components/HeaderActions.tsx | 119 - .../SimpleModule.PageBuilder/Pages/index.ts | 9 - .../src/SimpleModule.PageBuilder/README.md | 31 - .../SimpleModule.PageBuilder.csproj | 23 - .../src/SimpleModule.PageBuilder/package.json | 17 - .../puck/components/ButtonBlock.tsx | 55 - .../puck/components/Card.tsx | 29 - .../puck/components/Flex.tsx | 76 - .../puck/components/Grid.tsx | 40 - .../puck/components/Heading.tsx | 53 - .../puck/components/Hero.tsx | 36 - .../puck/components/Logos.tsx | 49 - .../puck/components/Space.tsx | 23 - .../puck/components/Stats.tsx | 38 - .../puck/components/Text.tsx | 57 - .../SimpleModule.PageBuilder/puck/config.tsx | 37 - .../puck/puck-styles.css | 1 - .../SimpleModule.PageBuilder/tsconfig.json | 9 - .../src/SimpleModule.PageBuilder/types.ts | 79 - .../SimpleModule.PageBuilder/vite.config.ts | 3 - .../GlobalUsings.cs | 1 - .../IntegrationTestCollection.cs | 6 - .../PageBuilderServiceTests.Extras.cs | 122 - .../PageBuilderServiceTests.Helpers.cs | 40 - .../PageBuilderServiceTests.Pages.cs | 178 - .../PageEndpointTests.Crud.cs | 97 - .../PageEndpointTests.Helpers.cs | 14 - .../PageEndpointTests.Publishing.cs | 112 - .../PageEndpointTests.SoftDelete.cs | 85 - .../PageEndpointTests.TemplatesAndTags.cs | 133 - .../SimpleModule.PageBuilder.Tests.csproj | 22 - .../CreateProductRequest.cs | 7 - .../Events/ProductCreatedEvent.cs | 5 - .../Events/ProductDeletedEvent.cs | 5 - .../Events/ProductUpdatedEvent.cs | 5 - .../IProductContracts.cs | 11 - .../Product.cs | 9 - .../ProductId.cs | 6 - .../ProductsConstants.cs | 22 - .../SimpleModule.Products.Contracts.csproj | 12 - .../UpdateProductRequest.cs | 7 - .../Agents/ProductKnowledgeSource.cs | 26 - .../Agents/ProductSearchAgent.cs | 18 - .../Agents/ProductToolProvider.cs | 15 - .../Endpoints/Products/CreateEndpoint.cs | 41 - .../Products/CreateRequestValidator.cs | 13 - .../Endpoints/Products/DeleteEndpoint.cs | 22 - .../Endpoints/Products/GetAllEndpoint.cs | 21 - .../Endpoints/Products/GetByIdEndpoint.cs | 21 - .../Endpoints/Products/UpdateEndpoint.cs | 41 - .../Products/UpdateRequestValidator.cs | 13 - .../ProductConfiguration.cs | 45 - .../src/SimpleModule.Products/Locales/en.json | 34 - .../src/SimpleModule.Products/Locales/keys.ts | 46 - .../SimpleModule.Products/Pages/Browse.tsx | 25 - .../Pages/BrowseEndpoint.cs | 30 - .../SimpleModule.Products/Pages/Create.tsx | 77 - .../Pages/CreateEndpoint.cs | 36 - .../src/SimpleModule.Products/Pages/Edit.tsx | 126 - .../Pages/EditEndpoint.cs | 56 - .../SimpleModule.Products/Pages/Manage.tsx | 118 - .../Pages/ManageEndpoint.cs | 24 - .../src/SimpleModule.Products/Pages/index.ts | 6 - .../SimpleModule.Products/ProductService.cs | 110 - .../ProductsDbContext.cs | 41 - .../SimpleModule.Products/ProductsFeatures.cs | 9 - .../SimpleModule.Products/ProductsModule.cs | 60 - .../ProductsModuleOptions.cs | 26 - .../ProductsPermissions.cs | 11 - .../src/SimpleModule.Products/README.md | 31 - .../SimpleModule.Products.csproj | 21 - .../src/SimpleModule.Products/package.json | 14 - .../src/SimpleModule.Products/tsconfig.json | 8 - .../src/SimpleModule.Products/types.ts | 20 - .../src/SimpleModule.Products/vite.config.ts | 3 - .../GlobalUsings.cs | 1 - .../ProductsBrowseEndpointTests.cs | 59 - .../Integration/ProductsEndpointTests.cs | 192 - .../IntegrationTestCollection.cs | 6 - .../SimpleModule.Products.Tests.csproj | 22 - .../Unit/CreateRequestValidatorTests.cs | 42 - .../Unit/ProductIdTests.cs | 49 - .../Unit/ProductServiceTests.cs | 128 - .../Unit/UpdateRequestValidatorTests.cs | 64 - .../IRagContracts.cs | 27 - .../SimpleModule.Rag.Contracts.csproj | 11 - .../EfStructuredKnowledgeCache.cs | 102 - .../CachedStructuredKnowledgeConfiguration.cs | 31 - .../Rag/src/SimpleModule.Rag.Module/README.md | 30 - .../SimpleModule.Rag.Module/RagConstants.cs | 6 - .../SimpleModule.Rag.Module/RagDbContext.cs | 23 - .../src/SimpleModule.Rag.Module/RagModule.cs | 19 - .../src/SimpleModule.Rag.Module/RagService.cs | 40 - .../SimpleModule.Rag.Module.csproj | 14 - .../SimpleModule.Rag.Tests/GlobalUsings.cs | 1 - .../IntegrationTestCollection.cs | 6 - .../SimpleModule.Rag.Tests.csproj | 21 - package-lock.json | 7598 ++++------------- packages/SimpleModule.Client/src/routes.ts | 120 - .../SimpleModule.UI/components/agent-chat.tsx | 101 - .../components/agent-playground.tsx | 36 - .../components/agent-selector.tsx | 49 - packages/SimpleModule.UI/components/index.ts | 3 - .../SimpleModule.UI/hooks/use-agent-chat.ts | 145 - template/SimpleModule.Host/Program.cs | 10 - .../SimpleModule.Host.csproj | 16 +- template/SimpleModule.Worker/Program.cs | 22 +- .../SimpleModule.Worker.csproj | 12 - .../Benchmarks/OrdersBenchmarks.cs | 75 - .../Benchmarks/PageBuilderBenchmarks.cs | 65 - .../Benchmarks/ProductsBenchmarks.cs | 76 - .../Benchmarks/SerializationBenchmarks.cs | 66 - .../NewProjectScaffoldTests.BuildTests.cs | 6 +- .../Infrastructure/ModuleOptionsTests.cs | 67 +- ...TestWebApplicationFactory.Configuration.cs | 6 - .../LoadTestWebApplicationFactory.cs | 16 - .../SimpleModule.LoadTests/LoadTestRunner.cs | 44 +- .../Scenarios/MarketplaceScenario.cs | 39 - .../Scenarios/MixedWorkloadScenario.cs | 121 - .../Scenarios/OrdersScenario.cs | 88 - .../Scenarios/PageBuilderScenario.cs | 139 - .../Scenarios/ProductsScenario.cs | 79 - .../Agents/AgentTestFixture.cs | 39 - .../Agents/MockChatClient.cs | 52 - .../Datasets/TestDatasetsDbContext.cs | 57 - .../Fakes/FakeDataGenerators.cs | 50 - .../Fakes/FakeOrderContracts.cs | 55 - .../Fakes/FakeProductContracts.cs | 61 - ...leModuleWebApplicationFactory.Databases.cs | 14 - .../SimpleModuleWebApplicationFactory.cs | 21 - .../SimpleModule.Tests.Shared.csproj | 10 +- 568 files changed, 1522 insertions(+), 31933 deletions(-) delete mode 100644 cli/SimpleModule.Cli/Commands/New/NewAgentCommand.cs delete mode 100644 cli/SimpleModule.Cli/Commands/New/NewAgentSettings.cs delete mode 100644 framework/SimpleModule.AI.Anthropic/AnthropicExtensions.cs delete mode 100644 framework/SimpleModule.AI.Anthropic/AnthropicOptions.cs delete mode 100644 framework/SimpleModule.AI.Anthropic/SimpleModule.AI.Anthropic.csproj delete mode 100644 framework/SimpleModule.AI.AzureOpenAI/AzureOpenAIExtensions.cs delete mode 100644 framework/SimpleModule.AI.AzureOpenAI/AzureOpenAIOptions.cs delete mode 100644 framework/SimpleModule.AI.AzureOpenAI/SimpleModule.AI.AzureOpenAI.csproj delete mode 100644 framework/SimpleModule.AI.Ollama/OllamaExtensions.cs delete mode 100644 framework/SimpleModule.AI.Ollama/OllamaOptions.cs delete mode 100644 framework/SimpleModule.AI.Ollama/SimpleModule.AI.Ollama.csproj delete mode 100644 framework/SimpleModule.AI.OpenAI/OpenAIExtensions.cs delete mode 100644 framework/SimpleModule.AI.OpenAI/OpenAIOptions.cs delete mode 100644 framework/SimpleModule.AI.OpenAI/SimpleModule.AI.OpenAI.csproj delete mode 100644 framework/SimpleModule.Agents/AgentBuilder.cs delete mode 100644 framework/SimpleModule.Agents/AgentChatService.cs delete mode 100644 framework/SimpleModule.Agents/AgentOptions.cs delete mode 100644 framework/SimpleModule.Agents/AgentRegistration.cs delete mode 100644 framework/SimpleModule.Agents/AgentRegistry.cs delete mode 100644 framework/SimpleModule.Agents/Dtos/AgentChatRequest.cs delete mode 100644 framework/SimpleModule.Agents/Dtos/AgentChatResponse.cs delete mode 100644 framework/SimpleModule.Agents/Dtos/AgentInfo.cs delete mode 100644 framework/SimpleModule.Agents/Guardrails/GuardrailDirection.cs delete mode 100644 framework/SimpleModule.Agents/Guardrails/GuardrailResult.cs delete mode 100644 framework/SimpleModule.Agents/Guardrails/IAgentGuardrail.cs delete mode 100644 framework/SimpleModule.Agents/IAgentRegistry.cs delete mode 100644 framework/SimpleModule.Agents/Middleware/AgentContext.cs delete mode 100644 framework/SimpleModule.Agents/Middleware/AgentMiddlewarePipeline.cs delete mode 100644 framework/SimpleModule.Agents/Middleware/IAgentMiddleware.cs delete mode 100644 framework/SimpleModule.Agents/SimpleModule.Agents.csproj delete mode 100644 framework/SimpleModule.Agents/SimpleModuleAgentExtensions.cs delete mode 100644 framework/SimpleModule.Rag.StructuredRag/ContentHasher.cs delete mode 100644 framework/SimpleModule.Rag.StructuredRag/Data/CachedStructuredKnowledge.cs delete mode 100644 framework/SimpleModule.Rag.StructuredRag/Data/IStructuredKnowledgeCache.cs delete mode 100644 framework/SimpleModule.Rag.StructuredRag/IKnowledgeStructurizer.cs delete mode 100644 framework/SimpleModule.Rag.StructuredRag/IStructureRouter.cs delete mode 100644 framework/SimpleModule.Rag.StructuredRag/IStructuredKnowledgeUtilizer.cs delete mode 100644 framework/SimpleModule.Rag.StructuredRag/LlmKnowledgeStructurizer.cs delete mode 100644 framework/SimpleModule.Rag.StructuredRag/LlmStructureRouter.cs delete mode 100644 framework/SimpleModule.Rag.StructuredRag/LlmStructuredKnowledgeUtilizer.cs delete mode 100644 framework/SimpleModule.Rag.StructuredRag/Preprocessing/LlmKnowledgePreprocessor.cs delete mode 100644 framework/SimpleModule.Rag.StructuredRag/SimpleModule.Rag.StructuredRag.csproj delete mode 100644 framework/SimpleModule.Rag.StructuredRag/StructurePrompts.cs delete mode 100644 framework/SimpleModule.Rag.StructuredRag/StructuredKnowledge.cs delete mode 100644 framework/SimpleModule.Rag.StructuredRag/StructuredRagExtensions.cs delete mode 100644 framework/SimpleModule.Rag.StructuredRag/StructuredRagOptions.cs delete mode 100644 framework/SimpleModule.Rag.StructuredRag/StructuredRagPipeline.cs delete mode 100644 framework/SimpleModule.Rag.VectorStore.InMemory/InMemoryVectorStoreExtensions.cs delete mode 100644 framework/SimpleModule.Rag.VectorStore.InMemory/SimpleModule.Rag.VectorStore.InMemory.csproj delete mode 100644 framework/SimpleModule.Rag.VectorStore.Postgres/PostgresVectorStoreExtensions.cs delete mode 100644 framework/SimpleModule.Rag.VectorStore.Postgres/PostgresVectorStoreOptions.cs delete mode 100644 framework/SimpleModule.Rag.VectorStore.Postgres/SimpleModule.Rag.VectorStore.Postgres.csproj delete mode 100644 framework/SimpleModule.Rag/IKnowledgeStore.cs delete mode 100644 framework/SimpleModule.Rag/IRagPipeline.cs delete mode 100644 framework/SimpleModule.Rag/KnowledgeIndexingHostedService.cs delete mode 100644 framework/SimpleModule.Rag/KnowledgeRecord.cs delete mode 100644 framework/SimpleModule.Rag/KnowledgeSearchResult.cs delete mode 100644 framework/SimpleModule.Rag/RagOptions.cs delete mode 100644 framework/SimpleModule.Rag/RagQueryOptions.cs delete mode 100644 framework/SimpleModule.Rag/RagResult.cs delete mode 100644 framework/SimpleModule.Rag/RagSettingsDefinitions.cs delete mode 100644 framework/SimpleModule.Rag/SimpleModule.Rag.csproj delete mode 100644 framework/SimpleModule.Rag/SimpleModuleRagExtensions.cs delete mode 100644 framework/SimpleModule.Rag/VectorKnowledgeStore.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Contracts/AgentMessage.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Contracts/AgentMessageDto.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Contracts/AgentMessageId.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Contracts/AgentSession.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Contracts/AgentSessionDto.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Contracts/AgentSessionId.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Contracts/Events/AgentMessageReceivedEvent.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Contracts/Events/AgentResponseGeneratedEvent.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Contracts/Events/AgentToolCalledEvent.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Contracts/IAgentsContracts.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Contracts/SimpleModule.Agents.Contracts.csproj delete mode 100644 modules/Agents/src/SimpleModule.Agents.Module/AgentEndpoints.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Module/AgentSettingsDefinitions.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Module/AgentsConstants.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Module/AgentsDbContext.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Module/AgentsModule.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Module/AgentsService.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Module/DevTools/AgentPlaygroundEndpoints.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Module/EfAgentSessionStore.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Module/EntityConfigurations/AgentMessageConfiguration.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Module/EntityConfigurations/AgentSessionConfiguration.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Module/Files/AgentFileService.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Module/Guardrails/ContentLengthGuardrail.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Module/Guardrails/PiiRedactionGuardrail.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Module/Guardrails/PromptInjectionGuardrail.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Module/Middleware/LoggingMiddleware.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Module/Middleware/RateLimitingMiddleware.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Module/Middleware/RetryMiddleware.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Module/Middleware/TokenTrackingMiddleware.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Module/README.md delete mode 100644 modules/Agents/src/SimpleModule.Agents.Module/Sessions/IAgentSessionStore.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Module/Sessions/InMemoryAgentSessionStore.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents.Module/SimpleModule.Agents.Module.csproj delete mode 100644 modules/Agents/src/SimpleModule.Agents.Module/Telemetry/AgentActivitySource.cs delete mode 100644 modules/Agents/src/SimpleModule.Agents/types.ts delete mode 100644 modules/Agents/tests/SimpleModule.Agents.Tests/GlobalUsings.cs delete mode 100644 modules/Agents/tests/SimpleModule.Agents.Tests/IntegrationTestCollection.cs delete mode 100644 modules/Agents/tests/SimpleModule.Agents.Tests/SimpleModule.Agents.Tests.csproj delete mode 100644 modules/Chat/src/SimpleModule.Chat.Contracts/ChatConstants.cs delete mode 100644 modules/Chat/src/SimpleModule.Chat.Contracts/ChatMessage.cs delete mode 100644 modules/Chat/src/SimpleModule.Chat.Contracts/ChatMessageId.cs delete mode 100644 modules/Chat/src/SimpleModule.Chat.Contracts/Conversation.cs delete mode 100644 modules/Chat/src/SimpleModule.Chat.Contracts/ConversationId.cs delete mode 100644 modules/Chat/src/SimpleModule.Chat.Contracts/CreateConversationRequest.cs delete mode 100644 modules/Chat/src/SimpleModule.Chat.Contracts/IChatContracts.cs delete mode 100644 modules/Chat/src/SimpleModule.Chat.Contracts/SimpleModule.Chat.Contracts.csproj delete mode 100644 modules/Chat/src/SimpleModule.Chat/ChatDbContext.cs delete mode 100644 modules/Chat/src/SimpleModule.Chat/ChatModule.cs delete mode 100644 modules/Chat/src/SimpleModule.Chat/ChatPermissions.cs delete mode 100644 modules/Chat/src/SimpleModule.Chat/ChatService.cs delete mode 100644 modules/Chat/src/SimpleModule.Chat/Dtos/TanStackDtos.cs delete mode 100644 modules/Chat/src/SimpleModule.Chat/Endpoints/Chat/CreateConversationEndpoint.cs delete mode 100644 modules/Chat/src/SimpleModule.Chat/Endpoints/Chat/DeleteConversationEndpoint.cs delete mode 100644 modules/Chat/src/SimpleModule.Chat/Endpoints/Chat/GetConversationEndpoint.cs delete mode 100644 modules/Chat/src/SimpleModule.Chat/Endpoints/Chat/GetMessagesEndpoint.cs delete mode 100644 modules/Chat/src/SimpleModule.Chat/Endpoints/Chat/ListConversationsEndpoint.cs delete mode 100644 modules/Chat/src/SimpleModule.Chat/Endpoints/Chat/RenameConversationEndpoint.cs delete mode 100644 modules/Chat/src/SimpleModule.Chat/Endpoints/Chat/SendMessageStreamEndpoint.cs delete mode 100644 modules/Chat/src/SimpleModule.Chat/EntityConfigurations/ConversationConfiguration.cs delete mode 100644 modules/Chat/src/SimpleModule.Chat/Pages/Browse.tsx delete mode 100644 modules/Chat/src/SimpleModule.Chat/Pages/BrowseEndpoint.cs delete mode 100644 modules/Chat/src/SimpleModule.Chat/Pages/Conversation.tsx delete mode 100644 modules/Chat/src/SimpleModule.Chat/Pages/ConversationEndpoint.cs delete mode 100644 modules/Chat/src/SimpleModule.Chat/Pages/index.ts delete mode 100644 modules/Chat/src/SimpleModule.Chat/README.md delete mode 100644 modules/Chat/src/SimpleModule.Chat/SimpleModule.Chat.csproj delete mode 100644 modules/Chat/src/SimpleModule.Chat/package.json delete mode 100644 modules/Chat/src/SimpleModule.Chat/tsconfig.json delete mode 100644 modules/Chat/src/SimpleModule.Chat/types.ts delete mode 100644 modules/Chat/src/SimpleModule.Chat/vite.config.ts delete mode 100644 modules/Chat/tests/SimpleModule.Chat.Tests/GlobalUsings.cs delete mode 100644 modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Authorization.cs delete mode 100644 modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Create.cs delete mode 100644 modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Helpers.cs delete mode 100644 modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Mutate.cs delete mode 100644 modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Query.cs delete mode 100644 modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.ErrorHandling.cs delete mode 100644 modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.HappyPath.cs delete mode 100644 modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.Helpers.cs delete mode 100644 modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.Validation.cs delete mode 100644 modules/Chat/tests/SimpleModule.Chat.Tests/IntegrationTestCollection.cs delete mode 100644 modules/Chat/tests/SimpleModule.Chat.Tests/SimpleModule.Chat.Tests.csproj delete mode 100644 modules/Chat/tests/SimpleModule.Chat.Tests/Unit/AgentHistoryReplayTests.cs delete mode 100644 modules/Chat/tests/SimpleModule.Chat.Tests/Unit/ChatServiceTests.Conversation.cs delete mode 100644 modules/Chat/tests/SimpleModule.Chat.Tests/Unit/ChatServiceTests.Helpers.cs delete mode 100644 modules/Chat/tests/SimpleModule.Chat.Tests/Unit/ChatServiceTests.Messages.cs delete mode 100644 modules/Chat/tests/SimpleModule.Chat.Tests/Unit/ConversationIdTests.cs delete mode 100644 modules/Chat/tests/SimpleModule.Chat.Tests/Unit/TanStackDtoSerializationTests.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets.Contracts/BoundingBoxDto.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets.Contracts/Dataset.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets.Contracts/DatasetDto.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets.Contracts/DatasetFormat.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets.Contracts/DatasetId.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets.Contracts/DatasetMetadata.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets.Contracts/DatasetStatus.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets.Contracts/DatasetsConstants.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets.Contracts/DatasetsPermissions.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets.Contracts/Events/DatasetProcessed.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets.Contracts/IDatasetsContracts.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets.Contracts/SimpleModule.Datasets.Contracts.csproj delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Agents/DatasetsToolProvider.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Converters/IDatasetConverter.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Converters/RasterToCogConverter.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Converters/VectorToGeoJsonConverter.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Converters/VectorToPmTilesConverter.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/DatasetsContractsService.Features.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/DatasetsContractsService.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/DatasetsDbContext.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/DatasetsModule.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Endpoints/Datasets/ConvertDatasetEndpoint.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Endpoints/Datasets/DeleteDatasetEndpoint.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Endpoints/Datasets/DownloadDatasetEndpoint.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Endpoints/Datasets/GetDatasetEndpoint.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Endpoints/Datasets/GetDatasetFeaturesEndpoint.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Endpoints/Datasets/ListDatasetsEndpoint.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Endpoints/Datasets/UploadDatasetEndpoint.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/EntityConfigurations/DatasetConfiguration.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Infrastructure/CliRunner.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Infrastructure/TempDirectory.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Jobs/ConvertDatasetJob.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Jobs/ProcessDatasetJob.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Jobs/PurgeDatasetJob.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Pages/Browse.tsx delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Pages/Detail.tsx delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Pages/Upload.tsx delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Pages/index.ts delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Pages/labels.ts delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Processing/CogProcessor.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Processing/DatasetProcessorRegistry.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Processing/GeoJsonBboxWalker.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Processing/GeoJsonProcessor.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Processing/IDatasetProcessor.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Processing/OgrVectorProcessor.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Processing/PmTilesProcessor.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/SimpleModule.Datasets.csproj delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Views/DatasetsBrowseView.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Views/DatasetsDetailView.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/Views/DatasetsUploadView.cs delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/package.json delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/tsconfig.json delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/types.ts delete mode 100644 modules/Datasets/src/SimpleModule.Datasets/vite.config.ts delete mode 100644 modules/Datasets/tests/SimpleModule.Datasets.Tests/Fixtures/sample.geojson delete mode 100644 modules/Datasets/tests/SimpleModule.Datasets.Tests/GeoJsonProcessorTests.cs delete mode 100644 modules/Datasets/tests/SimpleModule.Datasets.Tests/GlobalUsings.cs delete mode 100644 modules/Datasets/tests/SimpleModule.Datasets.Tests/PurgeDatasetJobTests.cs delete mode 100644 modules/Datasets/tests/SimpleModule.Datasets.Tests/SimpleModule.Datasets.Tests.csproj delete mode 100644 modules/Map/src/SimpleModule.Map.Contracts/Basemap.cs delete mode 100644 modules/Map/src/SimpleModule.Map.Contracts/BasemapId.cs delete mode 100644 modules/Map/src/SimpleModule.Map.Contracts/CreateBasemapRequest.cs delete mode 100644 modules/Map/src/SimpleModule.Map.Contracts/CreateLayerSourceFromDatasetRequest.cs delete mode 100644 modules/Map/src/SimpleModule.Map.Contracts/CreateLayerSourceRequest.cs delete mode 100644 modules/Map/src/SimpleModule.Map.Contracts/IMapContracts.cs delete mode 100644 modules/Map/src/SimpleModule.Map.Contracts/LayerSource.cs delete mode 100644 modules/Map/src/SimpleModule.Map.Contracts/LayerSourceId.cs delete mode 100644 modules/Map/src/SimpleModule.Map.Contracts/LayerSourceType.cs delete mode 100644 modules/Map/src/SimpleModule.Map.Contracts/MapBasemap.cs delete mode 100644 modules/Map/src/SimpleModule.Map.Contracts/MapConstants.cs delete mode 100644 modules/Map/src/SimpleModule.Map.Contracts/MapLayer.cs delete mode 100644 modules/Map/src/SimpleModule.Map.Contracts/SavedMap.cs delete mode 100644 modules/Map/src/SimpleModule.Map.Contracts/SavedMapId.cs delete mode 100644 modules/Map/src/SimpleModule.Map.Contracts/SimpleModule.Map.Contracts.csproj delete mode 100644 modules/Map/src/SimpleModule.Map.Contracts/UpdateBasemapRequest.cs delete mode 100644 modules/Map/src/SimpleModule.Map.Contracts/UpdateDefaultMapRequest.cs delete mode 100644 modules/Map/src/SimpleModule.Map.Contracts/UpdateLayerSourceRequest.cs delete mode 100644 modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/CreateBasemapEndpoint.cs delete mode 100644 modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/CreateBasemapRequestValidator.cs delete mode 100644 modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/DeleteBasemapEndpoint.cs delete mode 100644 modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/GetAllBasemapsEndpoint.cs delete mode 100644 modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/GetBasemapByIdEndpoint.cs delete mode 100644 modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/UpdateBasemapEndpoint.cs delete mode 100644 modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/UpdateBasemapRequestValidator.cs delete mode 100644 modules/Map/src/SimpleModule.Map/Endpoints/DefaultMap/GetDefaultMapEndpoint.cs delete mode 100644 modules/Map/src/SimpleModule.Map/Endpoints/DefaultMap/UpdateDefaultMapEndpoint.cs delete mode 100644 modules/Map/src/SimpleModule.Map/Endpoints/DefaultMap/UpdateDefaultMapRequestValidator.cs delete mode 100644 modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/CreateEndpoint.cs delete mode 100644 modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/CreateFromDatasetEndpoint.cs delete mode 100644 modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/CreateLayerSourceRequestValidator.cs delete mode 100644 modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/DeleteEndpoint.cs delete mode 100644 modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/GetAllEndpoint.cs delete mode 100644 modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/GetByIdEndpoint.cs delete mode 100644 modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/UpdateEndpoint.cs delete mode 100644 modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/UpdateLayerSourceRequestValidator.cs delete mode 100644 modules/Map/src/SimpleModule.Map/EntityConfigurations/BasemapConfiguration.cs delete mode 100644 modules/Map/src/SimpleModule.Map/EntityConfigurations/JsonDictionaryConverter.cs delete mode 100644 modules/Map/src/SimpleModule.Map/EntityConfigurations/LayerSourceConfiguration.cs delete mode 100644 modules/Map/src/SimpleModule.Map/EntityConfigurations/SavedMapConfiguration.cs delete mode 100644 modules/Map/src/SimpleModule.Map/MapDbContext.cs delete mode 100644 modules/Map/src/SimpleModule.Map/MapModule.cs delete mode 100644 modules/Map/src/SimpleModule.Map/MapModuleOptions.cs delete mode 100644 modules/Map/src/SimpleModule.Map/MapPermissions.cs delete mode 100644 modules/Map/src/SimpleModule.Map/MapService.Basemaps.cs delete mode 100644 modules/Map/src/SimpleModule.Map/MapService.Logging.cs delete mode 100644 modules/Map/src/SimpleModule.Map/MapService.cs delete mode 100644 modules/Map/src/SimpleModule.Map/Pages/Browse.tsx delete mode 100644 modules/Map/src/SimpleModule.Map/Pages/BrowseEndpoint.cs delete mode 100644 modules/Map/src/SimpleModule.Map/Pages/Layers.tsx delete mode 100644 modules/Map/src/SimpleModule.Map/Pages/LayersEndpoint.cs delete mode 100644 modules/Map/src/SimpleModule.Map/Pages/components/AddBasemapDialog.tsx delete mode 100644 modules/Map/src/SimpleModule.Map/Pages/components/AddLayerSourceDialog.tsx delete mode 100644 modules/Map/src/SimpleModule.Map/Pages/components/BasemapsPanel.tsx delete mode 100644 modules/Map/src/SimpleModule.Map/Pages/components/LayersPanel.tsx delete mode 100644 modules/Map/src/SimpleModule.Map/Pages/components/MapBottomControls.tsx delete mode 100644 modules/Map/src/SimpleModule.Map/Pages/components/MapCanvas.tsx delete mode 100644 modules/Map/src/SimpleModule.Map/Pages/components/MapTopControls.tsx delete mode 100644 modules/Map/src/SimpleModule.Map/Pages/components/css.d.ts delete mode 100644 modules/Map/src/SimpleModule.Map/Pages/components/layer-utils.ts delete mode 100644 modules/Map/src/SimpleModule.Map/Pages/components/useViewportInsets.ts delete mode 100644 modules/Map/src/SimpleModule.Map/Pages/index.ts delete mode 100644 modules/Map/src/SimpleModule.Map/Pages/lib/layer-builders.ts delete mode 100644 modules/Map/src/SimpleModule.Map/Pages/lib/protocols.ts delete mode 100644 modules/Map/src/SimpleModule.Map/SimpleModule.Map.csproj delete mode 100644 modules/Map/src/SimpleModule.Map/package.json delete mode 100644 modules/Map/src/SimpleModule.Map/tsconfig.json delete mode 100644 modules/Map/src/SimpleModule.Map/types.ts delete mode 100644 modules/Map/src/SimpleModule.Map/vite.config.ts delete mode 100644 modules/Map/tests/SimpleModule.Map.Tests/GlobalUsings.cs delete mode 100644 modules/Map/tests/SimpleModule.Map.Tests/Integration/BasemapsEndpointTests.cs delete mode 100644 modules/Map/tests/SimpleModule.Map.Tests/Integration/CreateFromDatasetEndpointTests.cs delete mode 100644 modules/Map/tests/SimpleModule.Map.Tests/Integration/LayerSourcesEndpointTests.cs delete mode 100644 modules/Map/tests/SimpleModule.Map.Tests/Integration/MapsEndpointTests.cs delete mode 100644 modules/Map/tests/SimpleModule.Map.Tests/IntegrationTestCollection.cs delete mode 100644 modules/Map/tests/SimpleModule.Map.Tests/SimpleModule.Map.Tests.csproj delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace.Contracts/IMarketplaceContracts.cs delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplaceCategory.cs delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplaceConstants.cs delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplacePackage.cs delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplacePackageDetail.cs delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplaceSearchRequest.cs delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplaceSearchResult.cs delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplaceSortOption.cs delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace.Contracts/SimpleModule.Marketplace.Contracts.csproj delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/CategoryMapper.cs delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/Endpoints/Marketplace/GetByIdEndpoint.cs delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/Endpoints/Marketplace/SearchEndpoint.cs delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/InstalledPackageDetector.cs delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/Locales/en.json delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/Locales/keys.ts delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceConstants.cs delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceModule.cs delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceModuleOptions.cs delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/MarketplacePermissions.cs delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/NuGetDtos.cs delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/NuGetMarketplaceService.cs delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/Pages/Browse.tsx delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/Pages/BrowseEndpoint.cs delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/Pages/Detail.tsx delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/Pages/DetailEndpoint.cs delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/Pages/components/DetailHelpers.tsx delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/Pages/index.ts delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/Pages/utils.ts delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/README.md delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/SimpleModule.Marketplace.csproj delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/package.json delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/tsconfig.json delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/types.ts delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/vite.config.ts delete mode 100644 modules/Marketplace/tests/SimpleModule.Marketplace.Tests/GlobalUsings.cs delete mode 100644 modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Integration/MarketplaceBrowseEndpointTests.cs delete mode 100644 modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Integration/MarketplaceEndpointTests.cs delete mode 100644 modules/Marketplace/tests/SimpleModule.Marketplace.Tests/IntegrationTestCollection.cs delete mode 100644 modules/Marketplace/tests/SimpleModule.Marketplace.Tests/SimpleModule.Marketplace.Tests.csproj delete mode 100644 modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Unit/CategoryMapperTests.cs delete mode 100644 modules/Orders/src/SimpleModule.Orders.Contracts/CreateOrderRequest.cs delete mode 100644 modules/Orders/src/SimpleModule.Orders.Contracts/Events/OrderCreatedEvent.cs delete mode 100644 modules/Orders/src/SimpleModule.Orders.Contracts/IOrderContracts.cs delete mode 100644 modules/Orders/src/SimpleModule.Orders.Contracts/Order.cs delete mode 100644 modules/Orders/src/SimpleModule.Orders.Contracts/OrderId.cs delete mode 100644 modules/Orders/src/SimpleModule.Orders.Contracts/OrderItem.cs delete mode 100644 modules/Orders/src/SimpleModule.Orders.Contracts/OrdersConstants.cs delete mode 100644 modules/Orders/src/SimpleModule.Orders.Contracts/SimpleModule.Orders.Contracts.csproj delete mode 100644 modules/Orders/src/SimpleModule.Orders.Contracts/UpdateOrderRequest.cs delete mode 100644 modules/Orders/src/SimpleModule.Orders/Endpoints/Orders/CreateEndpoint.cs delete mode 100644 modules/Orders/src/SimpleModule.Orders/Endpoints/Orders/CreateRequestValidator.cs delete mode 100644 modules/Orders/src/SimpleModule.Orders/Endpoints/Orders/DeleteEndpoint.cs delete mode 100644 modules/Orders/src/SimpleModule.Orders/Endpoints/Orders/GetAllEndpoint.cs delete mode 100644 modules/Orders/src/SimpleModule.Orders/Endpoints/Orders/GetByIdEndpoint.cs delete mode 100644 modules/Orders/src/SimpleModule.Orders/Endpoints/Orders/UpdateEndpoint.cs delete mode 100644 modules/Orders/src/SimpleModule.Orders/EntityConfigurations/OrderConfiguration.cs delete mode 100644 modules/Orders/src/SimpleModule.Orders/EntityConfigurations/OrderItemConfiguration.cs delete mode 100644 modules/Orders/src/SimpleModule.Orders/Locales/en.json delete mode 100644 modules/Orders/src/SimpleModule.Orders/Locales/keys.ts delete mode 100644 modules/Orders/src/SimpleModule.Orders/OrderService.cs delete mode 100644 modules/Orders/src/SimpleModule.Orders/OrdersConstants.cs delete mode 100644 modules/Orders/src/SimpleModule.Orders/OrdersDbContext.cs delete mode 100644 modules/Orders/src/SimpleModule.Orders/OrdersModule.cs delete mode 100644 modules/Orders/src/SimpleModule.Orders/OrdersModuleOptions.cs delete mode 100644 modules/Orders/src/SimpleModule.Orders/OrdersPermissions.cs delete mode 100644 modules/Orders/src/SimpleModule.Orders/Pages/Create.tsx delete mode 100644 modules/Orders/src/SimpleModule.Orders/Pages/CreateEndpoint.cs delete mode 100644 modules/Orders/src/SimpleModule.Orders/Pages/Edit.tsx delete mode 100644 modules/Orders/src/SimpleModule.Orders/Pages/EditEndpoint.cs delete mode 100644 modules/Orders/src/SimpleModule.Orders/Pages/List.tsx delete mode 100644 modules/Orders/src/SimpleModule.Orders/Pages/ListEndpoint.cs delete mode 100644 modules/Orders/src/SimpleModule.Orders/Pages/index.ts delete mode 100644 modules/Orders/src/SimpleModule.Orders/README.md delete mode 100644 modules/Orders/src/SimpleModule.Orders/Services/OrderSeedService.cs delete mode 100644 modules/Orders/src/SimpleModule.Orders/SimpleModule.Orders.csproj delete mode 100644 modules/Orders/src/SimpleModule.Orders/package.json delete mode 100644 modules/Orders/src/SimpleModule.Orders/tsconfig.json delete mode 100644 modules/Orders/src/SimpleModule.Orders/types.ts delete mode 100644 modules/Orders/src/SimpleModule.Orders/vite.config.ts delete mode 100644 modules/Orders/tests/SimpleModule.Orders.Tests/GlobalUsings.cs delete mode 100644 modules/Orders/tests/SimpleModule.Orders.Tests/Integration/OrdersEndpointTests.cs delete mode 100644 modules/Orders/tests/SimpleModule.Orders.Tests/IntegrationTestCollection.cs delete mode 100644 modules/Orders/tests/SimpleModule.Orders.Tests/SimpleModule.Orders.Tests.csproj delete mode 100644 modules/Orders/tests/SimpleModule.Orders.Tests/Unit/CreateOrderRequestValidatorTests.cs delete mode 100644 modules/Orders/tests/SimpleModule.Orders.Tests/Unit/OrderIdTests.cs delete mode 100644 modules/Orders/tests/SimpleModule.Orders.Tests/Unit/OrderServiceTests.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder.Contracts/AddTagRequest.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder.Contracts/CreatePageRequest.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder.Contracts/CreatePageTemplateRequest.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder.Contracts/Events/PageCreatedEvent.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder.Contracts/Events/PageDeletedEvent.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder.Contracts/Events/PagePublishedEvent.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder.Contracts/Events/PageUnpublishedEvent.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder.Contracts/IPageBuilderContracts.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder.Contracts/IPageBuilderTagContracts.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder.Contracts/IPageBuilderTemplateContracts.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder.Contracts/Page.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder.Contracts/PageBuilderConstants.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder.Contracts/PageId.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder.Contracts/PageSummary.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder.Contracts/PageTag.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder.Contracts/PageTagId.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder.Contracts/PageTemplate.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder.Contracts/PageTemplateId.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder.Contracts/SimpleModule.PageBuilder.Contracts.csproj delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder.Contracts/UpdatePageContentRequest.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder.Contracts/UpdatePageRequest.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Pages/CreateEndpoint.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Pages/CreatePageRequestValidator.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Pages/DeleteEndpoint.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Pages/GetAllEndpoint.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Pages/GetByIdEndpoint.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Pages/PermanentDeleteEndpoint.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Pages/PublishEndpoint.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Pages/RestoreEndpoint.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Pages/TrashEndpoint.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Pages/UnpublishEndpoint.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Pages/UpdateContentEndpoint.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Pages/UpdateEndpoint.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Pages/UpdatePageRequestValidator.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Tags/AddTagToPageEndpoint.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Tags/GetAllTagsEndpoint.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Tags/RemoveTagFromPageEndpoint.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Templates/CreateTemplateEndpoint.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Templates/DeleteTemplateEndpoint.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Endpoints/Templates/GetAllTemplatesEndpoint.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/EntityConfigurations/PageConfiguration.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/EntityConfigurations/PageTagConfiguration.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/EntityConfigurations/PageTemplateConfiguration.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Locales/en.json delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Locales/keys.ts delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/PageBuilderDbContext.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/PageBuilderModule.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/PageBuilderModuleOptions.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/PageBuilderPermissions.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/PageBuilderService.Logging.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/PageBuilderService.Templates.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/PageBuilderService.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Pages/Editor.tsx delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Pages/EditorEndpoint.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Pages/Manage.tsx delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Pages/ManageEndpoint.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Pages/PagesList.tsx delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Pages/PagesListEndpoint.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Pages/Viewer.tsx delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Pages/ViewerDraftEndpoint.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Pages/ViewerEndpoint.cs delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Pages/components/HeaderActions.tsx delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/Pages/index.ts delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/README.md delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/SimpleModule.PageBuilder.csproj delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/package.json delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/puck/components/ButtonBlock.tsx delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/puck/components/Card.tsx delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/puck/components/Flex.tsx delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/puck/components/Grid.tsx delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/puck/components/Heading.tsx delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/puck/components/Hero.tsx delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/puck/components/Logos.tsx delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/puck/components/Space.tsx delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/puck/components/Stats.tsx delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/puck/components/Text.tsx delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/puck/config.tsx delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/puck/puck-styles.css delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/tsconfig.json delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/types.ts delete mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/vite.config.ts delete mode 100644 modules/PageBuilder/tests/SimpleModule.PageBuilder.Tests/GlobalUsings.cs delete mode 100644 modules/PageBuilder/tests/SimpleModule.PageBuilder.Tests/IntegrationTestCollection.cs delete mode 100644 modules/PageBuilder/tests/SimpleModule.PageBuilder.Tests/PageBuilderServiceTests.Extras.cs delete mode 100644 modules/PageBuilder/tests/SimpleModule.PageBuilder.Tests/PageBuilderServiceTests.Helpers.cs delete mode 100644 modules/PageBuilder/tests/SimpleModule.PageBuilder.Tests/PageBuilderServiceTests.Pages.cs delete mode 100644 modules/PageBuilder/tests/SimpleModule.PageBuilder.Tests/PageEndpointTests.Crud.cs delete mode 100644 modules/PageBuilder/tests/SimpleModule.PageBuilder.Tests/PageEndpointTests.Helpers.cs delete mode 100644 modules/PageBuilder/tests/SimpleModule.PageBuilder.Tests/PageEndpointTests.Publishing.cs delete mode 100644 modules/PageBuilder/tests/SimpleModule.PageBuilder.Tests/PageEndpointTests.SoftDelete.cs delete mode 100644 modules/PageBuilder/tests/SimpleModule.PageBuilder.Tests/PageEndpointTests.TemplatesAndTags.cs delete mode 100644 modules/PageBuilder/tests/SimpleModule.PageBuilder.Tests/SimpleModule.PageBuilder.Tests.csproj delete mode 100644 modules/Products/src/SimpleModule.Products.Contracts/CreateProductRequest.cs delete mode 100644 modules/Products/src/SimpleModule.Products.Contracts/Events/ProductCreatedEvent.cs delete mode 100644 modules/Products/src/SimpleModule.Products.Contracts/Events/ProductDeletedEvent.cs delete mode 100644 modules/Products/src/SimpleModule.Products.Contracts/Events/ProductUpdatedEvent.cs delete mode 100644 modules/Products/src/SimpleModule.Products.Contracts/IProductContracts.cs delete mode 100644 modules/Products/src/SimpleModule.Products.Contracts/Product.cs delete mode 100644 modules/Products/src/SimpleModule.Products.Contracts/ProductId.cs delete mode 100644 modules/Products/src/SimpleModule.Products.Contracts/ProductsConstants.cs delete mode 100644 modules/Products/src/SimpleModule.Products.Contracts/SimpleModule.Products.Contracts.csproj delete mode 100644 modules/Products/src/SimpleModule.Products.Contracts/UpdateProductRequest.cs delete mode 100644 modules/Products/src/SimpleModule.Products/Agents/ProductKnowledgeSource.cs delete mode 100644 modules/Products/src/SimpleModule.Products/Agents/ProductSearchAgent.cs delete mode 100644 modules/Products/src/SimpleModule.Products/Agents/ProductToolProvider.cs delete mode 100644 modules/Products/src/SimpleModule.Products/Endpoints/Products/CreateEndpoint.cs delete mode 100644 modules/Products/src/SimpleModule.Products/Endpoints/Products/CreateRequestValidator.cs delete mode 100644 modules/Products/src/SimpleModule.Products/Endpoints/Products/DeleteEndpoint.cs delete mode 100644 modules/Products/src/SimpleModule.Products/Endpoints/Products/GetAllEndpoint.cs delete mode 100644 modules/Products/src/SimpleModule.Products/Endpoints/Products/GetByIdEndpoint.cs delete mode 100644 modules/Products/src/SimpleModule.Products/Endpoints/Products/UpdateEndpoint.cs delete mode 100644 modules/Products/src/SimpleModule.Products/Endpoints/Products/UpdateRequestValidator.cs delete mode 100644 modules/Products/src/SimpleModule.Products/EntityConfigurations/ProductConfiguration.cs delete mode 100644 modules/Products/src/SimpleModule.Products/Locales/en.json delete mode 100644 modules/Products/src/SimpleModule.Products/Locales/keys.ts delete mode 100644 modules/Products/src/SimpleModule.Products/Pages/Browse.tsx delete mode 100644 modules/Products/src/SimpleModule.Products/Pages/BrowseEndpoint.cs delete mode 100644 modules/Products/src/SimpleModule.Products/Pages/Create.tsx delete mode 100644 modules/Products/src/SimpleModule.Products/Pages/CreateEndpoint.cs delete mode 100644 modules/Products/src/SimpleModule.Products/Pages/Edit.tsx delete mode 100644 modules/Products/src/SimpleModule.Products/Pages/EditEndpoint.cs delete mode 100644 modules/Products/src/SimpleModule.Products/Pages/Manage.tsx delete mode 100644 modules/Products/src/SimpleModule.Products/Pages/ManageEndpoint.cs delete mode 100644 modules/Products/src/SimpleModule.Products/Pages/index.ts delete mode 100644 modules/Products/src/SimpleModule.Products/ProductService.cs delete mode 100644 modules/Products/src/SimpleModule.Products/ProductsDbContext.cs delete mode 100644 modules/Products/src/SimpleModule.Products/ProductsFeatures.cs delete mode 100644 modules/Products/src/SimpleModule.Products/ProductsModule.cs delete mode 100644 modules/Products/src/SimpleModule.Products/ProductsModuleOptions.cs delete mode 100644 modules/Products/src/SimpleModule.Products/ProductsPermissions.cs delete mode 100644 modules/Products/src/SimpleModule.Products/README.md delete mode 100644 modules/Products/src/SimpleModule.Products/SimpleModule.Products.csproj delete mode 100644 modules/Products/src/SimpleModule.Products/package.json delete mode 100644 modules/Products/src/SimpleModule.Products/tsconfig.json delete mode 100644 modules/Products/src/SimpleModule.Products/types.ts delete mode 100644 modules/Products/src/SimpleModule.Products/vite.config.ts delete mode 100644 modules/Products/tests/SimpleModule.Products.Tests/GlobalUsings.cs delete mode 100644 modules/Products/tests/SimpleModule.Products.Tests/Integration/ProductsBrowseEndpointTests.cs delete mode 100644 modules/Products/tests/SimpleModule.Products.Tests/Integration/ProductsEndpointTests.cs delete mode 100644 modules/Products/tests/SimpleModule.Products.Tests/IntegrationTestCollection.cs delete mode 100644 modules/Products/tests/SimpleModule.Products.Tests/SimpleModule.Products.Tests.csproj delete mode 100644 modules/Products/tests/SimpleModule.Products.Tests/Unit/CreateRequestValidatorTests.cs delete mode 100644 modules/Products/tests/SimpleModule.Products.Tests/Unit/ProductIdTests.cs delete mode 100644 modules/Products/tests/SimpleModule.Products.Tests/Unit/ProductServiceTests.cs delete mode 100644 modules/Products/tests/SimpleModule.Products.Tests/Unit/UpdateRequestValidatorTests.cs delete mode 100644 modules/Rag/src/SimpleModule.Rag.Contracts/IRagContracts.cs delete mode 100644 modules/Rag/src/SimpleModule.Rag.Contracts/SimpleModule.Rag.Contracts.csproj delete mode 100644 modules/Rag/src/SimpleModule.Rag.Module/EfStructuredKnowledgeCache.cs delete mode 100644 modules/Rag/src/SimpleModule.Rag.Module/EntityConfigurations/CachedStructuredKnowledgeConfiguration.cs delete mode 100644 modules/Rag/src/SimpleModule.Rag.Module/README.md delete mode 100644 modules/Rag/src/SimpleModule.Rag.Module/RagConstants.cs delete mode 100644 modules/Rag/src/SimpleModule.Rag.Module/RagDbContext.cs delete mode 100644 modules/Rag/src/SimpleModule.Rag.Module/RagModule.cs delete mode 100644 modules/Rag/src/SimpleModule.Rag.Module/RagService.cs delete mode 100644 modules/Rag/src/SimpleModule.Rag.Module/SimpleModule.Rag.Module.csproj delete mode 100644 modules/Rag/tests/SimpleModule.Rag.Tests/GlobalUsings.cs delete mode 100644 modules/Rag/tests/SimpleModule.Rag.Tests/IntegrationTestCollection.cs delete mode 100644 modules/Rag/tests/SimpleModule.Rag.Tests/SimpleModule.Rag.Tests.csproj delete mode 100644 packages/SimpleModule.UI/components/agent-chat.tsx delete mode 100644 packages/SimpleModule.UI/components/agent-playground.tsx delete mode 100644 packages/SimpleModule.UI/components/agent-selector.tsx delete mode 100644 packages/SimpleModule.UI/hooks/use-agent-chat.ts delete mode 100644 tests/SimpleModule.Benchmarks/Benchmarks/OrdersBenchmarks.cs delete mode 100644 tests/SimpleModule.Benchmarks/Benchmarks/PageBuilderBenchmarks.cs delete mode 100644 tests/SimpleModule.Benchmarks/Benchmarks/ProductsBenchmarks.cs delete mode 100644 tests/SimpleModule.Benchmarks/Benchmarks/SerializationBenchmarks.cs delete mode 100644 tests/SimpleModule.LoadTests/Scenarios/MarketplaceScenario.cs delete mode 100644 tests/SimpleModule.LoadTests/Scenarios/MixedWorkloadScenario.cs delete mode 100644 tests/SimpleModule.LoadTests/Scenarios/OrdersScenario.cs delete mode 100644 tests/SimpleModule.LoadTests/Scenarios/PageBuilderScenario.cs delete mode 100644 tests/SimpleModule.LoadTests/Scenarios/ProductsScenario.cs delete mode 100644 tests/SimpleModule.Tests.Shared/Agents/AgentTestFixture.cs delete mode 100644 tests/SimpleModule.Tests.Shared/Agents/MockChatClient.cs delete mode 100644 tests/SimpleModule.Tests.Shared/Datasets/TestDatasetsDbContext.cs delete mode 100644 tests/SimpleModule.Tests.Shared/Fakes/FakeOrderContracts.cs delete mode 100644 tests/SimpleModule.Tests.Shared/Fakes/FakeProductContracts.cs diff --git a/SimpleModule.slnx b/SimpleModule.slnx index ddce6f58..c2dba1d3 100644 --- a/SimpleModule.slnx +++ b/SimpleModule.slnx @@ -1,4 +1,4 @@ - + @@ -14,15 +14,6 @@ - - - - - - - - - @@ -36,21 +27,6 @@ - - - - - - - - - - - - - - - @@ -71,21 +47,11 @@ - - - - - - - - - - @@ -101,31 +67,11 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/cli/SimpleModule.Cli/Commands/New/NewAgentCommand.cs b/cli/SimpleModule.Cli/Commands/New/NewAgentCommand.cs deleted file mode 100644 index 37792dff..00000000 --- a/cli/SimpleModule.Cli/Commands/New/NewAgentCommand.cs +++ /dev/null @@ -1,173 +0,0 @@ -using SimpleModule.Cli.Infrastructure; -using Spectre.Console; -using Spectre.Console.Cli; - -namespace SimpleModule.Cli.Commands.New; - -public sealed class NewAgentCommand : Command -{ - public override int Execute(CommandContext context, NewAgentSettings settings) - { - var agentName = settings.ResolveName(); - - var solution = SolutionContext.Discover(); - if (solution is null) - { - AnsiConsole.MarkupLine( - "[red]No .slnx file found. Run this command from inside a SimpleModule project.[/]" - ); - return 1; - } - - if (solution.ExistingModules.Count == 0) - { - AnsiConsole.MarkupLine( - "[red]No modules found. Create a module first with 'sm new module'.[/]" - ); - return 1; - } - - var moduleName = settings.ResolveModule(solution.ExistingModules); - - if (!solution.ExistingModules.Contains(moduleName, StringComparer.OrdinalIgnoreCase)) - { - AnsiConsole.MarkupLine( - $"[red]Module '{Markup.Escape(moduleName)}' not found. Available: {Markup.Escape(string.Join(", ", solution.ExistingModules))}[/]" - ); - return 1; - } - - var moduleDir = solution.GetModuleProjectPath(moduleName); - var agentsDir = Path.Combine(moduleDir, "Agents"); - var knowledgeDir = Path.Combine(agentsDir, "Knowledge"); - - var ops = new List<(string Path, string Content)> - { - ( - Path.Combine(agentsDir, $"{agentName}Agent.cs"), - GenerateAgentDefinition(moduleName, agentName) - ), - ( - Path.Combine(agentsDir, $"{agentName}ToolProvider.cs"), - GenerateToolProvider(moduleName, agentName) - ), - ( - Path.Combine(agentsDir, $"{agentName}KnowledgeSource.cs"), - GenerateKnowledgeSource(moduleName, agentName) - ), - }; - - if (settings.DryRun) - { - AnsiConsole.MarkupLine("[yellow]Dry run — no files will be written.[/]"); - foreach (var (path, _) in ops) - { - var rel = Path.GetRelativePath(solution.RootPath, path); - AnsiConsole.MarkupLine($" [green]CREATE[/] {Markup.Escape(rel)}"); - } - - AnsiConsole.MarkupLine( - $" [green]CREATE[/] {Markup.Escape(Path.GetRelativePath(solution.RootPath, knowledgeDir))}/" - ); - return 0; - } - - Directory.CreateDirectory(agentsDir); - Directory.CreateDirectory(knowledgeDir); - - foreach (var (path, content) in ops) - { - if (File.Exists(path)) - { - AnsiConsole.MarkupLine( - $" [yellow]SKIP[/] {Markup.Escape(Path.GetRelativePath(solution.RootPath, path))} (already exists)" - ); - continue; - } - - File.WriteAllText(path, content); - AnsiConsole.MarkupLine( - $" [green]CREATE[/] {Markup.Escape(Path.GetRelativePath(solution.RootPath, path))}" - ); - } - - AnsiConsole.MarkupLine( - $"\n[green]Agent '{Markup.Escape(agentName)}' created in module '{Markup.Escape(moduleName)}'.[/]" - ); - return 0; - } - - private static string GenerateAgentDefinition(string moduleName, string agentName) => - $$""" - using SimpleModule.Core.Agents; - - namespace SimpleModule.{{moduleName}}.Agents; - - public class {{agentName}}Agent : IAgentDefinition - { - public string Name => "{{ToKebabCase(agentName)}}"; - - public string Description => "{{agentName}} agent for {{moduleName}}"; - - public string Instructions => - \"\"\" - You are a helpful assistant for the {{moduleName}} module. - Use the available tools to answer user questions. - \"\"\"; - } - """; - - private static string GenerateToolProvider(string moduleName, string agentName) => - $$""" - using SimpleModule.Core.Agents; - - namespace SimpleModule.{{moduleName}}.Agents; - - public class {{agentName}}ToolProvider : IAgentToolProvider - { - [AgentTool(Description = "Example tool — replace with real implementation")] - public Task ExampleTool(string query) => - Task.FromResult($"Result for: {query}"); - } - """; - - private static string GenerateKnowledgeSource(string moduleName, string agentName) => - $$""" - using SimpleModule.Core.Rag; - - namespace SimpleModule.{{moduleName}}.Agents; - - public class {{agentName}}KnowledgeSource : IKnowledgeSource - { - public string CollectionName => "{{ToKebabCase(agentName)}}-knowledge"; - - public Task> GetDocumentsAsync( - CancellationToken cancellationToken - ) => - Task.FromResult>( - [ - new( - "{{moduleName}} Overview", - "Add your {{moduleName}} module knowledge documents here." - ), - ] - ); - } - """; - - private static string ToKebabCase(string pascalCase) - { - var chars = new List(); - for (var i = 0; i < pascalCase.Length; i++) - { - if (i > 0 && char.IsUpper(pascalCase[i])) - { - chars.Add('-'); - } - - chars.Add(char.ToLowerInvariant(pascalCase[i])); - } - - return new string(chars.ToArray()); - } -} diff --git a/cli/SimpleModule.Cli/Commands/New/NewAgentSettings.cs b/cli/SimpleModule.Cli/Commands/New/NewAgentSettings.cs deleted file mode 100644 index 75bdc7f0..00000000 --- a/cli/SimpleModule.Cli/Commands/New/NewAgentSettings.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.ComponentModel; -using Spectre.Console; -using Spectre.Console.Cli; - -namespace SimpleModule.Cli.Commands.New; - -public sealed class NewAgentSettings : CommandSettings -{ - [CommandArgument(0, "[name]")] - [Description("Agent name in PascalCase (e.g. CustomerSupport)")] - public string? Name { get; set; } - - [CommandOption("--module ")] - [Description("Target module name (e.g. Products)")] - public string? Module { get; set; } - - [CommandOption("--dry-run")] - [Description("Show what would be created without writing any files")] - public bool DryRun { get; set; } - - public string ResolveName() - { - if (string.IsNullOrWhiteSpace(Name)) - { - Name = AnsiConsole.Ask("Agent name (PascalCase):"); - } - - if (string.IsNullOrWhiteSpace(Name)) - { - throw new InvalidOperationException("Agent name cannot be empty."); - } - - if (!char.IsUpper(Name[0])) - { - throw new InvalidOperationException( - $"'{Name}' is not PascalCase. Did you mean '{char.ToUpperInvariant(Name[0])}{Name[1..]}'?" - ); - } - - return Name; - } - - public string ResolveModule(IReadOnlyList existingModules) - { - if (string.IsNullOrWhiteSpace(Module)) - { - Module = AnsiConsole.Prompt( - new SelectionPrompt() - .Title("Which module should the agent be added to?") - .AddChoices(existingModules) - ); - } - - return Module; - } -} diff --git a/cli/SimpleModule.Cli/Program.cs b/cli/SimpleModule.Cli/Program.cs index 6513db82..9440fba8 100644 --- a/cli/SimpleModule.Cli/Program.cs +++ b/cli/SimpleModule.Cli/Program.cs @@ -15,8 +15,8 @@ config.SetApplicationVersion(VersionCommand.ResolveVersion()); config.AddExample("new", "project", "MyApp"); - config.AddExample("new", "module", "Products"); - config.AddExample("new", "feature", "CreateProduct", "--module", "Products"); + config.AddExample("new", "module", "Customers"); + config.AddExample("new", "feature", "CreateCustomer", "--module", "Customers"); config.AddExample("dev"); config.AddExample("list"); config.AddExample("doctor", "--fix"); @@ -35,14 +35,11 @@ newBranch .AddCommand("module") .WithDescription("Scaffold a new module") - .WithExample("new", "module", "Products"); + .WithExample("new", "module", "Customers"); newBranch .AddCommand("feature") .WithDescription("Add a feature to an existing module") - .WithExample("new", "feature", "CreateProduct", "--module", "Products"); - newBranch - .AddCommand("agent") - .WithDescription("Add an AI agent to an existing module"); + .WithExample("new", "feature", "CreateCustomer", "--module", "Customers"); } ); diff --git a/cli/SimpleModule.Cli/Templates/HostTemplates.cs b/cli/SimpleModule.Cli/Templates/HostTemplates.cs index d24fb83a..c07fae75 100644 --- a/cli/SimpleModule.Cli/Templates/HostTemplates.cs +++ b/cli/SimpleModule.Cli/Templates/HostTemplates.cs @@ -95,15 +95,6 @@ public static string ProgramCs() || line.Contains("MapDefaultEndpoints", StringComparison.Ordinal) || line.Contains("Storage.Local", StringComparison.Ordinal) || line.Contains("AddLocalStorage", StringComparison.Ordinal) - || line.Contains("SimpleModule.Agents", StringComparison.Ordinal) - || line.Contains("SimpleModule.AI.", StringComparison.Ordinal) - || line.Contains("SimpleModule.Rag", StringComparison.Ordinal) - || line.Contains("AddOllamaAI", StringComparison.Ordinal) - || line.Contains("AddInMemoryVectorStore", StringComparison.Ordinal) - || line.Contains("AddSimpleModuleRag", StringComparison.Ordinal) - || line.Contains("AddStructuredRag", StringComparison.Ordinal) - || line.Contains("AddSimpleModuleAgents", StringComparison.Ordinal) - || line.Contains("AddRagExtraction", StringComparison.Ordinal) ); lines = TemplateExtractor.CollapseBlankLines(lines); diff --git a/framework/SimpleModule.AI.Anthropic/AnthropicExtensions.cs b/framework/SimpleModule.AI.Anthropic/AnthropicExtensions.cs deleted file mode 100644 index a4f994c7..00000000 --- a/framework/SimpleModule.AI.Anthropic/AnthropicExtensions.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Anthropic.SDK; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; - -namespace SimpleModule.AI.Anthropic; - -public static class AnthropicExtensions -{ - public static IServiceCollection AddAnthropicAI( - this IServiceCollection services, - IConfiguration configuration - ) - { - services.Configure(configuration.GetSection("AI:Anthropic")); - - services.AddSingleton(sp => - { - var opts = - configuration.GetSection("AI:Anthropic").Get() - ?? new AnthropicOptions(); - var client = new AnthropicClient(opts.ApiKey); - return client.Messages; - }); - - return services; - } -} diff --git a/framework/SimpleModule.AI.Anthropic/AnthropicOptions.cs b/framework/SimpleModule.AI.Anthropic/AnthropicOptions.cs deleted file mode 100644 index ed75a0a7..00000000 --- a/framework/SimpleModule.AI.Anthropic/AnthropicOptions.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace SimpleModule.AI.Anthropic; - -public sealed class AnthropicOptions -{ - public string ApiKey { get; set; } = ""; - public string Model { get; set; } = "claude-sonnet-4-20250514"; -} diff --git a/framework/SimpleModule.AI.Anthropic/SimpleModule.AI.Anthropic.csproj b/framework/SimpleModule.AI.Anthropic/SimpleModule.AI.Anthropic.csproj deleted file mode 100644 index dd8c2cff..00000000 --- a/framework/SimpleModule.AI.Anthropic/SimpleModule.AI.Anthropic.csproj +++ /dev/null @@ -1,11 +0,0 @@ - - - net10.0 - Library - Anthropic Claude provider for SimpleModule. Registers IChatClient for Anthropic API. - - - - - - diff --git a/framework/SimpleModule.AI.AzureOpenAI/AzureOpenAIExtensions.cs b/framework/SimpleModule.AI.AzureOpenAI/AzureOpenAIExtensions.cs deleted file mode 100644 index 190d1627..00000000 --- a/framework/SimpleModule.AI.AzureOpenAI/AzureOpenAIExtensions.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.ClientModel; -using Azure.AI.OpenAI; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -namespace SimpleModule.AI.AzureOpenAI; - -public static class AzureOpenAIExtensions -{ - public static IServiceCollection AddAzureOpenAI( - this IServiceCollection services, - IConfiguration configuration - ) - { - services.Configure(configuration.GetSection("AI:AzureOpenAI")); - - services.AddSingleton(sp => - { - var opts = sp.GetRequiredService>().Value; - return new AzureOpenAIClient(new Uri(opts.Endpoint), new ApiKeyCredential(opts.ApiKey)); - }); - - services.AddSingleton(sp => - { - var opts = sp.GetRequiredService>().Value; - return sp.GetRequiredService() - .GetChatClient(opts.DeploymentName) - .AsIChatClient(); - }); - - services.AddSingleton>>(sp => - { - var opts = sp.GetRequiredService>().Value; - return sp.GetRequiredService() - .GetEmbeddingClient(opts.EmbeddingDeploymentName) - .AsIEmbeddingGenerator(); - }); - - return services; - } -} diff --git a/framework/SimpleModule.AI.AzureOpenAI/AzureOpenAIOptions.cs b/framework/SimpleModule.AI.AzureOpenAI/AzureOpenAIOptions.cs deleted file mode 100644 index ea6d7ee4..00000000 --- a/framework/SimpleModule.AI.AzureOpenAI/AzureOpenAIOptions.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace SimpleModule.AI.AzureOpenAI; - -public sealed class AzureOpenAIOptions -{ - public string Endpoint { get; set; } = ""; - public string DeploymentName { get; set; } = "gpt-4o"; - public string EmbeddingDeploymentName { get; set; } = "text-embedding-3-small"; - public string ApiKey { get; set; } = ""; -} diff --git a/framework/SimpleModule.AI.AzureOpenAI/SimpleModule.AI.AzureOpenAI.csproj b/framework/SimpleModule.AI.AzureOpenAI/SimpleModule.AI.AzureOpenAI.csproj deleted file mode 100644 index 8e0acc7e..00000000 --- a/framework/SimpleModule.AI.AzureOpenAI/SimpleModule.AI.AzureOpenAI.csproj +++ /dev/null @@ -1,12 +0,0 @@ - - - net10.0 - Library - Azure OpenAI provider for SimpleModule. Registers IChatClient and IEmbeddingGenerator for Azure OpenAI Service. - - - - - - - diff --git a/framework/SimpleModule.AI.Ollama/OllamaExtensions.cs b/framework/SimpleModule.AI.Ollama/OllamaExtensions.cs deleted file mode 100644 index 51d03956..00000000 --- a/framework/SimpleModule.AI.Ollama/OllamaExtensions.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -namespace SimpleModule.AI.Ollama; - -public static class OllamaExtensions -{ - public static IServiceCollection AddOllamaAI( - this IServiceCollection services, - IConfiguration configuration - ) - { - services.Configure(configuration.GetSection("AI:Ollama")); - - services.AddSingleton(sp => - { - var opts = sp.GetRequiredService>().Value; - return new OllamaChatClient(new Uri(opts.Endpoint), opts.Model); - }); - - services.AddSingleton>>(sp => - { - var opts = sp.GetRequiredService>().Value; - return new OllamaEmbeddingGenerator(new Uri(opts.Endpoint), opts.EmbeddingModel); - }); - - return services; - } -} diff --git a/framework/SimpleModule.AI.Ollama/OllamaOptions.cs b/framework/SimpleModule.AI.Ollama/OllamaOptions.cs deleted file mode 100644 index bd72d392..00000000 --- a/framework/SimpleModule.AI.Ollama/OllamaOptions.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace SimpleModule.AI.Ollama; - -public sealed class OllamaOptions -{ - public string Endpoint { get; set; } = "http://localhost:11434"; - public string Model { get; set; } = "llama3.2"; - public string EmbeddingModel { get; set; } = "nomic-embed-text"; -} diff --git a/framework/SimpleModule.AI.Ollama/SimpleModule.AI.Ollama.csproj b/framework/SimpleModule.AI.Ollama/SimpleModule.AI.Ollama.csproj deleted file mode 100644 index 0e32fb93..00000000 --- a/framework/SimpleModule.AI.Ollama/SimpleModule.AI.Ollama.csproj +++ /dev/null @@ -1,11 +0,0 @@ - - - net10.0 - Library - Ollama AI provider for SimpleModule. Registers IChatClient and IEmbeddingGenerator for local LLM inference. - - - - - - diff --git a/framework/SimpleModule.AI.OpenAI/OpenAIExtensions.cs b/framework/SimpleModule.AI.OpenAI/OpenAIExtensions.cs deleted file mode 100644 index cdbef6ef..00000000 --- a/framework/SimpleModule.AI.OpenAI/OpenAIExtensions.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using OpenAI; - -namespace SimpleModule.AI.OpenAI; - -public static class OpenAIExtensions -{ - public static IServiceCollection AddOpenAI( - this IServiceCollection services, - IConfiguration configuration - ) - { - services.Configure(configuration.GetSection("AI:OpenAI")); - - services.AddSingleton(sp => - { - var opts = sp.GetRequiredService>().Value; - return new OpenAIClient(opts.ApiKey); - }); - - services.AddSingleton(sp => - { - var opts = sp.GetRequiredService>().Value; - return sp.GetRequiredService().GetChatClient(opts.Model).AsIChatClient(); - }); - - services.AddSingleton>>(sp => - { - var opts = sp.GetRequiredService>().Value; - return sp.GetRequiredService() - .GetEmbeddingClient(opts.EmbeddingModel) - .AsIEmbeddingGenerator(); - }); - - return services; - } -} diff --git a/framework/SimpleModule.AI.OpenAI/OpenAIOptions.cs b/framework/SimpleModule.AI.OpenAI/OpenAIOptions.cs deleted file mode 100644 index 98d431a0..00000000 --- a/framework/SimpleModule.AI.OpenAI/OpenAIOptions.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace SimpleModule.AI.OpenAI; - -public sealed class OpenAIOptions -{ - public string ApiKey { get; set; } = ""; - public string Model { get; set; } = "gpt-4o"; - public string EmbeddingModel { get; set; } = "text-embedding-3-small"; -} diff --git a/framework/SimpleModule.AI.OpenAI/SimpleModule.AI.OpenAI.csproj b/framework/SimpleModule.AI.OpenAI/SimpleModule.AI.OpenAI.csproj deleted file mode 100644 index 83af17a3..00000000 --- a/framework/SimpleModule.AI.OpenAI/SimpleModule.AI.OpenAI.csproj +++ /dev/null @@ -1,12 +0,0 @@ - - - net10.0 - Library - OpenAI provider for SimpleModule. Registers IChatClient and IEmbeddingGenerator for OpenAI API. - - - - - - - diff --git a/framework/SimpleModule.Agents/AgentBuilder.cs b/framework/SimpleModule.Agents/AgentBuilder.cs deleted file mode 100644 index 1abff61f..00000000 --- a/framework/SimpleModule.Agents/AgentBuilder.cs +++ /dev/null @@ -1,23 +0,0 @@ -using SimpleModule.Core.Agents; - -namespace SimpleModule.Agents; - -public sealed class AgentBuilder : IAgentBuilder -{ - internal List AgentTypes { get; } = []; - internal List ToolProviderTypes { get; } = []; - - public IAgentBuilder AddAgent() - where TAgent : class, IAgentDefinition - { - AgentTypes.Add(typeof(TAgent)); - return this; - } - - public IAgentBuilder AddToolProvider() - where TProvider : class, IAgentToolProvider - { - ToolProviderTypes.Add(typeof(TProvider)); - return this; - } -} diff --git a/framework/SimpleModule.Agents/AgentChatService.cs b/framework/SimpleModule.Agents/AgentChatService.cs deleted file mode 100644 index 64321411..00000000 --- a/framework/SimpleModule.Agents/AgentChatService.cs +++ /dev/null @@ -1,160 +0,0 @@ -using System.Runtime.CompilerServices; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using SimpleModule.Agents.Dtos; -using SimpleModule.Core.Agents; -using SimpleModule.Rag; - -namespace SimpleModule.Agents; - -public sealed class AgentChatService( - IAgentRegistry registry, - IChatClient chatClient, - IServiceProvider serviceProvider, - IOptions agentOptions -) -{ - public async Task ChatAsync( - string agentName, - AgentChatRequest request, - CancellationToken cancellationToken = default - ) - { - using var scope = serviceProvider.CreateScope(); - var (messages, chatOptions) = await PrepareAgentCallAsync( - agentName, - request, - scope.ServiceProvider, - cancellationToken - ); - - var client = chatOptions.Tools is { Count: > 0 } - ? new FunctionInvokingChatClient(chatClient) - : chatClient; - var response = await client.GetResponseAsync(messages, chatOptions, cancellationToken); - var sessionId = request.SessionId ?? Guid.NewGuid().ToString(); - - return new AgentChatResponse(response.Text ?? "", sessionId); - } - - public async IAsyncEnumerable ChatStreamAsync( - string agentName, - AgentChatRequest request, - [EnumeratorCancellation] CancellationToken cancellationToken = default - ) - { - using var scope = serviceProvider.CreateScope(); - var (messages, chatOptions) = await PrepareAgentCallAsync( - agentName, - request, - scope.ServiceProvider, - cancellationToken - ); - - var client = chatOptions.Tools is { Count: > 0 } - ? new FunctionInvokingChatClient(chatClient) - : chatClient; - await foreach ( - var update in client.GetStreamingResponseAsync(messages, chatOptions, cancellationToken) - ) - { - foreach (var content in update.Contents) - { - if (content is TextContent textContent && textContent.Text is not null) - { - yield return textContent.Text; - } - } - } - } - - private async Task<(List Messages, ChatOptions Options)> PrepareAgentCallAsync( - string agentName, - AgentChatRequest request, - IServiceProvider scopedProvider, - CancellationToken cancellationToken - ) - { - var registration = - registry.GetByName(agentName) - ?? throw new InvalidOperationException($"Agent '{agentName}' not found"); - - var agentDef = (IAgentDefinition) - ActivatorUtilities.CreateInstance(scopedProvider, registration.AgentDefinitionType); - - var messages = new List { new(ChatRole.System, agentDef.Instructions) }; - - // Inject RAG context if available and enabled - var options = agentOptions.Value; - var enableRag = agentDef.EnableRag ?? options.EnableRag; - if (enableRag) - { - var ragPipeline = scopedProvider.GetService(); - if (ragPipeline is not null) - { - var ragQueryOptions = agentDef.RagCollectionName is not null - ? new RagQueryOptions { CollectionName = agentDef.RagCollectionName } - : null; - var ragResult = await ragPipeline.QueryAsync( - request.Message, - ragQueryOptions, - cancellationToken - ); - if (ragResult.Sources.Count > 0) - { - var contextText = string.Join( - "\n\n", - ragResult.Sources.Select(s => $"### {s.Title}\n{s.Content}") - ); - messages.Add( - new ChatMessage(ChatRole.System, $"## Retrieved Knowledge\n\n{contextText}") - ); - } - } - } - - // Resolve tools from tool providers - var tools = new List(); - foreach (var providerType in registration.ToolProviderTypes) - { - var provider = (IAgentToolProvider) - ActivatorUtilities.CreateInstance(scopedProvider, providerType); - - var methods = providerType - .GetMethods() - .Where(m => m.GetCustomAttributes(typeof(AgentToolAttribute), false).Length > 0); - - foreach (var method in methods) - { - tools.Add(AIFunctionFactory.Create(method, provider)); - } - } - - if (request.History is { Count: > 0 }) - { - foreach (var turn in request.History) - { - if (string.IsNullOrWhiteSpace(turn.Content)) - { - continue; - } - var role = string.Equals(turn.Role, "assistant", StringComparison.OrdinalIgnoreCase) - ? ChatRole.Assistant - : ChatRole.User; - messages.Add(new ChatMessage(role, turn.Content)); - } - } - - messages.Add(new ChatMessage(ChatRole.User, request.Message)); - - var chatOptions = new ChatOptions - { - MaxOutputTokens = agentDef.MaxTokens ?? options.MaxTokens, - Temperature = agentDef.Temperature ?? options.Temperature, - Tools = tools.Count > 0 ? tools : null, - }; - - return (messages, chatOptions); - } -} diff --git a/framework/SimpleModule.Agents/AgentOptions.cs b/framework/SimpleModule.Agents/AgentOptions.cs deleted file mode 100644 index b4e0b674..00000000 --- a/framework/SimpleModule.Agents/AgentOptions.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace SimpleModule.Agents; - -public sealed class AgentOptions -{ - public bool Enabled { get; set; } = true; - public int MaxTokens { get; set; } = 4096; - public float Temperature { get; set; } = 0.7f; - public bool EnableRag { get; set; } = true; - public bool EnableStreaming { get; set; } = true; - public TimeSpan SessionTimeout { get; set; } = TimeSpan.FromMinutes(30); - public AgentRateLimitOptions RateLimit { get; set; } = new(); -} - -public sealed class AgentRateLimitOptions -{ - public int RequestsPerMinute { get; set; } = 60; - public int TokensPerMinute { get; set; } = 100_000; -} diff --git a/framework/SimpleModule.Agents/AgentRegistration.cs b/framework/SimpleModule.Agents/AgentRegistration.cs deleted file mode 100644 index c73711be..00000000 --- a/framework/SimpleModule.Agents/AgentRegistration.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace SimpleModule.Agents; - -public sealed record AgentRegistration( - string Name, - string Description, - string ModuleName, - Type AgentDefinitionType, - IReadOnlyList ToolProviderTypes -); diff --git a/framework/SimpleModule.Agents/AgentRegistry.cs b/framework/SimpleModule.Agents/AgentRegistry.cs deleted file mode 100644 index 19bf2d6a..00000000 --- a/framework/SimpleModule.Agents/AgentRegistry.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace SimpleModule.Agents; - -public sealed class AgentRegistry : IAgentRegistry -{ - private readonly List _agents = []; - private readonly Dictionary _byName = new( - StringComparer.OrdinalIgnoreCase - ); - - public IReadOnlyList GetAll() => _agents; - - public AgentRegistration? GetByName(string name) => _byName.GetValueOrDefault(name); - - public void Register(AgentRegistration registration) - { - _agents.Add(registration); - _byName[registration.Name] = registration; - } -} diff --git a/framework/SimpleModule.Agents/Dtos/AgentChatRequest.cs b/framework/SimpleModule.Agents/Dtos/AgentChatRequest.cs deleted file mode 100644 index 9645a6ca..00000000 --- a/framework/SimpleModule.Agents/Dtos/AgentChatRequest.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace SimpleModule.Agents.Dtos; - -public sealed record AgentChatRequest( - string Message, - string? SessionId = null, - string? ResponseType = null, - IReadOnlyList? History = null -); - -/// -/// A prior turn in the conversation. Role must be "user" or "assistant". -/// System messages are provided by the agent definition and should not appear here. -/// -public sealed record AgentHistoryMessage(string Role, string Content); diff --git a/framework/SimpleModule.Agents/Dtos/AgentChatResponse.cs b/framework/SimpleModule.Agents/Dtos/AgentChatResponse.cs deleted file mode 100644 index 2d928f3e..00000000 --- a/framework/SimpleModule.Agents/Dtos/AgentChatResponse.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace SimpleModule.Agents.Dtos; - -public sealed record AgentChatResponse(string Message, string SessionId); diff --git a/framework/SimpleModule.Agents/Dtos/AgentInfo.cs b/framework/SimpleModule.Agents/Dtos/AgentInfo.cs deleted file mode 100644 index 78d6ede1..00000000 --- a/framework/SimpleModule.Agents/Dtos/AgentInfo.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace SimpleModule.Agents.Dtos; - -public sealed record AgentInfo(string Name, string Description, string Module); diff --git a/framework/SimpleModule.Agents/Guardrails/GuardrailDirection.cs b/framework/SimpleModule.Agents/Guardrails/GuardrailDirection.cs deleted file mode 100644 index 8e0ad45b..00000000 --- a/framework/SimpleModule.Agents/Guardrails/GuardrailDirection.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace SimpleModule.Agents.Guardrails; - -public enum GuardrailDirection -{ - Input, - Output, -} diff --git a/framework/SimpleModule.Agents/Guardrails/GuardrailResult.cs b/framework/SimpleModule.Agents/Guardrails/GuardrailResult.cs deleted file mode 100644 index 11d58615..00000000 --- a/framework/SimpleModule.Agents/Guardrails/GuardrailResult.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace SimpleModule.Agents.Guardrails; - -public sealed record GuardrailResult( - bool IsAllowed, - string? Reason = null, - string? SanitizedContent = null -) -{ - public static GuardrailResult Allowed() => new(true); - - public static GuardrailResult Blocked(string reason) => new(false, reason); - - public static GuardrailResult Sanitized(string sanitizedContent) => - new(true, SanitizedContent: sanitizedContent); -} diff --git a/framework/SimpleModule.Agents/Guardrails/IAgentGuardrail.cs b/framework/SimpleModule.Agents/Guardrails/IAgentGuardrail.cs deleted file mode 100644 index 899dbc96..00000000 --- a/framework/SimpleModule.Agents/Guardrails/IAgentGuardrail.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace SimpleModule.Agents.Guardrails; - -public interface IAgentGuardrail -{ - Task ValidateAsync( - string content, - GuardrailDirection direction, - CancellationToken cancellationToken = default - ); -} diff --git a/framework/SimpleModule.Agents/IAgentRegistry.cs b/framework/SimpleModule.Agents/IAgentRegistry.cs deleted file mode 100644 index 194e9623..00000000 --- a/framework/SimpleModule.Agents/IAgentRegistry.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace SimpleModule.Agents; - -public interface IAgentRegistry -{ - IReadOnlyList GetAll(); - AgentRegistration? GetByName(string name); -} diff --git a/framework/SimpleModule.Agents/Middleware/AgentContext.cs b/framework/SimpleModule.Agents/Middleware/AgentContext.cs deleted file mode 100644 index ed991bc5..00000000 --- a/framework/SimpleModule.Agents/Middleware/AgentContext.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Security.Claims; -using SimpleModule.Agents.Dtos; - -namespace SimpleModule.Agents.Middleware; - -public sealed class AgentContext -{ - public required string AgentName { get; init; } - public required AgentChatRequest Request { get; init; } - public ClaimsPrincipal? User { get; init; } - public string? SessionId { get; set; } - public AgentChatResponse? Response { get; set; } - public CancellationToken CancellationToken { get; init; } - public Dictionary Properties { get; } = []; - public DateTimeOffset StartedAt { get; } = DateTimeOffset.UtcNow; -} diff --git a/framework/SimpleModule.Agents/Middleware/AgentMiddlewarePipeline.cs b/framework/SimpleModule.Agents/Middleware/AgentMiddlewarePipeline.cs deleted file mode 100644 index 7875376c..00000000 --- a/framework/SimpleModule.Agents/Middleware/AgentMiddlewarePipeline.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace SimpleModule.Agents.Middleware; - -public sealed class AgentMiddlewarePipeline -{ - private readonly List _middlewares = []; - - public AgentMiddlewarePipeline Use(IAgentMiddleware middleware) - { - _middlewares.Add(middleware); - return this; - } - - public AgentMiddlewareDelegate Build(AgentMiddlewareDelegate finalHandler) - { - var handler = finalHandler; - for (var i = _middlewares.Count - 1; i >= 0; i--) - { - var middleware = _middlewares[i]; - var next = handler; - handler = context => middleware.InvokeAsync(context, next); - } - - return handler; - } -} diff --git a/framework/SimpleModule.Agents/Middleware/IAgentMiddleware.cs b/framework/SimpleModule.Agents/Middleware/IAgentMiddleware.cs deleted file mode 100644 index 1e5ea5f9..00000000 --- a/framework/SimpleModule.Agents/Middleware/IAgentMiddleware.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace SimpleModule.Agents.Middleware; - -#pragma warning disable CA1711 // Identifiers should not have incorrect suffix - Delegate naming is intentional -public delegate Task AgentMiddlewareDelegate(AgentContext context); -#pragma warning restore CA1711 - -public interface IAgentMiddleware -{ - Task InvokeAsync(AgentContext context, AgentMiddlewareDelegate next); -} diff --git a/framework/SimpleModule.Agents/SimpleModule.Agents.csproj b/framework/SimpleModule.Agents/SimpleModule.Agents.csproj deleted file mode 100644 index a33ebc34..00000000 --- a/framework/SimpleModule.Agents/SimpleModule.Agents.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - net10.0 - Library - AI Agent runtime for SimpleModule. Provides agent registration, tool discovery, chat service, and auto-generated REST/SSE endpoints. - - - - - - - - - diff --git a/framework/SimpleModule.Agents/SimpleModuleAgentExtensions.cs b/framework/SimpleModule.Agents/SimpleModuleAgentExtensions.cs deleted file mode 100644 index 8b80e3d4..00000000 --- a/framework/SimpleModule.Agents/SimpleModuleAgentExtensions.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; - -namespace SimpleModule.Agents; - -public static class SimpleModuleAgentExtensions -{ - public static IServiceCollection AddSimpleModuleAgents( - this IServiceCollection services, - IConfiguration configuration, - Action? configure = null - ) - { - services.Configure(configuration.GetSection("Agents")); - if (configure is not null) - services.PostConfigure(configure); - - services.AddScoped(); - - return services; - } -} diff --git a/framework/SimpleModule.Rag.StructuredRag/ContentHasher.cs b/framework/SimpleModule.Rag.StructuredRag/ContentHasher.cs deleted file mode 100644 index ffda3272..00000000 --- a/framework/SimpleModule.Rag.StructuredRag/ContentHasher.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Security.Cryptography; -using System.Text; - -namespace SimpleModule.Rag.StructuredRag; - -public static class ContentHasher -{ - private const string DocumentSeparator = "\n---\n"; - - public static string ComputeHash(IEnumerable contents) - { - var combined = string.Join(DocumentSeparator, contents); - var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(combined)); - return Convert.ToHexString(bytes); - } -} diff --git a/framework/SimpleModule.Rag.StructuredRag/Data/CachedStructuredKnowledge.cs b/framework/SimpleModule.Rag.StructuredRag/Data/CachedStructuredKnowledge.cs deleted file mode 100644 index a66629ca..00000000 --- a/framework/SimpleModule.Rag.StructuredRag/Data/CachedStructuredKnowledge.cs +++ /dev/null @@ -1,21 +0,0 @@ -using SimpleModule.Core.Rag; - -namespace SimpleModule.Rag.StructuredRag.Data; - -/// -/// Cached structured knowledge entry. Stored in a relational database -/// via the StructuredRagCache module. Framework code uses -/// to access this data. -/// -public sealed class CachedStructuredKnowledge -{ - public Guid Id { get; set; } = Guid.NewGuid(); - public string CollectionName { get; set; } = ""; - public string DocumentHash { get; set; } = ""; - public StructureType StructureType { get; set; } - public string StructuredContent { get; set; } = ""; - public string SourceTitle { get; set; } = ""; - public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; - public DateTimeOffset? ExpiresAt { get; set; } - public int HitCount { get; set; } -} diff --git a/framework/SimpleModule.Rag.StructuredRag/Data/IStructuredKnowledgeCache.cs b/framework/SimpleModule.Rag.StructuredRag/Data/IStructuredKnowledgeCache.cs deleted file mode 100644 index c2e4339a..00000000 --- a/framework/SimpleModule.Rag.StructuredRag/Data/IStructuredKnowledgeCache.cs +++ /dev/null @@ -1,28 +0,0 @@ -using SimpleModule.Core.Rag; - -namespace SimpleModule.Rag.StructuredRag.Data; - -public interface IStructuredKnowledgeCache -{ - Task GetAsync( - string collectionName, - string documentHash, - StructureType structureType, - CancellationToken cancellationToken = default - ); - - Task> GetByCollectionAsync( - string collectionName, - StructureType structureType, - CancellationToken cancellationToken = default - ); - - Task SaveAsync(CachedStructuredKnowledge entry, CancellationToken cancellationToken = default); - - Task SaveBatchAsync( - IReadOnlyList entries, - CancellationToken cancellationToken = default - ); - - Task CleanExpiredAsync(CancellationToken cancellationToken = default); -} diff --git a/framework/SimpleModule.Rag.StructuredRag/IKnowledgeStructurizer.cs b/framework/SimpleModule.Rag.StructuredRag/IKnowledgeStructurizer.cs deleted file mode 100644 index aaa7a6e9..00000000 --- a/framework/SimpleModule.Rag.StructuredRag/IKnowledgeStructurizer.cs +++ /dev/null @@ -1,13 +0,0 @@ -using SimpleModule.Core.Rag; - -namespace SimpleModule.Rag.StructuredRag; - -public interface IKnowledgeStructurizer -{ - Task StructurizeAsync( - StructureType type, - string query, - IReadOnlyList documents, - CancellationToken cancellationToken = default - ); -} diff --git a/framework/SimpleModule.Rag.StructuredRag/IStructureRouter.cs b/framework/SimpleModule.Rag.StructuredRag/IStructureRouter.cs deleted file mode 100644 index 5147b800..00000000 --- a/framework/SimpleModule.Rag.StructuredRag/IStructureRouter.cs +++ /dev/null @@ -1,12 +0,0 @@ -using SimpleModule.Core.Rag; - -namespace SimpleModule.Rag.StructuredRag; - -public interface IStructureRouter -{ - Task SelectStructureAsync( - string query, - IReadOnlyList documentSummaries, - CancellationToken cancellationToken = default - ); -} diff --git a/framework/SimpleModule.Rag.StructuredRag/IStructuredKnowledgeUtilizer.cs b/framework/SimpleModule.Rag.StructuredRag/IStructuredKnowledgeUtilizer.cs deleted file mode 100644 index 3db0c08d..00000000 --- a/framework/SimpleModule.Rag.StructuredRag/IStructuredKnowledgeUtilizer.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace SimpleModule.Rag.StructuredRag; - -public interface IStructuredKnowledgeUtilizer -{ - Task AnswerAsync( - string query, - StructuredKnowledge knowledge, - CancellationToken cancellationToken = default - ); -} diff --git a/framework/SimpleModule.Rag.StructuredRag/LlmKnowledgeStructurizer.cs b/framework/SimpleModule.Rag.StructuredRag/LlmKnowledgeStructurizer.cs deleted file mode 100644 index 12196abe..00000000 --- a/framework/SimpleModule.Rag.StructuredRag/LlmKnowledgeStructurizer.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Microsoft.Extensions.AI; -using SimpleModule.Core.Rag; - -namespace SimpleModule.Rag.StructuredRag; - -public sealed class LlmKnowledgeStructurizer(IChatClient chatClient) : IKnowledgeStructurizer -{ - public async Task StructurizeAsync( - StructureType type, - string query, - IReadOnlyList documents, - CancellationToken cancellationToken = default - ) - { - if (type == StructureType.Chunk) - { - return new StructuredKnowledge(type, string.Join("\n\n---\n\n", documents), query); - } - - var systemPrompt = StructurePrompts.GetStructurizerPrompt(type); - var documentsText = string.Join("\n\n---\n\n", documents); - - var messages = new List - { - new(ChatRole.System, systemPrompt), - new(ChatRole.User, $"Query: {query}\n\nDocuments:\n{documentsText}"), - }; - - var response = await chatClient.GetResponseAsync( - messages, - cancellationToken: cancellationToken - ); - - return new StructuredKnowledge(type, response.Text ?? "", query); - } -} diff --git a/framework/SimpleModule.Rag.StructuredRag/LlmStructureRouter.cs b/framework/SimpleModule.Rag.StructuredRag/LlmStructureRouter.cs deleted file mode 100644 index abcc8c50..00000000 --- a/framework/SimpleModule.Rag.StructuredRag/LlmStructureRouter.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Options; -using SimpleModule.Core.Rag; - -namespace SimpleModule.Rag.StructuredRag; - -public sealed class LlmStructureRouter( - IChatClient chatClient, - IOptions options -) : IStructureRouter -{ - public async Task SelectStructureAsync( - string query, - IReadOnlyList documentSummaries, - CancellationToken cancellationToken = default - ) - { - if (!options.Value.EnableRouter) - return options.Value.DefaultStructure; - - var summariesText = string.Join("\n---\n", documentSummaries.Take(5)); - - var messages = new List - { - new(ChatRole.System, StructurePrompts.RouterSystem), - new(ChatRole.User, $"Query: {query}\n\nDocument summaries:\n{summariesText}"), - }; - - var response = await chatClient.GetResponseAsync( - messages, - cancellationToken: cancellationToken - ); - var text = response.Text?.Trim() ?? ""; - - return text.ToUpperInvariant() switch - { - "TABLE" => StructureType.Table, - "GRAPH" => StructureType.Graph, - "ALGORITHM" => StructureType.Algorithm, - "CATALOGUE" or "CATALOG" => StructureType.Catalogue, - "CHUNK" => StructureType.Chunk, - _ => options.Value.DefaultStructure, - }; - } -} diff --git a/framework/SimpleModule.Rag.StructuredRag/LlmStructuredKnowledgeUtilizer.cs b/framework/SimpleModule.Rag.StructuredRag/LlmStructuredKnowledgeUtilizer.cs deleted file mode 100644 index 09240e19..00000000 --- a/framework/SimpleModule.Rag.StructuredRag/LlmStructuredKnowledgeUtilizer.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Microsoft.Extensions.AI; - -namespace SimpleModule.Rag.StructuredRag; - -public sealed class LlmStructuredKnowledgeUtilizer(IChatClient chatClient) - : IStructuredKnowledgeUtilizer -{ - public async Task AnswerAsync( - string query, - StructuredKnowledge knowledge, - CancellationToken cancellationToken = default - ) - { - var structureLabel = knowledge.Type.ToString().ToUpperInvariant(); - - var messages = new List - { - new(ChatRole.System, StructurePrompts.UtilizerSystem), - new( - ChatRole.User, - $"## Structured Knowledge ({structureLabel} format):\n\n{knowledge.Content}\n\n## Question:\n{query}" - ), - }; - - var response = await chatClient.GetResponseAsync( - messages, - cancellationToken: cancellationToken - ); - - return response.Text ?? ""; - } -} diff --git a/framework/SimpleModule.Rag.StructuredRag/Preprocessing/LlmKnowledgePreprocessor.cs b/framework/SimpleModule.Rag.StructuredRag/Preprocessing/LlmKnowledgePreprocessor.cs deleted file mode 100644 index 9cd1e836..00000000 --- a/framework/SimpleModule.Rag.StructuredRag/Preprocessing/LlmKnowledgePreprocessor.cs +++ /dev/null @@ -1,123 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using SimpleModule.Core.Rag; -using SimpleModule.Rag.StructuredRag.Data; - -namespace SimpleModule.Rag.StructuredRag.Preprocessing; - -public sealed partial class LlmKnowledgePreprocessor( - IKnowledgeStructurizer structurizer, - IStructuredKnowledgeCache cache, - IOptions options, - ILogger logger -) : IKnowledgePreprocessor -{ - // Structure types to preprocess (skip Chunk — it's a passthrough) - private static readonly StructureType[] StructureTypes = - [ - StructureType.Table, - StructureType.Graph, - StructureType.Algorithm, - StructureType.Catalogue, - ]; - - public async Task PreprocessAsync( - string collectionName, - IReadOnlyList documents, - CancellationToken cancellationToken = default - ) - { - if (documents.Count == 0) - return; - - var docContents = documents.Select(d => d.Content).ToList(); - var expiry = options.Value.PreprocessedCacheTtl is { } ttl - ? DateTimeOffset.UtcNow.Add(ttl) - : (DateTimeOffset?)null; - - foreach (var structureType in StructureTypes) - { - cancellationToken.ThrowIfCancellationRequested(); - - // Check if already cached for this content - var contentHash = ContentHasher.ComputeHash(docContents); - var existing = await cache.GetAsync( - collectionName, - contentHash, - structureType, - cancellationToken - ); - if (existing is not null) - { - LogSkippingCached(logger, collectionName, structureType); - continue; - } - - try - { - LogPreprocessing(logger, collectionName, structureType, documents.Count); - - var structured = await structurizer.StructurizeAsync( - structureType, - $"Preprocess documents for {structureType} format", - docContents, - cancellationToken - ); - - var entry = new CachedStructuredKnowledge - { - CollectionName = collectionName, - DocumentHash = contentHash, - StructureType = structureType, - StructuredContent = structured.Content, - SourceTitle = string.Join(", ", documents.Select(d => d.Title)), - ExpiresAt = expiry, - }; - - await cache.SaveAsync(entry, cancellationToken); - } - catch (OperationCanceledException) - { - throw; - } -#pragma warning disable CA1031 - catch (Exception ex) -#pragma warning restore CA1031 - { - LogPreprocessingFailed(logger, ex, collectionName, structureType); - } - } - } - - [LoggerMessage( - Level = LogLevel.Information, - Message = "Preprocessing {CollectionName} into {StructureType} format ({DocumentCount} docs)" - )] - private static partial void LogPreprocessing( - ILogger logger, - string collectionName, - StructureType structureType, - int documentCount - ); - - [LoggerMessage( - Level = LogLevel.Debug, - Message = "Skipping {CollectionName}/{StructureType} — already cached" - )] - private static partial void LogSkippingCached( - ILogger logger, - string collectionName, - StructureType structureType - ); - - [LoggerMessage( - Level = LogLevel.Error, - Message = "Failed to preprocess {CollectionName} into {StructureType}" - )] - private static partial void LogPreprocessingFailed( - ILogger logger, - Exception ex, - string collectionName, - StructureType structureType - ); -} diff --git a/framework/SimpleModule.Rag.StructuredRag/SimpleModule.Rag.StructuredRag.csproj b/framework/SimpleModule.Rag.StructuredRag/SimpleModule.Rag.StructuredRag.csproj deleted file mode 100644 index b8a2d675..00000000 --- a/framework/SimpleModule.Rag.StructuredRag/SimpleModule.Rag.StructuredRag.csproj +++ /dev/null @@ -1,12 +0,0 @@ - - - net10.0 - Library - StructRAG implementation for SimpleModule. Converts retrieved documents into optimal structured formats (tables, graphs, algorithms, catalogues) before reasoning. - - - - - - - diff --git a/framework/SimpleModule.Rag.StructuredRag/StructurePrompts.cs b/framework/SimpleModule.Rag.StructuredRag/StructurePrompts.cs deleted file mode 100644 index 10d3b9be..00000000 --- a/framework/SimpleModule.Rag.StructuredRag/StructurePrompts.cs +++ /dev/null @@ -1,63 +0,0 @@ -using SimpleModule.Core.Rag; - -namespace SimpleModule.Rag.StructuredRag; - -internal static class StructurePrompts -{ - internal const string RouterSystem = """ - You are a structure type classifier. Given a user query and document summaries, - select the most appropriate structure type for organizing the information. - - Structure types: - - Table: Best for statistical analysis, comparison of quantitative data, feature comparison - - Graph: Best for entity relationships, long-chain reasoning, dependency tracking - - Algorithm: Best for planning tasks, procedural steps, workflows - - Catalogue: Best for summarization, hierarchical organization, categorization - - Chunk: Best for simple factual questions, single-hop lookups - - Respond with ONLY one word: Table, Graph, Algorithm, Catalogue, or Chunk. - """; - - internal const string StructurizerTableSystem = """ - Convert the provided documents into a markdown table that captures the key information - relevant to the user's query. Include appropriate column headers. - Output ONLY the markdown table, no explanation. - """; - - internal const string StructurizerGraphSystem = """ - Extract entity-relationship triplets from the provided documents relevant to the user's query. - Format each triplet as: (Entity1) -[Relationship]-> (Entity2) - One triplet per line. Output ONLY the triplets, no explanation. - """; - - internal const string StructurizerAlgorithmSystem = """ - Convert the provided documents into a step-by-step algorithm or procedure - relevant to the user's query. Use numbered steps with clear actions. - Output ONLY the algorithm steps, no explanation. - """; - - internal const string StructurizerCatalogueSystem = """ - Organize the provided documents into a hierarchical catalogue structure - relevant to the user's query. Use markdown headers and nested bullet points. - Output ONLY the catalogue, no explanation. - """; - - internal const string UtilizerSystem = """ - You are a knowledge assistant. Answer the user's question using ONLY the structured - knowledge provided below. Decompose complex questions into sub-questions, extract - relevant facts from the structured data, and synthesize a comprehensive answer. - - If the structured knowledge does not contain enough information, say so explicitly. - """; - - internal static string GetStructurizerPrompt(StructureType type) => - type switch - { - StructureType.Table => StructurizerTableSystem, - StructureType.Graph => StructurizerGraphSystem, - StructureType.Algorithm => StructurizerAlgorithmSystem, - StructureType.Catalogue => StructurizerCatalogueSystem, - StructureType.Chunk => "", // No structurization needed for chunks - _ => "", - }; -} diff --git a/framework/SimpleModule.Rag.StructuredRag/StructuredKnowledge.cs b/framework/SimpleModule.Rag.StructuredRag/StructuredKnowledge.cs deleted file mode 100644 index 4dddc2c6..00000000 --- a/framework/SimpleModule.Rag.StructuredRag/StructuredKnowledge.cs +++ /dev/null @@ -1,5 +0,0 @@ -using SimpleModule.Core.Rag; - -namespace SimpleModule.Rag.StructuredRag; - -public sealed record StructuredKnowledge(StructureType Type, string Content, string RawQuery); diff --git a/framework/SimpleModule.Rag.StructuredRag/StructuredRagExtensions.cs b/framework/SimpleModule.Rag.StructuredRag/StructuredRagExtensions.cs deleted file mode 100644 index ef3d47c5..00000000 --- a/framework/SimpleModule.Rag.StructuredRag/StructuredRagExtensions.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using SimpleModule.Core.Rag; -using SimpleModule.Rag.StructuredRag.Preprocessing; - -namespace SimpleModule.Rag.StructuredRag; - -public static class StructuredRagExtensions -{ - public static IServiceCollection AddStructuredRag( - this IServiceCollection services, - IConfiguration? configuration = null, - Action? configure = null - ) - { - if (configuration is not null) - services.Configure(configuration.GetSection("Rag:StructuredRag")); - if (configure is not null) - services.PostConfigure(configure); - - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - // Preprocessor (requires IStructuredKnowledgeCache to be registered by the cache module) - services.AddScoped(); - - return services; - } -} diff --git a/framework/SimpleModule.Rag.StructuredRag/StructuredRagOptions.cs b/framework/SimpleModule.Rag.StructuredRag/StructuredRagOptions.cs deleted file mode 100644 index 0f71a7d3..00000000 --- a/framework/SimpleModule.Rag.StructuredRag/StructuredRagOptions.cs +++ /dev/null @@ -1,17 +0,0 @@ -using SimpleModule.Core.Rag; - -namespace SimpleModule.Rag.StructuredRag; - -public sealed class StructuredRagOptions -{ - public string? RouterModel { get; set; } - public StructureType DefaultStructure { get; set; } = StructureType.Chunk; - public bool EnableRouter { get; set; } = true; - public int StructurizerMaxTokens { get; set; } = 4096; - - /// When true, documents are preprocessed into all structure types at ingestion time. - public bool EnablePreprocessing { get; set; } - - /// TTL for preprocessed cache entries. Null means no expiration. - public TimeSpan? PreprocessedCacheTtl { get; set; } -} diff --git a/framework/SimpleModule.Rag.StructuredRag/StructuredRagPipeline.cs b/framework/SimpleModule.Rag.StructuredRag/StructuredRagPipeline.cs deleted file mode 100644 index 2f817b53..00000000 --- a/framework/SimpleModule.Rag.StructuredRag/StructuredRagPipeline.cs +++ /dev/null @@ -1,117 +0,0 @@ -using System.Diagnostics; -using Microsoft.Extensions.Options; -using SimpleModule.Core.Rag; -using SimpleModule.Rag.StructuredRag.Data; - -namespace SimpleModule.Rag.StructuredRag; - -public sealed class StructuredRagPipeline( - IKnowledgeStore knowledgeStore, - IStructureRouter structureRouter, - IKnowledgeStructurizer knowledgeStructurizer, - IStructuredKnowledgeUtilizer knowledgeUtilizer, - IOptions ragOptions, - IStructuredKnowledgeCache? cache = null -) : IRagPipeline -{ - public async Task QueryAsync( - string query, - RagQueryOptions? options = null, - CancellationToken cancellationToken = default - ) - { - var stopwatch = Stopwatch.StartNew(); - - var topK = options?.TopK ?? ragOptions.Value.DefaultTopK; - var minScore = options?.MinScore ?? ragOptions.Value.MinScore; - var collectionName = options?.CollectionName ?? "default"; - - // Stage 0: Retrieve documents via vector search - var searchResults = await knowledgeStore.SearchAsync( - collectionName, - query, - topK, - minScore, - cancellationToken - ); - - if (searchResults.Count == 0) - { - return new RagResult( - "No relevant documents found.", - [], - new RagMetadata("StructuredRag", null, stopwatch.Elapsed) - ); - } - - var documents = searchResults.Select(r => r.Content).ToList(); - var summaries = searchResults - .Select(r => r.Content.Length > 200 ? r.Content[..200] + "..." : r.Content) - .ToList(); - - // Stage 1: Route — select the optimal structure type - var structureType = - options?.ForceStructure - ?? await structureRouter.SelectStructureAsync(query, summaries, cancellationToken); - - // Stage 2: Structurize — try cache first, then fall back to LLM - StructuredKnowledge structuredKnowledge; - - if (cache is not null && structureType != StructureType.Chunk) - { - var contentHash = ContentHasher.ComputeHash(documents); - var cached = await cache.GetAsync( - collectionName, - contentHash, - structureType, - cancellationToken - ); - - if (cached is not null) - { - structuredKnowledge = new StructuredKnowledge( - structureType, - cached.StructuredContent, - query - ); - } - else - { - structuredKnowledge = await knowledgeStructurizer.StructurizeAsync( - structureType, - query, - documents, - cancellationToken - ); - } - } - else - { - structuredKnowledge = await knowledgeStructurizer.StructurizeAsync( - structureType, - query, - documents, - cancellationToken - ); - } - - // Stage 3: Utilize — reason over structured data to produce answer - var answer = await knowledgeUtilizer.AnswerAsync( - query, - structuredKnowledge, - cancellationToken - ); - - stopwatch.Stop(); - - var sources = searchResults - .Select(r => new RagSource(r.Title, r.Content, r.Score, r.Metadata)) - .ToList(); - - return new RagResult( - answer, - sources, - new RagMetadata("StructuredRag", structureType, stopwatch.Elapsed) - ); - } -} diff --git a/framework/SimpleModule.Rag.VectorStore.InMemory/InMemoryVectorStoreExtensions.cs b/framework/SimpleModule.Rag.VectorStore.InMemory/InMemoryVectorStoreExtensions.cs deleted file mode 100644 index 9d41d446..00000000 --- a/framework/SimpleModule.Rag.VectorStore.InMemory/InMemoryVectorStoreExtensions.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.SemanticKernel.Connectors.InMemory; - -namespace SimpleModule.Rag.VectorStore.InMemory; - -public static class InMemoryVectorStoreExtensions -{ - public static IServiceCollection AddInMemoryVectorStore(this IServiceCollection services) - { - services.AddSingleton( - typeof(Microsoft.Extensions.VectorData.VectorStore), - _ => new InMemoryVectorStore() - ); - return services; - } -} diff --git a/framework/SimpleModule.Rag.VectorStore.InMemory/SimpleModule.Rag.VectorStore.InMemory.csproj b/framework/SimpleModule.Rag.VectorStore.InMemory/SimpleModule.Rag.VectorStore.InMemory.csproj deleted file mode 100644 index 7557dff3..00000000 --- a/framework/SimpleModule.Rag.VectorStore.InMemory/SimpleModule.Rag.VectorStore.InMemory.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - net10.0 - Library - In-memory vector store provider for SimpleModule.Rag. For development and testing only. - - - - - - - - diff --git a/framework/SimpleModule.Rag.VectorStore.Postgres/PostgresVectorStoreExtensions.cs b/framework/SimpleModule.Rag.VectorStore.Postgres/PostgresVectorStoreExtensions.cs deleted file mode 100644 index cc71c164..00000000 --- a/framework/SimpleModule.Rag.VectorStore.Postgres/PostgresVectorStoreExtensions.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.SemanticKernel.Connectors.Postgres; -using Npgsql; - -namespace SimpleModule.Rag.VectorStore.Postgres; - -public static class PostgresVectorStoreExtensions -{ - public static IServiceCollection AddPostgresVectorStore( - this IServiceCollection services, - IConfiguration configuration - ) - { - services.Configure( - configuration.GetSection("Rag:VectorStore:Postgres") - ); - - services.AddSingleton( - typeof(Microsoft.Extensions.VectorData.VectorStore), - sp => - { - var opts = - configuration - .GetSection("Rag:VectorStore:Postgres") - .Get() - ?? new PostgresVectorStoreOptions(); - var dataSource = NpgsqlDataSource.Create(opts.ConnectionString); - return new PostgresVectorStore(dataSource); - } - ); - - return services; - } -} diff --git a/framework/SimpleModule.Rag.VectorStore.Postgres/PostgresVectorStoreOptions.cs b/framework/SimpleModule.Rag.VectorStore.Postgres/PostgresVectorStoreOptions.cs deleted file mode 100644 index f7dbbd75..00000000 --- a/framework/SimpleModule.Rag.VectorStore.Postgres/PostgresVectorStoreOptions.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace SimpleModule.Rag.VectorStore.Postgres; - -public sealed class PostgresVectorStoreOptions -{ - public string ConnectionString { get; set; } = ""; -} diff --git a/framework/SimpleModule.Rag.VectorStore.Postgres/SimpleModule.Rag.VectorStore.Postgres.csproj b/framework/SimpleModule.Rag.VectorStore.Postgres/SimpleModule.Rag.VectorStore.Postgres.csproj deleted file mode 100644 index 20be9226..00000000 --- a/framework/SimpleModule.Rag.VectorStore.Postgres/SimpleModule.Rag.VectorStore.Postgres.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - net10.0 - Library - false - - - - - - - - diff --git a/framework/SimpleModule.Rag/IKnowledgeStore.cs b/framework/SimpleModule.Rag/IKnowledgeStore.cs deleted file mode 100644 index 6d27b61d..00000000 --- a/framework/SimpleModule.Rag/IKnowledgeStore.cs +++ /dev/null @@ -1,23 +0,0 @@ -using SimpleModule.Core.Rag; - -namespace SimpleModule.Rag; - -public interface IKnowledgeStore -{ - Task IndexDocumentsAsync( - string collectionName, - IReadOnlyList documents, - CancellationToken cancellationToken = default - ); - Task> SearchAsync( - string collectionName, - string query, - int topK = 5, - float minScore = 0.0f, - CancellationToken cancellationToken = default - ); - Task DeleteCollectionAsync( - string collectionName, - CancellationToken cancellationToken = default - ); -} diff --git a/framework/SimpleModule.Rag/IRagPipeline.cs b/framework/SimpleModule.Rag/IRagPipeline.cs deleted file mode 100644 index 82598d8b..00000000 --- a/framework/SimpleModule.Rag/IRagPipeline.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace SimpleModule.Rag; - -public interface IRagPipeline -{ - Task QueryAsync( - string query, - RagQueryOptions? options = null, - CancellationToken cancellationToken = default - ); -} diff --git a/framework/SimpleModule.Rag/KnowledgeIndexingHostedService.cs b/framework/SimpleModule.Rag/KnowledgeIndexingHostedService.cs deleted file mode 100644 index bb0c4bda..00000000 --- a/framework/SimpleModule.Rag/KnowledgeIndexingHostedService.cs +++ /dev/null @@ -1,121 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using SimpleModule.Core.Rag; - -namespace SimpleModule.Rag; - -public sealed partial class KnowledgeIndexingHostedService( - IServiceProvider serviceProvider, - IOptions options, - ILogger logger -) : BackgroundService -{ - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - // Yield to allow the web server to start accepting requests - await Task.Yield(); - - if (!options.Value.IndexOnStartup) - { - LogIndexingDisabled(logger); - return; - } - - using var scope = serviceProvider.CreateScope(); - var knowledgeStore = scope.ServiceProvider.GetService(); - if (knowledgeStore is null) - { - LogNoKnowledgeStore(logger); - return; - } - - var sources = scope.ServiceProvider.GetServices().ToList(); - var tasks = sources.Select(async source => - { - using var sourceScope = serviceProvider.CreateScope(); - var scopedStore = sourceScope.ServiceProvider.GetRequiredService(); - await IndexSourceAsync(scopedStore, source, sourceScope.ServiceProvider, stoppingToken); - }); - await Task.WhenAll(tasks); - } - - private async Task IndexSourceAsync( - IKnowledgeStore knowledgeStore, - IKnowledgeSource source, - IServiceProvider scopedProvider, - CancellationToken cancellationToken - ) - { - try - { - var documents = await source.GetDocumentsAsync(cancellationToken); - if (documents.Count == 0) - return; - - LogIndexing(logger, documents.Count, source.CollectionName); - await knowledgeStore.IndexDocumentsAsync( - source.CollectionName, - documents, - cancellationToken - ); - - // Preprocess documents into structured formats if a preprocessor is registered - var preprocessor = scopedProvider.GetService(); - if (preprocessor is not null) - { - LogPreprocessing(logger, documents.Count, source.CollectionName); - await preprocessor.PreprocessAsync( - source.CollectionName, - documents, - cancellationToken - ); - } - } - catch (OperationCanceledException) - { - throw; - } -#pragma warning disable CA1031 // Catch more specific exception - intentional isolation of knowledge source failures - catch (Exception ex) -#pragma warning restore CA1031 - { - LogIndexingFailed(logger, ex, source.CollectionName); - } - } - - [LoggerMessage( - Level = LogLevel.Information, - Message = "Knowledge indexing on startup is disabled" - )] - private static partial void LogIndexingDisabled(ILogger logger); - - [LoggerMessage( - Level = LogLevel.Warning, - Message = "No IKnowledgeStore registered, skipping knowledge indexing" - )] - private static partial void LogNoKnowledgeStore(ILogger logger); - - [LoggerMessage( - Level = LogLevel.Information, - Message = "Indexing {Count} documents for collection '{CollectionName}'" - )] - private static partial void LogIndexing(ILogger logger, int count, string collectionName); - - [LoggerMessage( - Level = LogLevel.Information, - Message = "Preprocessing {Count} documents for collection '{CollectionName}'" - )] - private static partial void LogPreprocessing(ILogger logger, int count, string collectionName); - - [LoggerMessage( - Level = LogLevel.Error, - Message = "Failed to index knowledge for collection '{CollectionName}'" - )] - private static partial void LogIndexingFailed( - ILogger logger, - Exception ex, - string collectionName - ); -} diff --git a/framework/SimpleModule.Rag/KnowledgeRecord.cs b/framework/SimpleModule.Rag/KnowledgeRecord.cs deleted file mode 100644 index 09d9d92f..00000000 --- a/framework/SimpleModule.Rag/KnowledgeRecord.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Microsoft.Extensions.VectorData; - -namespace SimpleModule.Rag; - -public sealed class KnowledgeRecord -{ - [VectorStoreKey] - public string Id { get; set; } = ""; - - [VectorStoreData] - public string Title { get; set; } = ""; - - [VectorStoreData] - public string Content { get; set; } = ""; - - [VectorStoreData] - public string CollectionName { get; set; } = ""; - - [VectorStoreData(IsIndexed = true)] - public string? ModuleName { get; set; } - - [VectorStoreVector(1536)] - public ReadOnlyMemory Embedding { get; set; } -} diff --git a/framework/SimpleModule.Rag/KnowledgeSearchResult.cs b/framework/SimpleModule.Rag/KnowledgeSearchResult.cs deleted file mode 100644 index fc0f8b67..00000000 --- a/framework/SimpleModule.Rag/KnowledgeSearchResult.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace SimpleModule.Rag; - -public sealed record KnowledgeSearchResult( - string Title, - string Content, - float Score, - Dictionary? Metadata = null -); diff --git a/framework/SimpleModule.Rag/RagOptions.cs b/framework/SimpleModule.Rag/RagOptions.cs deleted file mode 100644 index 6d8b539d..00000000 --- a/framework/SimpleModule.Rag/RagOptions.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace SimpleModule.Rag; - -public sealed class RagOptions -{ - public int DefaultTopK { get; set; } = 5; - public float MinScore { get; set; } = 0.7f; - public int EmbeddingDimension { get; set; } = 1536; - public bool IndexOnStartup { get; set; } = true; -} diff --git a/framework/SimpleModule.Rag/RagQueryOptions.cs b/framework/SimpleModule.Rag/RagQueryOptions.cs deleted file mode 100644 index 4c81cfe0..00000000 --- a/framework/SimpleModule.Rag/RagQueryOptions.cs +++ /dev/null @@ -1,12 +0,0 @@ -using SimpleModule.Core.Rag; - -namespace SimpleModule.Rag; - -public sealed class RagQueryOptions -{ - public string CollectionName { get; set; } = "default"; - public int? TopK { get; set; } - public float? MinScore { get; set; } - public StructureType? ForceStructure { get; set; } - public bool IncludeStructuredContent { get; set; } -} diff --git a/framework/SimpleModule.Rag/RagResult.cs b/framework/SimpleModule.Rag/RagResult.cs deleted file mode 100644 index e1f29e35..00000000 --- a/framework/SimpleModule.Rag/RagResult.cs +++ /dev/null @@ -1,22 +0,0 @@ -using SimpleModule.Core.Rag; - -namespace SimpleModule.Rag; - -public sealed record RagResult( - string Answer, - IReadOnlyList Sources, - RagMetadata Metadata -); - -public sealed record RagSource( - string Title, - string Content, - float Score, - Dictionary? Metadata = null -); - -public sealed record RagMetadata( - string PipelineName, - StructureType? SelectedStructure, - TimeSpan Duration -); diff --git a/framework/SimpleModule.Rag/RagSettingsDefinitions.cs b/framework/SimpleModule.Rag/RagSettingsDefinitions.cs deleted file mode 100644 index f7e45a2b..00000000 --- a/framework/SimpleModule.Rag/RagSettingsDefinitions.cs +++ /dev/null @@ -1,74 +0,0 @@ -using SimpleModule.Core.Settings; - -namespace SimpleModule.Rag; - -public static class RagSettingsDefinitions -{ - public static void Register(ISettingsBuilder settings) - { - settings - .Add( - new SettingDefinition - { - Key = "Rag.DefaultTopK", - DisplayName = "Default Top-K Results", - Description = "Number of documents to retrieve per query", - Group = "RAG (Retrieval-Augmented Generation)", - Type = SettingType.Number, - Scope = SettingScope.Application, - DefaultValue = "5", - } - ) - .Add( - new SettingDefinition - { - Key = "Rag.MinScore", - DisplayName = "Minimum Similarity Score", - Description = "Minimum vector similarity threshold (0.0-1.0)", - Group = "RAG (Retrieval-Augmented Generation)", - Type = SettingType.Number, - Scope = SettingScope.Application, - DefaultValue = "0.7", - } - ) - .Add( - new SettingDefinition - { - Key = "Rag.IndexOnStartup", - DisplayName = "Index on Startup", - Description = - "Whether to index knowledge documents when the application starts", - Group = "RAG (Retrieval-Augmented Generation)", - Type = SettingType.Bool, - Scope = SettingScope.Application, - DefaultValue = "true", - } - ) - .Add( - new SettingDefinition - { - Key = "Rag.StructuredRag.EnableRouter", - DisplayName = "Enable Structure Router", - Description = - "Enable StructRAG hybrid router to select optimal format per query", - Group = "RAG (Retrieval-Augmented Generation)", - Type = SettingType.Bool, - Scope = SettingScope.Application, - DefaultValue = "true", - } - ) - .Add( - new SettingDefinition - { - Key = "Rag.StructuredRag.DefaultStructure", - DisplayName = "Default Structure Type", - Description = - "Fallback structure type when router is disabled (Table, Graph, Algorithm, Catalogue, Chunk)", - Group = "RAG (Retrieval-Augmented Generation)", - Type = SettingType.Text, - Scope = SettingScope.Application, - DefaultValue = "Chunk", - } - ); - } -} diff --git a/framework/SimpleModule.Rag/SimpleModule.Rag.csproj b/framework/SimpleModule.Rag/SimpleModule.Rag.csproj deleted file mode 100644 index eb11ed5e..00000000 --- a/framework/SimpleModule.Rag/SimpleModule.Rag.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - net10.0 - Library - RAG abstraction for SimpleModule. Defines IRagPipeline interface for pluggable retrieval-augmented generation backends. - - - - - - - - diff --git a/framework/SimpleModule.Rag/SimpleModuleRagExtensions.cs b/framework/SimpleModule.Rag/SimpleModuleRagExtensions.cs deleted file mode 100644 index 43e7319e..00000000 --- a/framework/SimpleModule.Rag/SimpleModuleRagExtensions.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; - -namespace SimpleModule.Rag; - -public static class SimpleModuleRagExtensions -{ - public static IServiceCollection AddSimpleModuleRag( - this IServiceCollection services, - IConfiguration configuration, - Action? configure = null - ) - { - services.Configure(configuration.GetSection("Rag")); - if (configure is not null) - services.PostConfigure(configure); - - services.AddSingleton(); - services.AddHostedService(); - - return services; - } -} diff --git a/framework/SimpleModule.Rag/VectorKnowledgeStore.cs b/framework/SimpleModule.Rag/VectorKnowledgeStore.cs deleted file mode 100644 index 61b4b940..00000000 --- a/framework/SimpleModule.Rag/VectorKnowledgeStore.cs +++ /dev/null @@ -1,102 +0,0 @@ -using Microsoft.Extensions.AI; -using Microsoft.Extensions.VectorData; -using SimpleModule.Core.Rag; - -namespace SimpleModule.Rag; - -public sealed class VectorKnowledgeStore( - VectorStore vectorStore, - IEmbeddingGenerator> embeddingGenerator -) : IKnowledgeStore -{ - public async Task IndexDocumentsAsync( - string collectionName, - IReadOnlyList documents, - CancellationToken cancellationToken = default - ) - { - var collection = vectorStore.GetCollection(collectionName); - await collection.EnsureCollectionExistsAsync(cancellationToken); - - var contents = documents.Select(d => d.Content).ToList(); - var embeddings = await embeddingGenerator.GenerateAsync( - contents, - cancellationToken: cancellationToken - ); - - // Upsert concurrently in batches for better throughput - var upsertTasks = new List(documents.Count); - for (var i = 0; i < documents.Count; i++) - { - var doc = documents[i]; - var record = new KnowledgeRecord - { - Id = Guid.NewGuid().ToString(), - Title = doc.Title, - Content = doc.Content, - CollectionName = collectionName, - ModuleName = doc.Metadata?.GetValueOrDefault("module"), - Embedding = embeddings[i].Vector, - }; - upsertTasks.Add(collection.UpsertAsync(record, cancellationToken)); - } - - await Task.WhenAll(upsertTasks); - } - - public async Task> SearchAsync( - string collectionName, - string query, - int topK = 5, - float minScore = 0.0f, - CancellationToken cancellationToken = default - ) - { - var collection = vectorStore.GetCollection(collectionName); - - if (!await collection.CollectionExistsAsync(cancellationToken)) - return []; - - var queryEmbeddings = await embeddingGenerator.GenerateAsync( - [query], - cancellationToken: cancellationToken - ); - var queryVector = queryEmbeddings[0].Vector; - - var results = new List(); - await foreach ( - var result in collection.SearchAsync( - queryVector, - top: topK, - cancellationToken: cancellationToken - ) - ) - { - var score = (float)(result.Score ?? 0.0); - if (score < minScore) - break; // Results are descending by score; remaining will also be below threshold - - results.Add( - new KnowledgeSearchResult( - result.Record.Title, - result.Record.Content, - score, - result.Record.ModuleName is not null - ? new Dictionary { ["module"] = result.Record.ModuleName } - : null - ) - ); - } - - return results; - } - - public async Task DeleteCollectionAsync( - string collectionName, - CancellationToken cancellationToken = default - ) - { - var collection = vectorStore.GetCollection(collectionName); - await collection.EnsureCollectionDeletedAsync(cancellationToken); - } -} diff --git a/modules/Agents/src/SimpleModule.Agents.Contracts/AgentMessage.cs b/modules/Agents/src/SimpleModule.Agents.Contracts/AgentMessage.cs deleted file mode 100644 index 7292b51c..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Contracts/AgentMessage.cs +++ /dev/null @@ -1,14 +0,0 @@ -using SimpleModule.Core; - -namespace SimpleModule.Agents.Contracts; - -[NoDtoGeneration] -public sealed class AgentMessage -{ - public AgentMessageId Id { get; set; } = AgentMessageId.From(Guid.NewGuid().ToString()); - public AgentSessionId SessionId { get; set; } = AgentSessionId.From(string.Empty); - public string Role { get; set; } = ""; - public string Content { get; set; } = ""; - public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow; - public int? TokenCount { get; set; } -} diff --git a/modules/Agents/src/SimpleModule.Agents.Contracts/AgentMessageDto.cs b/modules/Agents/src/SimpleModule.Agents.Contracts/AgentMessageDto.cs deleted file mode 100644 index 8c387ca6..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Contracts/AgentMessageDto.cs +++ /dev/null @@ -1,14 +0,0 @@ -using SimpleModule.Core; - -namespace SimpleModule.Agents.Contracts; - -[Dto] -public class AgentMessageDto -{ - public string Id { get; set; } = string.Empty; - public string SessionId { get; set; } = string.Empty; - public string Role { get; set; } = string.Empty; - public string Content { get; set; } = string.Empty; - public DateTimeOffset Timestamp { get; set; } - public int? TokenCount { get; set; } -} diff --git a/modules/Agents/src/SimpleModule.Agents.Contracts/AgentMessageId.cs b/modules/Agents/src/SimpleModule.Agents.Contracts/AgentMessageId.cs deleted file mode 100644 index 91b43089..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Contracts/AgentMessageId.cs +++ /dev/null @@ -1,6 +0,0 @@ -using Vogen; - -namespace SimpleModule.Agents.Contracts; - -[ValueObject(conversions: Conversions.SystemTextJson | Conversions.EfCoreValueConverter)] -public readonly partial struct AgentMessageId; diff --git a/modules/Agents/src/SimpleModule.Agents.Contracts/AgentSession.cs b/modules/Agents/src/SimpleModule.Agents.Contracts/AgentSession.cs deleted file mode 100644 index ba1cb898..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Contracts/AgentSession.cs +++ /dev/null @@ -1,15 +0,0 @@ -using SimpleModule.Core.Entities; - -namespace SimpleModule.Agents.Contracts; - -public sealed class AgentSession : Entity -{ - public AgentSession() - { - Id = AgentSessionId.From(Guid.NewGuid().ToString()); - } - - public string AgentName { get; set; } = ""; - public string? UserId { get; set; } - public DateTimeOffset LastMessageAt { get; set; } = DateTimeOffset.UtcNow; -} diff --git a/modules/Agents/src/SimpleModule.Agents.Contracts/AgentSessionDto.cs b/modules/Agents/src/SimpleModule.Agents.Contracts/AgentSessionDto.cs deleted file mode 100644 index dc18a060..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Contracts/AgentSessionDto.cs +++ /dev/null @@ -1,13 +0,0 @@ -using SimpleModule.Core; - -namespace SimpleModule.Agents.Contracts; - -[Dto] -public class AgentSessionDto -{ - public string Id { get; set; } = string.Empty; - public string AgentName { get; set; } = string.Empty; - public string? UserId { get; set; } - public DateTimeOffset CreatedAt { get; set; } - public DateTimeOffset LastMessageAt { get; set; } -} diff --git a/modules/Agents/src/SimpleModule.Agents.Contracts/AgentSessionId.cs b/modules/Agents/src/SimpleModule.Agents.Contracts/AgentSessionId.cs deleted file mode 100644 index b6522bbc..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Contracts/AgentSessionId.cs +++ /dev/null @@ -1,6 +0,0 @@ -using Vogen; - -namespace SimpleModule.Agents.Contracts; - -[ValueObject(conversions: Conversions.SystemTextJson | Conversions.EfCoreValueConverter)] -public readonly partial struct AgentSessionId; diff --git a/modules/Agents/src/SimpleModule.Agents.Contracts/Events/AgentMessageReceivedEvent.cs b/modules/Agents/src/SimpleModule.Agents.Contracts/Events/AgentMessageReceivedEvent.cs deleted file mode 100644 index cf8ba1b4..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Contracts/Events/AgentMessageReceivedEvent.cs +++ /dev/null @@ -1,12 +0,0 @@ -using SimpleModule.Core.Events; - -namespace SimpleModule.Agents.Contracts; - -public sealed class AgentMessageReceivedEvent : IEvent -{ - public required string AgentName { get; init; } - public required string Message { get; init; } - public required string SessionId { get; init; } - public string? UserId { get; init; } - public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow; -} diff --git a/modules/Agents/src/SimpleModule.Agents.Contracts/Events/AgentResponseGeneratedEvent.cs b/modules/Agents/src/SimpleModule.Agents.Contracts/Events/AgentResponseGeneratedEvent.cs deleted file mode 100644 index 005d185a..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Contracts/Events/AgentResponseGeneratedEvent.cs +++ /dev/null @@ -1,13 +0,0 @@ -using SimpleModule.Core.Events; - -namespace SimpleModule.Agents.Contracts; - -public sealed class AgentResponseGeneratedEvent : IEvent -{ - public required string AgentName { get; init; } - public required string Response { get; init; } - public required string SessionId { get; init; } - public TimeSpan Duration { get; init; } - public int? EstimatedTokens { get; init; } - public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow; -} diff --git a/modules/Agents/src/SimpleModule.Agents.Contracts/Events/AgentToolCalledEvent.cs b/modules/Agents/src/SimpleModule.Agents.Contracts/Events/AgentToolCalledEvent.cs deleted file mode 100644 index 35670be2..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Contracts/Events/AgentToolCalledEvent.cs +++ /dev/null @@ -1,11 +0,0 @@ -using SimpleModule.Core.Events; - -namespace SimpleModule.Agents.Contracts; - -public sealed class AgentToolCalledEvent : IEvent -{ - public required string AgentName { get; init; } - public required string ToolName { get; init; } - public required string SessionId { get; init; } - public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow; -} diff --git a/modules/Agents/src/SimpleModule.Agents.Contracts/IAgentsContracts.cs b/modules/Agents/src/SimpleModule.Agents.Contracts/IAgentsContracts.cs deleted file mode 100644 index 996cb98f..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Contracts/IAgentsContracts.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace SimpleModule.Agents.Contracts; - -public interface IAgentsContracts -{ - Task GetSessionAsync( - string sessionId, - CancellationToken cancellationToken = default - ); - - Task CreateSessionAsync( - string agentName, - string? userId, - CancellationToken cancellationToken = default - ); - - Task> GetSessionHistoryAsync( - string sessionId, - int? maxMessages = null, - CancellationToken cancellationToken = default - ); -} diff --git a/modules/Agents/src/SimpleModule.Agents.Contracts/SimpleModule.Agents.Contracts.csproj b/modules/Agents/src/SimpleModule.Agents.Contracts/SimpleModule.Agents.Contracts.csproj deleted file mode 100644 index e9105287..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Contracts/SimpleModule.Agents.Contracts.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - net10.0 - Library - Contracts for the SimpleModule Agents module. Events and shared types for cross-module use. - $(DefineConstants);VOGEN_NO_VALIDATION - - - - - - - diff --git a/modules/Agents/src/SimpleModule.Agents.Module/AgentEndpoints.cs b/modules/Agents/src/SimpleModule.Agents.Module/AgentEndpoints.cs deleted file mode 100644 index b9a69325..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Module/AgentEndpoints.cs +++ /dev/null @@ -1,76 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Agents; -using SimpleModule.Agents.Dtos; - -namespace SimpleModule.Agents.Module; - -public static class AgentEndpoints -{ - public static void MapAgentEndpoints(IEndpointRouteBuilder app, IAgentRegistry registry) - { - var group = app.MapGroup("/api/agents").WithTags("Agents").RequireAuthorization(); - - group.MapGet( - "/", - (IAgentRegistry reg) => - reg.GetAll().Select(a => new AgentInfo(a.Name, a.Description, a.ModuleName)) - ); - - group.MapPost( - "/{name}/chat", - async ( - string name, - AgentChatRequest request, - AgentChatService service, - CancellationToken ct - ) => - { - var response = await service.ChatAsync(name, request, ct); - return Results.Ok(response); - } - ); - - group.MapPost( - "/{name}/chat/stream", - async ( - string name, - AgentChatRequest request, - AgentChatService service, - HttpContext httpContext, - CancellationToken ct - ) => - { - httpContext.Response.ContentType = "text/event-stream"; - httpContext.Response.Headers.CacheControl = "no-cache"; - httpContext.Response.Headers.Connection = "keep-alive"; - - await foreach (var chunk in service.ChatStreamAsync(name, request, ct)) - { - var data = System.Text.Json.JsonSerializer.Serialize(new { text = chunk }); - await httpContext.Response.WriteAsync($"data: {data}\n\n", ct); - await httpContext.Response.Body.FlushAsync(ct); - } - - await httpContext.Response.WriteAsync("data: [DONE]\n\n", ct); - } - ); - - group - .MapPost( - "/{name}/chat/structured", - async ( - string name, - AgentChatRequest request, - AgentChatService service, - CancellationToken ct - ) => - { - var response = await service.ChatAsync(name, request, ct); - return Results.Ok(response); - } - ) - .WithDescription("Chat with structured JSON output"); - } -} diff --git a/modules/Agents/src/SimpleModule.Agents.Module/AgentSettingsDefinitions.cs b/modules/Agents/src/SimpleModule.Agents.Module/AgentSettingsDefinitions.cs deleted file mode 100644 index b5908328..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Module/AgentSettingsDefinitions.cs +++ /dev/null @@ -1,95 +0,0 @@ -using SimpleModule.Core.Settings; - -namespace SimpleModule.Agents.Module; - -public static class AgentSettingsDefinitions -{ - public static void Register(ISettingsBuilder settings) - { - settings - .Add( - new SettingDefinition - { - Key = "Agents.Enabled", - DisplayName = "Enable AI Agents", - Description = "Global kill switch for all AI agent endpoints", - Group = "AI Agents", - Type = SettingType.Bool, - Scope = SettingScope.Application, - DefaultValue = "true", - } - ) - .Add( - new SettingDefinition - { - Key = "Agents.MaxTokens", - DisplayName = "Max Tokens", - Description = "Default maximum tokens per agent response", - Group = "AI Agents", - Type = SettingType.Number, - Scope = SettingScope.Application, - DefaultValue = "4096", - } - ) - .Add( - new SettingDefinition - { - Key = "Agents.Temperature", - DisplayName = "Temperature", - Description = "Default temperature for agent responses (0.0-2.0)", - Group = "AI Agents", - Type = SettingType.Number, - Scope = SettingScope.Application, - DefaultValue = "0.7", - } - ) - .Add( - new SettingDefinition - { - Key = "Agents.EnableRag", - DisplayName = "Enable RAG Context", - Description = "Whether agents use retrieval-augmented generation for context", - Group = "AI Agents", - Type = SettingType.Bool, - Scope = SettingScope.Application, - DefaultValue = "true", - } - ) - .Add( - new SettingDefinition - { - Key = "Agents.EnableStreaming", - DisplayName = "Enable Streaming", - Description = "Enable SSE streaming endpoints for real-time responses", - Group = "AI Agents", - Type = SettingType.Bool, - Scope = SettingScope.Application, - DefaultValue = "true", - } - ) - .Add( - new SettingDefinition - { - Key = "Agents.RateLimit.RequestsPerMinute", - DisplayName = "Rate Limit (requests/min)", - Description = "Maximum agent requests per user per minute", - Group = "AI Agents", - Type = SettingType.Number, - Scope = SettingScope.Application, - DefaultValue = "60", - } - ) - .Add( - new SettingDefinition - { - Key = "Agents.RateLimit.TokensPerMinute", - DisplayName = "Token Limit (tokens/min)", - Description = "Maximum tokens per user per minute", - Group = "AI Agents", - Type = SettingType.Number, - Scope = SettingScope.Application, - DefaultValue = "100000", - } - ); - } -} diff --git a/modules/Agents/src/SimpleModule.Agents.Module/AgentsConstants.cs b/modules/Agents/src/SimpleModule.Agents.Module/AgentsConstants.cs deleted file mode 100644 index 54db6999..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Module/AgentsConstants.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace SimpleModule.Agents.Module; - -public static class AgentsConstants -{ - public const string ModuleName = "Agents"; -} diff --git a/modules/Agents/src/SimpleModule.Agents.Module/AgentsDbContext.cs b/modules/Agents/src/SimpleModule.Agents.Module/AgentsDbContext.cs deleted file mode 100644 index 4863bad9..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Module/AgentsDbContext.cs +++ /dev/null @@ -1,51 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Microsoft.Extensions.Options; -using SimpleModule.Agents.Contracts; -using SimpleModule.Database; - -namespace SimpleModule.Agents.Module; - -public sealed class AgentsDbContext( - DbContextOptions options, - IOptions dbOptions -) : DbContext(options) -{ - public DbSet Sessions => Set(); - public DbSet Messages => Set(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.ApplyConfiguration(new EntityConfigurations.AgentSessionConfiguration()); - modelBuilder.ApplyConfiguration(new EntityConfigurations.AgentMessageConfiguration()); - modelBuilder.ApplyModuleSchema(AgentsConstants.ModuleName, dbOptions.Value); - } - - protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) - { - configurationBuilder - .Properties() - .HaveConversion< - AgentSessionId.EfCoreValueConverter, - AgentSessionId.EfCoreValueComparer - >(); - configurationBuilder - .Properties() - .HaveConversion< - AgentMessageId.EfCoreValueConverter, - AgentMessageId.EfCoreValueComparer - >(); - - // SQLite cannot ORDER BY or compare DateTimeOffset expressions natively. - // Store as long (binary ticks) only when running against SQLite. - if (dbOptions.Value.DetectProvider(AgentsConstants.ModuleName) == DatabaseProvider.Sqlite) - { - configurationBuilder - .Properties() - .HaveConversion(); - configurationBuilder - .Properties() - .HaveConversion(); - } - } -} diff --git a/modules/Agents/src/SimpleModule.Agents.Module/AgentsModule.cs b/modules/Agents/src/SimpleModule.Agents.Module/AgentsModule.cs deleted file mode 100644 index 03e48268..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Module/AgentsModule.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using SimpleModule.Agents; -using SimpleModule.Agents.Contracts; -using SimpleModule.Agents.Guardrails; -using SimpleModule.Agents.Middleware; -using SimpleModule.Core; -using SimpleModule.Core.Settings; -using SimpleModule.Database; - -namespace SimpleModule.Agents.Module; - -[Module(AgentsConstants.ModuleName)] -public class AgentsModule : IModule -{ - public void ConfigureServices(IServiceCollection services, IConfiguration configuration) - { - services.AddModuleDbContext(configuration, AgentsConstants.ModuleName); - services.AddScoped(); - services.AddScoped(); - - // Middleware - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - // Guardrails - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - // File service - services.AddScoped(); - } - - public void ConfigureEndpoints(IEndpointRouteBuilder endpoints) - { - var registry = endpoints.ServiceProvider.GetRequiredService(); - AgentEndpoints.MapAgentEndpoints(endpoints, registry); - - var env = endpoints.ServiceProvider.GetService(); - if (env?.IsDevelopment() == true) - { - AgentPlaygroundEndpoints.Map(endpoints); - } - } - - public void ConfigureSettings(ISettingsBuilder settings) - { - AgentSettingsDefinitions.Register(settings); - } -} diff --git a/modules/Agents/src/SimpleModule.Agents.Module/AgentsService.cs b/modules/Agents/src/SimpleModule.Agents.Module/AgentsService.cs deleted file mode 100644 index c6648ffb..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Module/AgentsService.cs +++ /dev/null @@ -1,62 +0,0 @@ -using SimpleModule.Agents.Contracts; - -namespace SimpleModule.Agents.Module; - -public sealed class AgentsService(IAgentSessionStore sessionStore) : IAgentsContracts -{ - public async Task GetSessionAsync( - string sessionId, - CancellationToken cancellationToken = default - ) - { - var session = await sessionStore - .GetSessionAsync(sessionId, cancellationToken) - .ConfigureAwait(false); - return session is null ? null : ToDto(session); - } - - public async Task CreateSessionAsync( - string agentName, - string? userId, - CancellationToken cancellationToken = default - ) - { - var session = await sessionStore - .CreateSessionAsync(agentName, userId, cancellationToken) - .ConfigureAwait(false); - return ToDto(session); - } - - public async Task> GetSessionHistoryAsync( - string sessionId, - int? maxMessages = null, - CancellationToken cancellationToken = default - ) - { - var messages = await sessionStore - .GetHistoryAsync(sessionId, maxMessages, cancellationToken) - .ConfigureAwait(false); - return messages.Select(ToDto).ToList(); - } - - private static AgentSessionDto ToDto(AgentSession session) => - new() - { - Id = session.Id.Value, - AgentName = session.AgentName, - UserId = session.UserId, - CreatedAt = session.CreatedAt, - LastMessageAt = session.LastMessageAt, - }; - - private static AgentMessageDto ToDto(AgentMessage message) => - new() - { - Id = message.Id.Value, - SessionId = message.SessionId.Value, - Role = message.Role, - Content = message.Content, - Timestamp = message.Timestamp, - TokenCount = message.TokenCount, - }; -} diff --git a/modules/Agents/src/SimpleModule.Agents.Module/DevTools/AgentPlaygroundEndpoints.cs b/modules/Agents/src/SimpleModule.Agents.Module/DevTools/AgentPlaygroundEndpoints.cs deleted file mode 100644 index f7aab2a3..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Module/DevTools/AgentPlaygroundEndpoints.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Agents; -using SimpleModule.Agents.Dtos; - -namespace SimpleModule.Agents.Module; - -public static class AgentPlaygroundEndpoints -{ - public static void Map(IEndpointRouteBuilder app) - { - var group = app.MapGroup("/dev/agents") - .WithTags("DevTools") - .ExcludeFromDescription() - .AllowAnonymous(); - - group.MapGet( - "/", - (IAgentRegistry registry) => - { - var agents = registry - .GetAll() - .Select(a => new AgentInfo(a.Name, a.Description, a.ModuleName)) - .ToList(); - return Results.Ok(new { title = "Agent Playground", agents }); - } - ); - - group.MapGet( - "/{name}/info", - (string name, IAgentRegistry registry) => - { - var agent = registry.GetByName(name); - if (agent is null) - return Results.NotFound(); - - return Results.Ok( - new - { - agent.Name, - agent.Description, - agent.ModuleName, - agentType = agent.AgentDefinitionType.FullName, - toolProviders = agent.ToolProviderTypes.Select(t => t.FullName).ToList(), - } - ); - } - ); - - group.MapGet( - "/health", - (IAgentRegistry registry) => - Results.Ok(new { registeredAgents = registry.GetAll().Count, status = "ok" }) - ); - } -} diff --git a/modules/Agents/src/SimpleModule.Agents.Module/EfAgentSessionStore.cs b/modules/Agents/src/SimpleModule.Agents.Module/EfAgentSessionStore.cs deleted file mode 100644 index 97044e52..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Module/EfAgentSessionStore.cs +++ /dev/null @@ -1,68 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using SimpleModule.Agents.Contracts; - -namespace SimpleModule.Agents.Module; - -public sealed class EfAgentSessionStore(AgentsDbContext db) : IAgentSessionStore -{ - public async Task GetSessionAsync( - string sessionId, - CancellationToken cancellationToken = default - ) - { - var id = AgentSessionId.From(sessionId); - return await db.Sessions.FindAsync([id], cancellationToken); - } - - public async Task CreateSessionAsync( - string agentName, - string? userId, - CancellationToken cancellationToken = default - ) - { - var session = new AgentSession { AgentName = agentName, UserId = userId }; - db.Sessions.Add(session); - await db.SaveChangesAsync(cancellationToken); - return session; - } - - public async Task SaveMessageAsync( - string sessionId, - AgentMessage message, - CancellationToken cancellationToken = default - ) - { - var id = AgentSessionId.From(sessionId); - message.SessionId = id; - db.Messages.Add(message); - - var session = await db.Sessions.FindAsync([id], cancellationToken); - if (session is not null) - { - session.LastMessageAt = DateTimeOffset.UtcNow; - } - - await db.SaveChangesAsync(cancellationToken); - } - - public async Task> GetHistoryAsync( - string sessionId, - int? maxMessages = null, - CancellationToken cancellationToken = default - ) - { - var id = AgentSessionId.From(sessionId); - var query = db.Messages.Where(m => m.SessionId == id); - - if (maxMessages.HasValue) - { - return await query - .OrderByDescending(m => m.Timestamp) - .Take(maxMessages.Value) - .OrderBy(m => m.Timestamp) - .ToListAsync(cancellationToken); - } - - return await query.OrderBy(m => m.Timestamp).ToListAsync(cancellationToken); - } -} diff --git a/modules/Agents/src/SimpleModule.Agents.Module/EntityConfigurations/AgentMessageConfiguration.cs b/modules/Agents/src/SimpleModule.Agents.Module/EntityConfigurations/AgentMessageConfiguration.cs deleted file mode 100644 index 7a0899ae..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Module/EntityConfigurations/AgentMessageConfiguration.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using SimpleModule.Agents.Contracts; - -namespace SimpleModule.Agents.Module.EntityConfigurations; - -public sealed class AgentMessageConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasKey(e => e.Id); - builder.Property(e => e.Id).HasMaxLength(36); - builder.Property(e => e.SessionId).IsRequired().HasMaxLength(36); - builder.Property(e => e.Role).IsRequired().HasMaxLength(50); - builder.Property(e => e.Content).IsRequired(); - builder.Property(e => e.Timestamp).IsRequired(); - - builder.HasIndex(e => e.SessionId); - builder.HasIndex(e => new { e.SessionId, e.Timestamp }); - } -} diff --git a/modules/Agents/src/SimpleModule.Agents.Module/EntityConfigurations/AgentSessionConfiguration.cs b/modules/Agents/src/SimpleModule.Agents.Module/EntityConfigurations/AgentSessionConfiguration.cs deleted file mode 100644 index 7f384f33..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Module/EntityConfigurations/AgentSessionConfiguration.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using SimpleModule.Agents.Contracts; - -namespace SimpleModule.Agents.Module.EntityConfigurations; - -public sealed class AgentSessionConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasKey(e => e.Id); - builder.Property(e => e.Id).HasMaxLength(36); - builder.Property(e => e.AgentName).IsRequired().HasMaxLength(256); - builder.Property(e => e.UserId).HasMaxLength(256); - builder.Property(e => e.CreatedAt).IsRequired(); - builder.Property(e => e.UpdatedAt).IsRequired(); - builder.Property(e => e.ConcurrencyStamp).HasMaxLength(64); - builder.Property(e => e.LastMessageAt).IsRequired(); - - builder.HasIndex(e => e.AgentName); - builder.HasIndex(e => e.UserId); - } -} diff --git a/modules/Agents/src/SimpleModule.Agents.Module/Files/AgentFileService.cs b/modules/Agents/src/SimpleModule.Agents.Module/Files/AgentFileService.cs deleted file mode 100644 index 3b2dc84a..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Module/Files/AgentFileService.cs +++ /dev/null @@ -1,49 +0,0 @@ -using SimpleModule.Storage; - -namespace SimpleModule.Agents.Module; - -public sealed class AgentFileService(IStorageProvider storageProvider) -{ - private const string AgentFilesPrefix = "agents"; - - public async Task SaveFileAsync( - string agentName, - string fileName, - Stream content, - string contentType, - CancellationToken cancellationToken = default - ) - { - var path = StoragePathHelper.Combine($"{AgentFilesPrefix}/{agentName}", fileName); - return await storageProvider.SaveAsync(path, content, contentType, cancellationToken); - } - - public async Task GetFileAsync( - string agentName, - string fileName, - CancellationToken cancellationToken = default - ) - { - var path = StoragePathHelper.Combine($"{AgentFilesPrefix}/{agentName}", fileName); - return await storageProvider.GetAsync(path, cancellationToken); - } - - public async Task> ListFilesAsync( - string agentName, - CancellationToken cancellationToken = default - ) - { - var prefix = StoragePathHelper.Normalize($"{AgentFilesPrefix}/{agentName}") + "/"; - return await storageProvider.ListAsync(prefix, cancellationToken); - } - - public async Task DeleteFileAsync( - string agentName, - string fileName, - CancellationToken cancellationToken = default - ) - { - var path = StoragePathHelper.Combine($"{AgentFilesPrefix}/{agentName}", fileName); - return await storageProvider.DeleteAsync(path, cancellationToken); - } -} diff --git a/modules/Agents/src/SimpleModule.Agents.Module/Guardrails/ContentLengthGuardrail.cs b/modules/Agents/src/SimpleModule.Agents.Module/Guardrails/ContentLengthGuardrail.cs deleted file mode 100644 index 8f86931f..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Module/Guardrails/ContentLengthGuardrail.cs +++ /dev/null @@ -1,32 +0,0 @@ -using SimpleModule.Agents.Guardrails; - -namespace SimpleModule.Agents.Module; - -public sealed class ContentLengthGuardrail( - int maxInputLength = 10_000, - int maxOutputLength = 50_000 -) : IAgentGuardrail -{ - private static readonly Task _allowed = Task.FromResult( - GuardrailResult.Allowed() - ); - - public Task ValidateAsync( - string content, - GuardrailDirection direction, - CancellationToken cancellationToken = default - ) - { - var maxLength = direction == GuardrailDirection.Input ? maxInputLength : maxOutputLength; - if (content.Length > maxLength) - { - return Task.FromResult( - GuardrailResult.Blocked( - $"Content exceeds maximum length of {maxLength} characters ({content.Length} provided)" - ) - ); - } - - return _allowed; - } -} diff --git a/modules/Agents/src/SimpleModule.Agents.Module/Guardrails/PiiRedactionGuardrail.cs b/modules/Agents/src/SimpleModule.Agents.Module/Guardrails/PiiRedactionGuardrail.cs deleted file mode 100644 index a354f918..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Module/Guardrails/PiiRedactionGuardrail.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Text.RegularExpressions; -using SimpleModule.Agents.Guardrails; - -namespace SimpleModule.Agents.Module; - -public sealed partial class PiiRedactionGuardrail : IAgentGuardrail -{ - public Task ValidateAsync( - string content, - GuardrailDirection direction, - CancellationToken cancellationToken = default - ) - { - var sanitized = content; - sanitized = EmailRegex().Replace(sanitized, "[EMAIL_REDACTED]"); - sanitized = PhoneRegex().Replace(sanitized, "[PHONE_REDACTED]"); - sanitized = SsnRegex().Replace(sanitized, "[SSN_REDACTED]"); - - if (sanitized != content) - return Task.FromResult(GuardrailResult.Sanitized(sanitized)); - - return Task.FromResult(GuardrailResult.Allowed()); - } - - [GeneratedRegex(@"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", RegexOptions.Compiled)] - private static partial Regex EmailRegex(); - - [GeneratedRegex(@"\b\d{3}[-.]?\d{3}[-.]?\d{4}\b", RegexOptions.Compiled)] - private static partial Regex PhoneRegex(); - - [GeneratedRegex(@"\b\d{3}-\d{2}-\d{4}\b", RegexOptions.Compiled)] - private static partial Regex SsnRegex(); -} diff --git a/modules/Agents/src/SimpleModule.Agents.Module/Guardrails/PromptInjectionGuardrail.cs b/modules/Agents/src/SimpleModule.Agents.Module/Guardrails/PromptInjectionGuardrail.cs deleted file mode 100644 index 69bb5a6e..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Module/Guardrails/PromptInjectionGuardrail.cs +++ /dev/null @@ -1,46 +0,0 @@ -using SimpleModule.Agents.Guardrails; - -namespace SimpleModule.Agents.Module; - -public sealed class PromptInjectionGuardrail : IAgentGuardrail -{ - private static readonly Task _allowed = Task.FromResult( - GuardrailResult.Allowed() - ); - private static readonly string[] SuspiciousPatterns = - [ - "ignore previous instructions", - "ignore all previous", - "disregard previous", - "forget your instructions", - "you are now", - "new instructions:", - "system prompt:", - "ignore the above", - "override your", - ]; - - public Task ValidateAsync( - string content, - GuardrailDirection direction, - CancellationToken cancellationToken = default - ) - { - if (direction != GuardrailDirection.Input) - return _allowed; - - foreach (var pattern in SuspiciousPatterns) - { - if (content.Contains(pattern, StringComparison.OrdinalIgnoreCase)) - { - return Task.FromResult( - GuardrailResult.Blocked( - $"Input contains suspicious pattern that may be a prompt injection attempt" - ) - ); - } - } - - return _allowed; - } -} diff --git a/modules/Agents/src/SimpleModule.Agents.Module/Middleware/LoggingMiddleware.cs b/modules/Agents/src/SimpleModule.Agents.Module/Middleware/LoggingMiddleware.cs deleted file mode 100644 index c85b501b..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Module/Middleware/LoggingMiddleware.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Microsoft.Extensions.Logging; -using SimpleModule.Agents.Middleware; - -namespace SimpleModule.Agents.Module; - -public sealed partial class LoggingMiddleware(ILogger logger) : IAgentMiddleware -{ - public async Task InvokeAsync(AgentContext context, AgentMiddlewareDelegate next) - { - LogAgentInvocation(logger, context.AgentName, context.Request.Message.Length); - - var stopwatch = System.Diagnostics.Stopwatch.StartNew(); - await next(context); - stopwatch.Stop(); - - LogAgentResponse(logger, context.AgentName, stopwatch.ElapsedMilliseconds); - } - - [LoggerMessage( - Level = LogLevel.Information, - Message = "Agent '{AgentName}' invoked with {MessageLength} char message" - )] - private static partial void LogAgentInvocation( - ILogger logger, - string agentName, - int messageLength - ); - - [LoggerMessage( - Level = LogLevel.Information, - Message = "Agent '{AgentName}' responded in {ElapsedMs}ms" - )] - private static partial void LogAgentResponse(ILogger logger, string agentName, long elapsedMs); -} diff --git a/modules/Agents/src/SimpleModule.Agents.Module/Middleware/RateLimitingMiddleware.cs b/modules/Agents/src/SimpleModule.Agents.Module/Middleware/RateLimitingMiddleware.cs deleted file mode 100644 index 302a3e9a..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Module/Middleware/RateLimitingMiddleware.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Collections.Concurrent; -using Microsoft.Extensions.Options; -using SimpleModule.Agents; -using SimpleModule.Agents.Middleware; - -namespace SimpleModule.Agents.Module; - -public sealed class RateLimitingMiddleware(IOptions options) : IAgentMiddleware -{ - private readonly ConcurrentDictionary _entries = new(); - - public async Task InvokeAsync(AgentContext context, AgentMiddlewareDelegate next) - { - var userId = context.User?.Identity?.Name ?? "anonymous"; - var key = $"{userId}:{context.AgentName}"; - var limit = options.Value.RateLimit.RequestsPerMinute; - - var entry = _entries.GetOrAdd(key, _ => new RateLimitEntry()); - lock (entry) - { - entry.CleanExpired(); - if (entry.Count >= limit) - { - throw new InvalidOperationException( - $"Rate limit exceeded: {limit} requests per minute for agent '{context.AgentName}'" - ); - } - - entry.Add(); - } - - await next(context); - } - - private sealed class RateLimitEntry - { - private readonly Queue _timestamps = new(); - - public int Count => _timestamps.Count; - - public void Add() => _timestamps.Enqueue(DateTimeOffset.UtcNow); - - public void CleanExpired() - { - var cutoff = DateTimeOffset.UtcNow.AddMinutes(-1); - while (_timestamps.Count > 0 && _timestamps.Peek() < cutoff) - _timestamps.Dequeue(); - } - } -} diff --git a/modules/Agents/src/SimpleModule.Agents.Module/Middleware/RetryMiddleware.cs b/modules/Agents/src/SimpleModule.Agents.Module/Middleware/RetryMiddleware.cs deleted file mode 100644 index a6a4d24e..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Module/Middleware/RetryMiddleware.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Microsoft.Extensions.Logging; -using SimpleModule.Agents.Middleware; - -namespace SimpleModule.Agents.Module; - -public sealed partial class RetryMiddleware(ILogger logger) : IAgentMiddleware -{ - private const int MaxRetries = 3; - private static readonly TimeSpan[] Delays = - [ - TimeSpan.FromSeconds(1), - TimeSpan.FromSeconds(2), - TimeSpan.FromSeconds(4), - ]; - - public async Task InvokeAsync(AgentContext context, AgentMiddlewareDelegate next) - { - for (var attempt = 0; attempt <= MaxRetries; attempt++) - { - try - { - await next(context); - return; - } - catch (HttpRequestException) when (attempt < MaxRetries) - { - LogRetry(logger, context.AgentName, attempt + 1, MaxRetries); - await Task.Delay(Delays[attempt], context.CancellationToken); - } - } - } - - [LoggerMessage( - Level = LogLevel.Warning, - Message = "Agent '{AgentName}' retry {Attempt}/{MaxRetries} after transient failure" - )] - private static partial void LogRetry( - ILogger logger, - string agentName, - int attempt, - int maxRetries - ); -} diff --git a/modules/Agents/src/SimpleModule.Agents.Module/Middleware/TokenTrackingMiddleware.cs b/modules/Agents/src/SimpleModule.Agents.Module/Middleware/TokenTrackingMiddleware.cs deleted file mode 100644 index 40c853f3..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Module/Middleware/TokenTrackingMiddleware.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Microsoft.Extensions.Logging; -using SimpleModule.Agents.Middleware; - -namespace SimpleModule.Agents.Module; - -public sealed partial class TokenTrackingMiddleware(ILogger logger) - : IAgentMiddleware -{ - public async Task InvokeAsync(AgentContext context, AgentMiddlewareDelegate next) - { - await next(context); - - if (context.Response is not null) - { - var estimatedTokens = - EstimateTokens(context.Request.Message) + EstimateTokens(context.Response.Message); - context.Properties["EstimatedTokens"] = estimatedTokens; - LogTokenUsage(logger, context.AgentName, estimatedTokens); - } - } - - private static int EstimateTokens(string text) => text.Length / 4; - - [LoggerMessage( - Level = LogLevel.Debug, - Message = "Agent '{AgentName}' used ~{EstimatedTokens} tokens" - )] - private static partial void LogTokenUsage( - ILogger logger, - string agentName, - int estimatedTokens - ); -} diff --git a/modules/Agents/src/SimpleModule.Agents.Module/README.md b/modules/Agents/src/SimpleModule.Agents.Module/README.md deleted file mode 100644 index 9b8939b7..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Module/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# SimpleModule.Agents.Module - -AI agent persistence module for [SimpleModule](https://github.com/antosubash/SimpleModule) — a modular monolith framework for .NET. - -## Features - -- Database-backed session and message persistence for AI agents -- EF Core integration for agent data storage -- Contract-based service interface for cross-module communication - -## Installation - -```bash -sm install SimpleModule.Agents.Module -``` - -Or via .NET CLI: - -```bash -dotnet add package SimpleModule.Agents.Module -``` - -## Usage - -The module is auto-discovered by the SimpleModule framework. It provides `IAgentsContracts` for other modules to interact with agent sessions and messages. - -## License - -MIT diff --git a/modules/Agents/src/SimpleModule.Agents.Module/Sessions/IAgentSessionStore.cs b/modules/Agents/src/SimpleModule.Agents.Module/Sessions/IAgentSessionStore.cs deleted file mode 100644 index 7761ed7d..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Module/Sessions/IAgentSessionStore.cs +++ /dev/null @@ -1,26 +0,0 @@ -using SimpleModule.Agents.Contracts; - -namespace SimpleModule.Agents.Module; - -public interface IAgentSessionStore -{ - Task GetSessionAsync( - string sessionId, - CancellationToken cancellationToken = default - ); - Task CreateSessionAsync( - string agentName, - string? userId, - CancellationToken cancellationToken = default - ); - Task SaveMessageAsync( - string sessionId, - AgentMessage message, - CancellationToken cancellationToken = default - ); - Task> GetHistoryAsync( - string sessionId, - int? maxMessages = null, - CancellationToken cancellationToken = default - ); -} diff --git a/modules/Agents/src/SimpleModule.Agents.Module/Sessions/InMemoryAgentSessionStore.cs b/modules/Agents/src/SimpleModule.Agents.Module/Sessions/InMemoryAgentSessionStore.cs deleted file mode 100644 index 54453da0..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Module/Sessions/InMemoryAgentSessionStore.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Collections.Concurrent; -using SimpleModule.Agents.Contracts; - -namespace SimpleModule.Agents.Module; - -public sealed class InMemoryAgentSessionStore : IAgentSessionStore -{ - private readonly ConcurrentDictionary _sessions = new(); - private readonly ConcurrentDictionary> _messages = new(); - - public Task GetSessionAsync( - string sessionId, - CancellationToken cancellationToken = default - ) - { - _sessions.TryGetValue(AgentSessionId.From(sessionId), out var session); - return Task.FromResult(session); - } - - public Task CreateSessionAsync( - string agentName, - string? userId, - CancellationToken cancellationToken = default - ) - { - var session = new AgentSession { AgentName = agentName, UserId = userId }; - _sessions[session.Id] = session; - _messages.GetOrAdd(session.Id, _ => []); - return Task.FromResult(session); - } - - public Task SaveMessageAsync( - string sessionId, - AgentMessage message, - CancellationToken cancellationToken = default - ) - { - var id = AgentSessionId.From(sessionId); - message.SessionId = id; - var messages = _messages.GetOrAdd(id, _ => []); - lock (messages) - { - messages.Add(message); - if (_sessions.TryGetValue(id, out var session)) - { - session.LastMessageAt = DateTimeOffset.UtcNow; - } - } - - return Task.CompletedTask; - } - - public Task> GetHistoryAsync( - string sessionId, - int? maxMessages = null, - CancellationToken cancellationToken = default - ) - { - if (!_messages.TryGetValue(AgentSessionId.From(sessionId), out var messages)) - return Task.FromResult>([]); - - lock (messages) - { - IReadOnlyList result = maxMessages.HasValue - ? messages.TakeLast(maxMessages.Value).ToList() - : messages.ToList(); - return Task.FromResult(result); - } - } -} diff --git a/modules/Agents/src/SimpleModule.Agents.Module/SimpleModule.Agents.Module.csproj b/modules/Agents/src/SimpleModule.Agents.Module/SimpleModule.Agents.Module.csproj deleted file mode 100644 index 2f20f8a1..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Module/SimpleModule.Agents.Module.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - net10.0 - Library - Agents module for SimpleModule. Provides database-backed session and message persistence for AI agents. - - - - - - - - - - diff --git a/modules/Agents/src/SimpleModule.Agents.Module/Telemetry/AgentActivitySource.cs b/modules/Agents/src/SimpleModule.Agents.Module/Telemetry/AgentActivitySource.cs deleted file mode 100644 index f38bb56e..00000000 --- a/modules/Agents/src/SimpleModule.Agents.Module/Telemetry/AgentActivitySource.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Diagnostics; - -namespace SimpleModule.Agents.Module; - -public static class AgentActivitySource -{ - public const string Name = "SimpleModule.Agents"; - public static readonly ActivitySource Instance = new(Name, "1.0.0"); - - public static Activity? StartAgentInvocation(string agentName) => - Instance - .StartActivity("agent.invoke", ActivityKind.Internal) - ?.SetTag("agent.name", agentName); - - public static Activity? StartToolCall(string agentName, string toolName) => - Instance - .StartActivity("agent.tool.call", ActivityKind.Internal) - ?.SetTag("agent.name", agentName) - .SetTag("tool.name", toolName); - - public static Activity? StartLlmCall(string agentName) => - Instance - .StartActivity("agent.llm.call", ActivityKind.Client) - ?.SetTag("agent.name", agentName); - - public static Activity? StartRagSearch(string agentName) => - Instance - .StartActivity("agent.rag.search", ActivityKind.Internal) - ?.SetTag("agent.name", agentName); -} diff --git a/modules/Agents/src/SimpleModule.Agents/types.ts b/modules/Agents/src/SimpleModule.Agents/types.ts deleted file mode 100644 index 1dd5068c..00000000 --- a/modules/Agents/src/SimpleModule.Agents/types.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Auto-generated from [Dto] types — do not edit -export interface AgentMessageDto { - id: string; - sessionId: string; - role: string; - content: string; - timestamp: string; - tokenCount: number | null; -} - -export interface AgentSessionDto { - id: string; - agentName: string; - userId: string; - createdAt: string; - lastMessageAt: string; -} - -export interface AgentSession { - agentName: string; - userId: string; - lastMessageAt: string; - id: string; - createdAt: string; - updatedAt: string; - concurrencyStamp: string; -} - diff --git a/modules/Agents/tests/SimpleModule.Agents.Tests/GlobalUsings.cs b/modules/Agents/tests/SimpleModule.Agents.Tests/GlobalUsings.cs deleted file mode 100644 index c802f448..00000000 --- a/modules/Agents/tests/SimpleModule.Agents.Tests/GlobalUsings.cs +++ /dev/null @@ -1 +0,0 @@ -global using Xunit; diff --git a/modules/Agents/tests/SimpleModule.Agents.Tests/IntegrationTestCollection.cs b/modules/Agents/tests/SimpleModule.Agents.Tests/IntegrationTestCollection.cs deleted file mode 100644 index 8c4a61db..00000000 --- a/modules/Agents/tests/SimpleModule.Agents.Tests/IntegrationTestCollection.cs +++ /dev/null @@ -1,6 +0,0 @@ -using SimpleModule.Tests.Shared.Fixtures; -using Xunit; - -[CollectionDefinition(TestCollections.Integration)] -public sealed class IntegrationTestCollection - : ICollectionFixture; diff --git a/modules/Agents/tests/SimpleModule.Agents.Tests/SimpleModule.Agents.Tests.csproj b/modules/Agents/tests/SimpleModule.Agents.Tests/SimpleModule.Agents.Tests.csproj deleted file mode 100644 index e95a8b41..00000000 --- a/modules/Agents/tests/SimpleModule.Agents.Tests/SimpleModule.Agents.Tests.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - net10.0 - false - Exe - - - - - - - - - - - - - - - - diff --git a/modules/Chat/src/SimpleModule.Chat.Contracts/ChatConstants.cs b/modules/Chat/src/SimpleModule.Chat.Contracts/ChatConstants.cs deleted file mode 100644 index 42ab6e62..00000000 --- a/modules/Chat/src/SimpleModule.Chat.Contracts/ChatConstants.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace SimpleModule.Chat.Contracts; - -public static class ChatConstants -{ - public const string ModuleName = "Chat"; - public const string RoutePrefix = "/api/chat"; - public const string ViewPrefix = "/chat"; - - public static class Routes - { - public const string ListConversations = "/conversations"; - public const string CreateConversation = "/conversations"; - public const string GetConversation = "/conversations/{id:guid}"; - public const string RenameConversation = "/conversations/{id:guid}"; - public const string DeleteConversation = "/conversations/{id:guid}"; - public const string GetMessages = "/conversations/{id:guid}/messages"; - public const string SendMessageStream = "/conversations/{id:guid}/stream"; - - public const string Browse = "/"; - public const string Conversation = "/{id:guid}"; - } -} diff --git a/modules/Chat/src/SimpleModule.Chat.Contracts/ChatMessage.cs b/modules/Chat/src/SimpleModule.Chat.Contracts/ChatMessage.cs deleted file mode 100644 index 6b24dd9c..00000000 --- a/modules/Chat/src/SimpleModule.Chat.Contracts/ChatMessage.cs +++ /dev/null @@ -1,29 +0,0 @@ -using SimpleModule.Core.Entities; - -namespace SimpleModule.Chat.Contracts; - -public class ChatMessage : Entity -{ - public ConversationId ConversationId { get; set; } - public ChatRole Role { get; set; } - public string Content { get; set; } = string.Empty; -} - -public enum ChatRole -{ - User = 0, - Assistant = 1, - System = 2, -} - -public static class ChatRoleExtensions -{ - public static string ToWire(ChatRole role) => - role switch - { - ChatRole.User => "user", - ChatRole.Assistant => "assistant", - ChatRole.System => "system", - _ => "user", - }; -} diff --git a/modules/Chat/src/SimpleModule.Chat.Contracts/ChatMessageId.cs b/modules/Chat/src/SimpleModule.Chat.Contracts/ChatMessageId.cs deleted file mode 100644 index 2877cba8..00000000 --- a/modules/Chat/src/SimpleModule.Chat.Contracts/ChatMessageId.cs +++ /dev/null @@ -1,6 +0,0 @@ -using Vogen; - -namespace SimpleModule.Chat.Contracts; - -[ValueObject(conversions: Conversions.SystemTextJson | Conversions.EfCoreValueConverter)] -public readonly partial struct ChatMessageId; diff --git a/modules/Chat/src/SimpleModule.Chat.Contracts/Conversation.cs b/modules/Chat/src/SimpleModule.Chat.Contracts/Conversation.cs deleted file mode 100644 index 7c41a6b2..00000000 --- a/modules/Chat/src/SimpleModule.Chat.Contracts/Conversation.cs +++ /dev/null @@ -1,13 +0,0 @@ -using SimpleModule.Core.Entities; - -namespace SimpleModule.Chat.Contracts; - -public class Conversation : Entity -{ - public string UserId { get; set; } = string.Empty; - public string Title { get; set; } = "New conversation"; - public string AgentName { get; set; } = string.Empty; - public bool Pinned { get; set; } - - public List Messages { get; set; } = []; -} diff --git a/modules/Chat/src/SimpleModule.Chat.Contracts/ConversationId.cs b/modules/Chat/src/SimpleModule.Chat.Contracts/ConversationId.cs deleted file mode 100644 index 76b28600..00000000 --- a/modules/Chat/src/SimpleModule.Chat.Contracts/ConversationId.cs +++ /dev/null @@ -1,6 +0,0 @@ -using Vogen; - -namespace SimpleModule.Chat.Contracts; - -[ValueObject(conversions: Conversions.SystemTextJson | Conversions.EfCoreValueConverter)] -public readonly partial struct ConversationId; diff --git a/modules/Chat/src/SimpleModule.Chat.Contracts/CreateConversationRequest.cs b/modules/Chat/src/SimpleModule.Chat.Contracts/CreateConversationRequest.cs deleted file mode 100644 index 4136314d..00000000 --- a/modules/Chat/src/SimpleModule.Chat.Contracts/CreateConversationRequest.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace SimpleModule.Chat.Contracts; - -public sealed class CreateConversationRequest -{ - public string AgentName { get; set; } = string.Empty; - public string? Title { get; set; } -} - -public sealed class RenameConversationRequest -{ - public string Title { get; set; } = string.Empty; -} diff --git a/modules/Chat/src/SimpleModule.Chat.Contracts/IChatContracts.cs b/modules/Chat/src/SimpleModule.Chat.Contracts/IChatContracts.cs deleted file mode 100644 index aee2fc39..00000000 --- a/modules/Chat/src/SimpleModule.Chat.Contracts/IChatContracts.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace SimpleModule.Chat.Contracts; - -public interface IChatContracts -{ - Task> GetUserConversationsAsync( - string userId, - CancellationToken cancellationToken = default - ); - - Task StartConversationAsync( - string userId, - string agentName, - string? title, - CancellationToken cancellationToken = default - ); -} diff --git a/modules/Chat/src/SimpleModule.Chat.Contracts/SimpleModule.Chat.Contracts.csproj b/modules/Chat/src/SimpleModule.Chat.Contracts/SimpleModule.Chat.Contracts.csproj deleted file mode 100644 index 97fdfa07..00000000 --- a/modules/Chat/src/SimpleModule.Chat.Contracts/SimpleModule.Chat.Contracts.csproj +++ /dev/null @@ -1,12 +0,0 @@ - - - net10.0 - Library - $(DefineConstants);VOGEN_NO_VALIDATION - - - - - - - diff --git a/modules/Chat/src/SimpleModule.Chat/ChatDbContext.cs b/modules/Chat/src/SimpleModule.Chat/ChatDbContext.cs deleted file mode 100644 index 1666a75e..00000000 --- a/modules/Chat/src/SimpleModule.Chat/ChatDbContext.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; -using SimpleModule.Chat.Contracts; -using SimpleModule.Chat.EntityConfigurations; -using SimpleModule.Database; - -namespace SimpleModule.Chat; - -public class ChatDbContext( - DbContextOptions options, - IOptions dbOptions -) : DbContext(options) -{ - public DbSet Conversations => Set(); - public DbSet ChatMessages => Set(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.ApplyConfiguration(new ConversationConfiguration()); - modelBuilder.ApplyConfiguration(new ChatMessageConfiguration()); - modelBuilder.ApplyModuleSchema("Chat", dbOptions.Value); - } - - protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) - { - configurationBuilder - .Properties() - .HaveConversion< - ConversationId.EfCoreValueConverter, - ConversationId.EfCoreValueComparer - >(); - configurationBuilder - .Properties() - .HaveConversion< - ChatMessageId.EfCoreValueConverter, - ChatMessageId.EfCoreValueComparer - >(); - } -} diff --git a/modules/Chat/src/SimpleModule.Chat/ChatModule.cs b/modules/Chat/src/SimpleModule.Chat/ChatModule.cs deleted file mode 100644 index dbf98a42..00000000 --- a/modules/Chat/src/SimpleModule.Chat/ChatModule.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using SimpleModule.Chat.Contracts; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; -using SimpleModule.Core.Menu; -using SimpleModule.Database; - -namespace SimpleModule.Chat; - -[Module( - ChatConstants.ModuleName, - RoutePrefix = ChatConstants.RoutePrefix, - ViewPrefix = ChatConstants.ViewPrefix -)] -public class ChatModule : IModule, IModuleServices, IModuleMenu -{ - public void ConfigureServices(IServiceCollection services, IConfiguration configuration) - { - services.AddModuleDbContext(configuration, ChatConstants.ModuleName); - services.AddScoped(); - services.AddScoped(sp => sp.GetRequiredService()); - } - - public void ConfigurePermissions(PermissionRegistryBuilder builder) - { - builder.AddPermissions(); - } - - public void ConfigureMenu(IMenuBuilder menus) - { - menus.Add( - new MenuItem - { - Label = "Chat", - Url = "/chat", - Icon = - """""", - Order = 40, - Section = MenuSection.AppSidebar, - } - ); - } -} diff --git a/modules/Chat/src/SimpleModule.Chat/ChatPermissions.cs b/modules/Chat/src/SimpleModule.Chat/ChatPermissions.cs deleted file mode 100644 index 2e9aae47..00000000 --- a/modules/Chat/src/SimpleModule.Chat/ChatPermissions.cs +++ /dev/null @@ -1,10 +0,0 @@ -using SimpleModule.Core.Authorization; - -namespace SimpleModule.Chat; - -public sealed class ChatPermissions : IModulePermissions -{ - public const string View = "Chat.View"; - public const string Create = "Chat.Create"; - public const string ManageAll = "Chat.ManageAll"; -} diff --git a/modules/Chat/src/SimpleModule.Chat/ChatService.cs b/modules/Chat/src/SimpleModule.Chat/ChatService.cs deleted file mode 100644 index c498d397..00000000 --- a/modules/Chat/src/SimpleModule.Chat/ChatService.cs +++ /dev/null @@ -1,152 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using SimpleModule.Chat.Contracts; -using SimpleModule.Core.Exceptions; - -namespace SimpleModule.Chat; - -public partial class ChatService(ChatDbContext db, ILogger logger) : IChatContracts -{ - public async Task> GetUserConversationsAsync( - string userId, - CancellationToken cancellationToken = default - ) => - await db - .Conversations.AsNoTracking() - .Where(c => c.UserId == userId) - .OrderByDescending(c => c.Pinned) - .ThenByDescending(c => c.UpdatedAt) - .ToListAsync(cancellationToken); - - public async Task GetConversationAsync( - ConversationId id, - CancellationToken cancellationToken = default - ) => - await db - .Conversations.AsNoTracking() - .Include(c => c.Messages.OrderBy(m => m.CreatedAt)) - .FirstOrDefaultAsync(c => c.Id == id, cancellationToken); - - public async Task StartConversationAsync( - string userId, - string agentName, - string? title, - CancellationToken cancellationToken = default - ) - { - var now = DateTimeOffset.UtcNow; - var conversation = new Conversation - { - Id = ConversationId.From(Guid.NewGuid()), - UserId = userId, - AgentName = agentName, - Title = string.IsNullOrWhiteSpace(title) ? "New conversation" : title.Trim(), - CreatedAt = now, - UpdatedAt = now, - }; - - db.Conversations.Add(conversation); - await db.SaveChangesAsync(cancellationToken); - - LogConversationCreated(logger, conversation.Id.Value, userId, agentName); - return conversation; - } - - public async Task RenameAsync( - ConversationId id, - string userId, - string title, - CancellationToken cancellationToken = default - ) - { - var conversation = await LoadOwnedAsync(id, userId, cancellationToken); - conversation.Title = title.Trim(); - conversation.UpdatedAt = DateTimeOffset.UtcNow; - await db.SaveChangesAsync(cancellationToken); - return conversation; - } - - public async Task DeleteAsync( - ConversationId id, - string userId, - CancellationToken cancellationToken = default - ) - { - var conversation = await LoadOwnedAsync(id, userId, cancellationToken); - db.Conversations.Remove(conversation); - await db.SaveChangesAsync(cancellationToken); - } - - public async Task> GetMessagesAsync( - ConversationId id, - string userId, - CancellationToken cancellationToken = default - ) - { - _ = await LoadOwnedAsync(id, userId, cancellationToken); - return await db - .ChatMessages.AsNoTracking() - .Where(m => m.ConversationId == id) - .OrderBy(m => m.CreatedAt) - .ToListAsync(cancellationToken); - } - - public async Task AppendMessageAsync( - ConversationId conversationId, - ChatRole role, - string content, - CancellationToken cancellationToken = default - ) - { - var now = DateTimeOffset.UtcNow; - var message = new ChatMessage - { - Id = ChatMessageId.From(Guid.NewGuid()), - ConversationId = conversationId, - Role = role, - Content = content, - CreatedAt = now, - UpdatedAt = now, - }; - db.ChatMessages.Add(message); - - var conversation = await db.Conversations.FirstOrDefaultAsync( - c => c.Id == conversationId, - cancellationToken - ); - if (conversation is not null) - { - conversation.UpdatedAt = now; - } - - await db.SaveChangesAsync(cancellationToken); - return message; - } - - public async Task LoadOwnedAsync( - ConversationId id, - string userId, - CancellationToken cancellationToken = default - ) - { - var conversation = - await db.Conversations.FirstOrDefaultAsync(c => c.Id == id, cancellationToken) - ?? throw new NotFoundException("Conversation", id.Value); - if (!string.Equals(conversation.UserId, userId, StringComparison.Ordinal)) - { - throw new NotFoundException("Conversation", id.Value); - } - return conversation; - } - - [LoggerMessage( - Level = LogLevel.Information, - Message = "Conversation {ConversationId} created by {UserId} for agent {AgentName}" - )] - private static partial void LogConversationCreated( - ILogger logger, - Guid conversationId, - string userId, - string agentName - ); -} diff --git a/modules/Chat/src/SimpleModule.Chat/Dtos/TanStackDtos.cs b/modules/Chat/src/SimpleModule.Chat/Dtos/TanStackDtos.cs deleted file mode 100644 index fdf58eb6..00000000 --- a/modules/Chat/src/SimpleModule.Chat/Dtos/TanStackDtos.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.Text.Json.Serialization; - -// Wire types for the @tanstack/ai SSE protocol. Field names are load-bearing — -// they must serialize to exactly the JSON keys the TanStack client expects. - -namespace SimpleModule.Chat.Dtos; - -public sealed record TanStackChatRequest( - IReadOnlyList Messages, - Dictionary? Data = null -); - -public sealed record TanStackInboundMessage(string Role, string Content); - -public sealed record TanStackContentChunk( - [property: JsonPropertyName("type")] string Type, - [property: JsonPropertyName("id")] string Id, - [property: JsonPropertyName("model")] string Model, - [property: JsonPropertyName("timestamp")] long Timestamp, - [property: JsonPropertyName("delta")] string Delta, - [property: JsonPropertyName("content")] string Content, - [property: JsonPropertyName("role")] string Role -); - -public sealed record TanStackDoneChunk( - [property: JsonPropertyName("type")] string Type, - [property: JsonPropertyName("id")] string Id, - [property: JsonPropertyName("model")] string Model, - [property: JsonPropertyName("timestamp")] long Timestamp, - [property: JsonPropertyName("finishReason")] string FinishReason -); - -public sealed record TanStackErrorChunk( - [property: JsonPropertyName("type")] string Type, - [property: JsonPropertyName("id")] string Id, - [property: JsonPropertyName("model")] string Model, - [property: JsonPropertyName("timestamp")] long Timestamp, - [property: JsonPropertyName("error")] TanStackErrorBody Error -); - -public sealed record TanStackErrorBody( - [property: JsonPropertyName("message")] string Message, - [property: JsonPropertyName("code")] string Code -); - -public sealed record UiMessage( - [property: JsonPropertyName("id")] string Id, - [property: JsonPropertyName("role")] string Role, - [property: JsonPropertyName("parts")] IReadOnlyList Parts, - [property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt -); - -public sealed record UiMessagePart( - [property: JsonPropertyName("type")] string Type, - [property: JsonPropertyName("content")] string Content -); diff --git a/modules/Chat/src/SimpleModule.Chat/Endpoints/Chat/CreateConversationEndpoint.cs b/modules/Chat/src/SimpleModule.Chat/Endpoints/Chat/CreateConversationEndpoint.cs deleted file mode 100644 index b5934c57..00000000 --- a/modules/Chat/src/SimpleModule.Chat/Endpoints/Chat/CreateConversationEndpoint.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Security.Claims; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Chat.Contracts; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; -using SimpleModule.Core.Exceptions; - -namespace SimpleModule.Chat.Endpoints.Chat; - -public class CreateConversationEndpoint : IEndpoint -{ - public const string Route = ChatConstants.Routes.CreateConversation; - - public void Map(IEndpointRouteBuilder app) => - app.MapPost( - Route, - async ( - CreateConversationRequest request, - IChatContracts chat, - ClaimsPrincipal user, - CancellationToken ct - ) => - { - if (string.IsNullOrWhiteSpace(request.AgentName)) - { - throw new ValidationException("AgentName is required."); - } - var userId = user.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous"; - var conversation = await chat.StartConversationAsync( - userId, - request.AgentName, - request.Title, - ct - ); - return Results.Created( - $"{ChatConstants.RoutePrefix}/conversations/{conversation.Id.Value}", - conversation - ); - } - ) - .RequirePermission(ChatPermissions.Create); -} diff --git a/modules/Chat/src/SimpleModule.Chat/Endpoints/Chat/DeleteConversationEndpoint.cs b/modules/Chat/src/SimpleModule.Chat/Endpoints/Chat/DeleteConversationEndpoint.cs deleted file mode 100644 index 16f52fec..00000000 --- a/modules/Chat/src/SimpleModule.Chat/Endpoints/Chat/DeleteConversationEndpoint.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Security.Claims; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Chat.Contracts; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; - -namespace SimpleModule.Chat.Endpoints.Chat; - -public class DeleteConversationEndpoint : IEndpoint -{ - public const string Route = ChatConstants.Routes.DeleteConversation; - - public void Map(IEndpointRouteBuilder app) => - app.MapDelete( - Route, - async (Guid id, ChatService service, ClaimsPrincipal user, CancellationToken ct) => - { - var userId = user.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous"; - await service.DeleteAsync(ConversationId.From(id), userId, ct); - return Results.NoContent(); - } - ) - .RequirePermission(ChatPermissions.Create); -} diff --git a/modules/Chat/src/SimpleModule.Chat/Endpoints/Chat/GetConversationEndpoint.cs b/modules/Chat/src/SimpleModule.Chat/Endpoints/Chat/GetConversationEndpoint.cs deleted file mode 100644 index b291c6ad..00000000 --- a/modules/Chat/src/SimpleModule.Chat/Endpoints/Chat/GetConversationEndpoint.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Security.Claims; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Chat.Contracts; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; -using SimpleModule.Core.Exceptions; - -namespace SimpleModule.Chat.Endpoints.Chat; - -public class GetConversationEndpoint : IEndpoint -{ - public const string Route = ChatConstants.Routes.GetConversation; - - public void Map(IEndpointRouteBuilder app) => - app.MapGet( - Route, - async (Guid id, ChatService service, ClaimsPrincipal user, CancellationToken ct) => - { - var userId = user.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous"; - var conversation = await service.GetConversationAsync( - ConversationId.From(id), - ct - ); - if ( - conversation is null - || !string.Equals(conversation.UserId, userId, StringComparison.Ordinal) - ) - { - throw new NotFoundException("Conversation", id); - } - return Results.Ok(conversation); - } - ) - .RequirePermission(ChatPermissions.View); -} diff --git a/modules/Chat/src/SimpleModule.Chat/Endpoints/Chat/GetMessagesEndpoint.cs b/modules/Chat/src/SimpleModule.Chat/Endpoints/Chat/GetMessagesEndpoint.cs deleted file mode 100644 index 87ea45f8..00000000 --- a/modules/Chat/src/SimpleModule.Chat/Endpoints/Chat/GetMessagesEndpoint.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Security.Claims; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Chat.Contracts; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; - -namespace SimpleModule.Chat.Endpoints.Chat; - -public class GetMessagesEndpoint : IEndpoint -{ - public const string Route = ChatConstants.Routes.GetMessages; - - public void Map(IEndpointRouteBuilder app) => - app.MapGet( - Route, - async (Guid id, ChatService service, ClaimsPrincipal user, CancellationToken ct) => - { - var userId = user.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous"; - var messages = await service.GetMessagesAsync( - ConversationId.From(id), - userId, - ct - ); - return Results.Ok(messages); - } - ) - .RequirePermission(ChatPermissions.View); -} diff --git a/modules/Chat/src/SimpleModule.Chat/Endpoints/Chat/ListConversationsEndpoint.cs b/modules/Chat/src/SimpleModule.Chat/Endpoints/Chat/ListConversationsEndpoint.cs deleted file mode 100644 index 825cc9a7..00000000 --- a/modules/Chat/src/SimpleModule.Chat/Endpoints/Chat/ListConversationsEndpoint.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Security.Claims; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Chat.Contracts; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; - -namespace SimpleModule.Chat.Endpoints.Chat; - -public class ListConversationsEndpoint : IEndpoint -{ - public const string Route = ChatConstants.Routes.ListConversations; - - public void Map(IEndpointRouteBuilder app) => - app.MapGet( - Route, - async (IChatContracts chat, ClaimsPrincipal user, CancellationToken ct) => - { - var userId = user.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous"; - var conversations = await chat.GetUserConversationsAsync(userId, ct); - return Results.Ok(conversations); - } - ) - .RequirePermission(ChatPermissions.View); -} diff --git a/modules/Chat/src/SimpleModule.Chat/Endpoints/Chat/RenameConversationEndpoint.cs b/modules/Chat/src/SimpleModule.Chat/Endpoints/Chat/RenameConversationEndpoint.cs deleted file mode 100644 index 6c539603..00000000 --- a/modules/Chat/src/SimpleModule.Chat/Endpoints/Chat/RenameConversationEndpoint.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System.Security.Claims; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Chat.Contracts; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; -using SimpleModule.Core.Exceptions; - -namespace SimpleModule.Chat.Endpoints.Chat; - -public class RenameConversationEndpoint : IEndpoint -{ - public const string Route = ChatConstants.Routes.RenameConversation; - - public void Map(IEndpointRouteBuilder app) => - app.MapPatch( - Route, - async ( - Guid id, - RenameConversationRequest request, - ChatService service, - ClaimsPrincipal user, - CancellationToken ct - ) => - { - if (string.IsNullOrWhiteSpace(request.Title)) - { - throw new ValidationException("Title is required."); - } - var userId = user.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous"; - var conversation = await service.RenameAsync( - ConversationId.From(id), - userId, - request.Title, - ct - ); - return Results.Ok(conversation); - } - ) - .RequirePermission(ChatPermissions.Create); -} diff --git a/modules/Chat/src/SimpleModule.Chat/Endpoints/Chat/SendMessageStreamEndpoint.cs b/modules/Chat/src/SimpleModule.Chat/Endpoints/Chat/SendMessageStreamEndpoint.cs deleted file mode 100644 index c529ded3..00000000 --- a/modules/Chat/src/SimpleModule.Chat/Endpoints/Chat/SendMessageStreamEndpoint.cs +++ /dev/null @@ -1,179 +0,0 @@ -using System.Security.Claims; -using System.Text; -using System.Text.Json; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Agents; -using SimpleModule.Agents.Dtos; -using SimpleModule.Chat.Contracts; -using SimpleModule.Chat.Dtos; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; -using SimpleModule.Core.Exceptions; - -namespace SimpleModule.Chat.Endpoints.Chat; - -public class SendMessageStreamEndpoint : IEndpoint -{ - public const string Route = ChatConstants.Routes.SendMessageStream; - - public void Map(IEndpointRouteBuilder app) => - app.MapPost( - Route, - async ( - Guid id, - TanStackChatRequest request, - ChatService service, - AgentChatService agentChat, - ClaimsPrincipal user, - HttpContext httpContext, - CancellationToken ct - ) => - { - if (request.Messages is null || request.Messages.Count == 0) - { - throw new ValidationException("messages is required."); - } - - var lastUserIndex = -1; - for (var i = request.Messages.Count - 1; i >= 0; i--) - { - if ( - string.Equals( - request.Messages[i].Role, - "user", - StringComparison.OrdinalIgnoreCase - ) - ) - { - lastUserIndex = i; - break; - } - } - if (lastUserIndex < 0) - { - throw new ValidationException( - "messages must contain at least one user message." - ); - } - var lastUser = request.Messages[lastUserIndex]; - if (string.IsNullOrWhiteSpace(lastUser.Content)) - { - throw new ValidationException("latest user message cannot be empty."); - } - - var history = request - .Messages.Take(lastUserIndex) - .Where(m => - !string.Equals(m.Role, "system", StringComparison.OrdinalIgnoreCase) - ) - .Select(m => new AgentHistoryMessage(m.Role, m.Content)) - .ToArray(); - - var userId = user.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous"; - var conversationId = ConversationId.From(id); - var conversation = await service.LoadOwnedAsync(conversationId, userId, ct); - - // Persist user message first so history is correct even if streaming fails. - await service.AppendMessageAsync( - conversationId, - ChatRole.User, - lastUser.Content, - ct - ); - - httpContext.Response.ContentType = "text/event-stream"; - httpContext.Response.Headers.CacheControl = "no-cache"; - httpContext.Response.Headers.Connection = "keep-alive"; - - var messageId = $"msg-{Guid.NewGuid():N}"; - var model = conversation.AgentName; - var assistantBuffer = new StringBuilder(); - - var agentRequest = new AgentChatRequest( - lastUser.Content, - SessionId: conversationId.Value.ToString(), - History: history - ); - - try - { - await foreach ( - var chunk in agentChat.ChatStreamAsync( - conversation.AgentName, - agentRequest, - ct - ) - ) - { - assistantBuffer.Append(chunk); - var contentChunk = new TanStackContentChunk( - Type: "content", - Id: messageId, - Model: model, - Timestamp: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - Delta: chunk, - Content: chunk, - Role: "assistant" - ); - await WriteSseAsync(httpContext, contentChunk, ct); - } - - var doneChunk = new TanStackDoneChunk( - Type: "done", - Id: messageId, - Model: model, - Timestamp: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - FinishReason: "stop" - ); - await WriteSseAsync(httpContext, doneChunk, ct); - } - catch (OperationCanceledException) when (ct.IsCancellationRequested) - { - // Client disconnected — do not emit an error chunk, just bail. - } -#pragma warning disable CA1031 // we emit the error back over SSE instead of 500 - catch (Exception ex) - { - var errorChunk = new TanStackErrorChunk( - Type: "error", - Id: messageId, - Model: model, - Timestamp: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - Error: new TanStackErrorBody(Message: ex.Message, Code: "agent_error") - ); - await WriteSseAsync(httpContext, errorChunk, CancellationToken.None); - } -#pragma warning restore CA1031 - - if (assistantBuffer.Length > 0) - { - await service.AppendMessageAsync( - conversationId, - ChatRole.Assistant, - assistantBuffer.ToString(), - CancellationToken.None - ); - } - - await httpContext.Response.WriteAsync( - "data: [DONE]\n\n", - CancellationToken.None - ); - await httpContext.Response.Body.FlushAsync(CancellationToken.None); - } - ) - .RequirePermission(ChatPermissions.Create); - - private static async Task WriteSseAsync( - HttpContext httpContext, - T payload, - CancellationToken ct - ) - { - var json = JsonSerializer.Serialize(payload); - await httpContext.Response.WriteAsync($"data: {json}\n\n", ct); - await httpContext.Response.Body.FlushAsync(ct); - } -} diff --git a/modules/Chat/src/SimpleModule.Chat/EntityConfigurations/ConversationConfiguration.cs b/modules/Chat/src/SimpleModule.Chat/EntityConfigurations/ConversationConfiguration.cs deleted file mode 100644 index 7a147a2f..00000000 --- a/modules/Chat/src/SimpleModule.Chat/EntityConfigurations/ConversationConfiguration.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using SimpleModule.Chat.Contracts; - -namespace SimpleModule.Chat.EntityConfigurations; - -public class ConversationConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasKey(c => c.Id); - builder.Property(c => c.Id).ValueGeneratedNever(); - builder.Property(c => c.UserId).IsRequired().HasMaxLength(256); - builder.Property(c => c.Title).IsRequired().HasMaxLength(512); - builder.Property(c => c.AgentName).IsRequired().HasMaxLength(128); - // SQLite cannot ORDER BY DateTimeOffset natively; convert to binary long. - builder.Property(c => c.CreatedAt).HasConversion(); - builder.Property(c => c.UpdatedAt).HasConversion(); - builder.Property(c => c.ConcurrencyStamp).HasMaxLength(64); - builder.HasIndex(c => new { c.UserId, c.UpdatedAt }); - - builder - .HasMany(c => c.Messages) - .WithOne() - .HasForeignKey(m => m.ConversationId) - .OnDelete(DeleteBehavior.Cascade); - } -} - -public class ChatMessageConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasKey(m => m.Id); - builder.Property(m => m.Id).ValueGeneratedNever(); - builder.Property(m => m.Content).IsRequired(); - builder.Property(m => m.Role).HasConversion(); - builder.Property(m => m.CreatedAt).HasConversion(); - builder.Property(m => m.UpdatedAt).HasConversion(); - builder.Property(m => m.ConcurrencyStamp).HasMaxLength(64); - builder.HasIndex(m => new { m.ConversationId, m.CreatedAt }); - } -} diff --git a/modules/Chat/src/SimpleModule.Chat/Pages/Browse.tsx b/modules/Chat/src/SimpleModule.Chat/Pages/Browse.tsx deleted file mode 100644 index 80a76c47..00000000 --- a/modules/Chat/src/SimpleModule.Chat/Pages/Browse.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { router } from '@inertiajs/react'; -import { Button, Card, CardContent, Input, PageShell } from '@simplemodule/ui'; -import { useState } from 'react'; - -type Conversation = { - id: string; - title: string; - agentName: string; - updatedAt: string; -}; - -type AgentInfo = { - name: string; - description: string; -}; - -type Props = { - conversations: Conversation[]; - agents: AgentInfo[]; -}; - -export default function Browse({ conversations, agents }: Props) { - const [title, setTitle] = useState(''); - const [agentName, setAgentName] = useState(agents[0]?.name ?? ''); - const [creating, setCreating] = useState(false); - - async function createConversation() { - if (!agentName) return; - setCreating(true); - try { - const response = await fetch('/api/chat/conversations', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ agentName, title: title || null }), - }); - if (!response.ok) { - throw new Error(`Failed to create conversation: ${response.status}`); - } - const conversation = (await response.json()) as Conversation; - router.visit(`/chat/${conversation.id}`); - } finally { - setCreating(false); - } - } - - return ( - - - -
Start a new conversation
- - setTitle(e.target.value)} - /> - -
-
- -
- {conversations.length === 0 ? ( -
No conversations yet.
- ) : ( - conversations.map((c) => ( - router.visit(`/chat/${c.id}`)} - > - -
-
{c.title}
-
{c.agentName}
-
-
- {new Date(c.updatedAt).toLocaleString()} -
-
-
- )) - )} -
-
- ); -} diff --git a/modules/Chat/src/SimpleModule.Chat/Pages/BrowseEndpoint.cs b/modules/Chat/src/SimpleModule.Chat/Pages/BrowseEndpoint.cs deleted file mode 100644 index cc71fbab..00000000 --- a/modules/Chat/src/SimpleModule.Chat/Pages/BrowseEndpoint.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Security.Claims; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Agents; -using SimpleModule.Chat.Contracts; -using SimpleModule.Core; -using SimpleModule.Core.Inertia; - -namespace SimpleModule.Chat.Pages; - -public class BrowseEndpoint : IViewEndpoint -{ - public const string Route = ChatConstants.Routes.Browse; - - public void Map(IEndpointRouteBuilder app) - { - app.MapGet( - Route, - async ( - IChatContracts chat, - IAgentRegistry agents, - ClaimsPrincipal user, - CancellationToken ct - ) => - { - var userId = user.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous"; - var conversations = await chat.GetUserConversationsAsync(userId, ct); - var agentList = agents - .GetAll() - .Select(a => new { name = a.Name, description = a.Description }) - .ToArray(); - return Inertia.Render("Chat/Browse", new { conversations, agents = agentList }); - } - ) - .RequireAuthorization(); - } -} diff --git a/modules/Chat/src/SimpleModule.Chat/Pages/Conversation.tsx b/modules/Chat/src/SimpleModule.Chat/Pages/Conversation.tsx deleted file mode 100644 index d351893e..00000000 --- a/modules/Chat/src/SimpleModule.Chat/Pages/Conversation.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { Button, Card, CardContent, Input, PageShell } from '@simplemodule/ui'; -import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'; -import { useMemo, useState } from 'react'; - -type UiMessagePart = { type: 'text'; content: string }; - -type UiMessage = { - id: string; - role: 'user' | 'assistant'; - parts: UiMessagePart[]; - createdAt: string; -}; - -type ConversationDto = { - id: string; - title: string; - agentName: string; -}; - -type Props = { - conversation: ConversationDto; - initialMessages: UiMessage[]; -}; - -export default function Conversation({ conversation, initialMessages }: Props) { - const [input, setInput] = useState(''); - - const hydratedInitial = useMemo( - () => initialMessages.map((m) => ({ ...m, createdAt: new Date(m.createdAt) })), - [initialMessages], - ); - - const { messages, sendMessage, isLoading, stop, error } = useChat({ - id: conversation.id, - initialMessages: hydratedInitial, - connection: fetchServerSentEvents(`/api/chat/conversations/${conversation.id}/stream`), - }); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - const text = input.trim(); - if (!text || isLoading) return; - sendMessage(text); - setInput(''); - }; - - return ( - -
- {messages.map((message) => ( - - - {message.parts.map((part, idx) => { - if (part.type === 'thinking') { - return ( -
- {part.content} -
- ); - } - if (part.type === 'text') { - return {part.content}; - } - if (part.type === 'tool-call') { - return ( -
- tool: {(part as unknown as { name?: string }).name ?? 'call'} -
- ); - } - return null; - })} -
-
- ))} -
- -
- setInput(e.target.value)} - placeholder="Type a message…" - disabled={isLoading} - /> - {isLoading ? ( - - ) : ( - - )} -
- - {error &&
{error.message}
} -
- ); -} diff --git a/modules/Chat/src/SimpleModule.Chat/Pages/ConversationEndpoint.cs b/modules/Chat/src/SimpleModule.Chat/Pages/ConversationEndpoint.cs deleted file mode 100644 index 15d082ff..00000000 --- a/modules/Chat/src/SimpleModule.Chat/Pages/ConversationEndpoint.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Security.Claims; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Chat.Contracts; -using SimpleModule.Chat.Dtos; -using SimpleModule.Core; -using SimpleModule.Core.Exceptions; -using SimpleModule.Core.Inertia; - -namespace SimpleModule.Chat.Pages; - -public class ConversationEndpoint : IViewEndpoint -{ - public const string Route = ChatConstants.Routes.Conversation; - - public void Map(IEndpointRouteBuilder app) - { - app.MapGet( - Route, - async (Guid id, ChatService service, ClaimsPrincipal user, CancellationToken ct) => - { - var userId = user.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous"; - var conversation = await service.GetConversationAsync( - ConversationId.From(id), - ct - ); - if ( - conversation is null - || !string.Equals(conversation.UserId, userId, StringComparison.Ordinal) - ) - { - throw new NotFoundException("Conversation", id); - } - - var initialMessages = conversation - .Messages.Where(m => m.Role != ChatRole.System) - .Select(m => new UiMessage( - Id: m.Id.Value.ToString(), - Role: ChatRoleExtensions.ToWire(m.Role), - Parts: new[] { new UiMessagePart("text", m.Content) }, - CreatedAt: m.CreatedAt - )) - .ToArray(); - - return Inertia.Render( - "Chat/Conversation", - new { conversation, initialMessages } - ); - } - ) - .RequireAuthorization(); - } -} diff --git a/modules/Chat/src/SimpleModule.Chat/Pages/index.ts b/modules/Chat/src/SimpleModule.Chat/Pages/index.ts deleted file mode 100644 index 62bb964f..00000000 --- a/modules/Chat/src/SimpleModule.Chat/Pages/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const pages: Record = { - 'Chat/Browse': () => import('./Browse'), - 'Chat/Conversation': () => import('./Conversation'), -}; diff --git a/modules/Chat/src/SimpleModule.Chat/README.md b/modules/Chat/src/SimpleModule.Chat/README.md deleted file mode 100644 index 420b26d0..00000000 --- a/modules/Chat/src/SimpleModule.Chat/README.md +++ /dev/null @@ -1,83 +0,0 @@ -# SimpleModule.Chat - -User-facing chat module for [SimpleModule](https://github.com/antosubash/SimpleModule) — a modular monolith framework for .NET. - -Provides a conversations UI on top of the [Agents](../../../Agents/src/SimpleModule.Agents.Module) framework and its built-in RAG pipeline. The frontend uses [@tanstack/ai-react](https://github.com/tanstack/ai) for streaming chat rendering; the backend emits the TanStack SSE wire protocol. - -## Features - -- Persistent conversations per user with titles, pinning, timestamps -- Streaming replies via Server-Sent Events in the TanStack `StreamChunk` format -- Multi-turn history replay through `AgentChatService` (full context every turn) -- Automatic RAG augmentation — inherited from the agent definition's `EnableRag` / `RagCollectionName` -- Per-user isolation enforced at the service layer (other users' conversations return 404, not 403) -- Permission-gated endpoints: `Chat.View`, `Chat.Create`, `Chat.ManageAll` -- Graceful mid-stream error handling — LLM failures are delivered as TanStack `error` chunks; partial assistant replies are still persisted - -## Routes - -**API (`/api/chat`)** -- `GET /conversations` — list the current user's conversations -- `POST /conversations` — start a new conversation (`{ agentName, title? }`) -- `GET /conversations/{id}` — get a conversation with its messages -- `PATCH /conversations/{id}` — rename (`{ title }`) -- `DELETE /conversations/{id}` — delete (cascades messages) -- `GET /conversations/{id}/messages` — full message history -- `POST /conversations/{id}/stream` — SSE stream; accepts the TanStack `{ messages, data? }` body - -**Views (`/chat`)** -- `/chat` — conversation list + new-chat picker -- `/chat/{id}` — conversation view with streaming hook via `useChat` - -## Wire protocol - -The `/stream` endpoint emits SSE frames in the [TanStack AI protocol](https://github.com/tanstack/ai/blob/main/docs/protocol/sse-protocol.md): - -``` -data: {"type":"content","id":"msg-…","model":"agent-name","timestamp":…,"delta":"Hello","content":"Hello","role":"assistant"} -data: {"type":"content",…,"delta":" world","content":"Hello world","role":"assistant"} -data: {"type":"done","id":"msg-…","model":"agent-name","timestamp":…,"finishReason":"stop"} -data: [DONE] -``` - -On failure: - -``` -data: {"type":"content",…"delta":"Hel"…} -data: {"type":"error","id":"msg-…","error":{"message":"network blip","code":"agent_error"}} -data: [DONE] -``` - -The frontend consumes this via `fetchServerSentEvents('/api/chat/conversations/{id}/stream')` passed to `useChat`. - -## Public API for other modules - -```csharp -public interface IChatContracts -{ - Task> GetUserConversationsAsync(string userId, CancellationToken ct = default); - Task GetConversationAsync(ConversationId id, CancellationToken ct = default); - Task StartConversationAsync(string userId, string agentName, string? title, CancellationToken ct = default); -} -``` - -Inject `IChatContracts` to launch pre-seeded chats from elsewhere (e.g. a "Help" button on any page). - -## Data model - -- `ChatConversations` — one row per conversation (`Id`, `UserId`, `Title`, `AgentName`, `Pinned`, timestamps) -- `ChatMessages` — one row per turn (`Id`, `ConversationId`, `Role`, `Content`, `CreatedAt`) - -Chat owns its own persistence rather than delegating to the Agents module's `AgentSession` store, because chat needs UI-scoped metadata (title, pinning) and a stable history for rendering even when the LLM is stateless per turn. - -## Tests - -74 tests covering unit (service, DTO serialization, value objects, history replay) and integration (CRUD endpoints over HTTP, streaming endpoint with a fake `IChatClient`, error paths, permission enforcement, multi-user isolation). - -```bash -dotnet test modules/Chat/tests/SimpleModule.Chat.Tests -``` - -## License - -MIT diff --git a/modules/Chat/src/SimpleModule.Chat/SimpleModule.Chat.csproj b/modules/Chat/src/SimpleModule.Chat/SimpleModule.Chat.csproj deleted file mode 100644 index eb5c64bc..00000000 --- a/modules/Chat/src/SimpleModule.Chat/SimpleModule.Chat.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - net10.0 - Chat module for SimpleModule. User-facing conversations backed by the Agents framework with automatic RAG augmentation. - - - - - - - - - diff --git a/modules/Chat/src/SimpleModule.Chat/package.json b/modules/Chat/src/SimpleModule.Chat/package.json deleted file mode 100644 index 7a060620..00000000 --- a/modules/Chat/src/SimpleModule.Chat/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "private": true, - "name": "@simplemodule/chat", - "version": "0.0.0", - "scripts": { - "build": "cross-env VITE_MODE=prod vite build --configLoader runner", - "build:dev": "cross-env VITE_MODE=dev vite build --configLoader runner", - "watch": "cross-env VITE_MODE=dev vite build --configLoader runner --watch" - }, - "dependencies": { - "@tanstack/ai-client": "^0.1.0", - "@tanstack/ai-react": "^0.1.0" - }, - "peerDependencies": { - "react": "^19.0.0", - "react-dom": "^19.0.0" - } -} diff --git a/modules/Chat/src/SimpleModule.Chat/tsconfig.json b/modules/Chat/src/SimpleModule.Chat/tsconfig.json deleted file mode 100644 index 3e759d10..00000000 --- a/modules/Chat/src/SimpleModule.Chat/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "@simplemodule/tsconfig/base", - "compilerOptions": { - "paths": { - "@/*": ["./*"] - } - } -} diff --git a/modules/Chat/src/SimpleModule.Chat/types.ts b/modules/Chat/src/SimpleModule.Chat/types.ts deleted file mode 100644 index 3e87d5df..00000000 --- a/modules/Chat/src/SimpleModule.Chat/types.ts +++ /dev/null @@ -1,32 +0,0 @@ -// Auto-generated from [Dto] types — do not edit -export interface ChatMessage { - conversationId: string; - role: any; - content: string; - id: string; - createdAt: string; - updatedAt: string; - concurrencyStamp: string; -} - -export interface Conversation { - userId: string; - title: string; - agentName: string; - pinned: boolean; - messages: ChatMessage[]; - id: string; - createdAt: string; - updatedAt: string; - concurrencyStamp: string; -} - -export interface CreateConversationRequest { - agentName: string; - title: string; -} - -export interface RenameConversationRequest { - title: string; -} - diff --git a/modules/Chat/src/SimpleModule.Chat/vite.config.ts b/modules/Chat/src/SimpleModule.Chat/vite.config.ts deleted file mode 100644 index a247db62..00000000 --- a/modules/Chat/src/SimpleModule.Chat/vite.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { defineModuleConfig } from '@simplemodule/client/module'; - -export default defineModuleConfig(import.meta.dirname); diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/GlobalUsings.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/GlobalUsings.cs deleted file mode 100644 index c802f448..00000000 --- a/modules/Chat/tests/SimpleModule.Chat.Tests/GlobalUsings.cs +++ /dev/null @@ -1 +0,0 @@ -global using Xunit; diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Authorization.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Authorization.cs deleted file mode 100644 index 96331afc..00000000 --- a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Authorization.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Text.Json; -using FluentAssertions; -using SimpleModule.Chat; - -namespace Chat.Tests.Integration; - -public partial class ChatEndpointTests -{ - [Fact] - public async Task ListConversations_Unauthenticated_Returns401() - { - var client = _factory.CreateClient(); - - var response = await client.GetAsync("/api/chat/conversations"); - - response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); - } - - [Fact] - public async Task ListConversations_WithoutViewPermission_Returns403() - { - var client = _factory.CreateAuthenticatedClient([ChatPermissions.Create]); - - var response = await client.GetAsync("/api/chat/conversations"); - - response.StatusCode.Should().Be(HttpStatusCode.Forbidden); - } - - [Fact] - public async Task ListConversations_WithViewPermission_Returns200() - { - var client = _factory.CreateAuthenticatedClient([ChatPermissions.View]); - - var response = await client.GetAsync("/api/chat/conversations"); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task BrowseView_Unauthenticated_Returns401() - { - var client = _factory.CreateClient(); - - var response = await client.GetAsync("/chat"); - - response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); - } - - [Fact] - public async Task ListConversations_OnlyReturnsCurrentUsersConversations() - { - var alice = _factory.CreateAuthenticatedClient( - [ChatPermissions.View, ChatPermissions.Create], - new System.Security.Claims.Claim( - System.Security.Claims.ClaimTypes.NameIdentifier, - "alice-list" - ) - ); - var bob = _factory.CreateAuthenticatedClient( - [ChatPermissions.View, ChatPermissions.Create], - new System.Security.Claims.Claim( - System.Security.Claims.ClaimTypes.NameIdentifier, - "bob-list" - ) - ); - - await CreateConversationAsync(alice, "assistant", "alice-1"); - await CreateConversationAsync(alice, "assistant", "alice-2"); - await CreateConversationAsync(bob, "assistant", "bob-1"); - - var aliceList = await alice.GetFromJsonAsync>("/api/chat/conversations"); - - aliceList.Should().NotBeNull(); - aliceList! - .Select(e => e.GetProperty("title").GetString()) - .Should() - .BeEquivalentTo(ExpectedAliceTitles); - } -} diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Create.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Create.cs deleted file mode 100644 index 776a4dcd..00000000 --- a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Create.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Text.Json; -using FluentAssertions; -using SimpleModule.Chat; - -namespace Chat.Tests.Integration; - -public partial class ChatEndpointTests -{ - [Fact] - public async Task CreateConversation_WithCreatePermission_Returns201() - { - var client = _factory.CreateAuthenticatedClient([ChatPermissions.Create]); - - var response = await client.PostAsJsonAsync( - "/api/chat/conversations", - new { agentName = "assistant", title = "My chat" } - ); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - var json = await response.Content.ReadFromJsonAsync(); - json.GetProperty("title").GetString().Should().Be("My chat"); - json.GetProperty("agentName").GetString().Should().Be("assistant"); - } - - [Fact] - public async Task CreateConversation_WithoutPermission_Returns403() - { - var client = _factory.CreateAuthenticatedClient([ChatPermissions.View]); - - var response = await client.PostAsJsonAsync( - "/api/chat/conversations", - new { agentName = "assistant" } - ); - - response.StatusCode.Should().Be(HttpStatusCode.Forbidden); - } - - [Fact] - public async Task CreateConversation_WithoutAgentName_Returns400() - { - var client = _factory.CreateAuthenticatedClient([ChatPermissions.Create]); - - var response = await client.PostAsJsonAsync( - "/api/chat/conversations", - new { agentName = "" } - ); - - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } -} diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Helpers.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Helpers.cs deleted file mode 100644 index eeaf93d9..00000000 --- a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Helpers.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Net.Http.Json; -using System.Text.Json; -using SimpleModule.Chat.Contracts; -using SimpleModule.Tests.Shared.Fixtures; - -namespace Chat.Tests.Integration; - -[Collection(TestCollections.Integration)] -public partial class ChatEndpointTests -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNameCaseInsensitive = true, - }; - - private static readonly string[] ExpectedAliceTitles = ["alice-1", "alice-2"]; - - private readonly SimpleModuleWebApplicationFactory _factory; - - public ChatEndpointTests(SimpleModuleWebApplicationFactory factory) - { - _factory = factory; - } - - private static async Task CreateConversationAsync( - HttpClient client, - string agentName, - string title - ) - { - var response = await client.PostAsJsonAsync( - "/api/chat/conversations", - new { agentName, title } - ); - response.EnsureSuccessStatusCode(); - var created = await response.Content.ReadFromJsonAsync(JsonOptions); - return created!; - } -} diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Mutate.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Mutate.cs deleted file mode 100644 index 237ae918..00000000 --- a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Mutate.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Text.Json; -using FluentAssertions; -using SimpleModule.Chat; - -namespace Chat.Tests.Integration; - -public partial class ChatEndpointTests -{ - [Fact] - public async Task RenameConversation_WhenOwner_UpdatesTitle() - { - var client = _factory.CreateAuthenticatedClient([ - ChatPermissions.View, - ChatPermissions.Create, - ]); - var created = await CreateConversationAsync(client, "assistant", "Before"); - - var renameResponse = await client.PatchAsJsonAsync( - $"/api/chat/conversations/{created.Id.Value}", - new { title = "After" } - ); - - renameResponse.StatusCode.Should().Be(HttpStatusCode.OK); - var json = await renameResponse.Content.ReadFromJsonAsync(); - json.GetProperty("title").GetString().Should().Be("After"); - } - - [Fact] - public async Task RenameConversation_WithEmptyTitle_Returns400() - { - var client = _factory.CreateAuthenticatedClient([ - ChatPermissions.View, - ChatPermissions.Create, - ]); - var created = await CreateConversationAsync(client, "assistant", "Before"); - - var response = await client.PatchAsJsonAsync( - $"/api/chat/conversations/{created.Id.Value}", - new { title = " " } - ); - - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task DeleteConversation_WhenOwner_Returns204() - { - var client = _factory.CreateAuthenticatedClient([ - ChatPermissions.View, - ChatPermissions.Create, - ]); - var created = await CreateConversationAsync(client, "assistant", "doomed"); - - var response = await client.DeleteAsync($"/api/chat/conversations/{created.Id.Value}"); - - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Fact] - public async Task DeleteConversation_OtherUsers_Returns404() - { - var ownerClient = _factory.CreateAuthenticatedClient( - [ChatPermissions.View, ChatPermissions.Create], - new System.Security.Claims.Claim( - System.Security.Claims.ClaimTypes.NameIdentifier, - "alice" - ) - ); - var created = await CreateConversationAsync(ownerClient, "assistant", "alices"); - - var mallory = _factory.CreateAuthenticatedClient( - [ChatPermissions.View, ChatPermissions.Create], - new System.Security.Claims.Claim( - System.Security.Claims.ClaimTypes.NameIdentifier, - "mallory" - ) - ); - - var response = await mallory.DeleteAsync($"/api/chat/conversations/{created.Id.Value}"); - - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } -} diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Query.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Query.cs deleted file mode 100644 index 8678a528..00000000 --- a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatEndpointTests.Query.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Text.Json; -using FluentAssertions; -using SimpleModule.Chat; -using SimpleModule.Chat.Contracts; - -namespace Chat.Tests.Integration; - -public partial class ChatEndpointTests -{ - [Fact] - public async Task GetConversation_WhenOwner_Returns200() - { - var client = _factory.CreateAuthenticatedClient([ - ChatPermissions.View, - ChatPermissions.Create, - ]); - var created = await CreateConversationAsync(client, "assistant", "Owned"); - - var response = await client.GetAsync($"/api/chat/conversations/{created.Id.Value}"); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - var json = await response.Content.ReadFromJsonAsync(); - json.GetProperty("title").GetString().Should().Be("Owned"); - } - - [Fact] - public async Task GetConversation_WhenMissing_Returns404() - { - var client = _factory.CreateAuthenticatedClient([ChatPermissions.View]); - - var response = await client.GetAsync($"/api/chat/conversations/{Guid.NewGuid()}"); - - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task GetConversation_OtherUsersConversation_Returns404() - { - var ownerClient = _factory.CreateAuthenticatedClient( - [ChatPermissions.View, ChatPermissions.Create], - new System.Security.Claims.Claim( - System.Security.Claims.ClaimTypes.NameIdentifier, - "user-a" - ) - ); - var created = await CreateConversationAsync(ownerClient, "assistant", "Private"); - - var intruderClient = _factory.CreateAuthenticatedClient( - [ChatPermissions.View], - new System.Security.Claims.Claim( - System.Security.Claims.ClaimTypes.NameIdentifier, - "user-b" - ) - ); - - var response = await intruderClient.GetAsync($"/api/chat/conversations/{created.Id.Value}"); - - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task GetMessages_NewConversation_ReturnsEmptyArray() - { - var client = _factory.CreateAuthenticatedClient([ - ChatPermissions.View, - ChatPermissions.Create, - ]); - var created = await CreateConversationAsync(client, "assistant", "empty"); - - var response = await client.GetAsync( - $"/api/chat/conversations/{created.Id.Value}/messages" - ); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - var messages = await response.Content.ReadFromJsonAsync>(JsonOptions); - messages.Should().NotBeNull().And.BeEmpty(); - } -} diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.ErrorHandling.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.ErrorHandling.cs deleted file mode 100644 index 82e18159..00000000 --- a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.ErrorHandling.cs +++ /dev/null @@ -1,84 +0,0 @@ -// HttpClient instances come from WebApplicationFactory.CreateClient and are owned -// by the short-lived test-scoped factory; explicit disposal adds no value. -#pragma warning disable CA2000 -using System.Net; -using System.Net.Http.Json; -using System.Text.Json; -using FluentAssertions; -using SimpleModule.Chat; - -namespace Chat.Tests.Integration; - -public partial class ChatStreamingEndpointTests -{ - [Fact] - public async Task Stream_WhenChatClientThrowsImmediately_EmitsErrorChunk() - { - using var factory = CreateFactoryWithSpecificClient( - new RecordingChatClient(["ignored"], throwAfterTokens: 0, throwMessage: "upstream down") - ); - var client = CreateAuthenticated(factory, ChatPermissions.Create); - var conversation = await CreateConversationAsync(client); - - using var response = await client.PostAsJsonAsync( - $"/api/chat/conversations/{conversation.Id.Value}/stream", - new { messages = new[] { new { role = "user", content = "hi" } } } - ); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - var body = await response.Content.ReadAsStringAsync(); - var frames = ParseSseFrames(body); - - // Expect: error chunk, then [DONE]. - frames.Should().HaveCount(2); - var error = ParseJson(frames[0]); - error.GetProperty("type").GetString().Should().Be("error"); - error.GetProperty("error").GetProperty("message").GetString().Should().Be("upstream down"); - error.GetProperty("error").GetProperty("code").GetString().Should().Be("agent_error"); - frames[1].Should().Be("[DONE]"); - } - - [Fact] - public async Task Stream_WhenChatClientThrowsMidway_EmitsContentThenError() - { - using var factory = CreateFactoryWithSpecificClient( - new RecordingChatClient( - ["Hello", " world", "!"], - throwAfterTokens: 2, - throwMessage: "network blip" - ) - ); - var client = CreateAuthenticated(factory, ChatPermissions.View, ChatPermissions.Create); - var conversation = await CreateConversationAsync(client); - - using var response = await client.PostAsJsonAsync( - $"/api/chat/conversations/{conversation.Id.Value}/stream", - new { messages = new[] { new { role = "user", content = "hi" } } } - ); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - var frames = ParseSseFrames(await response.Content.ReadAsStringAsync()); - - // Expect: 2 content chunks, 1 error chunk, 1 [DONE]. - frames.Should().HaveCount(4); - var firstContent = ParseJson(frames[0]); - firstContent.GetProperty("type").GetString().Should().Be("content"); - firstContent.GetProperty("delta").GetString().Should().Be("Hello"); - var secondContent = ParseJson(frames[1]); - secondContent.GetProperty("delta").GetString().Should().Be(" world"); - var error = ParseJson(frames[2]); - error.GetProperty("type").GetString().Should().Be("error"); - error.GetProperty("error").GetProperty("message").GetString().Should().Be("network blip"); - frames[3].Should().Be("[DONE]"); - - // The partial assistant reply should still be persisted so the user can see - // what the model produced before failing. - var history = await client.GetAsync( - $"/api/chat/conversations/{conversation.Id.Value}/messages" - ); - var messages = await history.Content.ReadFromJsonAsync>(JsonOptions); - messages.Should().NotBeNull(); - messages!.Should().HaveCount(2); - messages[1].GetProperty("content").GetString().Should().Be("Hello world"); - } -} diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.HappyPath.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.HappyPath.cs deleted file mode 100644 index e258806a..00000000 --- a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.HappyPath.cs +++ /dev/null @@ -1,119 +0,0 @@ -// HttpClient instances come from WebApplicationFactory.CreateClient and are owned -// by the short-lived test-scoped factory; explicit disposal adds no value. -#pragma warning disable CA2000 -using System.Net; -using System.Net.Http.Json; -using System.Text.Json; -using FluentAssertions; -using SimpleModule.Chat; -using AiChatRole = Microsoft.Extensions.AI.ChatRole; - -namespace Chat.Tests.Integration; - -public partial class ChatStreamingEndpointTests -{ - [Fact] - public async Task Stream_EmitsTanStackContentChunks() - { - using var factory = CreateFactoryWithFakes(["Hello", " ", "world"]); - var client = CreateAuthenticated(factory, ChatPermissions.Create); - var conversation = await CreateConversationAsync(client); - - using var response = await client.PostAsJsonAsync( - $"/api/chat/conversations/{conversation.Id.Value}/stream", - new { messages = new[] { new { role = "user", content = "say hi" } } } - ); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Content.Headers.ContentType?.MediaType.Should().Be("text/event-stream"); - - var body = await response.Content.ReadAsStringAsync(); - var frames = ParseSseFrames(body); - - // Three content chunks + one done chunk + final [DONE] sentinel. - frames.Should().HaveCount(5); - - var contentChunks = frames.Take(3).Select(ParseJson).ToArray(); - contentChunks - .Should() - .AllSatisfy(f => f.GetProperty("type").GetString().Should().Be("content")); - contentChunks - .Select(f => f.GetProperty("delta").GetString()) - .Should() - .Equal("Hello", " ", "world"); - // Content mirrors delta per chunk (clients accumulate via useChat). - contentChunks[2].GetProperty("content").GetString().Should().Be("world"); - contentChunks - .Select(f => f.GetProperty("role").GetString()) - .Should() - .AllBeEquivalentTo("assistant"); - - var done = ParseJson(frames[3]); - done.GetProperty("type").GetString().Should().Be("done"); - done.GetProperty("finishReason").GetString().Should().Be("stop"); - - frames[4].Should().Be("[DONE]"); - } - - [Fact] - public async Task Stream_PersistsUserAndAssistantMessages() - { - using var factory = CreateFactoryWithFakes(["Reply"]); - var client = CreateAuthenticated(factory, ChatPermissions.View, ChatPermissions.Create); - var conversation = await CreateConversationAsync(client); - - var streamResponse = await client.PostAsJsonAsync( - $"/api/chat/conversations/{conversation.Id.Value}/stream", - new { messages = new[] { new { role = "user", content = "question" } } } - ); - streamResponse.EnsureSuccessStatusCode(); - _ = await streamResponse.Content.ReadAsStringAsync(); - - var historyResponse = await client.GetAsync( - $"/api/chat/conversations/{conversation.Id.Value}/messages" - ); - historyResponse.EnsureSuccessStatusCode(); - var messages = await historyResponse.Content.ReadFromJsonAsync>( - JsonOptions - ); - - messages.Should().NotBeNull(); - messages!.Should().HaveCount(2); - messages[0].GetProperty("content").GetString().Should().Be("question"); - messages[1].GetProperty("content").GetString().Should().Be("Reply"); - } - - [Fact] - public async Task Stream_ForwardsHistoryFromTanStackPayloadToChatClient() - { - var fakeClient = new RecordingChatClient(["ok"]); - using var factory = CreateFactoryWithSpecificClient(fakeClient); - var client = CreateAuthenticated(factory, ChatPermissions.Create); - var conversation = await CreateConversationAsync(client); - - _ = await client.PostAsJsonAsync( - $"/api/chat/conversations/{conversation.Id.Value}/stream", - new - { - messages = new[] - { - new { role = "user", content = "first turn" }, - new { role = "assistant", content = "first reply" }, - new { role = "user", content = "second turn" }, - }, - } - ); - - var captured = fakeClient.LastMessages; - captured.Should().NotBeNull(); - // Expected order: [system, user(history), assistant(history), user(new)] - var roles = captured!.Select(m => m.Role).ToArray(); - roles - .Should() - .Equal(AiChatRole.System, AiChatRole.User, AiChatRole.Assistant, AiChatRole.User); - captured - .Select(m => m.Text) - .Should() - .Equal("You are a test agent.", "first turn", "first reply", "second turn"); - } -} diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.Helpers.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.Helpers.cs deleted file mode 100644 index fe573da1..00000000 --- a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.Helpers.cs +++ /dev/null @@ -1,190 +0,0 @@ -// HttpClient instances come from WebApplicationFactory.CreateClient and are owned -// by the short-lived test-scoped factory; explicit disposal adds no value. -#pragma warning disable CA2000 -using System.Net.Http.Json; -using System.Security.Claims; -using System.Text.Json; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; -using SimpleModule.Agents; -using SimpleModule.Chat.Contracts; -using SimpleModule.Core.Agents; -using SimpleModule.Host; -using SimpleModule.Tests.Shared.Fixtures; -using AiChatRole = Microsoft.Extensions.AI.ChatRole; - -namespace Chat.Tests.Integration; - -[Collection(TestCollections.Integration)] -public partial class ChatStreamingEndpointTests -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNameCaseInsensitive = true, - }; - - private readonly SimpleModuleWebApplicationFactory _baseFactory; - - public ChatStreamingEndpointTests(SimpleModuleWebApplicationFactory factory) - { - _baseFactory = factory; - // Force the base factory to initialize its in-memory SQLite database so that - // schema exists on the shared connection before any delegate factory uses it. - _ = factory.CreateAuthenticatedClient(Array.Empty()); - } - - private static async Task CreateConversationAsync(HttpClient client) - { - var response = await client.PostAsJsonAsync( - "/api/chat/conversations", - new { agentName = "test-agent", title = "Streaming test" } - ); - response.EnsureSuccessStatusCode(); - return (await response.Content.ReadFromJsonAsync(JsonOptions))!; - } - - private WebApplicationFactory CreateFactoryWithFakes(string[] tokens) => - CreateFactoryWithSpecificClient(new RecordingChatClient(tokens)); - - private WebApplicationFactory CreateFactoryWithSpecificClient( - IChatClient chatClient - ) => - _baseFactory.WithWebHostBuilder(builder => - { - builder.ConfigureServices(services => - { - // Replace IChatClient with our recording fake. - var chatClientDescriptor = services.SingleOrDefault(d => - d.ServiceType == typeof(IChatClient) - ); - if (chatClientDescriptor is not null) - { - services.Remove(chatClientDescriptor); - } - services.AddSingleton(chatClient); - - // Replace IAgentRegistry with a single test agent. - var registryDescriptor = services.SingleOrDefault(d => - d.ServiceType == typeof(IAgentRegistry) - ); - if (registryDescriptor is not null) - { - services.Remove(registryDescriptor); - } - var registry = new AgentRegistry(); - registry.Register( - new AgentRegistration( - Name: "test-agent", - Description: "Integration test agent", - ModuleName: "Chat.Tests", - AgentDefinitionType: typeof(StreamingTestAgent), - ToolProviderTypes: Array.Empty() - ) - ); - services.AddSingleton(registry); - }); - }); - - private static HttpClient CreateAuthenticated( - WebApplicationFactory factory, - params string[] permissions - ) => CreateAuthenticatedAs(factory, "test-user-id", permissions); - - private static HttpClient CreateAuthenticatedAs( - WebApplicationFactory factory, - string userId, - params string[] permissions - ) - { - var client = factory.CreateClient(); - var claims = new List { new(ClaimTypes.NameIdentifier, userId) }; - claims.AddRange(permissions.Select(p => new Claim("permission", p))); - var headerValue = string.Join(";", claims.Select(c => $"{c.Type}={c.Value}")); - client.DefaultRequestHeaders.Add("X-Test-Claims", headerValue); - return client; - } - - private static List ParseSseFrames(string body) - { - // Each SSE frame is "data: \n\n" - var frames = new List(); - foreach ( - var segment in body.Split( - "\n\n", - StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries - ) - ) - { - if (segment.StartsWith("data: ", StringComparison.Ordinal)) - { - frames.Add(segment[6..]); - } - } - return frames; - } - - private static JsonElement ParseJson(string raw) => JsonDocument.Parse(raw).RootElement.Clone(); - - // ---------- fakes ---------- - - internal sealed class StreamingTestAgent : IAgentDefinition - { - public string Name => "test-agent"; - public string Description => "Test"; - public string Instructions => "You are a test agent."; - public bool? EnableRag => false; - } - - internal sealed class RecordingChatClient( - string[] tokens, - int? throwAfterTokens = null, - string throwMessage = "boom" - ) : IChatClient - { - public IList? LastMessages { get; private set; } - - public Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default - ) - { - LastMessages = messages.ToList(); - var response = new ChatResponse( - new Microsoft.Extensions.AI.ChatMessage(AiChatRole.Assistant, string.Concat(tokens)) - ); - return Task.FromResult(response); - } - - public async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - [System.Runtime.CompilerServices.EnumeratorCancellation] - CancellationToken cancellationToken = default - ) - { - LastMessages = messages.ToList(); - var emitted = 0; - foreach (var token in tokens) - { - if (throwAfterTokens is { } limit && emitted >= limit) - { - throw new InvalidOperationException(throwMessage); - } - yield return new ChatResponseUpdate(AiChatRole.Assistant, token); - emitted++; - } - if (throwAfterTokens == 0) - { - throw new InvalidOperationException(throwMessage); - } - await Task.CompletedTask; - } - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - - public void Dispose() { } - } -} diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.Validation.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.Validation.cs deleted file mode 100644 index fff28215..00000000 --- a/modules/Chat/tests/SimpleModule.Chat.Tests/Integration/ChatStreamingEndpointTests.Validation.cs +++ /dev/null @@ -1,94 +0,0 @@ -// HttpClient instances come from WebApplicationFactory.CreateClient and are owned -// by the short-lived test-scoped factory; explicit disposal adds no value. -#pragma warning disable CA2000 -using System.Net; -using System.Net.Http.Json; -using FluentAssertions; -using SimpleModule.Chat; - -namespace Chat.Tests.Integration; - -public partial class ChatStreamingEndpointTests -{ - [Fact] - public async Task Stream_MissingMessagesArray_Returns400() - { - using var factory = CreateFactoryWithFakes(["ignored"]); - var client = CreateAuthenticated(factory, ChatPermissions.Create); - var conversation = await CreateConversationAsync(client); - - var response = await client.PostAsJsonAsync( - $"/api/chat/conversations/{conversation.Id.Value}/stream", - new { messages = Array.Empty() } - ); - - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Stream_OnlyAssistantMessages_Returns400() - { - using var factory = CreateFactoryWithFakes(["ignored"]); - var client = CreateAuthenticated(factory, ChatPermissions.Create); - var conversation = await CreateConversationAsync(client); - - var response = await client.PostAsJsonAsync( - $"/api/chat/conversations/{conversation.Id.Value}/stream", - new { messages = new[] { new { role = "assistant", content = "hi" } } } - ); - - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Stream_WhitespaceLatestUserMessage_Returns400() - { - using var factory = CreateFactoryWithFakes(["ignored"]); - var client = CreateAuthenticated(factory, ChatPermissions.Create); - var conversation = await CreateConversationAsync(client); - - var response = await client.PostAsJsonAsync( - $"/api/chat/conversations/{conversation.Id.Value}/stream", - new { messages = new[] { new { role = "user", content = " " } } } - ); - - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Stream_NonExistentConversation_Returns404() - { - using var factory = CreateFactoryWithFakes(["x"]); - var client = CreateAuthenticated(factory, ChatPermissions.Create); - - var response = await client.PostAsJsonAsync( - $"/api/chat/conversations/{Guid.NewGuid()}/stream", - new { messages = new[] { new { role = "user", content = "hi" } } } - ); - - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Stream_WithoutCreatePermission_Returns403() - { - using var factory = CreateFactoryWithFakes(["x"]); - // Owner creates the conversation with Create permission. - var ownerClient = CreateAuthenticatedAs( - factory, - "owner-user", - ChatPermissions.View, - ChatPermissions.Create - ); - var conversation = await CreateConversationAsync(ownerClient); - - // Viewer with only View tries to send a message. - var viewerClient = CreateAuthenticatedAs(factory, "viewer-user", ChatPermissions.View); - var response = await viewerClient.PostAsJsonAsync( - $"/api/chat/conversations/{conversation.Id.Value}/stream", - new { messages = new[] { new { role = "user", content = "hi" } } } - ); - - response.StatusCode.Should().Be(HttpStatusCode.Forbidden); - } -} diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/IntegrationTestCollection.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/IntegrationTestCollection.cs deleted file mode 100644 index 8c4a61db..00000000 --- a/modules/Chat/tests/SimpleModule.Chat.Tests/IntegrationTestCollection.cs +++ /dev/null @@ -1,6 +0,0 @@ -using SimpleModule.Tests.Shared.Fixtures; -using Xunit; - -[CollectionDefinition(TestCollections.Integration)] -public sealed class IntegrationTestCollection - : ICollectionFixture; diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/SimpleModule.Chat.Tests.csproj b/modules/Chat/tests/SimpleModule.Chat.Tests/SimpleModule.Chat.Tests.csproj deleted file mode 100644 index 58fc0140..00000000 --- a/modules/Chat/tests/SimpleModule.Chat.Tests/SimpleModule.Chat.Tests.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - net10.0 - false - Exe - - - - - - - - - - - - - - - - - - diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/Unit/AgentHistoryReplayTests.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/Unit/AgentHistoryReplayTests.cs deleted file mode 100644 index f7db62d4..00000000 --- a/modules/Chat/tests/SimpleModule.Chat.Tests/Unit/AgentHistoryReplayTests.cs +++ /dev/null @@ -1,200 +0,0 @@ -using FluentAssertions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using SimpleModule.Agents; -using SimpleModule.Agents.Dtos; -using SimpleModule.Core.Agents; - -namespace Chat.Tests.Unit; - -/// -/// Verifies that replays -/// into the message list it sends to the underlying IChatClient. This is the framework -/// change made for the Chat module so multi-turn conversations have context. -/// -public sealed class AgentHistoryReplayTests -{ - [Fact] - public async Task ChatAsync_InjectsHistoryBetweenSystemAndUserMessages() - { - var (service, capture) = CreateService(); - - var response = await service.ChatAsync( - "test-agent", - new AgentChatRequest( - "What about tomorrow?", - History: new[] - { - new AgentHistoryMessage("user", "What's the weather today?"), - new AgentHistoryMessage("assistant", "Sunny and 72."), - } - ) - ); - - response.Message.Should().Be("ack"); - capture.Messages.Should().NotBeNull(); - var roles = capture.Messages!.Select(m => m.Role).ToArray(); - var texts = capture.Messages!.Select(m => m.Text).ToArray(); - - // Expected order: [system, user(history), assistant(history), user(new)] - roles.Should().Equal(ChatRole.System, ChatRole.User, ChatRole.Assistant, ChatRole.User); - texts[0].Should().Be("You are a test agent."); - texts[1].Should().Be("What's the weather today?"); - texts[2].Should().Be("Sunny and 72."); - texts[3].Should().Be("What about tomorrow?"); - } - - [Fact] - public async Task ChatAsync_SkipsWhitespaceHistoryEntries() - { - var (service, capture) = CreateService(); - - await service.ChatAsync( - "test-agent", - new AgentChatRequest( - "current", - History: new[] - { - new AgentHistoryMessage("user", "real"), - new AgentHistoryMessage("assistant", " "), - new AgentHistoryMessage("user", ""), - } - ) - ); - - // Only the non-empty turn should be replayed, plus the new user message. - capture - .Messages!.Select(m => m.Text) - .Should() - .Equal("You are a test agent.", "real", "current"); - } - - [Fact] - public async Task ChatAsync_WithoutHistory_HasOnlySystemAndUser() - { - var (service, capture) = CreateService(); - - await service.ChatAsync("test-agent", new AgentChatRequest("hello")); - - capture.Messages!.Select(m => m.Role).Should().Equal(ChatRole.System, ChatRole.User); - } - - [Fact] - public async Task ChatAsync_UnknownHistoryRoleDefaultsToUser() - { - var (service, capture) = CreateService(); - - await service.ChatAsync( - "test-agent", - new AgentChatRequest( - "current", - History: new[] { new AgentHistoryMessage("random", "weird") } - ) - ); - - capture - .Messages!.Select(m => m.Role) - .Should() - .Equal(ChatRole.System, ChatRole.User, ChatRole.User); - } - - [Fact] - public async Task ChatStreamAsync_ReplaysHistoryBeforeStreaming() - { - var (service, capture) = CreateService(streamedText: "pong"); - - var collected = new List(); - await foreach ( - var chunk in service.ChatStreamAsync( - "test-agent", - new AgentChatRequest( - "ping", - History: new[] { new AgentHistoryMessage("assistant", "earlier") } - ) - ) - ) - { - collected.Add(chunk); - } - - string.Concat(collected).Should().Be("pong"); - capture - .Messages!.Select(m => m.Text) - .Should() - .Equal("You are a test agent.", "earlier", "ping"); - } - - // ---------- helpers ---------- - - private static (AgentChatService service, CapturingChatClient capture) CreateService( - string textResponse = "ack", - string? streamedText = null - ) - { - var client = new CapturingChatClient(textResponse, streamedText ?? textResponse); - var registry = new FakeAgentRegistry( - new AgentRegistration( - Name: "test-agent", - Description: "Test", - ModuleName: "Chat.Tests", - AgentDefinitionType: typeof(TestAgent), - ToolProviderTypes: Array.Empty() - ) - ); - var sp = new ServiceCollection().BuildServiceProvider(); - var options = Options.Create(new AgentOptions { EnableRag = false }); - var service = new AgentChatService(registry, client, sp, options); - return (service, client); - } - - private sealed class TestAgent : IAgentDefinition - { - public string Name => "test-agent"; - public string Description => "Test"; - public string Instructions => "You are a test agent."; - public bool? EnableRag => false; - } - - private sealed class FakeAgentRegistry(AgentRegistration registration) : IAgentRegistry - { - public IReadOnlyList GetAll() => new[] { registration }; - - public AgentRegistration? GetByName(string name) => - name == registration.Name ? registration : null; - } - - private sealed class CapturingChatClient(string responseText, string streamText) : IChatClient - { - public IList? Messages { get; private set; } - - public Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default - ) - { - Messages = messages.ToList(); - var response = new ChatResponse( - new Microsoft.Extensions.AI.ChatMessage(ChatRole.Assistant, responseText) - ); - return Task.FromResult(response); - } - - public async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - [System.Runtime.CompilerServices.EnumeratorCancellation] - CancellationToken cancellationToken = default - ) - { - Messages = messages.ToList(); - yield return new ChatResponseUpdate(ChatRole.Assistant, streamText); - await Task.CompletedTask; - } - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - - public void Dispose() { } - } -} diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/Unit/ChatServiceTests.Conversation.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/Unit/ChatServiceTests.Conversation.cs deleted file mode 100644 index f7409c4e..00000000 --- a/modules/Chat/tests/SimpleModule.Chat.Tests/Unit/ChatServiceTests.Conversation.cs +++ /dev/null @@ -1,250 +0,0 @@ -using FluentAssertions; -using Microsoft.EntityFrameworkCore; -using SimpleModule.Chat.Contracts; -using SimpleModule.Core.Exceptions; - -namespace Chat.Tests.Unit; - -public sealed partial class ChatServiceTests -{ - // ---------- StartConversationAsync ---------- - - [Fact] - public async Task StartConversationAsync_PersistsWithDefaultTitle() - { - var conv = await _sut.StartConversationAsync("user-1", "assistant", null); - - conv.UserId.Should().Be("user-1"); - conv.AgentName.Should().Be("assistant"); - conv.Title.Should().Be("New conversation"); - conv.Id.Value.Should().NotBe(Guid.Empty); - } - - [Fact] - public async Task StartConversationAsync_TrimsTitle() - { - var conv = await _sut.StartConversationAsync("user-1", "assistant", " Hello "); - - conv.Title.Should().Be("Hello"); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData("\t\n")] - public async Task StartConversationAsync_WhitespaceTitleDefaults(string title) - { - var conv = await _sut.StartConversationAsync("user-1", "assistant", title); - - conv.Title.Should().Be("New conversation"); - } - - [Fact] - public async Task StartConversationAsync_SetsCreatedAndUpdatedEqual() - { - var conv = await _sut.StartConversationAsync("user-1", "assistant", null); - - conv.CreatedAt.Should().Be(conv.UpdatedAt); - } - - [Fact] - public async Task StartConversationAsync_IsPersistedToDatabase() - { - var conv = await _sut.StartConversationAsync("user-1", "assistant", null); - - var reloaded = await _db.Conversations.FindAsync(conv.Id); - reloaded.Should().NotBeNull(); - reloaded!.Id.Should().Be(conv.Id); - } - - // ---------- GetUserConversationsAsync ---------- - - [Fact] - public async Task GetUserConversationsAsync_ReturnsOnlyOwnConversations() - { - await _sut.StartConversationAsync("user-1", "assistant", "mine"); - await _sut.StartConversationAsync("user-2", "assistant", "theirs"); - - var results = await _sut.GetUserConversationsAsync("user-1"); - - results.Should().HaveCount(1); - results[0].Title.Should().Be("mine"); - } - - [Fact] - public async Task GetUserConversationsAsync_EmptyForNewUser() - { - var results = await _sut.GetUserConversationsAsync("never-seen"); - - results.Should().BeEmpty(); - } - - [Fact] - public async Task GetUserConversationsAsync_OrdersPinnedFirst() - { - var a = await _sut.StartConversationAsync("user-1", "assistant", "first"); - var b = await _sut.StartConversationAsync("user-1", "assistant", "second"); - - var tracked = await _db.Conversations.FirstAsync(c => c.Id == a.Id); - tracked.Pinned = true; - await _db.SaveChangesAsync(); - - var results = await _sut.GetUserConversationsAsync("user-1"); - - results.Should().HaveCount(2); - results[0].Id.Should().Be(a.Id); - results[1].Id.Should().Be(b.Id); - } - - [Fact] - public async Task GetUserConversationsAsync_OrdersByUpdatedAtDescendingWithinSamePinState() - { - var older = await _sut.StartConversationAsync("user-1", "assistant", "older"); - await Task.Delay(10); - var newer = await _sut.StartConversationAsync("user-1", "assistant", "newer"); - - var results = await _sut.GetUserConversationsAsync("user-1"); - - results[0].Id.Should().Be(newer.Id); - results[1].Id.Should().Be(older.Id); - } - - // ---------- GetConversationAsync ---------- - - [Fact] - public async Task GetConversationAsync_ReturnsNullWhenMissing() - { - var result = await _sut.GetConversationAsync(ConversationId.From(Guid.NewGuid())); - - result.Should().BeNull(); - } - - [Fact] - public async Task GetConversationAsync_IncludesMessagesInOrder() - { - var conv = await _sut.StartConversationAsync("user-1", "assistant", null); - await _sut.AppendMessageAsync(conv.Id, ChatRole.User, "a"); - await Task.Delay(5); - await _sut.AppendMessageAsync(conv.Id, ChatRole.Assistant, "b"); - await Task.Delay(5); - await _sut.AppendMessageAsync(conv.Id, ChatRole.User, "c"); - - var reloaded = await _sut.GetConversationAsync(conv.Id); - - reloaded!.Messages.Select(m => m.Content).Should().Equal("a", "b", "c"); - } - - // ---------- LoadOwnedAsync ---------- - - [Fact] - public async Task LoadOwnedAsync_ReturnsConversationForOwner() - { - var conv = await _sut.StartConversationAsync("user-1", "assistant", null); - - var loaded = await _sut.LoadOwnedAsync(conv.Id, "user-1"); - - loaded.Id.Should().Be(conv.Id); - } - - [Fact] - public async Task LoadOwnedAsync_ThrowsWhenAnotherUser() - { - var conv = await _sut.StartConversationAsync("user-1", "assistant", null); - - var act = () => _sut.LoadOwnedAsync(conv.Id, "user-2"); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task LoadOwnedAsync_ThrowsWhenConversationMissing() - { - var act = () => _sut.LoadOwnedAsync(ConversationId.From(Guid.NewGuid()), "user-1"); - - await act.Should().ThrowAsync(); - } - - // ---------- RenameAsync ---------- - - [Fact] - public async Task RenameAsync_UpdatesTitle() - { - var conv = await _sut.StartConversationAsync("user-1", "assistant", null); - - var updated = await _sut.RenameAsync(conv.Id, "user-1", "A better title"); - - updated.Title.Should().Be("A better title"); - } - - [Fact] - public async Task RenameAsync_TrimsWhitespace() - { - var conv = await _sut.StartConversationAsync("user-1", "assistant", null); - - var updated = await _sut.RenameAsync(conv.Id, "user-1", " trimmed "); - - updated.Title.Should().Be("trimmed"); - } - - [Fact] - public async Task RenameAsync_ThrowsForNonOwner() - { - var conv = await _sut.StartConversationAsync("user-1", "assistant", null); - - var act = () => _sut.RenameAsync(conv.Id, "user-2", "hijack"); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task RenameAsync_BumpsUpdatedAt() - { - var conv = await _sut.StartConversationAsync("user-1", "assistant", null); - var before = conv.UpdatedAt; - await Task.Delay(10); - - var after = await _sut.RenameAsync(conv.Id, "user-1", "renamed"); - - after.UpdatedAt.Should().BeAfter(before); - } - - // ---------- DeleteAsync ---------- - - [Fact] - public async Task DeleteAsync_RemovesConversationAndMessages() - { - var conv = await _sut.StartConversationAsync("user-1", "assistant", null); - await _sut.AppendMessageAsync(conv.Id, ChatRole.User, "hi"); - - await _sut.DeleteAsync(conv.Id, "user-1"); - - var found = await _sut.GetConversationAsync(conv.Id); - found.Should().BeNull(); - (await _db.ChatMessages.CountAsync()).Should().Be(0); - } - - [Fact] - public async Task DeleteAsync_ThrowsForNonOwner() - { - var conv = await _sut.StartConversationAsync("user-1", "assistant", null); - - var act = () => _sut.DeleteAsync(conv.Id, "user-2"); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task DeleteAsync_OnlyDeletesMessagesFromThatConversation() - { - var keep = await _sut.StartConversationAsync("user-1", "assistant", "keep"); - var drop = await _sut.StartConversationAsync("user-1", "assistant", "drop"); - await _sut.AppendMessageAsync(keep.Id, ChatRole.User, "keep-me"); - await _sut.AppendMessageAsync(drop.Id, ChatRole.User, "drop-me"); - - await _sut.DeleteAsync(drop.Id, "user-1"); - - var remaining = await _db.ChatMessages.ToListAsync(); - remaining.Should().HaveCount(1); - remaining[0].Content.Should().Be("keep-me"); - } -} diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/Unit/ChatServiceTests.Helpers.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/Unit/ChatServiceTests.Helpers.cs deleted file mode 100644 index 318ed232..00000000 --- a/modules/Chat/tests/SimpleModule.Chat.Tests/Unit/ChatServiceTests.Helpers.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using SimpleModule.Chat; -using SimpleModule.Database; - -namespace Chat.Tests.Unit; - -public sealed partial class ChatServiceTests : IDisposable -{ - private readonly ChatDbContext _db; - private readonly ChatService _sut; - - public ChatServiceTests() - { - var options = new DbContextOptionsBuilder() - .UseSqlite("Data Source=:memory:") - .Options; - var dbOptions = Options.Create( - new DatabaseOptions - { - ModuleConnections = new Dictionary - { - ["Chat"] = "Data Source=:memory:", - }, - } - ); - _db = new ChatDbContext(options, dbOptions); - _db.Database.OpenConnection(); - _db.Database.EnsureCreated(); - _sut = new ChatService(_db, NullLogger.Instance); - } - - public void Dispose() => _db.Dispose(); -} diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/Unit/ChatServiceTests.Messages.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/Unit/ChatServiceTests.Messages.cs deleted file mode 100644 index 236554e6..00000000 --- a/modules/Chat/tests/SimpleModule.Chat.Tests/Unit/ChatServiceTests.Messages.cs +++ /dev/null @@ -1,103 +0,0 @@ -using FluentAssertions; -using SimpleModule.Chat.Contracts; -using SimpleModule.Core.Exceptions; - -namespace Chat.Tests.Unit; - -public sealed partial class ChatServiceTests -{ - // ---------- AppendMessageAsync ---------- - - [Fact] - public async Task AppendMessageAsync_UpdatesConversationTimestamp() - { - var conv = await _sut.StartConversationAsync("user-1", "assistant", null); - var originalUpdatedAt = conv.UpdatedAt; - - await Task.Delay(10); - await _sut.AppendMessageAsync(conv.Id, ChatRole.User, "hello"); - - var reloaded = await _sut.GetConversationAsync(conv.Id); - reloaded!.UpdatedAt.Should().BeAfter(originalUpdatedAt); - reloaded.Messages.Should().HaveCount(1); - reloaded.Messages[0].Content.Should().Be("hello"); - } - - [Fact] - public async Task AppendMessageAsync_PersistsAssistantRoleCorrectly() - { - var conv = await _sut.StartConversationAsync("user-1", "assistant", null); - - await _sut.AppendMessageAsync(conv.Id, ChatRole.Assistant, "Hi there!"); - - var messages = await _sut.GetMessagesAsync(conv.Id, "user-1"); - messages.Should().HaveCount(1); - messages[0].Role.Should().Be(ChatRole.Assistant); - messages[0].Content.Should().Be("Hi there!"); - } - - [Fact] - public async Task AppendMessageAsync_OrdersChronologically() - { - var conv = await _sut.StartConversationAsync("user-1", "assistant", null); - - await _sut.AppendMessageAsync(conv.Id, ChatRole.User, "first"); - await Task.Delay(5); - await _sut.AppendMessageAsync(conv.Id, ChatRole.Assistant, "second"); - await Task.Delay(5); - await _sut.AppendMessageAsync(conv.Id, ChatRole.User, "third"); - - var messages = await _sut.GetMessagesAsync(conv.Id, "user-1"); - messages.Select(m => m.Content).Should().Equal("first", "second", "third"); - } - - [Fact] - public async Task AppendMessageAsync_GeneratesUniqueIds() - { - var conv = await _sut.StartConversationAsync("user-1", "assistant", null); - - var a = await _sut.AppendMessageAsync(conv.Id, ChatRole.User, "one"); - var b = await _sut.AppendMessageAsync(conv.Id, ChatRole.User, "two"); - - a.Id.Should().NotBe(b.Id); - } - - // ---------- GetMessagesAsync ---------- - - [Fact] - public async Task GetMessagesAsync_EmptyWhenNone() - { - var conv = await _sut.StartConversationAsync("user-1", "assistant", null); - - var messages = await _sut.GetMessagesAsync(conv.Id, "user-1"); - - messages.Should().BeEmpty(); - } - - [Fact] - public async Task GetMessagesAsync_ThrowsForNonOwner() - { - var conv = await _sut.StartConversationAsync("user-1", "assistant", null); - await _sut.AppendMessageAsync(conv.Id, ChatRole.User, "secret"); - - var act = () => _sut.GetMessagesAsync(conv.Id, "user-2"); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task GetMessagesAsync_IsolatesPerConversation() - { - var a = await _sut.StartConversationAsync("user-1", "assistant", null); - var b = await _sut.StartConversationAsync("user-1", "assistant", null); - await _sut.AppendMessageAsync(a.Id, ChatRole.User, "a1"); - await _sut.AppendMessageAsync(b.Id, ChatRole.User, "b1"); - await _sut.AppendMessageAsync(a.Id, ChatRole.Assistant, "a2"); - - var aMessages = await _sut.GetMessagesAsync(a.Id, "user-1"); - var bMessages = await _sut.GetMessagesAsync(b.Id, "user-1"); - - aMessages.Select(m => m.Content).Should().Equal("a1", "a2"); - bMessages.Select(m => m.Content).Should().Equal("b1"); - } -} diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/Unit/ConversationIdTests.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/Unit/ConversationIdTests.cs deleted file mode 100644 index 262a4fab..00000000 --- a/modules/Chat/tests/SimpleModule.Chat.Tests/Unit/ConversationIdTests.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System.Text.Json; -using FluentAssertions; -using SimpleModule.Chat.Contracts; - -namespace Chat.Tests.Unit; - -public sealed class ConversationIdTests -{ - [Fact] - public void From_WrapsGuidValue() - { - var guid = Guid.NewGuid(); - - var id = ConversationId.From(guid); - - id.Value.Should().Be(guid); - } - - [Fact] - public void Equality_IsValueBased() - { - var guid = Guid.NewGuid(); - - var a = ConversationId.From(guid); - var b = ConversationId.From(guid); - - a.Should().Be(b); - (a == b).Should().BeTrue(); - } - - [Fact] - public void DifferentGuidsAreNotEqual() - { - var a = ConversationId.From(Guid.NewGuid()); - var b = ConversationId.From(Guid.NewGuid()); - - a.Should().NotBe(b); - } - - [Fact] - public void JsonSerialization_RoundTripsAsGuidString() - { - var id = ConversationId.From(Guid.Parse("11111111-2222-3333-4444-555555555555")); - - var json = JsonSerializer.Serialize(id); - var deserialized = JsonSerializer.Deserialize(json); - - json.Should().Contain("11111111-2222-3333-4444-555555555555"); - deserialized.Should().Be(id); - } -} - -public sealed class ChatMessageIdTests -{ - [Fact] - public void From_WrapsGuidValue() - { - var guid = Guid.NewGuid(); - - var id = ChatMessageId.From(guid); - - id.Value.Should().Be(guid); - } - - [Fact] - public void Equality_IsValueBased() - { - var guid = Guid.NewGuid(); - ChatMessageId.From(guid).Should().Be(ChatMessageId.From(guid)); - } - - [Fact] - public void JsonSerialization_RoundTrips() - { - var id = ChatMessageId.From(Guid.NewGuid()); - - var json = JsonSerializer.Serialize(id); - var deserialized = JsonSerializer.Deserialize(json); - - deserialized.Should().Be(id); - } -} diff --git a/modules/Chat/tests/SimpleModule.Chat.Tests/Unit/TanStackDtoSerializationTests.cs b/modules/Chat/tests/SimpleModule.Chat.Tests/Unit/TanStackDtoSerializationTests.cs deleted file mode 100644 index 71154511..00000000 --- a/modules/Chat/tests/SimpleModule.Chat.Tests/Unit/TanStackDtoSerializationTests.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System.Text.Json; -using FluentAssertions; -using SimpleModule.Chat.Dtos; - -namespace Chat.Tests.Unit; - -public sealed class TanStackDtoSerializationTests -{ - private static readonly JsonSerializerOptions CamelCaseInsensitive = new() - { - PropertyNameCaseInsensitive = true, - }; - - [Fact] - public void TanStackContentChunk_SerializesWithExpectedFieldNames() - { - var chunk = new TanStackContentChunk( - Type: "content", - Id: "msg-1", - Model: "assistant", - Timestamp: 1700000000000L, - Delta: "Hello", - Content: "Hello", - Role: "assistant" - ); - - var json = JsonSerializer.Serialize(chunk); - - using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - root.GetProperty("type").GetString().Should().Be("content"); - root.GetProperty("id").GetString().Should().Be("msg-1"); - root.GetProperty("model").GetString().Should().Be("assistant"); - root.GetProperty("timestamp").GetInt64().Should().Be(1700000000000L); - root.GetProperty("delta").GetString().Should().Be("Hello"); - root.GetProperty("content").GetString().Should().Be("Hello"); - root.GetProperty("role").GetString().Should().Be("assistant"); - } - - [Fact] - public void TanStackDoneChunk_SerializesWithFinishReason() - { - var chunk = new TanStackDoneChunk( - Type: "done", - Id: "msg-1", - Model: "assistant", - Timestamp: 1700000000000L, - FinishReason: "stop" - ); - - var json = JsonSerializer.Serialize(chunk); - - using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - root.GetProperty("type").GetString().Should().Be("done"); - root.GetProperty("finishReason").GetString().Should().Be("stop"); - root.TryGetProperty("finish_reason", out _) - .Should() - .BeFalse("field name must be camelCase"); - } - - [Fact] - public void UiMessage_SerializesPartsArray() - { - var message = new UiMessage( - Id: "m-1", - Role: "assistant", - Parts: new[] { new UiMessagePart("text", "Hello world") }, - CreatedAt: DateTimeOffset.FromUnixTimeMilliseconds(1700000000000L) - ); - - var json = JsonSerializer.Serialize(message); - - using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - root.GetProperty("id").GetString().Should().Be("m-1"); - root.GetProperty("role").GetString().Should().Be("assistant"); - var parts = root.GetProperty("parts"); - parts.GetArrayLength().Should().Be(1); - parts[0].GetProperty("type").GetString().Should().Be("text"); - parts[0].GetProperty("content").GetString().Should().Be("Hello world"); - } - - [Fact] - public void TanStackChatRequest_DeserializesFromTanStackWireShape() - { - const string json = """ - { - "messages": [ - { "role": "user", "content": "Hi" }, - { "role": "assistant", "content": "Hello" }, - { "role": "user", "content": "How are you?" } - ], - "data": { "temperature": 0.5 } - } - """; - - var request = JsonSerializer.Deserialize(json, CamelCaseInsensitive); - - request.Should().NotBeNull(); - request!.Messages.Should().HaveCount(3); - request.Messages[0].Role.Should().Be("user"); - request.Messages[0].Content.Should().Be("Hi"); - request.Messages[2].Content.Should().Be("How are you?"); - request.Data.Should().NotBeNull(); - request.Data!.Should().ContainKey("temperature"); - } - - [Fact] - public void TanStackChatRequest_DeserializesWithMissingData() - { - const string json = """ - { "messages": [{ "role": "user", "content": "Hi" }] } - """; - - var request = JsonSerializer.Deserialize(json, CamelCaseInsensitive); - - request.Should().NotBeNull(); - request!.Messages.Should().HaveCount(1); - request.Data.Should().BeNull(); - } - - [Fact] - public void ContentChunk_FrameFormatIsSseCompliant() - { - var chunk = new TanStackContentChunk( - Type: "content", - Id: "msg-1", - Model: "m", - Timestamp: 0, - Delta: "x", - Content: "x", - Role: "assistant" - ); - var frame = $"data: {JsonSerializer.Serialize(chunk)}\n\n"; - - frame.Should().StartWith("data: "); - frame.Should().EndWith("\n\n"); - frame.Should().NotContain("\n\ndata"); // only one frame - } -} diff --git a/modules/Datasets/src/SimpleModule.Datasets.Contracts/BoundingBoxDto.cs b/modules/Datasets/src/SimpleModule.Datasets.Contracts/BoundingBoxDto.cs deleted file mode 100644 index b9f92915..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets.Contracts/BoundingBoxDto.cs +++ /dev/null @@ -1,12 +0,0 @@ -using SimpleModule.Core; - -namespace SimpleModule.Datasets.Contracts; - -[Dto] -public sealed class BoundingBoxDto -{ - public double MinX { get; set; } - public double MinY { get; set; } - public double MaxX { get; set; } - public double MaxY { get; set; } -} diff --git a/modules/Datasets/src/SimpleModule.Datasets.Contracts/Dataset.cs b/modules/Datasets/src/SimpleModule.Datasets.Contracts/Dataset.cs deleted file mode 100644 index c095e8b8..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets.Contracts/Dataset.cs +++ /dev/null @@ -1,27 +0,0 @@ -using SimpleModule.Core; -using SimpleModule.Core.Entities; - -namespace SimpleModule.Datasets.Contracts; - -[NoDtoGeneration] -public sealed class Dataset : FullAuditableEntity -{ - public string Name { get; set; } = string.Empty; - public string OriginalFileName { get; set; } = string.Empty; - public string? ContentHash { get; set; } - public DatasetFormat Format { get; set; } - public DatasetStatus Status { get; set; } - public int? SourceSrid { get; set; } - public int? Srid { get; set; } - public double? BboxMinX { get; set; } - public double? BboxMinY { get; set; } - public double? BboxMaxX { get; set; } - public double? BboxMaxY { get; set; } - public long? FeatureCount { get; set; } - public long SizeBytes { get; set; } - public string StoragePath { get; set; } = string.Empty; - public string? NormalizedPath { get; set; } - public string? MetadataJson { get; set; } - public string? ErrorMessage { get; set; } - public DateTimeOffset? ProcessedAt { get; set; } -} diff --git a/modules/Datasets/src/SimpleModule.Datasets.Contracts/DatasetDto.cs b/modules/Datasets/src/SimpleModule.Datasets.Contracts/DatasetDto.cs deleted file mode 100644 index 577c027a..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets.Contracts/DatasetDto.cs +++ /dev/null @@ -1,30 +0,0 @@ -using SimpleModule.Core; - -namespace SimpleModule.Datasets.Contracts; - -[Dto] -public sealed class DatasetDto -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public string OriginalFileName { get; set; } = string.Empty; - public DatasetFormat Format { get; set; } - public DatasetStatus Status { get; set; } - public int? SourceSrid { get; set; } - public int? Srid { get; set; } - public BoundingBoxDto? BoundingBox { get; set; } - public long? FeatureCount { get; set; } - public long SizeBytes { get; set; } - public string? ErrorMessage { get; set; } - public DatasetMetadata? Metadata { get; set; } - public DateTimeOffset CreatedAt { get; set; } - public DateTimeOffset? ProcessedAt { get; set; } -} - -[Dto] -public sealed class DatasetFeatureDto -{ - public string? Id { get; set; } - public string GeometryGeoJson { get; set; } = string.Empty; - public Dictionary Properties { get; set; } = new(); -} diff --git a/modules/Datasets/src/SimpleModule.Datasets.Contracts/DatasetFormat.cs b/modules/Datasets/src/SimpleModule.Datasets.Contracts/DatasetFormat.cs deleted file mode 100644 index ede41e22..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets.Contracts/DatasetFormat.cs +++ /dev/null @@ -1,57 +0,0 @@ -namespace SimpleModule.Datasets.Contracts; - -public enum DatasetFormat -{ - Unknown = 0, - GeoJson = 1, - Shapefile = 2, - Kml = 3, - Kmz = 4, - GeoPackage = 5, - PmTiles = 6, - Cog = 7, -} - -public static class DatasetFormatExtensions -{ - public static bool IsVector(this DatasetFormat format) => - format - is DatasetFormat.GeoJson - or DatasetFormat.Shapefile - or DatasetFormat.Kml - or DatasetFormat.Kmz - or DatasetFormat.GeoPackage; - - public static bool IsRaster(this DatasetFormat format) => format is DatasetFormat.Cog; - - public static bool IsTileSource(this DatasetFormat format) => format is DatasetFormat.PmTiles; - - public static DatasetFormat FromFileName(string fileName) - { - var ext = System.IO.Path.GetExtension(fileName).ToUpperInvariant(); - return ext switch - { - ".GEOJSON" or ".JSON" => DatasetFormat.GeoJson, - ".ZIP" or ".SHP" => DatasetFormat.Shapefile, - ".KML" => DatasetFormat.Kml, - ".KMZ" => DatasetFormat.Kmz, - ".GPKG" => DatasetFormat.GeoPackage, - ".PMTILES" => DatasetFormat.PmTiles, - ".TIF" or ".TIFF" => DatasetFormat.Cog, - _ => DatasetFormat.Unknown, - }; - } - - public static string FileExtension(this DatasetFormat format) => - format switch - { - DatasetFormat.GeoJson => ".geojson", - DatasetFormat.Shapefile => ".zip", - DatasetFormat.Kml => ".kml", - DatasetFormat.Kmz => ".kmz", - DatasetFormat.GeoPackage => ".gpkg", - DatasetFormat.PmTiles => ".pmtiles", - DatasetFormat.Cog => ".tif", - _ => ".bin", - }; -} diff --git a/modules/Datasets/src/SimpleModule.Datasets.Contracts/DatasetId.cs b/modules/Datasets/src/SimpleModule.Datasets.Contracts/DatasetId.cs deleted file mode 100644 index cb0f791f..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets.Contracts/DatasetId.cs +++ /dev/null @@ -1,6 +0,0 @@ -using Vogen; - -namespace SimpleModule.Datasets.Contracts; - -[ValueObject(conversions: Conversions.SystemTextJson | Conversions.EfCoreValueConverter)] -public readonly partial struct DatasetId; diff --git a/modules/Datasets/src/SimpleModule.Datasets.Contracts/DatasetMetadata.cs b/modules/Datasets/src/SimpleModule.Datasets.Contracts/DatasetMetadata.cs deleted file mode 100644 index 43d0240f..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets.Contracts/DatasetMetadata.cs +++ /dev/null @@ -1,86 +0,0 @@ -using SimpleModule.Core; - -namespace SimpleModule.Datasets.Contracts; - -/// -/// Full set of metadata extracted during dataset processing. Persisted as JSON on the Dataset row. -/// -[Dto] -public sealed class DatasetMetadata -{ - public CommonMetadata Common { get; set; } = new(); - public VectorMetadata? Vector { get; set; } - public RasterMetadata? Raster { get; set; } - public TileMetadata? Tiles { get; set; } - public List Derivatives { get; set; } = []; -} - -[Dto] -public sealed class CommonMetadata -{ - public string SourceFormat { get; set; } = string.Empty; - public string OriginalFileName { get; set; } = string.Empty; - public long SizeBytes { get; set; } - public string? ContentHash { get; set; } - public int? SourceSrid { get; set; } - public int? TargetSrid { get; set; } - public BoundingBoxDto? BoundingBox { get; set; } - public double ProcessingDurationMs { get; set; } - public string ProcessorVersion { get; set; } = "1.0.0"; -} - -[Dto] -public sealed class VectorMetadata -{ - public int FeatureCount { get; set; } - public List GeometryTypes { get; set; } = []; - public List AttributeSchema { get; set; } = []; - public List LayerNames { get; set; } = []; - public string? Encoding { get; set; } - public string? CrsWkt { get; set; } -} - -[Dto] -public sealed class AttributeField -{ - public string Name { get; set; } = string.Empty; - public string Type { get; set; } = string.Empty; - public List SampleValues { get; set; } = []; -} - -[Dto] -public sealed class RasterMetadata -{ - public int Width { get; set; } - public int Height { get; set; } - public int BandCount { get; set; } - public List BandTypes { get; set; } = []; - public double? NoDataValue { get; set; } - public double PixelSizeX { get; set; } - public double PixelSizeY { get; set; } - public List OverviewLevels { get; set; } = []; - public string? Compression { get; set; } - public string? CrsWkt { get; set; } -} - -[Dto] -public sealed class TileMetadata -{ - public string? TileFormat { get; set; } - public int MinZoom { get; set; } - public int MaxZoom { get; set; } - public double CenterLon { get; set; } - public double CenterLat { get; set; } - public long TileCount { get; set; } - public int HeaderVersion { get; set; } - public List LayerNames { get; set; } = []; -} - -[Dto] -public sealed class DatasetDerivative -{ - public DatasetFormat Format { get; set; } - public string StoragePath { get; set; } = string.Empty; - public long SizeBytes { get; set; } - public DateTimeOffset CreatedAt { get; set; } -} diff --git a/modules/Datasets/src/SimpleModule.Datasets.Contracts/DatasetStatus.cs b/modules/Datasets/src/SimpleModule.Datasets.Contracts/DatasetStatus.cs deleted file mode 100644 index 3388ec10..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets.Contracts/DatasetStatus.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace SimpleModule.Datasets.Contracts; - -public enum DatasetStatus -{ - Pending = 0, - Processing = 1, - Ready = 2, - Failed = 3, -} diff --git a/modules/Datasets/src/SimpleModule.Datasets.Contracts/DatasetsConstants.cs b/modules/Datasets/src/SimpleModule.Datasets.Contracts/DatasetsConstants.cs deleted file mode 100644 index 22d4861a..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets.Contracts/DatasetsConstants.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace SimpleModule.Datasets.Contracts; - -public static class DatasetsConstants -{ - public const string ModuleName = "Datasets"; - public const string RoutePrefix = "/api/datasets"; - public const string ViewPrefix = "/datasets"; - - public static class Routes - { - // API endpoints - public const string GetAll = "/"; - public const string Upload = "/"; - public const string GetById = "/{id}"; - public const string Delete = "/{id}"; - public const string Download = "/{id}/download"; - public const string Features = "/{id}/features"; - public const string Convert = "/{id}/convert"; - - // View endpoints - public const string Browse = "/"; - public const string UploadView = "/upload"; - public const string Detail = "/{id:guid}"; - } - - public static class SettingKeys - { - public const string MaxUploadSizeMb = "Datasets.MaxUploadSizeMb"; - public const string AllowedFormats = "Datasets.AllowedFormats"; - public const string DefaultTargetSrid = "Datasets.DefaultTargetSrid"; - public const string FeatureQueryLimit = "Datasets.FeatureQueryLimit"; - public const string DefaultVectorConversionFormat = - "Datasets.DefaultVectorConversionFormat"; - public const string DefaultRasterConversionFormat = - "Datasets.DefaultRasterConversionFormat"; - public const string AutoGenerateDefaultDerivative = - "Datasets.AutoGenerateDefaultDerivative"; - public const string StoragePrefix = "Datasets.StoragePrefix"; - } -} diff --git a/modules/Datasets/src/SimpleModule.Datasets.Contracts/DatasetsPermissions.cs b/modules/Datasets/src/SimpleModule.Datasets.Contracts/DatasetsPermissions.cs deleted file mode 100644 index 8adeadac..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets.Contracts/DatasetsPermissions.cs +++ /dev/null @@ -1,11 +0,0 @@ -using SimpleModule.Core.Authorization; - -namespace SimpleModule.Datasets.Contracts; - -public sealed class DatasetsPermissions : IModulePermissions -{ - public const string View = "Datasets.View"; - public const string Upload = "Datasets.Upload"; - public const string Convert = "Datasets.Convert"; - public const string Delete = "Datasets.Delete"; -} diff --git a/modules/Datasets/src/SimpleModule.Datasets.Contracts/Events/DatasetProcessed.cs b/modules/Datasets/src/SimpleModule.Datasets.Contracts/Events/DatasetProcessed.cs deleted file mode 100644 index 63127cba..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets.Contracts/Events/DatasetProcessed.cs +++ /dev/null @@ -1,7 +0,0 @@ -using SimpleModule.Core.Events; - -namespace SimpleModule.Datasets.Contracts.Events; - -public sealed record DatasetProcessed(DatasetId DatasetId, DatasetStatus Status) : IEvent; - -public sealed record DatasetDerivativeCreated(DatasetId DatasetId, DatasetFormat Format) : IEvent; diff --git a/modules/Datasets/src/SimpleModule.Datasets.Contracts/IDatasetsContracts.cs b/modules/Datasets/src/SimpleModule.Datasets.Contracts/IDatasetsContracts.cs deleted file mode 100644 index 85160023..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets.Contracts/IDatasetsContracts.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace SimpleModule.Datasets.Contracts; - -public interface IDatasetsContracts -{ - Task> GetAllAsync(CancellationToken ct = default); - - Task GetByIdAsync(DatasetId id, CancellationToken ct = default); - - Task CreateAsync( - Stream content, - string fileName, - string? name, - CancellationToken ct = default - ); - - Task DeleteAsync(DatasetId id, CancellationToken ct = default); - - Task GetOriginalAsync(DatasetId id, CancellationToken ct = default); - - Task GetDerivativeAsync( - DatasetId id, - DatasetFormat format, - CancellationToken ct = default - ); - - Task GetFeaturesGeoJsonAsync( - DatasetId id, - BoundingBoxDto? bbox = null, - int? limit = null, - CancellationToken ct = default - ); - - Task> FindByBoundingBoxAsync( - BoundingBoxDto bbox, - CancellationToken ct = default - ); - - Task EnqueueConversionAsync( - DatasetId id, - DatasetFormat? targetFormat = null, - CancellationToken ct = default - ); -} diff --git a/modules/Datasets/src/SimpleModule.Datasets.Contracts/SimpleModule.Datasets.Contracts.csproj b/modules/Datasets/src/SimpleModule.Datasets.Contracts/SimpleModule.Datasets.Contracts.csproj deleted file mode 100644 index 97fdfa07..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets.Contracts/SimpleModule.Datasets.Contracts.csproj +++ /dev/null @@ -1,12 +0,0 @@ - - - net10.0 - Library - $(DefineConstants);VOGEN_NO_VALIDATION - - - - - - - diff --git a/modules/Datasets/src/SimpleModule.Datasets/Agents/DatasetsToolProvider.cs b/modules/Datasets/src/SimpleModule.Datasets/Agents/DatasetsToolProvider.cs deleted file mode 100644 index 410f2814..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Agents/DatasetsToolProvider.cs +++ /dev/null @@ -1,63 +0,0 @@ -using SimpleModule.Core.Agents; -using SimpleModule.Datasets.Contracts; - -namespace SimpleModule.Datasets.Agents; - -public sealed class DatasetsToolProvider(IDatasetsContracts datasets) : IAgentToolProvider -{ - [AgentTool( - Description = "List all GIS datasets with their id, name, format, status, feature count and bounding box." - )] - public async Task> ListDatasets() => await datasets.GetAllAsync(); - - [AgentTool( - Description = "Get full metadata for a single dataset by id, including extracted CRS, schema, and format-specific metadata." - )] - public async Task GetDataset(Guid datasetId) => - await datasets.GetByIdAsync(DatasetId.From(datasetId)); - - [AgentTool( - Description = "Query features from a vector dataset, optionally filtered by an EPSG:4326 bounding box. Returns up to `limit` features as GeoJSON." - )] - public async Task QueryDatasetFeatures( - Guid datasetId, - double? minX = null, - double? minY = null, - double? maxX = null, - double? maxY = null, - int limit = 100 - ) - { - BoundingBoxDto? bbox = null; - if (minX is not null && minY is not null && maxX is not null && maxY is not null) - { - bbox = new BoundingBoxDto - { - MinX = minX.Value, - MinY = minY.Value, - MaxX = maxX.Value, - MaxY = maxY.Value, - }; - } - return await datasets.GetFeaturesGeoJsonAsync(DatasetId.From(datasetId), bbox, limit); - } - - [AgentTool( - Description = "Find datasets whose bounding box intersects the given EPSG:4326 bounding box." - )] - public async Task> FindDatasetsByBoundingBox( - double minX, - double minY, - double maxX, - double maxY - ) => - await datasets.FindByBoundingBoxAsync( - new BoundingBoxDto - { - MinX = minX, - MinY = minY, - MaxX = maxX, - MaxY = maxY, - } - ); -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/Converters/IDatasetConverter.cs b/modules/Datasets/src/SimpleModule.Datasets/Converters/IDatasetConverter.cs deleted file mode 100644 index bbc411d0..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Converters/IDatasetConverter.cs +++ /dev/null @@ -1,24 +0,0 @@ -using SimpleModule.Datasets.Contracts; - -namespace SimpleModule.Datasets.Converters; - -public interface IDatasetConverter -{ - DatasetFormat TargetFormat { get; } - bool CanConvertFrom(DatasetFormat source); - Task ConvertAsync(Stream source, DatasetFormat sourceFormat, CancellationToken ct); -} - -public sealed class DatasetConverterRegistry(IEnumerable converters) -{ - private readonly IDatasetConverter[] _converters = [.. converters]; - - public IDatasetConverter Resolve(DatasetFormat source, DatasetFormat target) - { - var converter = _converters.FirstOrDefault(c => - c.TargetFormat == target && c.CanConvertFrom(source) - ); - return converter - ?? throw new NotSupportedException($"No converter registered for {source} → {target}"); - } -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/Converters/RasterToCogConverter.cs b/modules/Datasets/src/SimpleModule.Datasets/Converters/RasterToCogConverter.cs deleted file mode 100644 index edae5944..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Converters/RasterToCogConverter.cs +++ /dev/null @@ -1,44 +0,0 @@ -using SimpleModule.Datasets.Contracts; -using SimpleModule.Datasets.Infrastructure; - -namespace SimpleModule.Datasets.Converters; - -/// -/// Converts a raster source (GeoTIFF) to Cloud-Optimized GeoTIFF (COG) using gdal_translate. -/// -public sealed class RasterToCogConverter : IDatasetConverter -{ - public DatasetFormat TargetFormat => DatasetFormat.Cog; - - public bool CanConvertFrom(DatasetFormat source) => source.IsRaster(); - - public async Task ConvertAsync( - Stream source, - DatasetFormat sourceFormat, - CancellationToken ct - ) - { - using var tmp = new TempDirectory("cog"); - var inputPath = Path.Combine(tmp.Path, "input.tif"); - var outputPath = Path.Combine(tmp.Path, "output.tif"); - - await using (var fs = File.Create(inputPath)) - { - await source.CopyToAsync(fs, ct); - } - - await CliRunner.RunAsync( - "gdal_translate", - ["-of", "COG", "-co", "COMPRESS=DEFLATE", inputPath, outputPath], - ct - ); - - var result = new MemoryStream(); - await using (var fs = File.OpenRead(outputPath)) - { - await fs.CopyToAsync(result, ct); - } - result.Position = 0; - return result; - } -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/Converters/VectorToGeoJsonConverter.cs b/modules/Datasets/src/SimpleModule.Datasets/Converters/VectorToGeoJsonConverter.cs deleted file mode 100644 index 57229075..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Converters/VectorToGeoJsonConverter.cs +++ /dev/null @@ -1,33 +0,0 @@ -using SimpleModule.Datasets.Contracts; - -namespace SimpleModule.Datasets.Converters; - -/// -/// Converts any vector source to GeoJSON. For GeoJSON sources this is a pass-through. -/// Non-GeoJSON vector formats require their respective processors to produce a normalized -/// GeoJSON cache first; this converter delegates to that cache via ConvertDatasetJob. -/// -public sealed class VectorToGeoJsonConverter : IDatasetConverter -{ - public DatasetFormat TargetFormat => DatasetFormat.GeoJson; - - public bool CanConvertFrom(DatasetFormat source) => source.IsVector(); - - public async Task ConvertAsync( - Stream source, - DatasetFormat sourceFormat, - CancellationToken ct - ) - { - if (sourceFormat == DatasetFormat.GeoJson) - { - var ms = new MemoryStream(); - await source.CopyToAsync(ms, ct); - ms.Position = 0; - return ms; - } - throw new NotSupportedException( - $"Converting {sourceFormat} to GeoJSON requires a processor that produces a normalized GeoJSON cache; read from the dataset's normalized.geojson instead." - ); - } -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/Converters/VectorToPmTilesConverter.cs b/modules/Datasets/src/SimpleModule.Datasets/Converters/VectorToPmTilesConverter.cs deleted file mode 100644 index 4bf0054e..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Converters/VectorToPmTilesConverter.cs +++ /dev/null @@ -1,55 +0,0 @@ -using SimpleModule.Datasets.Contracts; -using SimpleModule.Datasets.Infrastructure; - -namespace SimpleModule.Datasets.Converters; - -/// -/// Converts a vector GeoJSON source to PMTiles using the tippecanoe CLI. -/// Non-GeoJSON vector formats are normalized to GeoJSON by ConvertDatasetJob -/// before reaching this converter. -/// -public sealed class VectorToPmTilesConverter : IDatasetConverter -{ - public DatasetFormat TargetFormat => DatasetFormat.PmTiles; - - public bool CanConvertFrom(DatasetFormat source) => source.IsVector(); - - public async Task ConvertAsync( - Stream source, - DatasetFormat sourceFormat, - CancellationToken ct - ) - { - using var tmp = new TempDirectory("pmtiles"); - var inputPath = Path.Combine(tmp.Path, "input.geojson"); - var outputPath = Path.Combine(tmp.Path, "output.pmtiles"); - - await using (var fs = File.Create(inputPath)) - { - await source.CopyToAsync(fs, ct); - } - - await CliRunner.RunAsync( - "tippecanoe", - [ - "-o", - outputPath, - "--force", - "--no-feature-limit", - "--no-tile-size-limit", - "--minimum-zoom=0", - "--maximum-zoom=14", - inputPath, - ], - ct - ); - - var result = new MemoryStream(); - await using (var fs = File.OpenRead(outputPath)) - { - await fs.CopyToAsync(result, ct); - } - result.Position = 0; - return result; - } -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/DatasetsContractsService.Features.cs b/modules/Datasets/src/SimpleModule.Datasets/DatasetsContractsService.Features.cs deleted file mode 100644 index a476d5b6..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/DatasetsContractsService.Features.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System.Text.Json; -using Microsoft.EntityFrameworkCore; -using SimpleModule.Core.Settings; -using SimpleModule.Datasets.Contracts; - -namespace SimpleModule.Datasets; - -public sealed partial class DatasetsContractsService -{ - public async Task GetFeaturesGeoJsonAsync( - DatasetId id, - BoundingBoxDto? bbox = null, - int? limit = null, - CancellationToken ct = default - ) - { - var row = await db.Datasets.AsNoTracking().FirstOrDefaultAsync(d => d.Id == id, ct); - if (row is null) - { - throw new InvalidOperationException($"Dataset {id.Value} not found"); - } - if (!row.Format.IsVector() || row.NormalizedPath is null) - { - throw new InvalidOperationException( - "Feature query is only supported for vector datasets that have been processed." - ); - } - - await using var stream = await storage.GetAsync(row.NormalizedPath, ct); - if (stream is null) - { - throw new InvalidOperationException("Normalized GeoJSON not found in storage."); - } - - var effectiveLimit = - limit - ?? await settings.GetSettingAsync( - DatasetsConstants.SettingKeys.FeatureQueryLimit, - SettingScope.Application - ) - ?? 1000; - - using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct); - var root = doc.RootElement; - if ( - !root.TryGetProperty("features", out var features) - || features.ValueKind != JsonValueKind.Array - ) - { - return """{"type":"FeatureCollection","features":[]}"""; - } - - using var ms = new MemoryStream(); - await using (var writer = new Utf8JsonWriter(ms)) - { - writer.WriteStartObject(); - writer.WriteString("type", "FeatureCollection"); - writer.WriteStartArray("features"); - var count = 0; - foreach (var feature in features.EnumerateArray()) - { - if (count >= effectiveLimit) - { - break; - } - if (bbox is not null && !FeatureIntersectsBbox(feature, bbox)) - { - continue; - } - feature.WriteTo(writer); - count++; - } - writer.WriteEndArray(); - writer.WriteEndObject(); - } - return System.Text.Encoding.UTF8.GetString(ms.GetBuffer().AsSpan(0, (int)ms.Length)); - } - - private static bool FeatureIntersectsBbox(JsonElement feature, BoundingBoxDto bbox) - { - if ( - !feature.TryGetProperty("geometry", out var geometry) - || geometry.ValueKind != JsonValueKind.Object - || !geometry.TryGetProperty("coordinates", out var coords) - ) - { - return false; - } - - double minX = double.PositiveInfinity, - minY = double.PositiveInfinity; - double maxX = double.NegativeInfinity, - maxY = double.NegativeInfinity; - Processing.GeoJsonBboxWalker.Expand(coords, ref minX, ref minY, ref maxX, ref maxY); - if (double.IsInfinity(minX)) - { - return false; - } - return !(minX > bbox.MaxX || maxX < bbox.MinX || minY > bbox.MaxY || maxY < bbox.MinY); - } -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/DatasetsContractsService.cs b/modules/Datasets/src/SimpleModule.Datasets/DatasetsContractsService.cs deleted file mode 100644 index 7a61d029..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/DatasetsContractsService.cs +++ /dev/null @@ -1,245 +0,0 @@ -using System.Security.Cryptography; -using System.Text.Json; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using SimpleModule.BackgroundJobs.Contracts; -using SimpleModule.Core.Settings; -using SimpleModule.Datasets.Contracts; -using SimpleModule.Datasets.Jobs; -using SimpleModule.Settings.Contracts; -using SimpleModule.Storage; - -namespace SimpleModule.Datasets; - -public sealed partial class DatasetsContractsService( - DatasetsDbContext db, - IStorageProvider storage, - IBackgroundJobs jobs, - ISettingsContracts settings, - ILogger logger -) : IDatasetsContracts -{ - public async Task> GetAllAsync(CancellationToken ct = default) - { - var rows = await db - .Datasets.AsNoTracking() - .OrderByDescending(d => d.CreatedAt) - .ToListAsync(ct); - return rows.Select(ToDto).ToList(); - } - - public async Task GetByIdAsync(DatasetId id, CancellationToken ct = default) - { - var row = await db.Datasets.AsNoTracking().FirstOrDefaultAsync(d => d.Id == id, ct); - return row is null ? null : ToDto(row); - } - - public async Task CreateAsync( - Stream content, - string fileName, - string? name, - CancellationToken ct = default - ) - { - var format = DatasetFormatExtensions.FromFileName(fileName); - if (format == DatasetFormat.Unknown) - { - throw new InvalidOperationException( - $"Unknown or unsupported dataset format: {fileName}" - ); - } - - var prefix = - await settings.GetSettingAsync( - DatasetsConstants.SettingKeys.StoragePrefix, - SettingScope.Application - ) ?? "datasets"; - - var id = DatasetId.From(Guid.NewGuid()); - var ext = Path.GetExtension(fileName); - var storagePath = StoragePathHelper.Combine(prefix, $"{id.Value}/original{ext}"); - - using var hasher = SHA256.Create(); - await using var hashing = new CryptoStream( - content, - hasher, - CryptoStreamMode.Read, - leaveOpen: true - ); - var saveResult = await storage.SaveAsync( - storagePath, - hashing, - "application/octet-stream", - ct - ); - var hash = Convert.ToHexString(hasher.Hash ?? []); - - var row = new Dataset - { - Id = id, - Name = string.IsNullOrWhiteSpace(name) - ? Path.GetFileNameWithoutExtension(fileName) - : name, - OriginalFileName = fileName, - ContentHash = hash, - Format = format, - Status = DatasetStatus.Pending, - SizeBytes = saveResult.Size, - StoragePath = saveResult.Path, - CreatedAt = DateTimeOffset.UtcNow, - UpdatedAt = DateTimeOffset.UtcNow, - ConcurrencyStamp = Guid.NewGuid().ToString("N"), - }; - db.Datasets.Add(row); - await db.SaveChangesAsync(ct); - - await jobs.EnqueueAsync( - new ProcessDatasetJobData { DatasetId = id.Value }, - ct - ); - LogDatasetCreated(logger, id.Value, fileName); - return ToDto(row); - } - - public async Task DeleteAsync(DatasetId id, CancellationToken ct = default) - { - var row = await db.Datasets.FirstOrDefaultAsync(d => d.Id == id, ct); - if (row is null) - { - return; - } - row.IsDeleted = true; - row.DeletedAt = DateTimeOffset.UtcNow; - row.UpdatedAt = DateTimeOffset.UtcNow; - await db.SaveChangesAsync(ct); - LogDatasetDeleted(logger, id.Value); - - await jobs.EnqueueAsync( - new PurgeDatasetJobData { DatasetId = id.Value }, - ct - ); - } - - public async Task GetOriginalAsync(DatasetId id, CancellationToken ct = default) - { - var row = await db.Datasets.AsNoTracking().FirstOrDefaultAsync(d => d.Id == id, ct); - if (row is null) - { - return null; - } - return await storage.GetAsync(row.StoragePath, ct); - } - - public async Task GetDerivativeAsync( - DatasetId id, - DatasetFormat format, - CancellationToken ct = default - ) - { - var row = await db.Datasets.AsNoTracking().FirstOrDefaultAsync(d => d.Id == id, ct); - if (row is null) - { - return null; - } - var metadata = DeserializeMetadata(row.MetadataJson); - var derivative = metadata?.Derivatives.FirstOrDefault(d => d.Format == format); - if (derivative is null) - { - // Special case: vector "normalized" GeoJSON cache written by ProcessDatasetJob. - if (format == DatasetFormat.GeoJson && row.NormalizedPath is not null) - { - return await storage.GetAsync(row.NormalizedPath, ct); - } - return null; - } - return await storage.GetAsync(derivative.StoragePath, ct); - } - - public async Task> FindByBoundingBoxAsync( - BoundingBoxDto bbox, - CancellationToken ct = default - ) - { - var rows = await db - .Datasets.AsNoTracking() - .Where(d => - d.BboxMinX != null - && d.BboxMaxX != null - && d.BboxMinY != null - && d.BboxMaxY != null - && d.BboxMinX <= bbox.MaxX - && d.BboxMaxX >= bbox.MinX - && d.BboxMinY <= bbox.MaxY - && d.BboxMaxY >= bbox.MinY - ) - .ToListAsync(ct); - return rows.Select(ToDto).ToList(); - } - - public async Task EnqueueConversionAsync( - DatasetId id, - DatasetFormat? targetFormat = null, - CancellationToken ct = default - ) - { - var row = await db.Datasets.AsNoTracking().FirstOrDefaultAsync(d => d.Id == id, ct); - if (row is null) - { - throw new InvalidOperationException($"Dataset {id.Value} not found"); - } - var target = targetFormat ?? await ResolveDefaultDerivativeAsync(row.Format); - await jobs.EnqueueAsync( - new ConvertDatasetJobData { DatasetId = id.Value, TargetFormat = (int)target }, - ct - ); - } - - private async Task ResolveDefaultDerivativeAsync(DatasetFormat source) - { - var key = source.IsRaster() - ? DatasetsConstants.SettingKeys.DefaultRasterConversionFormat - : DatasetsConstants.SettingKeys.DefaultVectorConversionFormat; - var name = await settings.GetSettingAsync(key, SettingScope.Application); - if (!string.IsNullOrWhiteSpace(name) && Enum.TryParse(name, out var parsed)) - { - return parsed; - } - return source.IsRaster() ? DatasetFormat.Cog : DatasetFormat.PmTiles; - } - - internal static DatasetDto ToDto(Dataset row) => - new() - { - Id = row.Id.Value, - Name = row.Name, - OriginalFileName = row.OriginalFileName, - Format = row.Format, - Status = row.Status, - SourceSrid = row.SourceSrid, - Srid = row.Srid, - BoundingBox = row.BboxMinX is null - ? null - : new BoundingBoxDto - { - MinX = row.BboxMinX.Value, - MinY = row.BboxMinY!.Value, - MaxX = row.BboxMaxX!.Value, - MaxY = row.BboxMaxY!.Value, - }, - FeatureCount = row.FeatureCount, - SizeBytes = row.SizeBytes, - ErrorMessage = row.ErrorMessage, - Metadata = DeserializeMetadata(row.MetadataJson), - CreatedAt = row.CreatedAt, - ProcessedAt = row.ProcessedAt, - }; - - private static DatasetMetadata? DeserializeMetadata(string? json) => - string.IsNullOrWhiteSpace(json) ? null : JsonSerializer.Deserialize(json); - - [LoggerMessage(Level = LogLevel.Information, Message = "Dataset created: {Id} ({FileName})")] - private static partial void LogDatasetCreated(ILogger logger, Guid id, string fileName); - - [LoggerMessage(Level = LogLevel.Information, Message = "Dataset soft-deleted: {Id}")] - private static partial void LogDatasetDeleted(ILogger logger, Guid id); -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/DatasetsDbContext.cs b/modules/Datasets/src/SimpleModule.Datasets/DatasetsDbContext.cs deleted file mode 100644 index c973ce0f..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/DatasetsDbContext.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Microsoft.Extensions.Options; -using SimpleModule.Database; -using SimpleModule.Datasets.Contracts; -using SimpleModule.Datasets.EntityConfigurations; - -namespace SimpleModule.Datasets; - -public class DatasetsDbContext( - DbContextOptions options, - IOptions dbOptions -) : DbContext(options) -{ - public DbSet Datasets => Set(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.ApplyConfiguration(new DatasetConfiguration()); - modelBuilder.ApplyModuleSchema(DatasetsConstants.ModuleName, dbOptions.Value); - } - - protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) - { - configurationBuilder - .Properties() - .HaveConversion(); - - // SQLite cannot ORDER BY DateTimeOffset natively. Store as ISO-8601 TEXT so - // lexicographic ordering matches chronological ordering. Other providers - // keep their native DateTimeOffset mapping. - var provider = DatabaseProviderDetector.Detect( - dbOptions.Value.DefaultConnection, - dbOptions.Value.Provider - ); - if (provider == DatabaseProvider.Sqlite) - { - configurationBuilder - .Properties() - .HaveConversion(); - - configurationBuilder - .Properties() - .HaveConversion(); - } - } -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/DatasetsModule.cs b/modules/Datasets/src/SimpleModule.Datasets/DatasetsModule.cs deleted file mode 100644 index aadb2729..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/DatasetsModule.cs +++ /dev/null @@ -1,177 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using SimpleModule.BackgroundJobs.Contracts; -using SimpleModule.Core; -using SimpleModule.Core.Agents; -using SimpleModule.Core.Authorization; -using SimpleModule.Core.Menu; -using SimpleModule.Core.Settings; -using SimpleModule.Database; -using SimpleModule.Datasets.Agents; -using SimpleModule.Datasets.Contracts; -using SimpleModule.Datasets.Converters; -using SimpleModule.Datasets.Jobs; -using SimpleModule.Datasets.Processing; - -namespace SimpleModule.Datasets; - -[Module( - DatasetsConstants.ModuleName, - RoutePrefix = DatasetsConstants.RoutePrefix, - ViewPrefix = DatasetsConstants.ViewPrefix -)] -public class DatasetsModule : IModule -{ - public void ConfigureServices(IServiceCollection services, IConfiguration configuration) - { - services.AddModuleDbContext(configuration, DatasetsConstants.ModuleName); - services.AddScoped(); - services.AddScoped(); - - // Processors (one per format) - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - // Converters - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - // Background jobs - services.AddModuleJob(); - services.AddModuleJob(); - services.AddModuleJob(); - } - - public void ConfigureMenu(IMenuBuilder menus) - { - menus.Add( - new MenuItem - { - Label = "Datasets", - Url = DatasetsConstants.ViewPrefix, - Icon = - """""", - Order = 55, - Section = MenuSection.AppSidebar, - RequiredPermission = DatasetsPermissions.View, - } - ); - } - - public void ConfigurePermissions(PermissionRegistryBuilder builder) - { - builder.AddPermissions(); - } - - public void ConfigureSettings(ISettingsBuilder settings) - { - settings.Add( - new SettingDefinition - { - Key = DatasetsConstants.SettingKeys.MaxUploadSizeMb, - DisplayName = "Max Upload Size (MB)", - Description = "Maximum allowed upload size for GIS datasets in megabytes.", - Group = "Datasets", - Scope = SettingScope.Application, - DefaultValue = "1024", - Type = SettingType.Number, - } - ); - settings.Add( - new SettingDefinition - { - Key = DatasetsConstants.SettingKeys.AllowedFormats, - DisplayName = "Allowed Formats", - Description = "JSON array of DatasetFormat names accepted on upload.", - Group = "Datasets", - Scope = SettingScope.Application, - DefaultValue = - "[\"GeoJson\",\"Shapefile\",\"Kml\",\"Kmz\",\"GeoPackage\",\"PmTiles\",\"Cog\"]", - Type = SettingType.Json, - } - ); - settings.Add( - new SettingDefinition - { - Key = DatasetsConstants.SettingKeys.DefaultTargetSrid, - DisplayName = "Default Target SRID", - Description = "Target SRID used to reproject vector datasets during processing.", - Group = "Datasets", - Scope = SettingScope.Application, - DefaultValue = "4326", - Type = SettingType.Number, - } - ); - settings.Add( - new SettingDefinition - { - Key = DatasetsConstants.SettingKeys.FeatureQueryLimit, - DisplayName = "Feature Query Limit", - Description = - "Maximum number of features returned by the feature query endpoint / agent tool.", - Group = "Datasets", - Scope = SettingScope.Application, - DefaultValue = "1000", - Type = SettingType.Number, - } - ); - settings.Add( - new SettingDefinition - { - Key = DatasetsConstants.SettingKeys.DefaultVectorConversionFormat, - DisplayName = "Default Vector Conversion Format", - Description = - "Target format used when a vector dataset is converted without an explicit target.", - Group = "Datasets", - Scope = SettingScope.Application, - DefaultValue = "\"PmTiles\"", - Type = SettingType.Text, - } - ); - settings.Add( - new SettingDefinition - { - Key = DatasetsConstants.SettingKeys.DefaultRasterConversionFormat, - DisplayName = "Default Raster Conversion Format", - Description = - "Target format used when a raster dataset is converted without an explicit target.", - Group = "Datasets", - Scope = SettingScope.Application, - DefaultValue = "\"Cog\"", - Type = SettingType.Text, - } - ); - settings.Add( - new SettingDefinition - { - Key = DatasetsConstants.SettingKeys.AutoGenerateDefaultDerivative, - DisplayName = "Auto Generate Default Derivative", - Description = - "When true, successful processing auto-enqueues a conversion to the default derivative format (vector→PMTiles, raster→COG).", - Group = "Datasets", - Scope = SettingScope.Application, - DefaultValue = "true", - Type = SettingType.Bool, - } - ); - settings.Add( - new SettingDefinition - { - Key = DatasetsConstants.SettingKeys.StoragePrefix, - DisplayName = "Storage Prefix", - Description = "Root path under which dataset blobs are stored.", - Group = "Datasets", - Scope = SettingScope.Application, - DefaultValue = "\"datasets\"", - Type = SettingType.Text, - } - ); - } -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/Endpoints/Datasets/ConvertDatasetEndpoint.cs b/modules/Datasets/src/SimpleModule.Datasets/Endpoints/Datasets/ConvertDatasetEndpoint.cs deleted file mode 100644 index 201ce8e8..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Endpoints/Datasets/ConvertDatasetEndpoint.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; -using SimpleModule.Datasets.Contracts; - -namespace SimpleModule.Datasets.Endpoints.Datasets; - -public class ConvertDatasetEndpoint : IEndpoint -{ - public const string Route = DatasetsConstants.Routes.Convert; - public const string Method = "POST"; - - public sealed class ConvertRequest - { - public string? TargetFormat { get; set; } - } - - public void Map(IEndpointRouteBuilder app) => - app.MapPost( - Route, - async Task ( - Guid id, - ConvertRequest? request, - IDatasetsContracts datasets, - CancellationToken ct - ) => - { - DatasetFormat? target = null; - if (!string.IsNullOrWhiteSpace(request?.TargetFormat)) - { - if ( - !Enum.TryParse( - request.TargetFormat, - ignoreCase: true, - out var parsed - ) - ) - { - return TypedResults.BadRequest( - $"Unknown target format: {request.TargetFormat}" - ); - } - target = parsed; - } - - try - { - await datasets.EnqueueConversionAsync(DatasetId.From(id), target, ct); - return TypedResults.Accepted( - new Uri($"/api/datasets/{id}", UriKind.Relative) - ); - } - catch (InvalidOperationException ex) - { - return TypedResults.NotFound(ex.Message); - } - } - ) - .RequirePermission(DatasetsPermissions.Convert); -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/Endpoints/Datasets/DeleteDatasetEndpoint.cs b/modules/Datasets/src/SimpleModule.Datasets/Endpoints/Datasets/DeleteDatasetEndpoint.cs deleted file mode 100644 index 9382ddef..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Endpoints/Datasets/DeleteDatasetEndpoint.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; -using SimpleModule.Datasets.Contracts; - -namespace SimpleModule.Datasets.Endpoints.Datasets; - -public class DeleteDatasetEndpoint : IEndpoint -{ - public const string Route = DatasetsConstants.Routes.Delete; - public const string Method = "DELETE"; - - public void Map(IEndpointRouteBuilder app) => - app.MapDelete( - Route, - async Task (Guid id, IDatasetsContracts datasets, CancellationToken ct) => - { - await datasets.DeleteAsync(DatasetId.From(id), ct); - return TypedResults.NoContent(); - } - ) - .RequirePermission(DatasetsPermissions.Delete); -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/Endpoints/Datasets/DownloadDatasetEndpoint.cs b/modules/Datasets/src/SimpleModule.Datasets/Endpoints/Datasets/DownloadDatasetEndpoint.cs deleted file mode 100644 index 9b1ff7d4..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Endpoints/Datasets/DownloadDatasetEndpoint.cs +++ /dev/null @@ -1,59 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; -using SimpleModule.Datasets.Contracts; - -namespace SimpleModule.Datasets.Endpoints.Datasets; - -public class DownloadDatasetEndpoint : IEndpoint -{ - public const string Route = DatasetsConstants.Routes.Download; - public const string Method = "GET"; - - public void Map(IEndpointRouteBuilder app) => - app.MapGet( - Route, - async Task ( - Guid id, - string? variant, - IDatasetsContracts datasets, - CancellationToken ct - ) => - { - var datasetId = DatasetId.From(id); - var row = await datasets.GetByIdAsync(datasetId, ct); - if (row is null) - { - return TypedResults.NotFound(); - } - - Stream? stream; - string fileName; - if (string.IsNullOrWhiteSpace(variant) || variant == "original") - { - stream = await datasets.GetOriginalAsync(datasetId, ct); - fileName = row.OriginalFileName; - } - else if (Enum.TryParse(variant, ignoreCase: true, out var fmt)) - { - stream = await datasets.GetDerivativeAsync(datasetId, fmt, ct); - fileName = $"{row.Name}{fmt.FileExtension()}"; - } - else - { - return TypedResults.BadRequest( - "variant must be 'original' or a DatasetFormat name" - ); - } - - if (stream is null) - { - return TypedResults.NotFound(); - } - return TypedResults.File(stream, "application/octet-stream", fileName); - } - ) - .RequirePermission(DatasetsPermissions.View); -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/Endpoints/Datasets/GetDatasetEndpoint.cs b/modules/Datasets/src/SimpleModule.Datasets/Endpoints/Datasets/GetDatasetEndpoint.cs deleted file mode 100644 index db7019df..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Endpoints/Datasets/GetDatasetEndpoint.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; -using SimpleModule.Datasets.Contracts; - -namespace SimpleModule.Datasets.Endpoints.Datasets; - -public class GetDatasetEndpoint : IEndpoint -{ - public const string Route = DatasetsConstants.Routes.GetById; - public const string Method = "GET"; - - public void Map(IEndpointRouteBuilder app) => - app.MapGet( - Route, - async Task (Guid id, IDatasetsContracts datasets, CancellationToken ct) => - { - var dto = await datasets.GetByIdAsync(DatasetId.From(id), ct); - return dto is null ? TypedResults.NotFound() : TypedResults.Ok(dto); - } - ) - .RequirePermission(DatasetsPermissions.View); -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/Endpoints/Datasets/GetDatasetFeaturesEndpoint.cs b/modules/Datasets/src/SimpleModule.Datasets/Endpoints/Datasets/GetDatasetFeaturesEndpoint.cs deleted file mode 100644 index 12ba97b4..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Endpoints/Datasets/GetDatasetFeaturesEndpoint.cs +++ /dev/null @@ -1,85 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; -using SimpleModule.Datasets.Contracts; - -namespace SimpleModule.Datasets.Endpoints.Datasets; - -public class GetDatasetFeaturesEndpoint : IEndpoint -{ - public const string Route = DatasetsConstants.Routes.Features; - public const string Method = "GET"; - - public void Map(IEndpointRouteBuilder app) => - app.MapGet( - Route, - async Task ( - Guid id, - string? bbox, - int? limit, - IDatasetsContracts datasets, - CancellationToken ct - ) => - { - BoundingBoxDto? parsed = null; - if (!string.IsNullOrWhiteSpace(bbox)) - { - var parts = bbox.Split( - ',', - StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries - ); - if ( - parts.Length != 4 - || !double.TryParse( - parts[0], - System.Globalization.CultureInfo.InvariantCulture, - out var minX - ) - || !double.TryParse( - parts[1], - System.Globalization.CultureInfo.InvariantCulture, - out var minY - ) - || !double.TryParse( - parts[2], - System.Globalization.CultureInfo.InvariantCulture, - out var maxX - ) - || !double.TryParse( - parts[3], - System.Globalization.CultureInfo.InvariantCulture, - out var maxY - ) - ) - { - return TypedResults.BadRequest("bbox must be 'minX,minY,maxX,maxY'"); - } - parsed = new BoundingBoxDto - { - MinX = minX, - MinY = minY, - MaxX = maxX, - MaxY = maxY, - }; - } - - try - { - var json = await datasets.GetFeaturesGeoJsonAsync( - DatasetId.From(id), - parsed, - limit, - ct - ); - return TypedResults.Content(json, "application/geo+json"); - } - catch (InvalidOperationException ex) - { - return TypedResults.BadRequest(ex.Message); - } - } - ) - .RequirePermission(DatasetsPermissions.View); -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/Endpoints/Datasets/ListDatasetsEndpoint.cs b/modules/Datasets/src/SimpleModule.Datasets/Endpoints/Datasets/ListDatasetsEndpoint.cs deleted file mode 100644 index 97f2e1d0..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Endpoints/Datasets/ListDatasetsEndpoint.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; -using SimpleModule.Datasets.Contracts; - -namespace SimpleModule.Datasets.Endpoints.Datasets; - -public class ListDatasetsEndpoint : IEndpoint -{ - public const string Route = DatasetsConstants.Routes.GetAll; - public const string Method = "GET"; - - public void Map(IEndpointRouteBuilder app) => - app.MapGet( - Route, - async (IDatasetsContracts datasets, CancellationToken ct) => - await datasets.GetAllAsync(ct) - ) - .RequirePermission(DatasetsPermissions.View); -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/Endpoints/Datasets/UploadDatasetEndpoint.cs b/modules/Datasets/src/SimpleModule.Datasets/Endpoints/Datasets/UploadDatasetEndpoint.cs deleted file mode 100644 index 86a9a42d..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Endpoints/Datasets/UploadDatasetEndpoint.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; -using SimpleModule.Datasets.Contracts; - -namespace SimpleModule.Datasets.Endpoints.Datasets; - -public class UploadDatasetEndpoint : IEndpoint -{ - public const string Route = DatasetsConstants.Routes.Upload; - public const string Method = "POST"; - - public void Map(IEndpointRouteBuilder app) => - app.MapPost( - Route, - async Task ( - IFormFile? file, - string? name, - IDatasetsContracts datasets, - CancellationToken ct - ) => - { - if (file is null || file.Length == 0) - { - return TypedResults.BadRequest("A file is required."); - } - - await using var stream = file.OpenReadStream(); - try - { - var dataset = await datasets.CreateAsync(stream, file.FileName, name, ct); - return TypedResults.Created($"/api/datasets/{dataset.Id}", dataset); - } - catch (InvalidOperationException ex) - { - return TypedResults.BadRequest(ex.Message); - } - } - ) - .RequirePermission(DatasetsPermissions.Upload) - .DisableAntiforgery(); -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/EntityConfigurations/DatasetConfiguration.cs b/modules/Datasets/src/SimpleModule.Datasets/EntityConfigurations/DatasetConfiguration.cs deleted file mode 100644 index 648fb0f8..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/EntityConfigurations/DatasetConfiguration.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using SimpleModule.Datasets.Contracts; - -namespace SimpleModule.Datasets.EntityConfigurations; - -public sealed class DatasetConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasKey(d => d.Id); - builder.Property(d => d.Id).ValueGeneratedNever(); - builder.Property(d => d.Name).IsRequired().HasMaxLength(256); - builder.Property(d => d.OriginalFileName).IsRequired().HasMaxLength(512); - builder.Property(d => d.ContentHash).HasMaxLength(128); - builder.Property(d => d.StoragePath).IsRequired().HasMaxLength(1024); - builder.Property(d => d.NormalizedPath).HasMaxLength(1024); - builder.Property(d => d.ErrorMessage).HasMaxLength(4096); - builder.Property(d => d.ConcurrencyStamp).IsConcurrencyToken().HasMaxLength(64); - builder.HasIndex(d => d.Status); - builder.HasIndex(d => d.Format); - builder.HasIndex(d => d.ContentHash); - builder.HasIndex(d => new - { - d.BboxMinX, - d.BboxMaxX, - d.BboxMinY, - d.BboxMaxY, - }); - builder.HasIndex(d => new { d.IsDeleted, d.CreatedAt }); - // Soft-delete query filter is applied by ApplyEntityConventions via the named - // filter key; adding an anonymous filter here conflicts with it. - } -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/Infrastructure/CliRunner.cs b/modules/Datasets/src/SimpleModule.Datasets/Infrastructure/CliRunner.cs deleted file mode 100644 index e69dd2f2..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Infrastructure/CliRunner.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Diagnostics; - -namespace SimpleModule.Datasets.Infrastructure; - -/// -/// Runs a CLI tool asynchronously. Reads stdout and stderr concurrently to avoid -/// pipe-buffer deadlocks, and throws on non-zero exit codes. -/// -internal static class CliRunner -{ - public static async Task RunAsync( - string fileName, - IEnumerable args, - CancellationToken ct - ) - { - using var process = new Process(); - process.StartInfo = new ProcessStartInfo - { - FileName = fileName, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - foreach (var arg in args) - { - process.StartInfo.ArgumentList.Add(arg); - } - - process.Start(); - - // Read both streams concurrently to prevent deadlocks when either - // pipe buffer fills before the other is drained. - var stdoutTask = process.StandardOutput.ReadToEndAsync(ct); - var stderrTask = process.StandardError.ReadToEndAsync(ct); - await Task.WhenAll(stdoutTask, stderrTask); - await process.WaitForExitAsync(ct); - - var stderr = await stderrTask; - if (process.ExitCode != 0) - { - throw new InvalidOperationException( - $"{fileName} exited with code {process.ExitCode}: {stderr}" - ); - } - - return await stdoutTask; - } -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/Infrastructure/TempDirectory.cs b/modules/Datasets/src/SimpleModule.Datasets/Infrastructure/TempDirectory.cs deleted file mode 100644 index 9995e794..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Infrastructure/TempDirectory.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace SimpleModule.Datasets.Infrastructure; - -/// -/// Creates a uniquely-named temporary directory that is deleted on disposal. -/// -internal sealed class TempDirectory : IDisposable -{ - public string Path { get; } - - public TempDirectory(string prefix) - { - Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"{prefix}-{Guid.NewGuid():N}"); - Directory.CreateDirectory(Path); - } - -#pragma warning disable CA1031 - public void Dispose() - { - try - { - Directory.Delete(Path, recursive: true); - } - catch - { /* best-effort cleanup */ - } - } -#pragma warning restore CA1031 -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/Jobs/ConvertDatasetJob.cs b/modules/Datasets/src/SimpleModule.Datasets/Jobs/ConvertDatasetJob.cs deleted file mode 100644 index dd3acc30..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Jobs/ConvertDatasetJob.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System.Text.Json; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using SimpleModule.BackgroundJobs.Contracts; -using SimpleModule.Datasets.Contracts; -using SimpleModule.Datasets.Contracts.Events; -using SimpleModule.Datasets.Converters; -using SimpleModule.Storage; -using Wolverine; - -namespace SimpleModule.Datasets.Jobs; - -public sealed partial class ConvertDatasetJob( - DatasetsDbContext db, - IStorageProvider storage, - DatasetConverterRegistry converters, - IMessageBus bus, - ILogger logger -) : IModuleJob -{ - public async Task ExecuteAsync( - IJobExecutionContext context, - CancellationToken cancellationToken - ) - { - var payload = context.GetData(); - var datasetId = DatasetId.From(payload.DatasetId); - var target = (DatasetFormat)payload.TargetFormat; - - var row = await db.Datasets.FirstOrDefaultAsync(d => d.Id == datasetId, cancellationToken); - if (row is null) - { - LogDatasetMissing(logger, payload.DatasetId); - return; - } - if (row.Status != DatasetStatus.Ready) - { - LogSkippedNotReady(logger, payload.DatasetId, row.Status); - return; - } - - try - { - var converter = converters.Resolve(row.Format, target); - - // For non-GeoJSON vector sources, read the normalized GeoJSON cache - // produced during processing instead of the raw original file. - string sourcePath; - DatasetFormat sourceFormat; - if ( - row.Format != DatasetFormat.GeoJson - && row.Format.IsVector() - && !string.IsNullOrWhiteSpace(row.NormalizedPath) - ) - { - sourcePath = row.NormalizedPath; - sourceFormat = DatasetFormat.GeoJson; - } - else - { - sourcePath = row.StoragePath; - sourceFormat = row.Format; - } - - await using var source = - await storage.GetAsync(sourcePath, cancellationToken) - ?? throw new InvalidOperationException($"Source blob missing: {sourcePath}"); - await using var output = await converter.ConvertAsync( - source, - sourceFormat, - cancellationToken - ); - output.Position = 0; - - var ext = target.FileExtension(); - var derivativePath = StoragePathHelper.Combine( - Path.GetDirectoryName(row.StoragePath)?.Replace('\\', '/') ?? "datasets", - $"derivatives/{target}{ext}" - ); - var save = await storage.SaveAsync( - derivativePath, - output, - "application/octet-stream", - cancellationToken - ); - - var metadata = string.IsNullOrWhiteSpace(row.MetadataJson) - ? new DatasetMetadata() - : JsonSerializer.Deserialize(row.MetadataJson) ?? new(); - metadata.Derivatives.RemoveAll(d => d.Format == target); - metadata.Derivatives.Add( - new DatasetDerivative - { - Format = target, - StoragePath = save.Path, - SizeBytes = save.Size, - CreatedAt = DateTimeOffset.UtcNow, - } - ); - row.MetadataJson = JsonSerializer.Serialize(metadata); - row.UpdatedAt = DateTimeOffset.UtcNow; - await db.SaveChangesAsync(cancellationToken); - - LogDerivativeCreated(logger, payload.DatasetId, target); - await bus.PublishAsync(new DatasetDerivativeCreated(datasetId, target)); - } -#pragma warning disable CA1031 - catch (Exception ex) -#pragma warning restore CA1031 - { - LogConversionFailed(logger, payload.DatasetId, target, ex); - // Conversion failure does not mark the dataset as Failed — original is still Ready. - } - } - - [LoggerMessage(Level = LogLevel.Warning, Message = "Dataset {Id} not found for conversion")] - private static partial void LogDatasetMissing(ILogger logger, Guid id); - - [LoggerMessage( - Level = LogLevel.Warning, - Message = "Skipping conversion of {Id} — status {Status} is not Ready" - )] - private static partial void LogSkippedNotReady(ILogger logger, Guid id, DatasetStatus status); - - [LoggerMessage(Level = LogLevel.Information, Message = "Derivative created: {Id} → {Format}")] - private static partial void LogDerivativeCreated(ILogger logger, Guid id, DatasetFormat format); - - [LoggerMessage(Level = LogLevel.Error, Message = "Dataset {Id} conversion to {Format} failed")] - private static partial void LogConversionFailed( - ILogger logger, - Guid id, - DatasetFormat format, - Exception exception - ); -} - -public sealed class ConvertDatasetJobData -{ - public Guid DatasetId { get; set; } - public int TargetFormat { get; set; } -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/Jobs/ProcessDatasetJob.cs b/modules/Datasets/src/SimpleModule.Datasets/Jobs/ProcessDatasetJob.cs deleted file mode 100644 index aaf8df66..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Jobs/ProcessDatasetJob.cs +++ /dev/null @@ -1,143 +0,0 @@ -using System.Diagnostics; -using System.Text.Json; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using SimpleModule.BackgroundJobs.Contracts; -using SimpleModule.Core.Settings; -using SimpleModule.Datasets.Contracts; -using SimpleModule.Datasets.Contracts.Events; -using SimpleModule.Datasets.Processing; -using SimpleModule.Settings.Contracts; -using SimpleModule.Storage; -using Wolverine; - -namespace SimpleModule.Datasets.Jobs; - -public sealed partial class ProcessDatasetJob( - DatasetsDbContext db, - IStorageProvider storage, - DatasetProcessorRegistry processors, - IMessageBus bus, - IBackgroundJobs jobs, - ISettingsContracts settings, - ILogger logger -) : IModuleJob -{ - public async Task ExecuteAsync( - IJobExecutionContext context, - CancellationToken cancellationToken - ) - { - var payload = context.GetData(); - var datasetId = DatasetId.From(payload.DatasetId); - var row = await db.Datasets.FirstOrDefaultAsync(d => d.Id == datasetId, cancellationToken); - if (row is null) - { - LogDatasetMissing(logger, payload.DatasetId); - return; - } - - var sw = Stopwatch.StartNew(); - row.Status = DatasetStatus.Processing; - row.UpdatedAt = DateTimeOffset.UtcNow; - await db.SaveChangesAsync(cancellationToken); - - DatasetStatus finalStatus; - try - { - await using var stream = - await storage.GetAsync(row.StoragePath, cancellationToken) - ?? throw new InvalidOperationException( - $"Original blob missing at {row.StoragePath}" - ); - - var processor = processors.Resolve(row.Format); - var result = await processor.ProcessAsync(stream, cancellationToken); - result.Metadata.Common.OriginalFileName = row.OriginalFileName; - result.Metadata.Common.SizeBytes = row.SizeBytes; - result.Metadata.Common.ContentHash = row.ContentHash; - result.Metadata.Common.ProcessingDurationMs = sw.Elapsed.TotalMilliseconds; - - row.SourceSrid = result.SourceSrid; - row.Srid = result.TargetSrid; - row.BboxMinX = result.BoundingBox?.MinX; - row.BboxMinY = result.BoundingBox?.MinY; - row.BboxMaxX = result.BoundingBox?.MaxX; - row.BboxMaxY = result.BoundingBox?.MaxY; - row.FeatureCount = result.FeatureCount; - - if (result.NormalizedGeoJson is not null) - { - var normalizedPath = StoragePathHelper.Combine( - Path.GetDirectoryName(row.StoragePath)?.Replace('\\', '/') ?? "datasets", - "normalized.geojson" - ); - await using var normalizedStream = new MemoryStream(result.NormalizedGeoJson); - var normalized = await storage.SaveAsync( - normalizedPath, - normalizedStream, - "application/geo+json", - cancellationToken - ); - row.NormalizedPath = normalized.Path; - } - - row.MetadataJson = JsonSerializer.Serialize(result.Metadata); - row.Status = DatasetStatus.Ready; - row.ErrorMessage = null; - row.ProcessedAt = DateTimeOffset.UtcNow; - row.UpdatedAt = DateTimeOffset.UtcNow; - finalStatus = DatasetStatus.Ready; - await db.SaveChangesAsync(cancellationToken); - LogDatasetProcessed(logger, payload.DatasetId, row.Format); - - if ( - await settings.GetSettingAsync( - DatasetsConstants.SettingKeys.AutoGenerateDefaultDerivative, - SettingScope.Application - ) - is true - or null - ) - { - var target = row.Format.IsRaster() ? DatasetFormat.Cog : DatasetFormat.PmTiles; - await jobs.EnqueueAsync( - new ConvertDatasetJobData - { - DatasetId = payload.DatasetId, - TargetFormat = (int)target, - }, - cancellationToken - ); - } - } -#pragma warning disable CA1031 // Top-level job handler surfaces failures through the row. - catch (Exception ex) -#pragma warning restore CA1031 - { - row.Status = DatasetStatus.Failed; - row.ErrorMessage = ex.Message; - row.ProcessedAt = DateTimeOffset.UtcNow; - row.UpdatedAt = DateTimeOffset.UtcNow; - finalStatus = DatasetStatus.Failed; - await db.SaveChangesAsync(CancellationToken.None); - LogDatasetFailed(logger, payload.DatasetId, ex); - } - - await bus.PublishAsync(new DatasetProcessed(datasetId, finalStatus)); - } - - [LoggerMessage(Level = LogLevel.Warning, Message = "Dataset {Id} not found for processing")] - private static partial void LogDatasetMissing(ILogger logger, Guid id); - - [LoggerMessage(Level = LogLevel.Information, Message = "Dataset {Id} processed ({Format})")] - private static partial void LogDatasetProcessed(ILogger logger, Guid id, DatasetFormat format); - - [LoggerMessage(Level = LogLevel.Error, Message = "Dataset {Id} processing failed")] - private static partial void LogDatasetFailed(ILogger logger, Guid id, Exception exception); -} - -public sealed class ProcessDatasetJobData -{ - public Guid DatasetId { get; set; } -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/Jobs/PurgeDatasetJob.cs b/modules/Datasets/src/SimpleModule.Datasets/Jobs/PurgeDatasetJob.cs deleted file mode 100644 index 3d098553..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Jobs/PurgeDatasetJob.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Text.Json; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using SimpleModule.BackgroundJobs.Contracts; -using SimpleModule.Datasets.Contracts; -using SimpleModule.Storage; - -namespace SimpleModule.Datasets.Jobs; - -/// -/// Deletes every blob associated with a soft-deleted dataset: the original upload, -/// the normalized GeoJSON cache, and any derivative files produced by . -/// Runs after so the HTTP request returns -/// immediately while the (potentially large) storage cleanup happens in the background. -/// -public sealed partial class PurgeDatasetJob( - DatasetsDbContext db, - IStorageProvider storage, - ILogger logger -) : IModuleJob -{ - public async Task ExecuteAsync( - IJobExecutionContext context, - CancellationToken cancellationToken - ) - { - var payload = context.GetData(); - var datasetId = DatasetId.From(payload.DatasetId); - - // The soft-delete query filter hides deleted rows by default — bypass it. - var row = await db - .Datasets.AsNoTracking() - .IgnoreQueryFilters() - .FirstOrDefaultAsync(d => d.Id == datasetId, cancellationToken); - if (row is null) - { - LogDatasetMissing(logger, payload.DatasetId); - return; - } - - var paths = new List(); - if (!string.IsNullOrWhiteSpace(row.StoragePath)) - { - paths.Add(row.StoragePath); - } - if (!string.IsNullOrWhiteSpace(row.NormalizedPath)) - { - paths.Add(row.NormalizedPath); - } - if (!string.IsNullOrWhiteSpace(row.MetadataJson)) - { - var metadata = JsonSerializer.Deserialize(row.MetadataJson); - if (metadata is not null) - { - paths.AddRange( - metadata - .Derivatives.Select(d => d.StoragePath) - .Where(p => !string.IsNullOrWhiteSpace(p)) - ); - } - } - - await Task.WhenAll(paths.Select(p => storage.DeleteAsync(p, cancellationToken))); - LogDatasetPurged(logger, payload.DatasetId, paths.Count); - } - - [LoggerMessage( - Level = LogLevel.Warning, - Message = "Dataset {Id} not found for purge; nothing to delete" - )] - private static partial void LogDatasetMissing(ILogger logger, Guid id); - - [LoggerMessage( - Level = LogLevel.Information, - Message = "Dataset {Id} purged: {BlobCount} storage path(s) processed" - )] - private static partial void LogDatasetPurged(ILogger logger, Guid id, int blobCount); -} - -public sealed class PurgeDatasetJobData -{ - public Guid DatasetId { get; set; } -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/Pages/Browse.tsx b/modules/Datasets/src/SimpleModule.Datasets/Pages/Browse.tsx deleted file mode 100644 index 9edffa7f..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Pages/Browse.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { router } from '@inertiajs/react'; -import { - Button, - DataGridPage, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@simplemodule/ui'; -import type * as React from 'react'; -import { DATASET_STATUS, FORMAT_NAMES, STATUS_NAMES } from './labels'; - -interface DatasetSummary { - id: string; - name: string; - originalFileName: string; - format: number; - status: number; - featureCount: number | null; - sizeBytes: number; - createdAt: string; - boundingBox: { minX: number; minY: number; maxX: number; maxY: number } | null; -} - -function formatSize(bytes: number): string { - if (bytes === 0) return '0 B'; - const units = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - return `${(bytes / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${units[i]}`; -} - -function statusBadge(status: number): React.ReactElement { - const name = STATUS_NAMES[status] ?? 'Unknown'; - const color = - status === DATASET_STATUS.Ready - ? 'text-green-600' - : status === DATASET_STATUS.Failed - ? 'text-red-600' - : status === DATASET_STATUS.Processing - ? 'text-blue-600' - : 'text-text-muted'; - return {name}; -} - -interface Props { - datasets: DatasetSummary[]; -} - -export default function Browse({ datasets }: Props) { - return ( - router.visit('/datasets/upload')}>Upload} - data={datasets} - emptyTitle="No datasets yet" - emptyDescription="Upload a GIS file to get started." - > - {() => ( - - - - Name - Format - Status - Features - Size - Created - - - - {datasets.map((d) => ( - router.visit(`/datasets/${d.id}`)} - > - {d.name} - {FORMAT_NAMES[d.format] ?? d.format} - {statusBadge(d.status)} - {d.featureCount ?? '—'} - {formatSize(d.sizeBytes)} - {new Date(d.createdAt).toLocaleString()} - - ))} - -
- )} -
- ); -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/Pages/Detail.tsx b/modules/Datasets/src/SimpleModule.Datasets/Pages/Detail.tsx deleted file mode 100644 index 2c5138f8..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Pages/Detail.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { router } from '@inertiajs/react'; -import { Button } from '@simplemodule/ui'; -import { useState } from 'react'; -import { FORMAT_NAMES, isRaster, isVector, STATUS_NAMES } from './labels'; - -interface DatasetDto { - id: string; - name: string; - originalFileName: string; - format: number; - status: number; - sourceSrid: number | null; - srid: number | null; - boundingBox: { minX: number; minY: number; maxX: number; maxY: number } | null; - featureCount: number | null; - sizeBytes: number; - errorMessage: string | null; - metadata: unknown; - createdAt: string; - processedAt: string | null; -} - -interface Props { - dataset: DatasetDto; -} - -export default function Detail({ dataset }: Props) { - const [busy, setBusy] = useState(false); - const vector = isVector(dataset.format); - const raster = isRaster(dataset.format); - - async function convertTo(target: string) { - setBusy(true); - const res = await fetch(`/api/datasets/${dataset.id}/convert`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ targetFormat: target }), - }); - setBusy(false); - if (res.ok) { - router.reload(); - } else { - alert(`Conversion enqueue failed: ${await res.text()}`); - } - } - - async function handleDelete() { - if (!confirm(`Delete ${dataset.name}?`)) return; - await fetch(`/api/datasets/${dataset.id}`, { method: 'DELETE' }); - router.visit('/datasets'); - } - - return ( -
-
-
-

{dataset.name}

-
{dataset.originalFileName}
-
-
- - {vector && ( - - )} - {vector && ( - - )} - {raster && ( - - )} - -
-
- -
- - - - - - - - - {dataset.boundingBox && ( - - )} -
- - {dataset.errorMessage && ( -
- Error: {dataset.errorMessage} -
- )} - - {dataset.metadata ? ( -
- Full metadata -
-            {JSON.stringify(dataset.metadata, null, 2)}
-          
-
- ) : null} -
- ); -} - -function Field({ label, value }: { label: string; value: string }) { - return ( -
-
{label}
-
{value}
-
- ); -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/Pages/Upload.tsx b/modules/Datasets/src/SimpleModule.Datasets/Pages/Upload.tsx deleted file mode 100644 index 57bacf1a..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Pages/Upload.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { router } from '@inertiajs/react'; -import { Button } from '@simplemodule/ui'; -import { useRef, useState } from 'react'; -import { DATASET_STATUS } from './labels'; - -export default function Upload() { - const inputRef = useRef(null); - const [status, setStatus] = useState(null); - const [busy, setBusy] = useState(false); - - async function pollUntilComplete(id: string) { - for (let i = 0; i < 120; i++) { - const res = await fetch(`/api/datasets/${id}`); - if (!res.ok) break; - const dto = await res.json(); - if (dto.status === DATASET_STATUS.Ready || dto.status === DATASET_STATUS.Failed) { - router.visit(`/datasets/${id}`); - return; - } - setStatus(`Processing… (${i + 1}s)`); - await new Promise((r) => setTimeout(r, 1000)); - } - setStatus('Timed out waiting for processing.'); - } - - async function handleUpload(e: React.ChangeEvent) { - const file = e.target.files?.[0]; - if (!file) return; - setBusy(true); - setStatus(`Uploading ${file.name}…`); - const form = new FormData(); - form.append('file', file); - const res = await fetch('/api/datasets', { method: 'POST', body: form }); - if (!res.ok) { - setStatus(`Upload failed: ${await res.text()}`); - setBusy(false); - return; - } - const dto = await res.json(); - setStatus(`Uploaded. Waiting for processing…`); - await pollUntilComplete(dto.id); - setBusy(false); - } - - return ( -
-

Upload GIS Dataset

-

- Supported formats: GeoJSON (.geojson, .json), Shapefile (.zip), KML/KMZ, GeoPackage (.gpkg), - PMTiles (.pmtiles), COG (.tif/.tiff). -

- - - {status &&
{status}
} -
- ); -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/Pages/index.ts b/modules/Datasets/src/SimpleModule.Datasets/Pages/index.ts deleted file mode 100644 index 7cbcc4fd..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Pages/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const pages: Record = { - 'Datasets/Browse': () => import('./Browse'), - 'Datasets/Upload': () => import('./Upload'), - 'Datasets/Detail': () => import('./Detail'), -}; diff --git a/modules/Datasets/src/SimpleModule.Datasets/Pages/labels.ts b/modules/Datasets/src/SimpleModule.Datasets/Pages/labels.ts deleted file mode 100644 index 782be1a7..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Pages/labels.ts +++ /dev/null @@ -1,33 +0,0 @@ -export const FORMAT_NAMES = [ - 'Unknown', - 'GeoJSON', - 'Shapefile', - 'KML', - 'KMZ', - 'GeoPackage', - 'PMTiles', - 'COG', -]; - -export const STATUS_NAMES = ['Pending', 'Processing', 'Ready', 'Failed']; - -export const DATASET_STATUS = { Pending: 0, Processing: 1, Ready: 2, Failed: 3 } as const; - -export const DATASET_FORMAT = { - Unknown: 0, - GeoJson: 1, - Shapefile: 2, - Kml: 3, - Kmz: 4, - GeoPackage: 5, - PmTiles: 6, - Cog: 7, -} as const; - -export function isVector(format: number): boolean { - return format >= DATASET_FORMAT.GeoJson && format <= DATASET_FORMAT.GeoPackage; -} - -export function isRaster(format: number): boolean { - return format === DATASET_FORMAT.Cog; -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/Processing/CogProcessor.cs b/modules/Datasets/src/SimpleModule.Datasets/Processing/CogProcessor.cs deleted file mode 100644 index a1bdaaa1..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Processing/CogProcessor.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System.Diagnostics; -using System.Text.Json; -using SimpleModule.Datasets.Contracts; -using SimpleModule.Datasets.Infrastructure; - -namespace SimpleModule.Datasets.Processing; - -/// -/// Extracts raster metadata from a GeoTIFF/COG using the gdalinfo CLI. -/// NormalizedGeoJson is null because raster sources do not produce a vector cache. -/// -public sealed class CogProcessor : IDatasetProcessor -{ - public DatasetFormat Format => DatasetFormat.Cog; - - public async Task ProcessAsync(Stream content, CancellationToken ct) - { - var sw = Stopwatch.StartNew(); - using var tmp = new TempDirectory("cog-proc"); - var inputPath = Path.Combine(tmp.Path, "input.tif"); - - await using (var fs = File.Create(inputPath)) - { - await content.CopyToAsync(fs, ct); - } - - var json = await CliRunner.RunAsync("gdalinfo", ["-json", inputPath], ct); - using var doc = JsonDocument.Parse(json); - - var raster = ParseGdalInfo(doc); - var srid = raster.Srid ?? 4326; - - var metadata = new DatasetMetadata - { - Common = new CommonMetadata - { - SourceFormat = nameof(DatasetFormat.Cog), - SourceSrid = srid, - TargetSrid = srid, - BoundingBox = raster.BoundingBox, - ProcessingDurationMs = sw.Elapsed.TotalMilliseconds, - }, - Raster = new RasterMetadata - { - Width = raster.Width, - Height = raster.Height, - BandCount = raster.BandCount, - PixelSizeX = raster.PixelSizeX, - PixelSizeY = raster.PixelSizeY, - Compression = raster.Compression, - }, - }; - - return new DatasetProcessingResult - { - SourceSrid = srid, - TargetSrid = srid, - BoundingBox = raster.BoundingBox, - FeatureCount = null, - Metadata = metadata, - NormalizedGeoJson = null, - }; - } - - private static GdalInfoParsed ParseGdalInfo(JsonDocument doc) - { - var root = doc.RootElement; - var result = new GdalInfoParsed(); - - if (root.TryGetProperty("size", out var size) && size.GetArrayLength() >= 2) - { - result.Width = size[0].GetInt32(); - result.Height = size[1].GetInt32(); - } - - if (root.TryGetProperty("bands", out var bands)) - { - result.BandCount = bands.GetArrayLength(); - } - - // Extract SRID from projjson (same approach as OgrVectorProcessor) - if ( - root.TryGetProperty("coordinateSystem", out var cs) - && cs.TryGetProperty("projjson", out var projjson) - && projjson.TryGetProperty("id", out var id) - && id.TryGetProperty("code", out var code) - ) - { - result.Srid = code.GetInt32(); - } - - if (root.TryGetProperty("geoTransform", out var gt) && gt.GetArrayLength() >= 6) - { - result.PixelSizeX = Math.Abs(gt[1].GetDouble()); - result.PixelSizeY = Math.Abs(gt[5].GetDouble()); - - var originX = gt[0].GetDouble(); - var originY = gt[3].GetDouble(); - var pxX = gt[1].GetDouble(); - var pxY = gt[5].GetDouble(); - - if (result.Width > 0 && result.Height > 0) - { - var x1 = originX; - var y1 = originY; - var x2 = originX + pxX * result.Width; - var y2 = originY + pxY * result.Height; - - result.BoundingBox = new BoundingBoxDto - { - MinX = Math.Min(x1, x2), - MinY = Math.Min(y1, y2), - MaxX = Math.Max(x1, x2), - MaxY = Math.Max(y1, y2), - }; - } - } - - if ( - root.TryGetProperty("metadata", out var meta) - && meta.TryGetProperty("IMAGE_STRUCTURE", out var imgStruct) - && imgStruct.TryGetProperty("COMPRESSION", out var compression) - ) - { - result.Compression = compression.GetString(); - } - - return result; - } - - private sealed class GdalInfoParsed - { - public int Width { get; set; } - public int Height { get; set; } - public int BandCount { get; set; } - public int? Srid { get; set; } - public double PixelSizeX { get; set; } - public double PixelSizeY { get; set; } - public string? Compression { get; set; } - public BoundingBoxDto? BoundingBox { get; set; } - } -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/Processing/DatasetProcessorRegistry.cs b/modules/Datasets/src/SimpleModule.Datasets/Processing/DatasetProcessorRegistry.cs deleted file mode 100644 index 94b371c6..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Processing/DatasetProcessorRegistry.cs +++ /dev/null @@ -1,14 +0,0 @@ -using SimpleModule.Datasets.Contracts; - -namespace SimpleModule.Datasets.Processing; - -public sealed class DatasetProcessorRegistry(IEnumerable processors) -{ - private readonly Dictionary _byFormat = - processors.ToDictionary(p => p.Format); - - public IDatasetProcessor Resolve(DatasetFormat format) => - _byFormat.TryGetValue(format, out var processor) - ? processor - : throw new InvalidOperationException($"No processor registered for format {format}"); -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/Processing/GeoJsonBboxWalker.cs b/modules/Datasets/src/SimpleModule.Datasets/Processing/GeoJsonBboxWalker.cs deleted file mode 100644 index 9c0b51c7..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Processing/GeoJsonBboxWalker.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Text.Json; - -namespace SimpleModule.Datasets.Processing; - -internal static class GeoJsonBboxWalker -{ - public static void Expand( - JsonElement element, - ref double minX, - ref double minY, - ref double maxX, - ref double maxY - ) - { - if (element.ValueKind != JsonValueKind.Array) - { - return; - } - using var enumerator = element.EnumerateArray().GetEnumerator(); - if (!enumerator.MoveNext()) - { - return; - } - if (enumerator.Current.ValueKind == JsonValueKind.Number) - { - var x = enumerator.Current.GetDouble(); - if (!enumerator.MoveNext()) - { - return; - } - var y = enumerator.Current.GetDouble(); - if (x < minX) - { - minX = x; - } - if (y < minY) - { - minY = y; - } - if (x > maxX) - { - maxX = x; - } - if (y > maxY) - { - maxY = y; - } - return; - } - foreach (var child in element.EnumerateArray()) - { - Expand(child, ref minX, ref minY, ref maxX, ref maxY); - } - } -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/Processing/GeoJsonProcessor.cs b/modules/Datasets/src/SimpleModule.Datasets/Processing/GeoJsonProcessor.cs deleted file mode 100644 index 501766ce..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Processing/GeoJsonProcessor.cs +++ /dev/null @@ -1,138 +0,0 @@ -using System.Diagnostics; -using System.Text.Json; -using SimpleModule.Datasets.Contracts; - -namespace SimpleModule.Datasets.Processing; - -/// -/// Fully functional GeoJSON processor. Parses a FeatureCollection (or single Feature/Geometry), -/// computes a bbox, feature count, attribute schema and geometry type summary. -/// Assumes input is in EPSG:4326 (GeoJSON spec default) — no reprojection performed. -/// -public sealed class GeoJsonProcessor : IDatasetProcessor -{ - public DatasetFormat Format => DatasetFormat.GeoJson; - - public async Task ProcessAsync(Stream content, CancellationToken ct) - { - var sw = Stopwatch.StartNew(); - using var doc = await JsonDocument.ParseAsync(content, cancellationToken: ct); - var root = doc.RootElement; - - double minX = double.PositiveInfinity, - minY = double.PositiveInfinity; - double maxX = double.NegativeInfinity, - maxY = double.NegativeInfinity; - - long featureCount = 0; - var geometryTypes = new HashSet(StringComparer.Ordinal); - var attributes = new Dictionary(StringComparer.Ordinal); - - void ConsumeFeature(JsonElement feature) - { - featureCount++; - if ( - feature.TryGetProperty("geometry", out var geom) - && geom.ValueKind == JsonValueKind.Object - ) - { - if (geom.TryGetProperty("type", out var gt) && gt.ValueKind == JsonValueKind.String) - { - geometryTypes.Add(gt.GetString() ?? "Unknown"); - } - if (geom.TryGetProperty("coordinates", out var coords)) - { - GeoJsonBboxWalker.Expand(coords, ref minX, ref minY, ref maxX, ref maxY); - } - } - if ( - feature.TryGetProperty("properties", out var props) - && props.ValueKind == JsonValueKind.Object - ) - { - foreach (var prop in props.EnumerateObject()) - { - if (!attributes.TryGetValue(prop.Name, out var field)) - { - field = new AttributeField - { - Name = prop.Name, - Type = prop.Value.ValueKind.ToString(), - }; - attributes[prop.Name] = field; - } - if (field.SampleValues.Count < 5) - { - field.SampleValues.Add(prop.Value.ToString()); - } - } - } - } - - if (root.TryGetProperty("type", out var typeEl) && typeEl.ValueKind == JsonValueKind.String) - { - var type = typeEl.GetString(); - if (type == "FeatureCollection" && root.TryGetProperty("features", out var features)) - { - foreach (var feature in features.EnumerateArray()) - { - ConsumeFeature(feature); - } - } - else if (type == "Feature") - { - ConsumeFeature(root); - } - else if (root.TryGetProperty("coordinates", out var coords)) - { - featureCount = 1; - geometryTypes.Add(type ?? "Unknown"); - GeoJsonBboxWalker.Expand(coords, ref minX, ref minY, ref maxX, ref maxY); - } - } - - var bbox = double.IsInfinity(minX) - ? null - : new BoundingBoxDto - { - MinX = minX, - MinY = minY, - MaxX = maxX, - MaxY = maxY, - }; - - var metadata = new DatasetMetadata - { - Common = new CommonMetadata - { - SourceFormat = nameof(DatasetFormat.GeoJson), - SourceSrid = 4326, - TargetSrid = 4326, - BoundingBox = bbox, - ProcessingDurationMs = sw.Elapsed.TotalMilliseconds, - }, - Vector = new VectorMetadata - { - FeatureCount = (int)featureCount, - GeometryTypes = [.. geometryTypes], - AttributeSchema = [.. attributes.Values], - CrsWkt = null, - }, - }; - - // Re-serialize the raw bytes as the normalized cache. Parse already validated it. - content.Position = 0; - using var ms = new MemoryStream(); - await content.CopyToAsync(ms, ct); - - return new DatasetProcessingResult - { - SourceSrid = 4326, - TargetSrid = 4326, - BoundingBox = bbox, - FeatureCount = featureCount, - Metadata = metadata, - NormalizedGeoJson = ms.ToArray(), - }; - } -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/Processing/IDatasetProcessor.cs b/modules/Datasets/src/SimpleModule.Datasets/Processing/IDatasetProcessor.cs deleted file mode 100644 index c288974c..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Processing/IDatasetProcessor.cs +++ /dev/null @@ -1,27 +0,0 @@ -using SimpleModule.Datasets.Contracts; - -namespace SimpleModule.Datasets.Processing; - -public interface IDatasetProcessor -{ - DatasetFormat Format { get; } - - Task ProcessAsync(Stream content, CancellationToken ct); -} - -public sealed class DatasetProcessingResult -{ - public int? SourceSrid { get; init; } - public int TargetSrid { get; init; } = 4326; - public BoundingBoxDto? BoundingBox { get; init; } - public long? FeatureCount { get; init; } - public DatasetMetadata Metadata { get; init; } = new(); - - /// - /// Normalized (reprojected + canonicalized) GeoJSON bytes for vector datasets. - /// Null for tile/raster sources which do not produce a normalized GeoJSON cache. - /// -#pragma warning disable CA1819 - public byte[]? NormalizedGeoJson { get; init; } -#pragma warning restore CA1819 -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/Processing/OgrVectorProcessor.cs b/modules/Datasets/src/SimpleModule.Datasets/Processing/OgrVectorProcessor.cs deleted file mode 100644 index e9cf84b6..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Processing/OgrVectorProcessor.cs +++ /dev/null @@ -1,234 +0,0 @@ -using System.Diagnostics; -using System.Text.Json; -using SimpleModule.Datasets.Contracts; -using SimpleModule.Datasets.Infrastructure; - -namespace SimpleModule.Datasets.Processing; - -/// -/// Common base for vector formats that GDAL/OGR can read natively (Shapefile, KML, GeoPackage). -/// Shells out to ogrinfo -json for metadata and ogr2ogr for GeoJSON normalization. -/// -public abstract class OgrVectorProcessor : IDatasetProcessor -{ - public abstract DatasetFormat Format { get; } - - protected abstract string TempFileExtension { get; } - - /// - /// For Shapefiles (.zip), unzip and return the .shp path. - /// Default returns the input path unchanged. - /// - protected virtual Task PrepareInputAsync( - string inputPath, - string tempDir, - CancellationToken ct - ) => Task.FromResult(inputPath); - - public async Task ProcessAsync(Stream content, CancellationToken ct) - { - var sw = Stopwatch.StartNew(); - using var tmp = new TempDirectory("ogr"); - var inputPath = Path.Combine(tmp.Path, $"input{TempFileExtension}"); - - await using (var fs = File.Create(inputPath)) - { - await content.CopyToAsync(fs, ct); - } - - var ogrPath = await PrepareInputAsync(inputPath, tmp.Path, ct); - - var infoJson = await CliRunner.RunAsync("ogrinfo", ["-json", "-al", "-so", ogrPath], ct); - using var ogrInfo = JsonDocument.Parse(infoJson); - var parsed = ParseOgrInfo(ogrInfo); - - var geojsonPath = Path.Combine(tmp.Path, "output.geojson"); - await CliRunner.RunAsync( - "ogr2ogr", - ["-f", "GeoJSON", "-t_srs", "EPSG:4326", geojsonPath, ogrPath], - ct - ); - var normalizedGeoJson = await File.ReadAllBytesAsync(geojsonPath, ct); - - var metadata = new DatasetMetadata - { - Common = new CommonMetadata - { - SourceFormat = Format.ToString(), - SourceSrid = parsed.Srid ?? 4326, - TargetSrid = 4326, - BoundingBox = parsed.BoundingBox, - ProcessingDurationMs = sw.Elapsed.TotalMilliseconds, - }, - Vector = new VectorMetadata - { - FeatureCount = parsed.FeatureCount, - GeometryTypes = parsed.GeometryTypes, - AttributeSchema = parsed.Attributes, - LayerNames = parsed.LayerNames, - CrsWkt = parsed.CrsWkt, - }, - }; - - return new DatasetProcessingResult - { - SourceSrid = parsed.Srid ?? 4326, - TargetSrid = 4326, - BoundingBox = parsed.BoundingBox, - FeatureCount = parsed.FeatureCount, - Metadata = metadata, - NormalizedGeoJson = normalizedGeoJson, - }; - } - - private static OgrInfoParsed ParseOgrInfo(JsonDocument doc) - { - var result = new OgrInfoParsed(); - var root = doc.RootElement; - - if (!root.TryGetProperty("layers", out var layers)) - return result; - - double minX = double.PositiveInfinity, - minY = double.PositiveInfinity; - double maxX = double.NegativeInfinity, - maxY = double.NegativeInfinity; - - foreach (var layer in layers.EnumerateArray()) - { - if (layer.TryGetProperty("name", out var layerName)) - { - result.LayerNames.Add(layerName.GetString() ?? ""); - } - - if (layer.TryGetProperty("featureCount", out var fc)) - { - result.FeatureCount += fc.GetInt32(); - } - - if ( - layer.TryGetProperty("geometryFields", out var geoFields) - && geoFields.GetArrayLength() > 0 - ) - { - foreach (var gf in geoFields.EnumerateArray()) - { - if (gf.TryGetProperty("type", out var gt)) - { - var gtype = gt.GetString() ?? ""; - if (gtype.Length > 0 && !result.GeometryTypes.Contains(gtype)) - { - result.GeometryTypes.Add(gtype); - } - } - - if (gf.TryGetProperty("extent", out var extent) && extent.GetArrayLength() >= 4) - { - var eMinX = extent[0].GetDouble(); - var eMinY = extent[1].GetDouble(); - var eMaxX = extent[2].GetDouble(); - var eMaxY = extent[3].GetDouble(); - - if (eMinX < minX) - minX = eMinX; - if (eMinY < minY) - minY = eMinY; - if (eMaxX > maxX) - maxX = eMaxX; - if (eMaxY > maxY) - maxY = eMaxY; - } - - if (gf.TryGetProperty("coordinateSystem", out var cs)) - { - if (cs.TryGetProperty("wkt", out var wkt)) - { - result.CrsWkt = wkt.GetString(); - } - if ( - cs.TryGetProperty("projjson", out var projjson) - && projjson.TryGetProperty("id", out var id) - && id.TryGetProperty("code", out var code) - ) - { - result.Srid = code.GetInt32(); - } - } - } - } - - if (layer.TryGetProperty("fields", out var fields)) - { - foreach (var field in fields.EnumerateArray()) - { - var name = field.TryGetProperty("name", out var n) ? n.GetString() ?? "" : ""; - var type = field.TryGetProperty("type", out var t) ? t.GetString() ?? "" : ""; - result.Attributes.Add(new AttributeField { Name = name, Type = type }); - } - } - } - - if (!double.IsInfinity(minX)) - { - result.BoundingBox = new BoundingBoxDto - { - MinX = minX, - MinY = minY, - MaxX = maxX, - MaxY = maxY, - }; - } - - return result; - } - - private sealed class OgrInfoParsed - { - public int FeatureCount { get; set; } - public List GeometryTypes { get; } = []; - public List Attributes { get; } = []; - public List LayerNames { get; } = []; - public BoundingBoxDto? BoundingBox { get; set; } - public int? Srid { get; set; } - public string? CrsWkt { get; set; } - } -} - -public sealed class ShapefileProcessor : OgrVectorProcessor -{ - public override DatasetFormat Format => DatasetFormat.Shapefile; - protected override string TempFileExtension => ".zip"; - - protected override async Task PrepareInputAsync( - string inputPath, - string tempDir, - CancellationToken ct - ) - { - var extractDir = Path.Combine(tempDir, "shp"); - Directory.CreateDirectory(extractDir); - - await CliRunner.RunAsync("unzip", ["-o", inputPath, "-d", extractDir], ct); - - var shpFile = Directory - .EnumerateFiles(extractDir, "*.shp", SearchOption.AllDirectories) - .FirstOrDefault(); - - return shpFile - ?? throw new InvalidOperationException( - "No .shp file found inside the uploaded zip archive." - ); - } -} - -public sealed class KmlProcessor : OgrVectorProcessor -{ - public override DatasetFormat Format => DatasetFormat.Kml; - protected override string TempFileExtension => ".kml"; -} - -public sealed class GeoPackageProcessor : OgrVectorProcessor -{ - public override DatasetFormat Format => DatasetFormat.GeoPackage; - protected override string TempFileExtension => ".gpkg"; -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/Processing/PmTilesProcessor.cs b/modules/Datasets/src/SimpleModule.Datasets/Processing/PmTilesProcessor.cs deleted file mode 100644 index 8fca9f0a..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Processing/PmTilesProcessor.cs +++ /dev/null @@ -1,174 +0,0 @@ -using System.Buffers.Binary; -using System.Diagnostics; -using System.Text.Json; -using SimpleModule.Datasets.Contracts; - -namespace SimpleModule.Datasets.Processing; - -/// -/// Extracts metadata from a PMTiles archive by reading the binary header. -/// PMTiles v3 header spec: https://github.com/protomaps/PMTiles/blob/main/spec/v3/spec.md -/// -/// Header layout (127 bytes total): -/// 0-6 : magic "PMTiles" (7 bytes UTF-8) -/// 7 : version (uint8) -/// 8-15 : root_dir_offset (uint64 LE) -/// 16-23 : root_dir_length (uint64 LE) -/// 24-31 : metadata_offset (uint64 LE) -/// 32-39 : metadata_length (uint64 LE) -/// 40-47 : leaf_dirs_offset (uint64 LE) -/// 48-55 : leaf_dirs_length (uint64 LE) -/// 56-63 : tile_data_offset (uint64 LE) -/// 64-71 : tile_data_length (uint64 LE) -/// 72-79 : num_addressed_tiles (uint64 LE) -/// 80-87 : num_tile_entries (uint64 LE) -/// 88-95 : num_tile_contents (uint64 LE) -/// 96 : clustered (uint8) -/// 97 : internal_compression (uint8) -/// 98 : tile_compression (uint8) -/// 99 : tile_type (uint8) -/// 100 : min_zoom (uint8) -/// 101 : max_zoom (uint8) -/// 102-105: min_lon_e7 (int32 LE) -/// 106-109: min_lat_e7 (int32 LE) -/// 110-113: max_lon_e7 (int32 LE) -/// 114-117: max_lat_e7 (int32 LE) -/// 118 : center_zoom (uint8) -/// 119-122: center_lon_e7 (int32 LE) -/// 123-126: center_lat_e7 (int32 LE) -/// -public sealed class PmTilesProcessor : IDatasetProcessor -{ - public DatasetFormat Format => DatasetFormat.PmTiles; - - private static ReadOnlySpan Magic => "PMTiles"u8; - private const int HeaderSize = 127; - - public async Task ProcessAsync(Stream content, CancellationToken ct) - { - var sw = Stopwatch.StartNew(); - - var header = new byte[HeaderSize]; - var bytesRead = await content.ReadAsync(header.AsMemory(0, HeaderSize), ct); - if (bytesRead < HeaderSize) - { - throw new InvalidOperationException( - $"PMTiles file too small: expected at least {HeaderSize} bytes, got {bytesRead}" - ); - } - - if (!header.AsSpan(0, 7).SequenceEqual(Magic)) - { - throw new InvalidOperationException("Not a valid PMTiles file: magic header mismatch"); - } - - var version = header[7]; - var metadataOffset = BinaryPrimitives.ReadUInt64LittleEndian(header.AsSpan(24, 8)); - var metadataLength = BinaryPrimitives.ReadUInt64LittleEndian(header.AsSpan(32, 8)); - var tileCount = (long)BinaryPrimitives.ReadUInt64LittleEndian(header.AsSpan(72, 8)); - var tileType = header[99]; - var minZoom = header[100]; - var maxZoom = header[101]; - var minLonE7 = BinaryPrimitives.ReadInt32LittleEndian(header.AsSpan(102, 4)); - var minLatE7 = BinaryPrimitives.ReadInt32LittleEndian(header.AsSpan(106, 4)); - var maxLonE7 = BinaryPrimitives.ReadInt32LittleEndian(header.AsSpan(110, 4)); - var maxLatE7 = BinaryPrimitives.ReadInt32LittleEndian(header.AsSpan(114, 4)); - var centerLonE7 = BinaryPrimitives.ReadInt32LittleEndian(header.AsSpan(119, 4)); - var centerLatE7 = BinaryPrimitives.ReadInt32LittleEndian(header.AsSpan(123, 4)); - - var tileFormatName = tileType switch - { - 0 => "Unknown", - 1 => "mvt", - 2 => "png", - 3 => "jpeg", - 4 => "webp", - 5 => "avif", - _ => $"type_{tileType}", - }; - - var layerNames = new List(); - - if ( - metadataLength > 0 - && metadataLength < 10 * 1024 * 1024 - && metadataOffset >= HeaderSize - && content.CanSeek - ) - { - try - { - content.Position = (long)metadataOffset; - var metaBytes = new byte[(int)metadataLength]; - await content.ReadExactlyAsync(metaBytes, ct); - - // PMTiles metadata may be gzip-compressed; try JSON parse first - try - { - using var metaDoc = JsonDocument.Parse(metaBytes); - if (metaDoc.RootElement.TryGetProperty("vector_layers", out var vectorLayers)) - { - foreach (var vl in vectorLayers.EnumerateArray()) - { - if (vl.TryGetProperty("id", out var id)) - { - layerNames.Add(id.GetString() ?? ""); - } - } - } - } -#pragma warning disable CA1031 - catch - { /* gzip-compressed or invalid JSON — skip */ - } -#pragma warning restore CA1031 - } -#pragma warning disable CA1031 - catch - { /* seek failed — skip metadata parsing */ - } -#pragma warning restore CA1031 - } - - var bbox = new BoundingBoxDto - { - MinX = minLonE7 / 10_000_000.0, - MinY = minLatE7 / 10_000_000.0, - MaxX = maxLonE7 / 10_000_000.0, - MaxY = maxLatE7 / 10_000_000.0, - }; - - var metadata = new DatasetMetadata - { - Common = new CommonMetadata - { - SourceFormat = nameof(DatasetFormat.PmTiles), - SourceSrid = 4326, - TargetSrid = 4326, - BoundingBox = bbox, - ProcessingDurationMs = sw.Elapsed.TotalMilliseconds, - }, - Tiles = new TileMetadata - { - TileFormat = tileFormatName, - MinZoom = minZoom, - MaxZoom = maxZoom, - CenterLon = centerLonE7 / 10_000_000.0, - CenterLat = centerLatE7 / 10_000_000.0, - TileCount = tileCount, - HeaderVersion = version, - LayerNames = layerNames, - }, - }; - - return new DatasetProcessingResult - { - SourceSrid = 4326, - TargetSrid = 4326, - BoundingBox = bbox, - FeatureCount = null, - Metadata = metadata, - NormalizedGeoJson = null, - }; - } -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/SimpleModule.Datasets.csproj b/modules/Datasets/src/SimpleModule.Datasets/SimpleModule.Datasets.csproj deleted file mode 100644 index dc3a2c4c..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/SimpleModule.Datasets.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - net10.0 - GIS datasets module for SimpleModule. Upload, process and expose GIS datasets (GeoJSON, Shapefile, KML, GeoPackage, PMTiles, COG). - - - - - - - - - - - - - %(Filename)View.cs - - - diff --git a/modules/Datasets/src/SimpleModule.Datasets/Views/DatasetsBrowseView.cs b/modules/Datasets/src/SimpleModule.Datasets/Views/DatasetsBrowseView.cs deleted file mode 100644 index a20902be..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Views/DatasetsBrowseView.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; -using SimpleModule.Core.Inertia; -using SimpleModule.Datasets.Contracts; - -namespace SimpleModule.Datasets.Views; - -public class DatasetsBrowseView : IViewEndpoint -{ - public const string Route = DatasetsConstants.Routes.Browse; - - public void Map(IEndpointRouteBuilder app) => - app.MapGet( - Route, - async (IDatasetsContracts datasets, CancellationToken ct) => - { - var rows = await datasets.GetAllAsync(ct); - return Inertia.Render("Datasets/Browse", new { datasets = rows }); - } - ) - .RequirePermission(DatasetsPermissions.View); -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/Views/DatasetsDetailView.cs b/modules/Datasets/src/SimpleModule.Datasets/Views/DatasetsDetailView.cs deleted file mode 100644 index 1768dc97..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Views/DatasetsDetailView.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; -using SimpleModule.Core.Inertia; -using SimpleModule.Datasets.Contracts; - -namespace SimpleModule.Datasets.Views; - -public class DatasetsDetailView : IViewEndpoint -{ - public const string Route = DatasetsConstants.Routes.Detail; - - public void Map(IEndpointRouteBuilder app) => - app.MapGet( - Route, - async (Guid id, IDatasetsContracts datasets, CancellationToken ct) => - { - var dto = await datasets.GetByIdAsync(DatasetId.From(id), ct); - if (dto is null) - { - return Results.NotFound(); - } - return Inertia.Render("Datasets/Detail", new { dataset = dto }); - } - ) - .RequirePermission(DatasetsPermissions.View); -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/Views/DatasetsUploadView.cs b/modules/Datasets/src/SimpleModule.Datasets/Views/DatasetsUploadView.cs deleted file mode 100644 index 63f2dad7..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/Views/DatasetsUploadView.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; -using SimpleModule.Core.Inertia; -using SimpleModule.Datasets.Contracts; - -namespace SimpleModule.Datasets.Views; - -public class DatasetsUploadView : IViewEndpoint -{ - public const string Route = DatasetsConstants.Routes.UploadView; - - public void Map(IEndpointRouteBuilder app) => - app.MapGet(Route, () => Inertia.Render("Datasets/Upload")) - .RequirePermission(DatasetsPermissions.Upload); -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/package.json b/modules/Datasets/src/SimpleModule.Datasets/package.json deleted file mode 100644 index cab72a3f..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "private": true, - "name": "@simplemodule/datasets", - "version": "0.0.0", - "scripts": { - "build": "cross-env VITE_MODE=prod vite build --configLoader runner", - "build:dev": "cross-env VITE_MODE=dev vite build --configLoader runner", - "watch": "cross-env VITE_MODE=dev vite build --configLoader runner --watch" - }, - "peerDependencies": { - "react": "^19.0.0", - "react-dom": "^19.0.0" - } -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/tsconfig.json b/modules/Datasets/src/SimpleModule.Datasets/tsconfig.json deleted file mode 100644 index 3e759d10..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "@simplemodule/tsconfig/base", - "compilerOptions": { - "paths": { - "@/*": ["./*"] - } - } -} diff --git a/modules/Datasets/src/SimpleModule.Datasets/types.ts b/modules/Datasets/src/SimpleModule.Datasets/types.ts deleted file mode 100644 index fce41fbc..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/types.ts +++ /dev/null @@ -1,100 +0,0 @@ -// Auto-generated from [Dto] types — do not edit -export interface BoundingBoxDto { - minX: number; - minY: number; - maxX: number; - maxY: number; -} - -export interface DatasetDto { - id: string; - name: string; - originalFileName: string; - format: any; - status: any; - sourceSrid: number | null; - srid: number | null; - boundingBox: BoundingBoxDto; - featureCount: number | null; - sizeBytes: number; - errorMessage: string; - metadata: DatasetMetadata; - createdAt: string; - processedAt: string | null; -} - -export interface DatasetFeatureDto { - id: string; - geometryGeoJson: string; - properties: Record; -} - -export interface DatasetMetadata { - common: CommonMetadata; - vector: VectorMetadata; - raster: RasterMetadata; - tiles: TileMetadata; - derivatives: DatasetDerivative[]; -} - -export interface CommonMetadata { - sourceFormat: string; - originalFileName: string; - sizeBytes: number; - contentHash: string; - sourceSrid: number | null; - targetSrid: number | null; - boundingBox: BoundingBoxDto; - processingDurationMs: number; - processorVersion: string; -} - -export interface VectorMetadata { - featureCount: number; - geometryTypes: string[]; - attributeSchema: AttributeField[]; - layerNames: string[]; - encoding: string; - crsWkt: string; -} - -export interface AttributeField { - name: string; - type: string; - sampleValues: string[]; -} - -export interface RasterMetadata { - width: number; - height: number; - bandCount: number; - bandTypes: string[]; - noDataValue: number | null; - pixelSizeX: number; - pixelSizeY: number; - overviewLevels: number[]; - compression: string; - crsWkt: string; -} - -export interface TileMetadata { - tileFormat: string; - minZoom: number; - maxZoom: number; - centerLon: number; - centerLat: number; - tileCount: number; - headerVersion: number; - layerNames: string[]; -} - -export interface DatasetDerivative { - format: any; - storagePath: string; - sizeBytes: number; - createdAt: string; -} - -export interface DatasetsPermissions { -} - diff --git a/modules/Datasets/src/SimpleModule.Datasets/vite.config.ts b/modules/Datasets/src/SimpleModule.Datasets/vite.config.ts deleted file mode 100644 index a247db62..00000000 --- a/modules/Datasets/src/SimpleModule.Datasets/vite.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { defineModuleConfig } from '@simplemodule/client/module'; - -export default defineModuleConfig(import.meta.dirname); diff --git a/modules/Datasets/tests/SimpleModule.Datasets.Tests/Fixtures/sample.geojson b/modules/Datasets/tests/SimpleModule.Datasets.Tests/Fixtures/sample.geojson deleted file mode 100644 index ad43ca66..00000000 --- a/modules/Datasets/tests/SimpleModule.Datasets.Tests/Fixtures/sample.geojson +++ /dev/null @@ -1,15 +0,0 @@ -{ - "type": "FeatureCollection", - "features": [ - { - "type": "Feature", - "geometry": { "type": "Point", "coordinates": [10.0, 20.0] }, - "properties": { "name": "A", "count": 1 } - }, - { - "type": "Feature", - "geometry": { "type": "Point", "coordinates": [30.0, 40.0] }, - "properties": { "name": "B", "count": 2 } - } - ] -} diff --git a/modules/Datasets/tests/SimpleModule.Datasets.Tests/GeoJsonProcessorTests.cs b/modules/Datasets/tests/SimpleModule.Datasets.Tests/GeoJsonProcessorTests.cs deleted file mode 100644 index 9dc0cc7b..00000000 --- a/modules/Datasets/tests/SimpleModule.Datasets.Tests/GeoJsonProcessorTests.cs +++ /dev/null @@ -1,27 +0,0 @@ -using FluentAssertions; -using SimpleModule.Datasets.Contracts; -using SimpleModule.Datasets.Processing; - -namespace SimpleModule.Datasets.Tests; - -public sealed class GeoJsonProcessorTests -{ - [Fact] - public async Task ProcessAsync_Extracts_Bbox_And_Feature_Count() - { - var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "sample.geojson"); - await using var stream = File.OpenRead(path); - var processor = new GeoJsonProcessor(); - - var result = await processor.ProcessAsync(stream, TestContext.Current.CancellationToken); - - processor.Format.Should().Be(DatasetFormat.GeoJson); - result.FeatureCount.Should().Be(2); - result.BoundingBox.Should().NotBeNull(); - result.BoundingBox!.MinX.Should().Be(10.0); - result.BoundingBox.MinY.Should().Be(20.0); - result.BoundingBox.MaxX.Should().Be(30.0); - result.BoundingBox.MaxY.Should().Be(40.0); - result.NormalizedGeoJson.Should().NotBeNull(); - } -} diff --git a/modules/Datasets/tests/SimpleModule.Datasets.Tests/GlobalUsings.cs b/modules/Datasets/tests/SimpleModule.Datasets.Tests/GlobalUsings.cs deleted file mode 100644 index c802f448..00000000 --- a/modules/Datasets/tests/SimpleModule.Datasets.Tests/GlobalUsings.cs +++ /dev/null @@ -1 +0,0 @@ -global using Xunit; diff --git a/modules/Datasets/tests/SimpleModule.Datasets.Tests/PurgeDatasetJobTests.cs b/modules/Datasets/tests/SimpleModule.Datasets.Tests/PurgeDatasetJobTests.cs deleted file mode 100644 index 9735c49a..00000000 --- a/modules/Datasets/tests/SimpleModule.Datasets.Tests/PurgeDatasetJobTests.cs +++ /dev/null @@ -1,241 +0,0 @@ -using System.Text.Json; -using FluentAssertions; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using SimpleModule.BackgroundJobs.Contracts; -using SimpleModule.Database; -using SimpleModule.Datasets.Contracts; -using SimpleModule.Datasets.Jobs; -using SimpleModule.Storage; - -namespace SimpleModule.Datasets.Tests; - -public sealed class PurgeDatasetJobTests : IDisposable -{ - private readonly SqliteConnection _connection; - - public PurgeDatasetJobTests() - { - _connection = new SqliteConnection("Data Source=:memory:"); - _connection.Open(); - } - - public void Dispose() => _connection.Dispose(); - - [Fact] - public async Task ExecuteAsync_Deletes_Original_Normalized_And_All_Derivatives() - { - await using var db = CreateDbContext(); - var storage = new InMemoryStorage(); - - var id = DatasetId.From(Guid.NewGuid()); - const string original = "datasets/abc/original.geojson"; - const string normalized = "datasets/abc/normalized.geojson"; - const string derivativePmTiles = "datasets/abc/derivatives/PmTiles.pmtiles"; - const string derivativeCog = "datasets/abc/derivatives/Cog.tif"; - - storage.Seed(original); - storage.Seed(normalized); - storage.Seed(derivativePmTiles); - storage.Seed(derivativeCog); - - var metadata = new DatasetMetadata - { - Derivatives = - { - new DatasetDerivative - { - Format = DatasetFormat.PmTiles, - StoragePath = derivativePmTiles, - SizeBytes = 1024, - CreatedAt = DateTimeOffset.UtcNow, - }, - new DatasetDerivative - { - Format = DatasetFormat.Cog, - StoragePath = derivativeCog, - SizeBytes = 2048, - CreatedAt = DateTimeOffset.UtcNow, - }, - }, - }; - - db.Datasets.Add( - new Dataset - { - Id = id, - Name = "sample", - OriginalFileName = "sample.geojson", - Format = DatasetFormat.GeoJson, - Status = DatasetStatus.Ready, - StoragePath = original, - NormalizedPath = normalized, - SizeBytes = 100, - MetadataJson = JsonSerializer.Serialize(metadata), - CreatedAt = DateTimeOffset.UtcNow, - UpdatedAt = DateTimeOffset.UtcNow, - IsDeleted = true, - DeletedAt = DateTimeOffset.UtcNow, - ConcurrencyStamp = Guid.NewGuid().ToString("N"), - } - ); - await db.SaveChangesAsync(TestContext.Current.CancellationToken); - - var job = new PurgeDatasetJob(db, storage, NullLogger.Instance); - var context = new FakeJobExecutionContext( - JsonSerializer.Serialize(new PurgeDatasetJobData { DatasetId = id.Value }) - ); - - await job.ExecuteAsync(context, TestContext.Current.CancellationToken); - - storage.Contains(original).Should().BeFalse(); - storage.Contains(normalized).Should().BeFalse(); - storage.Contains(derivativePmTiles).Should().BeFalse(); - storage.Contains(derivativeCog).Should().BeFalse(); - } - - [Fact] - public async Task ExecuteAsync_Finds_SoftDeleted_Row_Despite_QueryFilter() - { - await using var db = CreateDbContext(); - var storage = new InMemoryStorage(); - - var id = DatasetId.From(Guid.NewGuid()); - const string original = "datasets/xyz/original.geojson"; - storage.Seed(original); - - db.Datasets.Add( - new Dataset - { - Id = id, - Name = "hidden", - OriginalFileName = "hidden.geojson", - Format = DatasetFormat.GeoJson, - Status = DatasetStatus.Ready, - StoragePath = original, - SizeBytes = 10, - CreatedAt = DateTimeOffset.UtcNow, - UpdatedAt = DateTimeOffset.UtcNow, - IsDeleted = true, - DeletedAt = DateTimeOffset.UtcNow, - ConcurrencyStamp = Guid.NewGuid().ToString("N"), - } - ); - await db.SaveChangesAsync(TestContext.Current.CancellationToken); - - // Sanity check: default query filter hides the row. - ( - await db.Datasets.FirstOrDefaultAsync( - d => d.Id == id, - TestContext.Current.CancellationToken - ) - ) - .Should() - .BeNull(); - - var job = new PurgeDatasetJob(db, storage, NullLogger.Instance); - var context = new FakeJobExecutionContext( - JsonSerializer.Serialize(new PurgeDatasetJobData { DatasetId = id.Value }) - ); - - await job.ExecuteAsync(context, TestContext.Current.CancellationToken); - - storage.Contains(original).Should().BeFalse(); - } - - [Fact] - public async Task ExecuteAsync_Missing_Dataset_Returns_Without_Throwing() - { - await using var db = CreateDbContext(); - var storage = new InMemoryStorage(); - - var job = new PurgeDatasetJob(db, storage, NullLogger.Instance); - var context = new FakeJobExecutionContext( - JsonSerializer.Serialize(new PurgeDatasetJobData { DatasetId = Guid.NewGuid() }) - ); - - var act = async () => - await job.ExecuteAsync(context, TestContext.Current.CancellationToken); - await act.Should().NotThrowAsync(); - } - - private TestDatasetsDbContext CreateDbContext() - { - var options = new DbContextOptionsBuilder() - .UseSqlite(_connection) - .Options; - var dbOptions = Options.Create( - new DatabaseOptions { DefaultConnection = "Data Source=:memory:" } - ); - var context = new TestDatasetsDbContext(options, dbOptions); - context.Database.EnsureCreated(); - return context; - } - - private sealed class TestDatasetsDbContext( - DbContextOptions options, - IOptions dbOptions - ) : DatasetsDbContext(options, dbOptions) - { - protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) - { - base.ConfigureConventions(configurationBuilder); - configurationBuilder - .Properties() - .HaveConversion(); - configurationBuilder - .Properties() - .HaveConversion(); - } - } - - private sealed class InMemoryStorage : IStorageProvider - { - private readonly HashSet _paths = new(StringComparer.Ordinal); - - public void Seed(string path) => _paths.Add(path); - - public bool Contains(string path) => _paths.Contains(path); - - public Task SaveAsync( - string path, - Stream content, - string contentType, - CancellationToken cancellationToken = default - ) - { - _paths.Add(path); - return Task.FromResult(new StorageResult(path, 0, contentType)); - } - - public Task GetAsync(string path, CancellationToken cancellationToken = default) => - Task.FromResult(_paths.Contains(path) ? new MemoryStream() : null); - - public Task DeleteAsync(string path, CancellationToken cancellationToken = default) => - Task.FromResult(_paths.Remove(path)); - - public Task ExistsAsync(string path, CancellationToken cancellationToken = default) => - Task.FromResult(_paths.Contains(path)); - - public Task> ListAsync( - string prefix, - CancellationToken cancellationToken = default - ) => Task.FromResult>([]); - } - - private sealed class FakeJobExecutionContext(string data) : IJobExecutionContext - { - public JobId JobId { get; } = JobId.From(Guid.NewGuid()); - - public T GetData() => - JsonSerializer.Deserialize(data) - ?? throw new InvalidOperationException("null payload"); - - public void ReportProgress(int percentage, string? message = null) { } - - public void Log(string message) { } - } -} diff --git a/modules/Datasets/tests/SimpleModule.Datasets.Tests/SimpleModule.Datasets.Tests.csproj b/modules/Datasets/tests/SimpleModule.Datasets.Tests/SimpleModule.Datasets.Tests.csproj deleted file mode 100644 index 107082c7..00000000 --- a/modules/Datasets/tests/SimpleModule.Datasets.Tests/SimpleModule.Datasets.Tests.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - net10.0 - false - Exe - - - - - - - - - - - - - - - - - - - diff --git a/modules/Map/src/SimpleModule.Map.Contracts/Basemap.cs b/modules/Map/src/SimpleModule.Map.Contracts/Basemap.cs deleted file mode 100644 index c570735a..00000000 --- a/modules/Map/src/SimpleModule.Map.Contracts/Basemap.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using SimpleModule.Core; -using SimpleModule.Core.Entities; - -namespace SimpleModule.Map.Contracts; - -/// -/// A reusable base map definition. Each basemap points at a MapLibre style JSON URL -/// (vector or raster) and is shown to the user as a swappable background underneath -/// the overlay stack of a . -/// -[Dto] -public class Basemap : AuditableEntity -{ - public string Name { get; set; } = string.Empty; - public string? Description { get; set; } - - /// MapLibre style JSON URL (vector or raster style). - [SuppressMessage( - "Design", - "CA1056:URI-like properties should not be strings", - Justification = "Stored verbatim and serialized to the JS client." - )] - public string StyleUrl { get; set; } = string.Empty; - - public string? Attribution { get; set; } - - /// Optional preview thumbnail URL displayed in the basemap switcher. - [SuppressMessage( - "Design", - "CA1056:URI-like properties should not be strings", - Justification = "Stored verbatim and serialized to the JS client." - )] - public string? ThumbnailUrl { get; set; } -} diff --git a/modules/Map/src/SimpleModule.Map.Contracts/BasemapId.cs b/modules/Map/src/SimpleModule.Map.Contracts/BasemapId.cs deleted file mode 100644 index df37e429..00000000 --- a/modules/Map/src/SimpleModule.Map.Contracts/BasemapId.cs +++ /dev/null @@ -1,6 +0,0 @@ -using Vogen; - -namespace SimpleModule.Map.Contracts; - -[ValueObject(conversions: Conversions.SystemTextJson | Conversions.EfCoreValueConverter)] -public readonly partial struct BasemapId; diff --git a/modules/Map/src/SimpleModule.Map.Contracts/CreateBasemapRequest.cs b/modules/Map/src/SimpleModule.Map.Contracts/CreateBasemapRequest.cs deleted file mode 100644 index 78f44bb8..00000000 --- a/modules/Map/src/SimpleModule.Map.Contracts/CreateBasemapRequest.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using SimpleModule.Core; - -namespace SimpleModule.Map.Contracts; - -[Dto] -public class CreateBasemapRequest -{ - public string Name { get; set; } = string.Empty; - public string? Description { get; set; } - - [SuppressMessage( - "Design", - "CA1056:URI-like properties should not be strings", - Justification = "Stored verbatim and serialized to the JS client." - )] - public string StyleUrl { get; set; } = string.Empty; - - public string? Attribution { get; set; } - - [SuppressMessage( - "Design", - "CA1056:URI-like properties should not be strings", - Justification = "Stored verbatim and serialized to the JS client." - )] - public string? ThumbnailUrl { get; set; } -} diff --git a/modules/Map/src/SimpleModule.Map.Contracts/CreateLayerSourceFromDatasetRequest.cs b/modules/Map/src/SimpleModule.Map.Contracts/CreateLayerSourceFromDatasetRequest.cs deleted file mode 100644 index 09845032..00000000 --- a/modules/Map/src/SimpleModule.Map.Contracts/CreateLayerSourceFromDatasetRequest.cs +++ /dev/null @@ -1,11 +0,0 @@ -using SimpleModule.Core; - -namespace SimpleModule.Map.Contracts; - -[Dto] -public class CreateLayerSourceFromDatasetRequest -{ - public Guid DatasetId { get; set; } - public string? Name { get; set; } - public string? Description { get; set; } -} diff --git a/modules/Map/src/SimpleModule.Map.Contracts/CreateLayerSourceRequest.cs b/modules/Map/src/SimpleModule.Map.Contracts/CreateLayerSourceRequest.cs deleted file mode 100644 index 02bd3772..00000000 --- a/modules/Map/src/SimpleModule.Map.Contracts/CreateLayerSourceRequest.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using SimpleModule.Core; - -namespace SimpleModule.Map.Contracts; - -[Dto] -public class CreateLayerSourceRequest -{ - public string Name { get; set; } = string.Empty; - public string? Description { get; set; } - public LayerSourceType Type { get; set; } - - [SuppressMessage( - "Design", - "CA1056:URI-like properties should not be strings", - Justification = "Stored verbatim and serialized to the JS client; Uri normalization would change the value." - )] - public string Url { get; set; } = string.Empty; - - public string? Attribution { get; set; } - public int? MinZoom { get; set; } - public int? MaxZoom { get; set; } - - [SuppressMessage( - "Performance", - "CA1819:Properties should not return arrays", - Justification = "Bounding box is a fixed-length [w,s,e,n] tuple serialized as a JSON array." - )] - public double[]? Bounds { get; set; } - - public Dictionary Metadata { get; set; } = []; -} diff --git a/modules/Map/src/SimpleModule.Map.Contracts/IMapContracts.cs b/modules/Map/src/SimpleModule.Map.Contracts/IMapContracts.cs deleted file mode 100644 index e866d60b..00000000 --- a/modules/Map/src/SimpleModule.Map.Contracts/IMapContracts.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace SimpleModule.Map.Contracts; - -public interface IMapContracts -{ - // Layer source catalog - Task> GetAllLayerSourcesAsync(); - Task GetLayerSourceByIdAsync(LayerSourceId id); - Task CreateLayerSourceAsync(CreateLayerSourceRequest request); - Task CreateLayerSourceFromDatasetAsync( - CreateLayerSourceFromDatasetRequest request, - CancellationToken ct = default - ); - Task UpdateLayerSourceAsync(LayerSourceId id, UpdateLayerSourceRequest request); - Task DeleteLayerSourceAsync(LayerSourceId id); - - /// - /// Returns the singleton default map. Creates it lazily on first access using - /// MapModuleOptions defaults so the application always has exactly one map. - /// - Task GetDefaultMapAsync(CancellationToken ct = default); - - /// - /// Upserts the singleton default map. The fixed - /// is preserved across calls; layers and basemaps are replaced wholesale. - /// - Task UpdateDefaultMapAsync( - UpdateDefaultMapRequest request, - CancellationToken ct = default - ); - - // Basemap catalog - Task> GetAllBasemapsAsync(); - Task GetBasemapByIdAsync(BasemapId id); - Task CreateBasemapAsync(CreateBasemapRequest request); - Task UpdateBasemapAsync(BasemapId id, UpdateBasemapRequest request); - Task DeleteBasemapAsync(BasemapId id); -} diff --git a/modules/Map/src/SimpleModule.Map.Contracts/LayerSource.cs b/modules/Map/src/SimpleModule.Map.Contracts/LayerSource.cs deleted file mode 100644 index 736d67fc..00000000 --- a/modules/Map/src/SimpleModule.Map.Contracts/LayerSource.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Text.Json.Serialization; -using NetTopologySuite.Geometries; -using SimpleModule.Core; -using SimpleModule.Core.Entities; - -namespace SimpleModule.Map.Contracts; - -/// -/// A reusable definition of a remote map data source (WMS, WFS, WMTS, PMTiles, COG, etc.). -/// Layer sources are catalogued centrally and referenced by compositions. -/// -[Dto] -public class LayerSource : AuditableEntity -{ - public string Name { get; set; } = string.Empty; - public string? Description { get; set; } - public LayerSourceType Type { get; set; } - - /// Base URL of the source. Format depends on . - [SuppressMessage( - "Design", - "CA1056:URI-like properties should not be strings", - Justification = "Stored verbatim and serialized to the JS client; Uri normalization would change the value." - )] - public string Url { get; set; } = string.Empty; - - public string? Attribution { get; set; } - public int? MinZoom { get; set; } - public int? MaxZoom { get; set; } - - /// - /// JSON-friendly bounding box [west, south, east, north] in WGS84 (EPSG:4326) - /// for transport to the React client. - /// - [SuppressMessage( - "Performance", - "CA1819:Properties should not return arrays", - Justification = "Bounding box is a fixed-length [w,s,e,n] tuple serialized as a JSON array for the client." - )] - public double[]? Bounds { get; set; } - - /// - /// Server-side spatial coverage polygon backed by a provider-native geometry column - /// (PostGIS, SQL Server geometry, or SpatiaLite). Used for spatial queries - /// (e.g., ST_Intersects) but never serialized to the client. - /// Kept in sync with by MapService. - /// - [JsonIgnore] - public Geometry? Coverage { get; set; } - - /// - /// Free-form, type-specific metadata stored as JSON. Examples: - /// WMS: { "layers": "OSM-WMS", "format": "image/png", "crs": "EPSG:3857", "transparent": true } - /// WMTS: { "layer": "...", "tileMatrixSet": "...", "style": "default", "format": "image/png" } - /// WFS: { "typeName": "...", "outputFormat": "application/json", "version": "2.0.0" } - /// PMTiles: { "tileType": "vector" } - /// COG: { "rescale": "0,255", "colormap": "viridis" } - /// - public Dictionary Metadata { get; set; } = []; -} diff --git a/modules/Map/src/SimpleModule.Map.Contracts/LayerSourceId.cs b/modules/Map/src/SimpleModule.Map.Contracts/LayerSourceId.cs deleted file mode 100644 index 949fc5ec..00000000 --- a/modules/Map/src/SimpleModule.Map.Contracts/LayerSourceId.cs +++ /dev/null @@ -1,6 +0,0 @@ -using Vogen; - -namespace SimpleModule.Map.Contracts; - -[ValueObject(conversions: Conversions.SystemTextJson | Conversions.EfCoreValueConverter)] -public readonly partial struct LayerSourceId; diff --git a/modules/Map/src/SimpleModule.Map.Contracts/LayerSourceType.cs b/modules/Map/src/SimpleModule.Map.Contracts/LayerSourceType.cs deleted file mode 100644 index e3c776d2..00000000 --- a/modules/Map/src/SimpleModule.Map.Contracts/LayerSourceType.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace SimpleModule.Map.Contracts; - -/// -/// Supported layer source types for the map. Each value maps to a specific -/// MapLibre source/protocol on the client. -/// -public enum LayerSourceType -{ - Wms = 0, - Wmts = 1, - Wfs = 2, - Xyz = 3, - VectorTile = 4, - PmTiles = 5, - Cog = 6, - GeoJson = 7, - Dataset = 8, -} diff --git a/modules/Map/src/SimpleModule.Map.Contracts/MapBasemap.cs b/modules/Map/src/SimpleModule.Map.Contracts/MapBasemap.cs deleted file mode 100644 index e51b910a..00000000 --- a/modules/Map/src/SimpleModule.Map.Contracts/MapBasemap.cs +++ /dev/null @@ -1,17 +0,0 @@ -using SimpleModule.Core; - -namespace SimpleModule.Map.Contracts; - -/// -/// A reference to a catalogued from a . -/// The viewer's basemap switcher offers all entries in this list; the lowest -/// is shown by default. -/// -[Dto] -public class MapBasemap -{ - public BasemapId BasemapId { get; set; } - - /// Display order in the basemap switcher; lowest is the default. - public int Order { get; set; } -} diff --git a/modules/Map/src/SimpleModule.Map.Contracts/MapConstants.cs b/modules/Map/src/SimpleModule.Map.Contracts/MapConstants.cs deleted file mode 100644 index 793d7ae4..00000000 --- a/modules/Map/src/SimpleModule.Map.Contracts/MapConstants.cs +++ /dev/null @@ -1,55 +0,0 @@ -namespace SimpleModule.Map.Contracts; - -public static class MapConstants -{ - public const string ModuleName = "Map"; - public const string RoutePrefix = "/api/map"; - public const string ViewPrefix = "/map"; - - /// - /// Stable identity of the singleton default map. The Map module manages exactly - /// one map composition; this fixed id lets the service upsert it without a - /// dedicated "is default" column. - /// - public static readonly SavedMapId DefaultMapId = SavedMapId.From( - new Guid("00000000-0000-0000-0000-000000000001") - ); - - public static class Routes - { - // Layer source CRUD - public const string GetAllSources = "/sources"; - public const string GetSourceById = "/sources/{id}"; - public const string CreateSource = "/sources"; - public const string CreateSourceFromDataset = "/sources/from-dataset"; - public const string UpdateSource = "/sources/{id}"; - public const string DeleteSource = "/sources/{id}"; - - // Basemap catalog CRUD - public const string GetAllBasemaps = "/basemaps"; - public const string GetBasemapById = "/basemaps/{id}"; - public const string CreateBasemap = "/basemaps"; - public const string UpdateBasemap = "/basemaps/{id}"; - public const string DeleteBasemap = "/basemaps/{id}"; - - // Default map (singleton) - public const string GetDefaultMap = "/default"; - public const string UpdateDefaultMap = "/default"; - - // Views - public const string Browse = "/"; - public const string Layers = "/layers"; - } - - /// - /// Runtime-editable setting keys registered by MapModule.ConfigureSettings - /// and exposed in the generic admin settings UI. Values override the compile-time - /// defaults from MapModuleOptions. - /// - public static class SettingKeys - { - public const string EnableMeasureTools = "Map.EnableMeasureTools"; - public const string EnableExportPng = "Map.EnableExportPng"; - public const string EnableGeolocate = "Map.EnableGeolocate"; - } -} diff --git a/modules/Map/src/SimpleModule.Map.Contracts/MapLayer.cs b/modules/Map/src/SimpleModule.Map.Contracts/MapLayer.cs deleted file mode 100644 index 0294455b..00000000 --- a/modules/Map/src/SimpleModule.Map.Contracts/MapLayer.cs +++ /dev/null @@ -1,24 +0,0 @@ -using SimpleModule.Core; - -namespace SimpleModule.Map.Contracts; - -/// -/// A reference to a within a , -/// with composition-specific overrides (order, visibility, opacity, style). -/// Owned by its parent . -/// -[Dto] -public class MapLayer -{ - public LayerSourceId LayerSourceId { get; set; } - - /// Render order, low values draw first (bottom). - public int Order { get; set; } - public bool Visible { get; set; } = true; - - /// 0..1 opacity multiplier applied client-side. - public double Opacity { get; set; } = 1.0; - - /// Optional MapLibre paint/layout property overrides as JSON. - public Dictionary StyleOverrides { get; set; } = []; -} diff --git a/modules/Map/src/SimpleModule.Map.Contracts/SavedMap.cs b/modules/Map/src/SimpleModule.Map.Contracts/SavedMap.cs deleted file mode 100644 index a50bbba6..00000000 --- a/modules/Map/src/SimpleModule.Map.Contracts/SavedMap.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Text.Json.Serialization; -using NetTopologySuite.Geometries; -using SimpleModule.Core; -using SimpleModule.Core.Entities; - -namespace SimpleModule.Map.Contracts; - -/// -/// A named, persistent map composition: viewport state, base style and an ordered list of -/// entries pointing at catalogued definitions. -/// -[Dto] -public class SavedMap : AuditableEntity -{ - public string Name { get; set; } = string.Empty; - public string? Description { get; set; } - - /// JSON-friendly viewport center longitude (WGS84) for the React client. - public double CenterLng { get; set; } - - /// JSON-friendly viewport center latitude (WGS84) for the React client. - public double CenterLat { get; set; } - - public double Zoom { get; set; } - public double Pitch { get; set; } - public double Bearing { get; set; } - - /// MapLibre style JSON URL used as the base layer for this map. - [SuppressMessage( - "Design", - "CA1056:URI-like properties should not be strings", - Justification = "Stored verbatim and serialized to the JS client; Uri normalization would change the value." - )] - public string BaseStyleUrl { get; set; } = string.Empty; - - /// - /// Server-side spatial point of the viewport center, backed by a provider-native - /// geometry column. Used for spatial queries (e.g., "maps near this location"). - /// Kept in sync with / by MapService. - /// - [JsonIgnore] - public Point? Center { get; set; } - - public List Layers { get; set; } = []; - - /// - /// Catalogued basemaps available in this map's basemap switcher. The entry with - /// the lowest is the default. When empty the map - /// falls back to . - /// - public List Basemaps { get; set; } = []; -} diff --git a/modules/Map/src/SimpleModule.Map.Contracts/SavedMapId.cs b/modules/Map/src/SimpleModule.Map.Contracts/SavedMapId.cs deleted file mode 100644 index f548edf8..00000000 --- a/modules/Map/src/SimpleModule.Map.Contracts/SavedMapId.cs +++ /dev/null @@ -1,6 +0,0 @@ -using Vogen; - -namespace SimpleModule.Map.Contracts; - -[ValueObject(conversions: Conversions.SystemTextJson | Conversions.EfCoreValueConverter)] -public readonly partial struct SavedMapId; diff --git a/modules/Map/src/SimpleModule.Map.Contracts/SimpleModule.Map.Contracts.csproj b/modules/Map/src/SimpleModule.Map.Contracts/SimpleModule.Map.Contracts.csproj deleted file mode 100644 index 016eded6..00000000 --- a/modules/Map/src/SimpleModule.Map.Contracts/SimpleModule.Map.Contracts.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - net10.0 - Library - $(DefineConstants);VOGEN_NO_VALIDATION - - - - - - - - diff --git a/modules/Map/src/SimpleModule.Map.Contracts/UpdateBasemapRequest.cs b/modules/Map/src/SimpleModule.Map.Contracts/UpdateBasemapRequest.cs deleted file mode 100644 index 84ad7f5c..00000000 --- a/modules/Map/src/SimpleModule.Map.Contracts/UpdateBasemapRequest.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using SimpleModule.Core; - -namespace SimpleModule.Map.Contracts; - -[Dto] -public class UpdateBasemapRequest -{ - public string Name { get; set; } = string.Empty; - public string? Description { get; set; } - - [SuppressMessage( - "Design", - "CA1056:URI-like properties should not be strings", - Justification = "Stored verbatim and serialized to the JS client." - )] - public string StyleUrl { get; set; } = string.Empty; - - public string? Attribution { get; set; } - - [SuppressMessage( - "Design", - "CA1056:URI-like properties should not be strings", - Justification = "Stored verbatim and serialized to the JS client." - )] - public string? ThumbnailUrl { get; set; } -} diff --git a/modules/Map/src/SimpleModule.Map.Contracts/UpdateDefaultMapRequest.cs b/modules/Map/src/SimpleModule.Map.Contracts/UpdateDefaultMapRequest.cs deleted file mode 100644 index b0c9c736..00000000 --- a/modules/Map/src/SimpleModule.Map.Contracts/UpdateDefaultMapRequest.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using SimpleModule.Core; - -namespace SimpleModule.Map.Contracts; - -/// -/// Upsert payload for the singleton default map. Carries viewport state, the -/// fallback base style, the ordered overlay layers and the catalog basemaps the -/// switcher exposes. Name/Description are managed server-side. -/// -[Dto] -public class UpdateDefaultMapRequest -{ - public double CenterLng { get; set; } - public double CenterLat { get; set; } - public double Zoom { get; set; } - public double Pitch { get; set; } - public double Bearing { get; set; } - - [SuppressMessage( - "Design", - "CA1056:URI-like properties should not be strings", - Justification = "Stored verbatim and serialized to the JS client; Uri normalization would change the value." - )] - public string BaseStyleUrl { get; set; } = string.Empty; - - public List Layers { get; set; } = []; - public List Basemaps { get; set; } = []; -} diff --git a/modules/Map/src/SimpleModule.Map.Contracts/UpdateLayerSourceRequest.cs b/modules/Map/src/SimpleModule.Map.Contracts/UpdateLayerSourceRequest.cs deleted file mode 100644 index 28b9d0d7..00000000 --- a/modules/Map/src/SimpleModule.Map.Contracts/UpdateLayerSourceRequest.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using SimpleModule.Core; - -namespace SimpleModule.Map.Contracts; - -[Dto] -public class UpdateLayerSourceRequest -{ - public string Name { get; set; } = string.Empty; - public string? Description { get; set; } - public LayerSourceType Type { get; set; } - - [SuppressMessage( - "Design", - "CA1056:URI-like properties should not be strings", - Justification = "Stored verbatim and serialized to the JS client; Uri normalization would change the value." - )] - public string Url { get; set; } = string.Empty; - - public string? Attribution { get; set; } - public int? MinZoom { get; set; } - public int? MaxZoom { get; set; } - - [SuppressMessage( - "Performance", - "CA1819:Properties should not return arrays", - Justification = "Bounding box is a fixed-length [w,s,e,n] tuple serialized as a JSON array." - )] - public double[]? Bounds { get; set; } - - public Dictionary Metadata { get; set; } = []; -} diff --git a/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/CreateBasemapEndpoint.cs b/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/CreateBasemapEndpoint.cs deleted file mode 100644 index 60bfc012..00000000 --- a/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/CreateBasemapEndpoint.cs +++ /dev/null @@ -1,41 +0,0 @@ -using FluentValidation; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; -using SimpleModule.Core.Endpoints; -using SimpleModule.Core.Validation; -using SimpleModule.Map.Contracts; - -namespace SimpleModule.Map.Endpoints.Basemaps; - -public class CreateBasemapEndpoint : IEndpoint -{ - public const string Route = MapConstants.Routes.CreateBasemap; - public const string Method = "POST"; - - public void Map(IEndpointRouteBuilder app) => - app.MapPost( - Route, - async ( - CreateBasemapRequest request, - IValidator validator, - IMapContracts map - ) => - { - var validation = await validator.ValidateAsync(request); - if (!validation.IsValid) - { - throw new Core.Exceptions.ValidationException( - validation.ToValidationErrors() - ); - } - - return await CrudEndpoints.Create( - () => map.CreateBasemapAsync(request), - b => $"{MapConstants.RoutePrefix}/basemaps/{b.Id}" - ); - } - ) - .RequirePermission(MapPermissions.ManageSources); -} diff --git a/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/CreateBasemapRequestValidator.cs b/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/CreateBasemapRequestValidator.cs deleted file mode 100644 index 3c742838..00000000 --- a/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/CreateBasemapRequestValidator.cs +++ /dev/null @@ -1,17 +0,0 @@ -using FluentValidation; -using SimpleModule.Map.Contracts; - -namespace SimpleModule.Map.Endpoints.Basemaps; - -public sealed class CreateBasemapRequestValidator : AbstractValidator -{ - public CreateBasemapRequestValidator() - { - RuleFor(x => x.Name).NotEmpty().WithMessage("Name is required."); - RuleFor(x => x.StyleUrl) - .NotEmpty() - .WithMessage("StyleUrl is required.") - .MaximumLength(2048) - .WithMessage("StyleUrl must be 2048 characters or fewer."); - } -} diff --git a/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/DeleteBasemapEndpoint.cs b/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/DeleteBasemapEndpoint.cs deleted file mode 100644 index 7bb2e5b8..00000000 --- a/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/DeleteBasemapEndpoint.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; -using SimpleModule.Core.Endpoints; -using SimpleModule.Map.Contracts; - -namespace SimpleModule.Map.Endpoints.Basemaps; - -public class DeleteBasemapEndpoint : IEndpoint -{ - public const string Route = MapConstants.Routes.DeleteBasemap; - public const string Method = "DELETE"; - - public void Map(IEndpointRouteBuilder app) => - app.MapDelete( - Route, - (BasemapId id, IMapContracts map) => - CrudEndpoints.Delete(() => map.DeleteBasemapAsync(id)) - ) - .RequirePermission(MapPermissions.ManageSources); -} diff --git a/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/GetAllBasemapsEndpoint.cs b/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/GetAllBasemapsEndpoint.cs deleted file mode 100644 index b5541b1b..00000000 --- a/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/GetAllBasemapsEndpoint.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; -using SimpleModule.Core.Endpoints; -using SimpleModule.Map.Contracts; - -namespace SimpleModule.Map.Endpoints.Basemaps; - -public class GetAllBasemapsEndpoint : IEndpoint -{ - public const string Route = MapConstants.Routes.GetAllBasemaps; - - public void Map(IEndpointRouteBuilder app) => - app.MapGet(Route, (IMapContracts map) => CrudEndpoints.GetAll(map.GetAllBasemapsAsync)) - .RequirePermission(MapPermissions.View); -} diff --git a/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/GetBasemapByIdEndpoint.cs b/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/GetBasemapByIdEndpoint.cs deleted file mode 100644 index 6b3791b7..00000000 --- a/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/GetBasemapByIdEndpoint.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; -using SimpleModule.Core.Endpoints; -using SimpleModule.Map.Contracts; - -namespace SimpleModule.Map.Endpoints.Basemaps; - -public class GetBasemapByIdEndpoint : IEndpoint -{ - public const string Route = MapConstants.Routes.GetBasemapById; - - public void Map(IEndpointRouteBuilder app) => - app.MapGet( - Route, - (BasemapId id, IMapContracts map) => - CrudEndpoints.GetById(() => map.GetBasemapByIdAsync(id)) - ) - .RequirePermission(MapPermissions.View); -} diff --git a/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/UpdateBasemapEndpoint.cs b/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/UpdateBasemapEndpoint.cs deleted file mode 100644 index 6c635838..00000000 --- a/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/UpdateBasemapEndpoint.cs +++ /dev/null @@ -1,39 +0,0 @@ -using FluentValidation; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; -using SimpleModule.Core.Endpoints; -using SimpleModule.Core.Validation; -using SimpleModule.Map.Contracts; - -namespace SimpleModule.Map.Endpoints.Basemaps; - -public class UpdateBasemapEndpoint : IEndpoint -{ - public const string Route = MapConstants.Routes.UpdateBasemap; - public const string Method = "PUT"; - - public void Map(IEndpointRouteBuilder app) => - app.MapPut( - Route, - async ( - BasemapId id, - UpdateBasemapRequest request, - IValidator validator, - IMapContracts map - ) => - { - var validation = await validator.ValidateAsync(request); - if (!validation.IsValid) - { - throw new Core.Exceptions.ValidationException( - validation.ToValidationErrors() - ); - } - - return await CrudEndpoints.Update(() => map.UpdateBasemapAsync(id, request)); - } - ) - .RequirePermission(MapPermissions.ManageSources); -} diff --git a/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/UpdateBasemapRequestValidator.cs b/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/UpdateBasemapRequestValidator.cs deleted file mode 100644 index 4ff8bb8f..00000000 --- a/modules/Map/src/SimpleModule.Map/Endpoints/Basemaps/UpdateBasemapRequestValidator.cs +++ /dev/null @@ -1,17 +0,0 @@ -using FluentValidation; -using SimpleModule.Map.Contracts; - -namespace SimpleModule.Map.Endpoints.Basemaps; - -public sealed class UpdateBasemapRequestValidator : AbstractValidator -{ - public UpdateBasemapRequestValidator() - { - RuleFor(x => x.Name).NotEmpty().WithMessage("Name is required."); - RuleFor(x => x.StyleUrl) - .NotEmpty() - .WithMessage("StyleUrl is required.") - .MaximumLength(2048) - .WithMessage("StyleUrl must be 2048 characters or fewer."); - } -} diff --git a/modules/Map/src/SimpleModule.Map/Endpoints/DefaultMap/GetDefaultMapEndpoint.cs b/modules/Map/src/SimpleModule.Map/Endpoints/DefaultMap/GetDefaultMapEndpoint.cs deleted file mode 100644 index 1bbc21e5..00000000 --- a/modules/Map/src/SimpleModule.Map/Endpoints/DefaultMap/GetDefaultMapEndpoint.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; -using SimpleModule.Map.Contracts; - -namespace SimpleModule.Map.Endpoints.DefaultMap; - -public class GetDefaultMapEndpoint : IEndpoint -{ - public const string Route = MapConstants.Routes.GetDefaultMap; - - public void Map(IEndpointRouteBuilder app) => - app.MapGet( - Route, - async (IMapContracts map) => TypedResults.Ok(await map.GetDefaultMapAsync()) - ) - .RequirePermission(MapPermissions.View); -} diff --git a/modules/Map/src/SimpleModule.Map/Endpoints/DefaultMap/UpdateDefaultMapEndpoint.cs b/modules/Map/src/SimpleModule.Map/Endpoints/DefaultMap/UpdateDefaultMapEndpoint.cs deleted file mode 100644 index a8715164..00000000 --- a/modules/Map/src/SimpleModule.Map/Endpoints/DefaultMap/UpdateDefaultMapEndpoint.cs +++ /dev/null @@ -1,38 +0,0 @@ -using FluentValidation; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; -using SimpleModule.Core.Endpoints; -using SimpleModule.Core.Validation; -using SimpleModule.Map.Contracts; - -namespace SimpleModule.Map.Endpoints.DefaultMap; - -public class UpdateDefaultMapEndpoint : IEndpoint -{ - public const string Route = MapConstants.Routes.UpdateDefaultMap; - public const string Method = "PUT"; - - public void Map(IEndpointRouteBuilder app) => - app.MapPut( - Route, - async ( - UpdateDefaultMapRequest request, - IValidator validator, - IMapContracts map - ) => - { - var validation = await validator.ValidateAsync(request); - if (!validation.IsValid) - { - throw new Core.Exceptions.ValidationException( - validation.ToValidationErrors() - ); - } - - return await CrudEndpoints.Update(() => map.UpdateDefaultMapAsync(request)); - } - ) - .RequirePermission(MapPermissions.Update); -} diff --git a/modules/Map/src/SimpleModule.Map/Endpoints/DefaultMap/UpdateDefaultMapRequestValidator.cs b/modules/Map/src/SimpleModule.Map/Endpoints/DefaultMap/UpdateDefaultMapRequestValidator.cs deleted file mode 100644 index 51c459a8..00000000 --- a/modules/Map/src/SimpleModule.Map/Endpoints/DefaultMap/UpdateDefaultMapRequestValidator.cs +++ /dev/null @@ -1,24 +0,0 @@ -using FluentValidation; -using Microsoft.Extensions.Options; -using SimpleModule.Map.Contracts; - -namespace SimpleModule.Map.Endpoints.DefaultMap; - -public sealed class UpdateDefaultMapRequestValidator : AbstractValidator -{ - public UpdateDefaultMapRequestValidator(IOptions options) - { - var maxLayers = options.Value.MaxLayersPerMap; - - RuleFor(x => x.CenterLat) - .InclusiveBetween(-90, 90) - .WithMessage("Latitude must be between -90 and 90."); - RuleFor(x => x.CenterLng) - .InclusiveBetween(-180, 180) - .WithMessage("Longitude must be between -180 and 180."); - RuleFor(x => x.Zoom).InclusiveBetween(0, 24).WithMessage("Zoom must be between 0 and 24."); - RuleFor(x => x.Layers) - .Must(layers => layers is null || layers.Count <= maxLayers) - .WithMessage($"A map may not contain more than {maxLayers} layers."); - } -} diff --git a/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/CreateEndpoint.cs b/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/CreateEndpoint.cs deleted file mode 100644 index d96b54f1..00000000 --- a/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/CreateEndpoint.cs +++ /dev/null @@ -1,41 +0,0 @@ -using FluentValidation; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; -using SimpleModule.Core.Endpoints; -using SimpleModule.Core.Validation; -using SimpleModule.Map.Contracts; - -namespace SimpleModule.Map.Endpoints.LayerSources; - -public class CreateLayerSourceEndpoint : IEndpoint -{ - public const string Route = MapConstants.Routes.CreateSource; - public const string Method = "POST"; - - public void Map(IEndpointRouteBuilder app) => - app.MapPost( - Route, - async ( - CreateLayerSourceRequest request, - IValidator validator, - IMapContracts map - ) => - { - var validation = await validator.ValidateAsync(request); - if (!validation.IsValid) - { - throw new Core.Exceptions.ValidationException( - validation.ToValidationErrors() - ); - } - - return await CrudEndpoints.Create( - () => map.CreateLayerSourceAsync(request), - s => $"{MapConstants.RoutePrefix}/sources/{s.Id}" - ); - } - ) - .RequirePermission(MapPermissions.ManageSources); -} diff --git a/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/CreateFromDatasetEndpoint.cs b/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/CreateFromDatasetEndpoint.cs deleted file mode 100644 index dac2e213..00000000 --- a/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/CreateFromDatasetEndpoint.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; -using SimpleModule.Core.Endpoints; -using SimpleModule.Map.Contracts; - -namespace SimpleModule.Map.Endpoints.LayerSources; - -public class CreateFromDatasetEndpoint : IEndpoint -{ - public const string Route = MapConstants.Routes.CreateSourceFromDataset; - public const string Method = "POST"; - - public void Map(IEndpointRouteBuilder app) => - app.MapPost( - Route, - ( - CreateLayerSourceFromDatasetRequest request, - IMapContracts map, - CancellationToken ct - ) => - CrudEndpoints.Create( - () => map.CreateLayerSourceFromDatasetAsync(request, ct), - s => $"{MapConstants.RoutePrefix}/sources/{s.Id}" - ) - ) - .RequirePermission(MapPermissions.ManageSources); -} diff --git a/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/CreateLayerSourceRequestValidator.cs b/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/CreateLayerSourceRequestValidator.cs deleted file mode 100644 index a12a7583..00000000 --- a/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/CreateLayerSourceRequestValidator.cs +++ /dev/null @@ -1,20 +0,0 @@ -using FluentValidation; -using SimpleModule.Map.Contracts; - -namespace SimpleModule.Map.Endpoints.LayerSources; - -public sealed class CreateLayerSourceRequestValidator : AbstractValidator -{ - public CreateLayerSourceRequestValidator() - { - RuleFor(x => x.Name).NotEmpty().WithMessage("Name is required."); - RuleFor(x => x.Url) - .NotEmpty() - .WithMessage("Url is required.") - .MaximumLength(2048) - .WithMessage("Url must be 2048 characters or fewer."); - RuleFor(x => x.Type) - .Must(t => Enum.IsDefined(t)) - .WithMessage("Type must be a known LayerSourceType."); - } -} diff --git a/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/DeleteEndpoint.cs b/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/DeleteEndpoint.cs deleted file mode 100644 index 383652a5..00000000 --- a/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/DeleteEndpoint.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; -using SimpleModule.Core.Endpoints; -using SimpleModule.Map.Contracts; - -namespace SimpleModule.Map.Endpoints.LayerSources; - -public class DeleteLayerSourceEndpoint : IEndpoint -{ - public const string Route = MapConstants.Routes.DeleteSource; - public const string Method = "DELETE"; - - public void Map(IEndpointRouteBuilder app) => - app.MapDelete( - Route, - (LayerSourceId id, IMapContracts map) => - CrudEndpoints.Delete(() => map.DeleteLayerSourceAsync(id)) - ) - .RequirePermission(MapPermissions.ManageSources); -} diff --git a/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/GetAllEndpoint.cs b/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/GetAllEndpoint.cs deleted file mode 100644 index 343c7821..00000000 --- a/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/GetAllEndpoint.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; -using SimpleModule.Core.Endpoints; -using SimpleModule.Map.Contracts; - -namespace SimpleModule.Map.Endpoints.LayerSources; - -public class GetAllLayerSourcesEndpoint : IEndpoint -{ - public const string Route = MapConstants.Routes.GetAllSources; - - public void Map(IEndpointRouteBuilder app) => - app.MapGet(Route, (IMapContracts map) => CrudEndpoints.GetAll(map.GetAllLayerSourcesAsync)) - .RequirePermission(MapPermissions.ViewSources); -} diff --git a/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/GetByIdEndpoint.cs b/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/GetByIdEndpoint.cs deleted file mode 100644 index b4d14edd..00000000 --- a/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/GetByIdEndpoint.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; -using SimpleModule.Core.Endpoints; -using SimpleModule.Map.Contracts; - -namespace SimpleModule.Map.Endpoints.LayerSources; - -public class GetLayerSourceByIdEndpoint : IEndpoint -{ - public const string Route = MapConstants.Routes.GetSourceById; - - public void Map(IEndpointRouteBuilder app) => - app.MapGet( - Route, - (LayerSourceId id, IMapContracts map) => - CrudEndpoints.GetById(() => map.GetLayerSourceByIdAsync(id)) - ) - .RequirePermission(MapPermissions.ViewSources); -} diff --git a/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/UpdateEndpoint.cs b/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/UpdateEndpoint.cs deleted file mode 100644 index d39c5a28..00000000 --- a/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/UpdateEndpoint.cs +++ /dev/null @@ -1,41 +0,0 @@ -using FluentValidation; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Core; -using SimpleModule.Core.Authorization; -using SimpleModule.Core.Endpoints; -using SimpleModule.Core.Validation; -using SimpleModule.Map.Contracts; - -namespace SimpleModule.Map.Endpoints.LayerSources; - -public class UpdateLayerSourceEndpoint : IEndpoint -{ - public const string Route = MapConstants.Routes.UpdateSource; - public const string Method = "PUT"; - - public void Map(IEndpointRouteBuilder app) => - app.MapPut( - Route, - async ( - LayerSourceId id, - UpdateLayerSourceRequest request, - IValidator validator, - IMapContracts map - ) => - { - var validation = await validator.ValidateAsync(request); - if (!validation.IsValid) - { - throw new Core.Exceptions.ValidationException( - validation.ToValidationErrors() - ); - } - - return await CrudEndpoints.Update(() => - map.UpdateLayerSourceAsync(id, request) - ); - } - ) - .RequirePermission(MapPermissions.ManageSources); -} diff --git a/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/UpdateLayerSourceRequestValidator.cs b/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/UpdateLayerSourceRequestValidator.cs deleted file mode 100644 index e546a8a9..00000000 --- a/modules/Map/src/SimpleModule.Map/Endpoints/LayerSources/UpdateLayerSourceRequestValidator.cs +++ /dev/null @@ -1,20 +0,0 @@ -using FluentValidation; -using SimpleModule.Map.Contracts; - -namespace SimpleModule.Map.Endpoints.LayerSources; - -public sealed class UpdateLayerSourceRequestValidator : AbstractValidator -{ - public UpdateLayerSourceRequestValidator() - { - RuleFor(x => x.Name).NotEmpty().WithMessage("Name is required."); - RuleFor(x => x.Url) - .NotEmpty() - .WithMessage("Url is required.") - .MaximumLength(2048) - .WithMessage("Url must be 2048 characters or fewer."); - RuleFor(x => x.Type) - .Must(t => Enum.IsDefined(t)) - .WithMessage("Type must be a known LayerSourceType."); - } -} diff --git a/modules/Map/src/SimpleModule.Map/EntityConfigurations/BasemapConfiguration.cs b/modules/Map/src/SimpleModule.Map/EntityConfigurations/BasemapConfiguration.cs deleted file mode 100644 index f012b6ef..00000000 --- a/modules/Map/src/SimpleModule.Map/EntityConfigurations/BasemapConfiguration.cs +++ /dev/null @@ -1,120 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using SimpleModule.Map.Contracts; - -namespace SimpleModule.Map.EntityConfigurations; - -public class BasemapConfiguration : IEntityTypeConfiguration -{ - /// - /// Fixed ids for the basemaps seeded via . - /// Reference these from other seeders that link back to this catalog. - /// - public static class SeedIds - { - public static readonly BasemapId MapLibreDemotiles = BasemapId.From( - new Guid("22222222-2222-2222-2222-000000000001") - ); - public static readonly BasemapId OpenFreeMapLiberty = BasemapId.From( - new Guid("22222222-2222-2222-2222-000000000002") - ); - public static readonly BasemapId OpenFreeMapPositron = BasemapId.From( - new Guid("22222222-2222-2222-2222-000000000003") - ); - public static readonly BasemapId OpenFreeMapBright = BasemapId.From( - new Guid("22222222-2222-2222-2222-000000000004") - ); - public static readonly BasemapId VersatilesColorful = BasemapId.From( - new Guid("22222222-2222-2222-2222-000000000005") - ); - - public static IReadOnlyList All { get; } = - [ - MapLibreDemotiles, - OpenFreeMapLiberty, - OpenFreeMapPositron, - OpenFreeMapBright, - VersatilesColorful, - ]; - } - - public void Configure(EntityTypeBuilder builder) - { - builder.HasKey(b => b.Id); - builder.Property(b => b.Name).IsRequired().HasMaxLength(200); - builder.Property(b => b.Description).HasMaxLength(2000); - builder.Property(b => b.StyleUrl).IsRequired().HasMaxLength(2048); - builder.Property(b => b.Attribution).HasMaxLength(500); - builder.Property(b => b.ThumbnailUrl).HasMaxLength(2048); - builder.Property(b => b.ConcurrencyStamp).IsConcurrencyToken(); - - builder.HasData(GenerateSeedBasemaps()); - } - - /// - /// Seed catalog of free, publicly-hosted basemap styles. The first one - /// (MapLibre demotiles) is the global fallback used by every newly-created map. - /// - private static Basemap[] GenerateSeedBasemaps() - { - var seededAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero); - return - [ - new Basemap - { - Id = SeedIds.MapLibreDemotiles, - Name = "MapLibre Demotiles", - Description = "Official MapLibre demo vector style. Free for development.", - StyleUrl = "https://demotiles.maplibre.org/style.json", - Attribution = "MapLibre", - CreatedAt = seededAt, - UpdatedAt = seededAt, - ConcurrencyStamp = "seed-basemap-demotiles", - }, - new Basemap - { - Id = SeedIds.OpenFreeMapLiberty, - Name = "OpenFreeMap Liberty", - Description = "OpenFreeMap free vector basemap, Liberty style.", - StyleUrl = "https://tiles.openfreemap.org/styles/liberty", - Attribution = "© OpenStreetMap contributors, OpenFreeMap", - CreatedAt = seededAt, - UpdatedAt = seededAt, - ConcurrencyStamp = "seed-basemap-openfreemap-liberty", - }, - new Basemap - { - Id = SeedIds.OpenFreeMapPositron, - Name = "OpenFreeMap Positron", - Description = "OpenFreeMap free vector basemap, light Positron style.", - StyleUrl = "https://tiles.openfreemap.org/styles/positron", - Attribution = "© OpenStreetMap contributors, OpenFreeMap", - CreatedAt = seededAt, - UpdatedAt = seededAt, - ConcurrencyStamp = "seed-basemap-openfreemap-positron", - }, - new Basemap - { - Id = SeedIds.OpenFreeMapBright, - Name = "OpenFreeMap Bright", - Description = "OpenFreeMap free vector basemap, Bright style.", - StyleUrl = "https://tiles.openfreemap.org/styles/bright", - Attribution = "© OpenStreetMap contributors, OpenFreeMap", - CreatedAt = seededAt, - UpdatedAt = seededAt, - ConcurrencyStamp = "seed-basemap-openfreemap-bright", - }, - new Basemap - { - Id = SeedIds.VersatilesColorful, - Name = "Versatiles Colorful", - Description = "VersaTiles free OSM-based vector basemap, Colorful style.", - StyleUrl = "https://tiles.versatiles.org/assets/styles/colorful/style.json", - Attribution = "© OpenStreetMap contributors, VersaTiles", - CreatedAt = seededAt, - UpdatedAt = seededAt, - ConcurrencyStamp = "seed-basemap-versatiles-colorful", - }, - ]; - } -} diff --git a/modules/Map/src/SimpleModule.Map/EntityConfigurations/JsonDictionaryConverter.cs b/modules/Map/src/SimpleModule.Map/EntityConfigurations/JsonDictionaryConverter.cs deleted file mode 100644 index cd7a50f5..00000000 --- a/modules/Map/src/SimpleModule.Map/EntityConfigurations/JsonDictionaryConverter.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Text.Json; -using Microsoft.EntityFrameworkCore.ChangeTracking; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace SimpleModule.Map.EntityConfigurations; - -/// -/// Maps a property to a JSON string column with -/// a that does element-wise change tracking. Without -/// the comparer, EF Core only diff-checks the reference and never persists in-place -/// dictionary mutations. -/// -internal static class JsonDictionaryConverter -{ - private static readonly JsonSerializerOptions JsonOptions = new(); - - public static PropertyBuilder> HasJsonDictionaryConversion( - this PropertyBuilder> property - ) - { - return property.HasConversion( - v => JsonSerializer.Serialize(v, JsonOptions), - v => - string.IsNullOrEmpty(v) - ? new Dictionary() - : JsonSerializer.Deserialize>(v, JsonOptions) - ?? new Dictionary(), - new ValueComparer>( - (a, b) => - (a == null && b == null) - || (a != null && b != null && a.Count == b.Count && !a.Except(b).Any()), - v => - v == null - ? 0 - : v.Aggregate(0, (h, kv) => HashCode.Combine(h, kv.Key, kv.Value)), - v => - v == null ? new Dictionary() : new Dictionary(v) - ) - ); - } -} diff --git a/modules/Map/src/SimpleModule.Map/EntityConfigurations/LayerSourceConfiguration.cs b/modules/Map/src/SimpleModule.Map/EntityConfigurations/LayerSourceConfiguration.cs deleted file mode 100644 index 41cc86bd..00000000 --- a/modules/Map/src/SimpleModule.Map/EntityConfigurations/LayerSourceConfiguration.cs +++ /dev/null @@ -1,247 +0,0 @@ -using System.Text.Json; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.ChangeTracking; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using SimpleModule.Map.Contracts; - -namespace SimpleModule.Map.EntityConfigurations; - -public class LayerSourceConfiguration : IEntityTypeConfiguration -{ - /// - /// Fixed ids for layer sources seeded via . - /// Reference these from other seeders that link back to this catalog. - /// - public static class SeedIds - { - public static readonly LayerSourceId OpenStreetMapXyz = LayerSourceId.From( - new Guid("11111111-1111-1111-1111-000000000001") - ); - public static readonly LayerSourceId TerrestrisOsmWms = LayerSourceId.From( - new Guid("11111111-1111-1111-1111-000000000002") - ); - public static readonly LayerSourceId TerrestrisTopoWms = LayerSourceId.From( - new Guid("11111111-1111-1111-1111-000000000003") - ); - public static readonly LayerSourceId MapLibreDemotilesVector = LayerSourceId.From( - new Guid("11111111-1111-1111-1111-000000000004") - ); - public static readonly LayerSourceId ProtomapsFirenzePmTiles = LayerSourceId.From( - new Guid("11111111-1111-1111-1111-000000000005") - ); - public static readonly LayerSourceId GeomaticoKrigingCog = LayerSourceId.From( - new Guid("11111111-1111-1111-1111-000000000006") - ); - public static readonly LayerSourceId MapLibreEarthquakesGeoJson = LayerSourceId.From( - new Guid("11111111-1111-1111-1111-000000000007") - ); - } - - /// - /// Toggles mapping of the spatial column. - /// Defaults to false; flipped on by MapModule.ConfigureServices - /// when the host opts in via Modules:Map:EnableSpatial = true and the - /// EF Core provider is configured with NetTopologySuite. - /// - public static bool EnableSpatial { get; set; } - - public void Configure(EntityTypeBuilder builder) - { - var enableSpatial = EnableSpatial; - builder.HasKey(s => s.Id); - builder.Property(s => s.Name).IsRequired().HasMaxLength(200); - builder.Property(s => s.Description).HasMaxLength(2000); - builder.Property(s => s.Url).IsRequired().HasMaxLength(2048); - builder.Property(s => s.Attribution).HasMaxLength(500); - builder.Property(s => s.ConcurrencyStamp).IsConcurrencyToken(); - - if (enableSpatial) - { - // Spatial coverage column. NetTopologySuite Geometry maps to: - // - PostgreSQL: geometry(Geometry, 4326) via PostGIS - // - SQL Server: geometry - // - SQLite: SpatiaLite GEOMETRY - // SRID 4326 (WGS84) is the standard for web maps (MapLibre, GeoJSON). - builder.Property(s => s.Coverage).HasColumnType("geometry"); - } - else - { - builder.Ignore(s => s.Coverage); - } - - var jsonOptions = new JsonSerializerOptions(); - - builder - .Property(s => s.Bounds) - .HasConversion( - v => v == null ? null : JsonSerializer.Serialize(v, jsonOptions), - v => - string.IsNullOrEmpty(v) - ? null - : JsonSerializer.Deserialize(v, jsonOptions), - new ValueComparer( - (a, b) => - (a == null && b == null) || (a != null && b != null && a.SequenceEqual(b)), - v => - v == null - ? 0 - : v.Aggregate(0, (h, x) => HashCode.Combine(h, x.GetHashCode())), - v => v == null ? null : v.ToArray() - ) - ); - - builder.Property(s => s.Metadata).HasJsonDictionaryConversion(); - - builder.HasData(GenerateSeedSources()); - } - - /// - /// Seed catalog using freely-available demo layers from the official MapLibre - /// examples and the maplibre-cog-protocol demo. These are stable URLs the project - /// is happy to depend on for development and integration smoke-testing. - /// - private static LayerSource[] GenerateSeedSources() - { - // Fixed deterministic timestamp so HasData isn't dirty on every model snapshot. - var seededAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero); - - // World bbox in WGS84 — every public web tile service covers it. - double[] worldBounds = [-180, -85, 180, 85]; - - return - [ - // ── Raster basemaps (XYZ) ──────────────────────────────────────────── - new LayerSource - { - Id = SeedIds.OpenStreetMapXyz, - Name = "OpenStreetMap (raster tiles)", - Description = - "Standard OSM raster tiles. Free for low-volume use; respect the OSMF tile usage policy.", - Type = LayerSourceType.Xyz, - Url = "https://tile.openstreetmap.org/{z}/{x}/{y}.png", - Attribution = "© OpenStreetMap contributors", - MinZoom = 0, - MaxZoom = 19, - Bounds = worldBounds, - Metadata = [], - CreatedAt = seededAt, - UpdatedAt = seededAt, - ConcurrencyStamp = "seed-osm-xyz", - }, - // ── WMS (terrestris demo, used in the official MapLibre WMS example) ─ - new LayerSource - { - Id = SeedIds.TerrestrisOsmWms, - Name = "terrestris OSM-WMS", - Description = - "Public WMS by terrestris. Used in the official MapLibre 'Add a WMS source' example.", - Type = LayerSourceType.Wms, - Url = "https://ows.terrestris.de/osm/service", - Attribution = "© OpenStreetMap contributors, terrestris", - Bounds = worldBounds, - Metadata = new Dictionary - { - ["layers"] = "OSM-WMS", - ["format"] = "image/png", - ["version"] = "1.1.1", - ["crs"] = "EPSG:3857", - ["transparent"] = "true", - }, - CreatedAt = seededAt, - UpdatedAt = seededAt, - ConcurrencyStamp = "seed-terrestris-wms", - }, - new LayerSource - { - Id = SeedIds.TerrestrisTopoWms, - Name = "terrestris TOPO-WMS", - Description = "terrestris topographic WMS overlay layer (transparent).", - Type = LayerSourceType.Wms, - Url = "https://ows.terrestris.de/osm/service", - Attribution = "© OpenStreetMap contributors, terrestris", - Bounds = worldBounds, - Metadata = new Dictionary - { - ["layers"] = "TOPO-WMS,OSM-Overlay-WMS", - ["format"] = "image/png", - ["version"] = "1.1.1", - ["crs"] = "EPSG:3857", - ["transparent"] = "true", - }, - CreatedAt = seededAt, - UpdatedAt = seededAt, - ConcurrencyStamp = "seed-terrestris-topo", - }, - // ── Vector tiles (MapLibre demotiles) ──────────────────────────────── - new LayerSource - { - Id = SeedIds.MapLibreDemotilesVector, - Name = "MapLibre demotiles (vector)", - Description = "Official MapLibre demo MVT vector tileset. Free for development.", - Type = LayerSourceType.VectorTile, - Url = "https://demotiles.maplibre.org/tiles/{z}/{x}/{y}.pbf", - Attribution = "MapLibre", - MinZoom = 0, - MaxZoom = 14, - Bounds = worldBounds, - Metadata = new Dictionary { ["sourceLayer"] = "countries" }, - CreatedAt = seededAt, - UpdatedAt = seededAt, - ConcurrencyStamp = "seed-maplibre-demotiles", - }, - // ── PMTiles (Protomaps demo archive used in MapLibre PMTiles example) ─ - new LayerSource - { - Id = SeedIds.ProtomapsFirenzePmTiles, - Name = "Protomaps Firenze (PMTiles)", - Description = - "Public PMTiles vector archive of Florence (ODbL). Used in the MapLibre PMTiles example.", - Type = LayerSourceType.PmTiles, - Url = "https://pmtiles.io/protomaps(vector)ODbL_firenze.pmtiles", - Attribution = "© OpenStreetMap contributors, Protomaps", - Bounds = [11.154, 43.727, 11.328, 43.823], - Metadata = new Dictionary - { - ["tileType"] = "vector", - ["sourceLayer"] = "landuse", - }, - CreatedAt = seededAt, - UpdatedAt = seededAt, - ConcurrencyStamp = "seed-protomaps-firenze", - }, - // ── COG (geomatico demo Cloud-Optimized GeoTIFF) ───────────────────── - new LayerSource - { - Id = SeedIds.GeomaticoKrigingCog, - Name = "Geomatico kriging COG (demo)", - Description = - "Public Cloud-Optimized GeoTIFF demo from the maplibre-cog-protocol sample viewer.", - Type = LayerSourceType.Cog, - Url = "https://labs.geomatico.es/maplibre-cog-protocol/data/kriging.tif", - Attribution = "Geomatico", - Bounds = worldBounds, - Metadata = [], - CreatedAt = seededAt, - UpdatedAt = seededAt, - ConcurrencyStamp = "seed-geomatico-cog", - }, - // ── GeoJSON (raw OSM Overpass-style demo: world airports subset) ───── - new LayerSource - { - Id = SeedIds.MapLibreEarthquakesGeoJson, - Name = "MapLibre demotiles point sample (GeoJSON)", - Description = - "Small public GeoJSON FeatureCollection from the MapLibre demo assets.", - Type = LayerSourceType.GeoJson, - Url = - "https://maplibre.org/maplibre-gl-js/docs/assets/significant-earthquakes-2015.geojson", - Attribution = "USGS / MapLibre demo", - Bounds = worldBounds, - Metadata = new Dictionary { ["color"] = "#ef4444" }, - CreatedAt = seededAt, - UpdatedAt = seededAt, - ConcurrencyStamp = "seed-maplibre-earthquakes", - }, - ]; - } -} diff --git a/modules/Map/src/SimpleModule.Map/EntityConfigurations/SavedMapConfiguration.cs b/modules/Map/src/SimpleModule.Map/EntityConfigurations/SavedMapConfiguration.cs deleted file mode 100644 index a8a9c71b..00000000 --- a/modules/Map/src/SimpleModule.Map/EntityConfigurations/SavedMapConfiguration.cs +++ /dev/null @@ -1,71 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using SimpleModule.Map.Contracts; - -namespace SimpleModule.Map.EntityConfigurations; - -public class SavedMapConfiguration : IEntityTypeConfiguration -{ - /// - /// Toggles mapping of the spatial column. - /// Defaults to false; flipped on by MapModule.ConfigureServices - /// when the host opts in via Modules:Map:EnableSpatial = true. - /// - public static bool EnableSpatial { get; set; } - - public void Configure(EntityTypeBuilder builder) - { - var enableSpatial = EnableSpatial; - builder.HasKey(m => m.Id); - builder.Property(m => m.Name).IsRequired().HasMaxLength(200); - builder.Property(m => m.Description).HasMaxLength(2000); - builder.Property(m => m.BaseStyleUrl).IsRequired().HasMaxLength(2048); - builder.Property(m => m.ConcurrencyStamp).IsConcurrencyToken(); - - if (enableSpatial) - { - // Spatial point of the map's center, SRID 4326 (WGS84). Backed by: - // - PostgreSQL: geometry(Point, 4326) via PostGIS - // - SQL Server: geometry - // - SQLite: SpatiaLite POINT - builder.Property(m => m.Center).HasColumnType("geometry"); - } - else - { - builder.Ignore(m => m.Center); - } - - builder.OwnsMany( - m => m.Basemaps, - bm => - { - // Pin the owned-entity table name so it's stable across both the - // per-module MapDbContext (module-prefixed) and the host-level - // HostDbContext used for migrations. - bm.ToTable("Map_MapBasemap"); - bm.WithOwner().HasForeignKey("SavedMapId"); - bm.Property("Id").ValueGeneratedOnAdd(); - bm.HasKey("Id"); - bm.Property(b => b.BasemapId).IsRequired(); - bm.Property(b => b.Order); - } - ); - - builder.OwnsMany( - m => m.Layers, - layer => - { - layer.ToTable("Map_MapLayer"); - layer.WithOwner().HasForeignKey("SavedMapId"); - layer.Property("Id").ValueGeneratedOnAdd(); - layer.HasKey("Id"); - layer.Property(l => l.LayerSourceId).IsRequired(); - layer.Property(l => l.Order); - layer.Property(l => l.Visible); - layer.Property(l => l.Opacity); - - layer.Property(l => l.StyleOverrides).HasJsonDictionaryConversion(); - } - ); - } -} diff --git a/modules/Map/src/SimpleModule.Map/MapDbContext.cs b/modules/Map/src/SimpleModule.Map/MapDbContext.cs deleted file mode 100644 index 11931f52..00000000 --- a/modules/Map/src/SimpleModule.Map/MapDbContext.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; -using SimpleModule.Database; -using SimpleModule.Map.Contracts; -using SimpleModule.Map.EntityConfigurations; - -namespace SimpleModule.Map; - -public class MapDbContext( - DbContextOptions options, - IOptions dbOptions -) : DbContext(options) -{ - public DbSet LayerSources => Set(); - public DbSet SavedMaps => Set(); - public DbSet Basemaps => Set(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.ApplyConfiguration(new LayerSourceConfiguration()); - modelBuilder.ApplyConfiguration(new SavedMapConfiguration()); - modelBuilder.ApplyConfiguration(new BasemapConfiguration()); - modelBuilder.ApplyModuleSchema("Map", dbOptions.Value); - } - - protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) - { - configurationBuilder - .Properties() - .HaveConversion< - LayerSourceId.EfCoreValueConverter, - LayerSourceId.EfCoreValueComparer - >(); - configurationBuilder - .Properties() - .HaveConversion(); - configurationBuilder - .Properties() - .HaveConversion(); - } -} diff --git a/modules/Map/src/SimpleModule.Map/MapModule.cs b/modules/Map/src/SimpleModule.Map/MapModule.cs deleted file mode 100644 index 8f94383f..00000000 --- a/modules/Map/src/SimpleModule.Map/MapModule.cs +++ /dev/null @@ -1,106 +0,0 @@ -using FluentValidation; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using SimpleModule.Core; -using SimpleModule.Core.Menu; -using SimpleModule.Core.Settings; -using SimpleModule.Database; -using SimpleModule.Map.Contracts; -using SimpleModule.Map.EntityConfigurations; - -namespace SimpleModule.Map; - -[Module( - MapConstants.ModuleName, - RoutePrefix = MapConstants.RoutePrefix, - ViewPrefix = MapConstants.ViewPrefix -)] -public class MapModule : IModule, IModuleServices, IModuleMenu, IModuleSettings -{ - public void ConfigureServices(IServiceCollection services, IConfiguration configuration) - { - // Spatial columns (PostGIS / SQL Server geometry / SpatiaLite) are opt-in: - // they require a provider configured with NetTopologySuite. Default off so - // vanilla SQLite (no mod_spatialite) and other plain providers continue to - // work. Hosts opt in via "Modules:Map:EnableSpatial": true in appsettings. - var enableSpatial = configuration.GetValue("Modules:Map:EnableSpatial"); - LayerSourceConfiguration.EnableSpatial = enableSpatial; - SavedMapConfiguration.EnableSpatial = enableSpatial; - - services.AddModuleDbContext( - configuration, - MapConstants.ModuleName, - enableSpatial: enableSpatial - ); - services.AddScoped(); - services.AddValidatorsFromAssemblyContaining(); - } - - public void ConfigureMenu(IMenuBuilder menus) - { - menus.Add( - new MenuItem - { - Label = "Map", - Url = MapConstants.ViewPrefix, - Icon = - """""", - Order = 40, - Section = MenuSection.AppSidebar, - } - ); - menus.Add( - new MenuItem - { - Label = "Layer Sources", - Url = MapConstants.ViewPrefix + "/layers", - Icon = - """""", - Order = 41, - Section = MenuSection.AppSidebar, - } - ); - } - - public void ConfigureSettings(ISettingsBuilder settings) - { - settings - .Add( - new SettingDefinition - { - Key = MapConstants.SettingKeys.EnableMeasureTools, - DisplayName = "Enable measure tools", - Description = "Show the distance / area measure tools in the map viewer.", - Group = "Map", - Scope = SettingScope.Application, - DefaultValue = "true", - Type = SettingType.Bool, - } - ) - .Add( - new SettingDefinition - { - Key = MapConstants.SettingKeys.EnableExportPng, - DisplayName = "Enable PNG export", - Description = "Show the canvas-to-PNG export button in the map viewer.", - Group = "Map", - Scope = SettingScope.Application, - DefaultValue = "true", - Type = SettingType.Bool, - } - ) - .Add( - new SettingDefinition - { - Key = MapConstants.SettingKeys.EnableGeolocate, - DisplayName = "Enable geolocate control", - Description = - "Show the browser geolocation control that centers the map on the user's position.", - Group = "Map", - Scope = SettingScope.Application, - DefaultValue = "true", - Type = SettingType.Bool, - } - ); - } -} diff --git a/modules/Map/src/SimpleModule.Map/MapModuleOptions.cs b/modules/Map/src/SimpleModule.Map/MapModuleOptions.cs deleted file mode 100644 index cc16de57..00000000 --- a/modules/Map/src/SimpleModule.Map/MapModuleOptions.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using SimpleModule.Core; - -namespace SimpleModule.Map; - -/// -/// Configurable options for the Map module. -/// Override defaults from the host application: -/// -/// builder.AddSimpleModule(o => -/// { -/// o.ConfigureMap(m => m.BaseStyleUrl = "https://my.tiles/style.json"); -/// }); -/// -/// -public class MapModuleOptions : IModuleOptions -{ - /// Default map center longitude. Default: 0. - public double DefaultCenterLng { get; set; } - - /// Default map center latitude. Default: 20. - public double DefaultCenterLat { get; set; } = 20; - - /// Default zoom level. Default: 2. - public double DefaultZoom { get; set; } = 2; - - /// Default pitch in degrees. Default: 0. - public double DefaultPitch { get; set; } - - /// Default bearing in degrees. Default: 0. - public double DefaultBearing { get; set; } - - /// Base MapLibre style JSON URL used when a saved map has no override. - [SuppressMessage( - "Design", - "CA1056:URI-like properties should not be strings", - Justification = "Stored verbatim and serialized to the JS client; Uri normalization would change the value." - )] - public string BaseStyleUrl { get; set; } = "https://demotiles.maplibre.org/style.json"; - - /// Hard cap on layers per saved map. Default: 50. - public int MaxLayersPerMap { get; set; } = 50; - - /// Hard cap on layer source URL length. Default: 2048. - public int MaxLayerSourceUrlLength { get; set; } = 2048; - - /// - /// Map spatial geometry columns (Coverage, Center) onto provider-native - /// spatial types (PostGIS, SQL Server geometry, SpatiaLite). Disable this only when - /// running against an environment without spatial support, such as in-memory SQLite - /// integration tests where mod_spatialite is unavailable. Default: true. - /// - public bool EnableSpatialColumns { get; set; } = true; -} diff --git a/modules/Map/src/SimpleModule.Map/MapPermissions.cs b/modules/Map/src/SimpleModule.Map/MapPermissions.cs deleted file mode 100644 index d1655d7a..00000000 --- a/modules/Map/src/SimpleModule.Map/MapPermissions.cs +++ /dev/null @@ -1,16 +0,0 @@ -using SimpleModule.Core.Authorization; - -namespace SimpleModule.Map; - -public sealed class MapPermissions : IModulePermissions -{ - // Saved maps - public const string View = "Map.View"; - public const string Create = "Map.Create"; - public const string Update = "Map.Update"; - public const string Delete = "Map.Delete"; - - // Layer source catalog (typically admin-only) - public const string ViewSources = "Map.ViewSources"; - public const string ManageSources = "Map.ManageSources"; -} diff --git a/modules/Map/src/SimpleModule.Map/MapService.Basemaps.cs b/modules/Map/src/SimpleModule.Map/MapService.Basemaps.cs deleted file mode 100644 index a2e44462..00000000 --- a/modules/Map/src/SimpleModule.Map/MapService.Basemaps.cs +++ /dev/null @@ -1,67 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using SimpleModule.Core.Exceptions; -using SimpleModule.Map.Contracts; - -namespace SimpleModule.Map; - -public partial class MapService -{ - public async Task> GetAllBasemapsAsync() => - await db.Basemaps.AsNoTracking().OrderBy(b => b.Name).ToListAsync(); - - public async Task GetBasemapByIdAsync(BasemapId id) - { - var basemap = await db.Basemaps.FindAsync(id); - if (basemap is null) - { - LogBasemapNotFound(logger, id); - } - - return basemap; - } - - public async Task CreateBasemapAsync(CreateBasemapRequest request) - { - var basemap = new Basemap - { - Id = BasemapId.From(Guid.NewGuid()), - Name = request.Name, - Description = request.Description, - StyleUrl = request.StyleUrl, - Attribution = request.Attribution, - ThumbnailUrl = request.ThumbnailUrl, - }; - - db.Basemaps.Add(basemap); - await db.SaveChangesAsync(); - - LogBasemapCreated(logger, basemap.Id, basemap.Name); - return basemap; - } - - public async Task UpdateBasemapAsync(BasemapId id, UpdateBasemapRequest request) - { - var basemap = await db.Basemaps.FindAsync(id) ?? throw new NotFoundException("Basemap", id); - - basemap.Name = request.Name; - basemap.Description = request.Description; - basemap.StyleUrl = request.StyleUrl; - basemap.Attribution = request.Attribution; - basemap.ThumbnailUrl = request.ThumbnailUrl; - - await db.SaveChangesAsync(); - - LogBasemapUpdated(logger, basemap.Id, basemap.Name); - return basemap; - } - - public async Task DeleteBasemapAsync(BasemapId id) - { - var basemap = await db.Basemaps.FindAsync(id) ?? throw new NotFoundException("Basemap", id); - - db.Basemaps.Remove(basemap); - await db.SaveChangesAsync(); - - LogBasemapDeleted(logger, id); - } -} diff --git a/modules/Map/src/SimpleModule.Map/MapService.Logging.cs b/modules/Map/src/SimpleModule.Map/MapService.Logging.cs deleted file mode 100644 index a3b417ac..00000000 --- a/modules/Map/src/SimpleModule.Map/MapService.Logging.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Microsoft.Extensions.Logging; -using SimpleModule.Map.Contracts; - -namespace SimpleModule.Map; - -public partial class MapService -{ - [LoggerMessage(Level = LogLevel.Warning, Message = "LayerSource {Id} not found")] - private static partial void LogLayerSourceNotFound(ILogger logger, LayerSourceId id); - - [LoggerMessage(Level = LogLevel.Information, Message = "LayerSource {Id} created: {Name}")] - private static partial void LogLayerSourceCreated( - ILogger logger, - LayerSourceId id, - string name - ); - - [LoggerMessage(Level = LogLevel.Information, Message = "LayerSource {Id} updated: {Name}")] - private static partial void LogLayerSourceUpdated( - ILogger logger, - LayerSourceId id, - string name - ); - - [LoggerMessage(Level = LogLevel.Information, Message = "LayerSource {Id} deleted")] - private static partial void LogLayerSourceDeleted(ILogger logger, LayerSourceId id); - - [LoggerMessage( - Level = LogLevel.Information, - Message = "Default map seeded from MapModuleOptions" - )] - private static partial void LogDefaultMapSeeded(ILogger logger); - - [LoggerMessage(Level = LogLevel.Information, Message = "Default map updated")] - private static partial void LogDefaultMapUpdated(ILogger logger); - - [LoggerMessage(Level = LogLevel.Warning, Message = "Basemap {Id} not found")] - private static partial void LogBasemapNotFound(ILogger logger, BasemapId id); - - [LoggerMessage(Level = LogLevel.Information, Message = "Basemap {Id} created: {Name}")] - private static partial void LogBasemapCreated(ILogger logger, BasemapId id, string name); - - [LoggerMessage(Level = LogLevel.Information, Message = "Basemap {Id} updated: {Name}")] - private static partial void LogBasemapUpdated(ILogger logger, BasemapId id, string name); - - [LoggerMessage(Level = LogLevel.Information, Message = "Basemap {Id} deleted")] - private static partial void LogBasemapDeleted(ILogger logger, BasemapId id); -} diff --git a/modules/Map/src/SimpleModule.Map/MapService.cs b/modules/Map/src/SimpleModule.Map/MapService.cs deleted file mode 100644 index 719e0d6d..00000000 --- a/modules/Map/src/SimpleModule.Map/MapService.cs +++ /dev/null @@ -1,279 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using NetTopologySuite.Geometries; -using SimpleModule.Core.Exceptions; -using SimpleModule.Datasets.Contracts; -using SimpleModule.Map.Contracts; -using SimpleModule.Map.EntityConfigurations; - -namespace SimpleModule.Map; - -public partial class MapService( - MapDbContext db, - IDatasetsContracts datasets, - IOptions options, - ILogger logger -) : IMapContracts -{ - private MapModuleOptions Options => options.Value; - - /// - /// Shared geometry factory configured for SRID 4326 (WGS84). NetTopologySuite uses - /// this for all client-facing spatial values so PostGIS / SQL Server / SpatiaLite - /// columns are tagged with the right coordinate reference system. - /// - private static readonly GeometryFactory GeometryFactory = - NetTopologySuite.NtsGeometryServices.Instance.CreateGeometryFactory(srid: 4326); - - private static Polygon? PolygonFromBounds(double[]? bounds) - { - if (bounds is not { Length: 4 }) - { - return null; - } - - var west = bounds[0]; - var south = bounds[1]; - var east = bounds[2]; - var north = bounds[3]; - - var ring = GeometryFactory.CreateLinearRing([ - new Coordinate(west, south), - new Coordinate(east, south), - new Coordinate(east, north), - new Coordinate(west, north), - new Coordinate(west, south), - ]); - return GeometryFactory.CreatePolygon(ring); - } - - private static Point PointFromLngLat(double lng, double lat) => - GeometryFactory.CreatePoint(new Coordinate(lng, lat)); - - // ---------- Layer sources ---------- - - public async Task> GetAllLayerSourcesAsync() => - await db.LayerSources.AsNoTracking().OrderBy(s => s.Name).ToListAsync(); - - public async Task GetLayerSourceByIdAsync(LayerSourceId id) - { - var source = await db.LayerSources.FindAsync(id); - if (source is null) - { - LogLayerSourceNotFound(logger, id); - } - - return source; - } - - public async Task CreateLayerSourceAsync(CreateLayerSourceRequest request) - { - var source = new LayerSource - { - Id = LayerSourceId.From(Guid.NewGuid()), - Name = request.Name, - Description = request.Description, - Type = request.Type, - Url = request.Url, - Attribution = request.Attribution, - MinZoom = request.MinZoom, - MaxZoom = request.MaxZoom, - Bounds = request.Bounds, - Coverage = PolygonFromBounds(request.Bounds), - Metadata = request.Metadata ?? [], - }; - - db.LayerSources.Add(source); - await db.SaveChangesAsync(); - - LogLayerSourceCreated(logger, source.Id, source.Name); - return source; - } - - public async Task CreateLayerSourceFromDatasetAsync( - CreateLayerSourceFromDatasetRequest request, - CancellationToken ct = default - ) - { - var datasetId = DatasetId.From(request.DatasetId); - var dataset = - await datasets.GetByIdAsync(datasetId, ct) - ?? throw new NotFoundException("Dataset", request.DatasetId); - - double[]? bounds = dataset.BoundingBox is { } bb - ? [bb.MinX, bb.MinY, bb.MaxX, bb.MaxY] - : null; - - var source = new LayerSource - { - Id = LayerSourceId.From(Guid.NewGuid()), - Name = string.IsNullOrWhiteSpace(request.Name) ? dataset.Name : request.Name, - Description = request.Description, - Type = LayerSourceType.Dataset, - Url = $"/api/datasets/{request.DatasetId}/features", - Bounds = bounds, - Coverage = PolygonFromBounds(bounds), - Metadata = new Dictionary - { - ["datasetId"] = request.DatasetId.ToString(), - }, - }; - - db.LayerSources.Add(source); - await db.SaveChangesAsync(ct); - - LogLayerSourceCreated(logger, source.Id, source.Name); - return source; - } - - public async Task UpdateLayerSourceAsync( - LayerSourceId id, - UpdateLayerSourceRequest request - ) - { - var source = - await db.LayerSources.FindAsync(id) ?? throw new NotFoundException("LayerSource", id); - - source.Name = request.Name; - source.Description = request.Description; - source.Type = request.Type; - source.Url = request.Url; - source.Attribution = request.Attribution; - source.MinZoom = request.MinZoom; - source.MaxZoom = request.MaxZoom; - source.Bounds = request.Bounds; - source.Coverage = PolygonFromBounds(request.Bounds); - source.Metadata = request.Metadata ?? []; - - await db.SaveChangesAsync(); - - LogLayerSourceUpdated(logger, source.Id, source.Name); - return source; - } - - public async Task DeleteLayerSourceAsync(LayerSourceId id) - { - var source = - await db.LayerSources.FindAsync(id) ?? throw new NotFoundException("LayerSource", id); - - db.LayerSources.Remove(source); - await db.SaveChangesAsync(); - - LogLayerSourceDeleted(logger, id); - } - - // ---------- Default map (singleton) ---------- - - private const string DefaultMapName = "Default Map"; - - public async Task GetDefaultMapAsync(CancellationToken ct = default) - { - var map = await db - .SavedMaps.AsNoTracking() - .Include(m => m.Layers) - .Include(m => m.Basemaps) - .AsSplitQuery() - .FirstOrDefaultAsync(m => m.Id == MapConstants.DefaultMapId, ct); - - if (map is not null) - { - return map; - } - - // Lazily seed from MapModuleOptions so the application always has exactly one map. - var seed = new SavedMap - { - Id = MapConstants.DefaultMapId, - Name = DefaultMapName, - Description = null, - CenterLng = Options.DefaultCenterLng, - CenterLat = Options.DefaultCenterLat, - Center = PointFromLngLat(Options.DefaultCenterLng, Options.DefaultCenterLat), - Zoom = Options.DefaultZoom, - Pitch = Options.DefaultPitch, - Bearing = Options.DefaultBearing, - BaseStyleUrl = Options.BaseStyleUrl, - Basemaps = BasemapConfiguration - .SeedIds.All.Select((id, i) => new MapBasemap { BasemapId = id, Order = i }) - .ToList(), - Layers = - [ - new MapLayer - { - LayerSourceId = LayerSourceConfiguration.SeedIds.OpenStreetMapXyz, - Order = 0, - Visible = true, - Opacity = 1, - }, - new MapLayer - { - LayerSourceId = LayerSourceConfiguration.SeedIds.MapLibreEarthquakesGeoJson, - Order = 1, - Visible = true, - Opacity = 1, - }, - new MapLayer - { - LayerSourceId = LayerSourceConfiguration.SeedIds.TerrestrisOsmWms, - Order = 2, - Visible = false, - Opacity = 1, - }, - ], - }; - - db.SavedMaps.Add(seed); - await db.SaveChangesAsync(ct); - - LogDefaultMapSeeded(logger); - return seed; - } - - public async Task UpdateDefaultMapAsync( - UpdateDefaultMapRequest request, - CancellationToken ct = default - ) - { - var map = await db - .SavedMaps.Include(m => m.Layers) - .Include(m => m.Basemaps) - .AsSplitQuery() - .FirstOrDefaultAsync(m => m.Id == MapConstants.DefaultMapId, ct); - - if (map is null) - { - map = new SavedMap { Id = MapConstants.DefaultMapId, Name = DefaultMapName }; - db.SavedMaps.Add(map); - } - - map.CenterLng = request.CenterLng; - map.CenterLat = request.CenterLat; - map.Center = PointFromLngLat(request.CenterLng, request.CenterLat); - map.Zoom = request.Zoom; - map.Pitch = request.Pitch; - map.Bearing = request.Bearing; - map.BaseStyleUrl = string.IsNullOrWhiteSpace(request.BaseStyleUrl) - ? Options.BaseStyleUrl - : request.BaseStyleUrl; - - // Replace owned layers wholesale. - map.Layers.Clear(); - foreach (var layer in request.Layers ?? []) - { - map.Layers.Add(layer); - } - - // Replace owned basemaps wholesale. - map.Basemaps.Clear(); - foreach (var basemap in request.Basemaps ?? []) - { - map.Basemaps.Add(basemap); - } - - await db.SaveChangesAsync(ct); - - LogDefaultMapUpdated(logger); - return map; - } -} diff --git a/modules/Map/src/SimpleModule.Map/Pages/Browse.tsx b/modules/Map/src/SimpleModule.Map/Pages/Browse.tsx deleted file mode 100644 index 6af58432..00000000 --- a/modules/Map/src/SimpleModule.Map/Pages/Browse.tsx +++ /dev/null @@ -1,294 +0,0 @@ -import { router } from '@inertiajs/react'; -import type { Map as MapLibreMap } from 'maplibre-gl'; -import { type CSSProperties, useMemo, useRef, useState } from 'react'; -import type { - Basemap, - LayerSource, - MapBasemap, - MapLayer, - SavedMap, - UpdateDefaultMapRequest, -} from '@/types'; -import { BasemapsPanel } from './components/BasemapsPanel'; -import { LayersPanel } from './components/LayersPanel'; -import { removeAt } from './components/layer-utils'; -import { MapBottomControls } from './components/MapBottomControls'; -import MapCanvas from './components/MapCanvas'; -import { MapTopControls } from './components/MapTopControls'; -import { useViewportInsets } from './components/useViewportInsets'; - -// Inline styles for positioning — deliberately NOT Tailwind utility classes. -// The Tailwind utilities top-3/bottom-3/right-3/etc. aren't always generated -// by the host's CSS pipeline when only module .tsx sources change, so the map -// layout regressed in CI. Inline styles make the positioning survive that. -const controlOverlayStyle: CSSProperties = { - position: 'absolute', - inset: 0, - zIndex: 1000, - pointerEvents: 'none', -}; - -const floatingControlStyle: CSSProperties = { - position: 'absolute', - pointerEvents: 'auto', -}; - -const floatingPanelStyle: CSSProperties = { - ...floatingControlStyle, -}; - -interface Props { - map: SavedMap; - sources: LayerSource[]; - basemaps: Basemap[]; - defaultStyleUrl: string; - maxLayers: number; - enableMeasure: boolean; - enableExportPng: boolean; - enableGeolocate: boolean; -} - -export default function Browse({ - map, - sources, - basemaps, - defaultStyleUrl, - maxLayers, - enableExportPng, - enableGeolocate, -}: Props) { - const [layers, setLayers] = useState(map.layers); - const [mapBasemaps, setMapBasemaps] = useState(map.basemaps); - const [styleUrl, setStyleUrl] = useState(map.baseStyleUrl || defaultStyleUrl); - const [pickerSourceId, setPickerSourceId] = useState(''); - const [pickerBasemapId, setPickerBasemapId] = useState(''); - const [availableSources, setAvailableSources] = useState(sources); - const [datasets, setDatasets] = useState>([]); - const [pickerDatasetId, setPickerDatasetId] = useState(''); - const [datasetsLoaded, setDatasetsLoaded] = useState(false); - const [saving, setSaving] = useState(false); - - // Single-value state enforces mutual exclusion — two panels cannot be open at once. - const [openPanel, setOpenPanel] = useState<'layers' | 'basemaps' | null>(null); - const layersPanelOpen = openPanel === 'layers'; - const basemapsPanelOpen = openPanel === 'basemaps'; - - const mapInstanceRef = useRef(null); - const insets = useViewportInsets(); - - const basemapById = useMemo(() => new Map(basemaps.map((b) => [b.id, b])), [basemaps]); - const sourceById = useMemo( - () => new Map(availableSources.map((s) => [s.id, s])), - [availableSources], - ); - - const availableBasemaps = useMemo(() => { - return [...mapBasemaps] - .sort((a, b) => a.order - b.order) - .map((mb) => basemapById.get(mb.basemapId)) - .filter((b): b is Basemap => Boolean(b)); - }, [mapBasemaps, basemapById]); - - const [activeBasemapId, setActiveBasemapId] = useState( - () => availableBasemaps[0]?.id, - ); - const activeBasemap = - availableBasemaps.find((b) => b.id === activeBasemapId) ?? availableBasemaps[0]; - const resolvedStyleUrl = activeBasemap?.styleUrl ?? styleUrl; - - async function loadDatasets() { - if (datasetsLoaded) return; - const res = await fetch('/api/datasets/', { headers: { Accept: 'application/json' } }); - if (!res.ok) return; - const list = (await res.json()) as Array<{ id: string; name: string }>; - setDatasets(list); - setDatasetsLoaded(true); - } - - async function addFromDataset() { - if (!pickerDatasetId || layers.length >= maxLayers) return; - const res = await fetch('/api/map/sources/from-dataset', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ datasetId: pickerDatasetId }), - }); - if (!res.ok) return; - const created = (await res.json()) as LayerSource; - setAvailableSources((prev) => [...prev, created]); - setLayers([ - ...layers, - { - layerSourceId: created.id, - order: layers.length, - visible: true, - opacity: 1, - styleOverrides: {}, - }, - ]); - setPickerDatasetId(''); - } - - function addLayer() { - if (!pickerSourceId || layers.length >= maxLayers) return; - setLayers([ - ...layers, - { - layerSourceId: pickerSourceId, - order: layers.length, - visible: true, - opacity: 1, - styleOverrides: {}, - }, - ]); - setPickerSourceId(''); - } - - function addBasemap() { - if (!pickerBasemapId) return; - if (mapBasemaps.some((b) => b.basemapId === pickerBasemapId)) return; - setMapBasemaps([...mapBasemaps, { basemapId: pickerBasemapId, order: mapBasemaps.length }]); - if (!activeBasemapId) { - setActiveBasemapId(pickerBasemapId); - } - setPickerBasemapId(''); - } - - function removeBasemap(idx: number) { - const removedId = mapBasemaps[idx]?.basemapId; - const next = removeAt(mapBasemaps, idx); - setMapBasemaps(next); - if (removedId && activeBasemapId === removedId) { - setActiveBasemapId(next[0]?.basemapId); - } - } - - async function handleSave() { - setSaving(true); - try { - const live = mapInstanceRef.current; - const center = live?.getCenter(); - const body: UpdateDefaultMapRequest = { - centerLng: center?.lng ?? map.centerLng, - centerLat: center?.lat ?? map.centerLat, - zoom: live?.getZoom() ?? map.zoom, - pitch: live?.getPitch() ?? map.pitch, - bearing: live?.getBearing() ?? map.bearing, - baseStyleUrl: styleUrl, - layers, - basemaps: mapBasemaps, - }; - const res = await fetch('/api/map/default', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - if (res.ok) { - router.reload({ only: ['map'] }); - } - } finally { - setSaving(false); - } - } - - function exportPng() { - const m = mapInstanceRef.current; - if (!m) return; - m.getCanvas().toBlob((blob) => { - if (!blob) return; - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'default-map.png'; - a.click(); - URL.revokeObjectURL(url); - }); - } - - const visibleLayerCount = layers.filter((l) => l.visible).length; - - return ( -
- { - mapInstanceRef.current = m; - }} - /> - -
- setOpenPanel(layersPanelOpen ? null : 'layers')} - onToggleBasemaps={() => setOpenPanel(basemapsPanelOpen ? null : 'basemaps')} - onSave={handleSave} - /> - - {layersPanelOpen && ( - - )} - - {basemapsPanelOpen && ( - - )} - - -
-
- ); -} diff --git a/modules/Map/src/SimpleModule.Map/Pages/BrowseEndpoint.cs b/modules/Map/src/SimpleModule.Map/Pages/BrowseEndpoint.cs deleted file mode 100644 index 56f86f5f..00000000 --- a/modules/Map/src/SimpleModule.Map/Pages/BrowseEndpoint.cs +++ /dev/null @@ -1,71 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.Options; -using SimpleModule.Core; -using SimpleModule.Core.Inertia; -using SimpleModule.Core.Settings; -using SimpleModule.Map.Contracts; -using SimpleModule.Settings.Contracts; - -namespace SimpleModule.Map.Pages; - -/// -/// Renders the singleton default map. Layer sources, basemaps and tool flags are -/// preloaded so the React page can show the map and let users add/remove overlays -/// in a single screen — there is no longer a list of saved maps. -/// -public class BrowseEndpoint : IViewEndpoint -{ - public const string Route = MapConstants.Routes.Browse; - - public void Map(IEndpointRouteBuilder app) - { - app.MapGet( - Route, - async ( - IMapContracts maps, - ISettingsContracts settings, - IOptions options - ) => - { - var map = await maps.GetDefaultMapAsync(); - var sources = await maps.GetAllLayerSourcesAsync(); - var basemaps = await maps.GetAllBasemapsAsync(); - - // Fetch all three Map tool toggles in one query instead of three. - var toolSettings = await settings.GetSettingsAsync( - new SettingsFilter { Scope = SettingScope.Application, Group = "Map" } - ); - var toolsByKey = toolSettings.ToDictionary(s => s.Key, s => s.Value); - - return Inertia.Render( - "Map/Browse", - new - { - map, - sources, - basemaps, - defaultStyleUrl = options.Value.BaseStyleUrl, - maxLayers = options.Value.MaxLayersPerMap, - enableMeasure = ResolveBool( - toolsByKey, - MapConstants.SettingKeys.EnableMeasureTools - ), - enableExportPng = ResolveBool( - toolsByKey, - MapConstants.SettingKeys.EnableExportPng - ), - enableGeolocate = ResolveBool( - toolsByKey, - MapConstants.SettingKeys.EnableGeolocate - ), - } - ); - } - ) - .AllowAnonymous(); - } - - private static bool ResolveBool(Dictionary values, string key) => - values.TryGetValue(key, out var raw) && bool.TryParse(raw, out var parsed) ? parsed : true; -} diff --git a/modules/Map/src/SimpleModule.Map/Pages/Layers.tsx b/modules/Map/src/SimpleModule.Map/Pages/Layers.tsx deleted file mode 100644 index d94710fd..00000000 --- a/modules/Map/src/SimpleModule.Map/Pages/Layers.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import { router } from '@inertiajs/react'; -import { Button, Card, CardContent, PageShell } from '@simplemodule/ui'; -import { useState } from 'react'; -import type { Basemap, CreateBasemapRequest, CreateLayerSourceRequest, LayerSource } from '@/types'; -import { AddBasemapDialog } from './components/AddBasemapDialog'; -import { AddLayerSourceDialog, TYPE_LABELS } from './components/AddLayerSourceDialog'; -import { LayerSourceType } from './lib/layer-builders'; - -interface Props { - sources: LayerSource[]; - basemaps: Basemap[]; -} - -const blankBasemap: CreateBasemapRequest = { - name: '', - description: '', - styleUrl: '', - attribution: '', - thumbnailUrl: '', -}; - -const blankRequest: CreateLayerSourceRequest = { - name: '', - description: '', - type: LayerSourceType.Wms, - url: '', - attribution: '', - minZoom: null, - maxZoom: null, - bounds: null, - metadata: {}, -}; - -export default function Layers({ sources, basemaps }: Props) { - const [open, setOpen] = useState(false); - const [form, setForm] = useState(blankRequest); - const [metaText, setMetaText] = useState(''); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); - - const [bmOpen, setBmOpen] = useState(false); - const [bmForm, setBmForm] = useState(blankBasemap); - const [bmSubmitting, setBmSubmitting] = useState(false); - const [bmError, setBmError] = useState(null); - - async function handleBasemapSubmit(e: React.FormEvent) { - e.preventDefault(); - setBmSubmitting(true); - setBmError(null); - try { - const res = await fetch('/api/map/basemaps', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(bmForm), - }); - if (!res.ok) { - setBmError((await res.text()) || `Server returned ${res.status}`); - return; - } - setBmOpen(false); - setBmForm(blankBasemap); - router.reload({ only: ['sources', 'basemaps'] }); - } finally { - setBmSubmitting(false); - } - } - - async function handleBasemapDelete(id: string) { - if (!window.confirm('Delete this basemap?')) return; - await fetch(`/api/map/basemaps/${id}`, { method: 'DELETE' }); - router.reload({ only: ['sources', 'basemaps'] }); - } - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setSubmitting(true); - setError(null); - try { - const metadata: Record = {}; - if (metaText.trim()) { - try { - Object.assign(metadata, JSON.parse(metaText)); - } catch { - setError('Metadata must be valid JSON.'); - setSubmitting(false); - return; - } - } - const res = await fetch('/api/map/sources', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...form, metadata }), - }); - if (!res.ok) { - const text = await res.text(); - setError(text || `Server returned ${res.status}`); - setSubmitting(false); - return; - } - setOpen(false); - setForm(blankRequest); - setMetaText(''); - router.reload({ only: ['sources', 'basemaps'] }); - } finally { - setSubmitting(false); - } - } - - async function handleDelete(id: string) { - if (!window.confirm('Delete this layer source?')) return; - await fetch(`/api/map/sources/${id}`, { method: 'DELETE' }); - router.reload({ only: ['sources', 'basemaps'] }); - } - - return ( - -
-

Basemaps

- -
-
- {basemaps.length === 0 && ( - - No basemaps yet. - - )} - {basemaps.map((b) => ( - - -
-
{b.name}
-
{b.styleUrl}
- {b.attribution &&
{b.attribution}
} -
- -
-
- ))} -
- -
-

Overlay sources

- -
- -
- {sources.length === 0 && ( - - - No layer sources yet. Add one above. - - - )} - {sources.map((s) => ( - - -
-
{s.name}
-
- {TYPE_LABELS[s.type as unknown as number] ?? 'Unknown'} · {s.url} -
- {s.attribution &&
{s.attribution}
} -
- -
-
- ))} -
- - - - -
- ); -} diff --git a/modules/Map/src/SimpleModule.Map/Pages/LayersEndpoint.cs b/modules/Map/src/SimpleModule.Map/Pages/LayersEndpoint.cs deleted file mode 100644 index 92d883b4..00000000 --- a/modules/Map/src/SimpleModule.Map/Pages/LayersEndpoint.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Core; -using SimpleModule.Core.Inertia; -using SimpleModule.Map.Contracts; - -namespace SimpleModule.Map.Pages; - -public class LayersEndpoint : IViewEndpoint -{ - public const string Route = MapConstants.Routes.Layers; - - public void Map(IEndpointRouteBuilder app) - { - app.MapGet( - Route, - async (IMapContracts maps) => - { - var sourcesTask = maps.GetAllLayerSourcesAsync(); - var basemapsTask = maps.GetAllBasemapsAsync(); - return Inertia.Render( - "Map/Layers", - new { sources = await sourcesTask, basemaps = await basemapsTask } - ); - } - ) - .AllowAnonymous(); - } -} diff --git a/modules/Map/src/SimpleModule.Map/Pages/components/AddBasemapDialog.tsx b/modules/Map/src/SimpleModule.Map/Pages/components/AddBasemapDialog.tsx deleted file mode 100644 index 8171e8b1..00000000 --- a/modules/Map/src/SimpleModule.Map/Pages/components/AddBasemapDialog.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { - Button, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - Field, - FieldGroup, - Input, - Label, -} from '@simplemodule/ui'; -import type { CreateBasemapRequest } from '@/types'; - -interface Props { - open: boolean; - onOpenChange: (open: boolean) => void; - form: CreateBasemapRequest; - setForm: (form: CreateBasemapRequest) => void; - submitting: boolean; - error: string | null; - onSubmit: (e: React.FormEvent) => void; -} - -export function AddBasemapDialog({ - open, - onOpenChange, - form, - setForm, - submitting, - error, - onSubmit, -}: Props) { - return ( - - - - Add basemap - -
- - - - setForm({ ...form, name: e.currentTarget.value })} - /> - - - - setForm({ ...form, styleUrl: e.currentTarget.value })} - placeholder="https://demotiles.maplibre.org/style.json" - /> - - - - setForm({ ...form, attribution: e.currentTarget.value })} - /> - - - - setForm({ ...form, thumbnailUrl: e.currentTarget.value })} - /> - - {error &&
{error}
} -
- - - - -
-
-
- ); -} diff --git a/modules/Map/src/SimpleModule.Map/Pages/components/AddLayerSourceDialog.tsx b/modules/Map/src/SimpleModule.Map/Pages/components/AddLayerSourceDialog.tsx deleted file mode 100644 index 04f04f64..00000000 --- a/modules/Map/src/SimpleModule.Map/Pages/components/AddLayerSourceDialog.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { - Button, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - Field, - FieldGroup, - Input, - Label, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Textarea, -} from '@simplemodule/ui'; -import type { CreateLayerSourceRequest } from '@/types'; -import { LayerSourceType } from '../lib/layer-builders'; - -export const TYPE_LABELS: Record = { - [LayerSourceType.Wms]: 'WMS', - [LayerSourceType.Wmts]: 'WMTS', - [LayerSourceType.Wfs]: 'WFS', - [LayerSourceType.Xyz]: 'XYZ tiles', - [LayerSourceType.VectorTile]: 'Vector tiles', - [LayerSourceType.PmTiles]: 'PMTiles', - [LayerSourceType.Cog]: 'COG (cloud-optimized GeoTIFF)', - [LayerSourceType.GeoJson]: 'GeoJSON', - [LayerSourceType.Dataset]: 'Dataset', -}; - -interface Props { - open: boolean; - onOpenChange: (open: boolean) => void; - form: CreateLayerSourceRequest; - setForm: (form: CreateLayerSourceRequest) => void; - metaText: string; - setMetaText: (v: string) => void; - submitting: boolean; - error: string | null; - onSubmit: (e: React.FormEvent) => void; -} - -export function AddLayerSourceDialog({ - open, - onOpenChange, - form, - setForm, - metaText, - setMetaText, - submitting, - error, - onSubmit, -}: Props) { - return ( - - - - Add layer source - -
- - - - setForm({ ...form, name: e.currentTarget.value })} - /> - - - - - - - - setForm({ ...form, url: e.currentTarget.value })} - placeholder="https://example.com/wms" - /> - - - - setForm({ ...form, attribution: e.currentTarget.value })} - /> - - - -