From 24edb895ce2ddabe2715e91da9f7265c0e38ddbe Mon Sep 17 00:00:00 2001 From: Adnan Ahmad Date: Thu, 23 Jul 2026 23:28:44 +0500 Subject: [PATCH 1/5] feat: add full InventoryFlow codebase --- .env.example | 4 + backend/.config/dotnet-tools.json | 13 + backend/.dockerignore | 11 + backend/Directory.Build.props | 10 + backend/Directory.Packages.props | 30 + backend/Dockerfile | 46 + backend/InventoryFlow.sln | 129 ++ backend/README.md | 15 + .../Controllers/AuthController.cs | 99 ++ .../Controllers/CollaborationController.cs | 81 ++ .../Controllers/InventoryController.cs | 57 + .../Controllers/ProductsController.cs | 50 + .../Controllers/PurchaseReceiptsController.cs | 37 + .../SalesFulfillmentsController.cs | 37 + .../Controllers/SuppliersController.cs | 49 + .../WarehouseTransfersController.cs | 37 + .../Controllers/WarehousesController.cs | 50 + .../GlobalExceptionHandler.cs | 94 ++ .../InventoryFlow.Api.csproj | 20 + .../InventoryFlow.Api/InventoryFlow.Api.http | 30 + backend/src/InventoryFlow.Api/Program.cs | 66 + .../Properties/launchSettings.json | 14 + .../src/InventoryFlow.Api/appsettings.json | 33 + .../Common/Behaviors/ValidationBehavior.cs | 19 + .../Common/Tenancy/CurrentWorkspace.cs | 6 + .../Common/Tenancy/ICurrentWorkspace.cs | 8 + .../ApplicationServiceCollectionExtensions.cs | 32 + .../Authentication/AuthenticationHandlers.cs | 31 + .../Authentication/AuthenticationModels.cs | 26 + .../AuthenticationValidators.cs | 24 + .../Authentication/IAuthenticationService.cs | 18 + .../Collaboration/CollaborationHandlers.cs | 23 + .../Collaboration/CollaborationModels.cs | 43 + .../Collaboration/ICollaborationService.cs | 23 + .../Features/Inventory/IInventoryLedger.cs | 15 + .../InventoryArchiveConflictException.cs | 8 + .../Features/Inventory/InventoryHandlers.cs | 40 + .../Features/Inventory/InventoryModels.cs | 19 + .../Inventory/InventoryMovementCommands.cs | 33 + .../Features/Inventory/InventoryQueries.cs | 7 + .../Features/Inventory/InventoryValidators.cs | 33 + .../Features/Products/IProductCatalog.cs | 25 + .../Features/Products/ProductHandlers.cs | 29 + .../Features/Products/ProductModels.cs | 19 + .../Features/Products/ProductValidators.cs | 16 + .../Purchases/PurchaseReceiptHandlers.cs | 21 + .../Purchases/PurchaseReceiptModels.cs | 26 + .../Purchases/PurchaseReceiptValidators.cs | 19 + .../Sales/SalesFulfillmentHandlers.cs | 22 + .../Features/Sales/SalesFulfillmentModels.cs | 26 + .../Sales/SalesFulfillmentValidators.cs | 18 + .../Features/Suppliers/ISupplierCatalog.cs | 25 + .../Features/Suppliers/SupplierHandlers.cs | 32 + .../Features/Suppliers/SupplierModels.cs | 19 + .../Features/Suppliers/SupplierValidators.cs | 15 + .../Transfers/WarehouseTransferHandlers.cs | 22 + .../Transfers/WarehouseTransferModels.cs | 27 + .../Transfers/WarehouseTransferValidators.cs | 20 + .../Features/Warehouses/IWarehouseCatalog.cs | 25 + .../Features/Warehouses/WarehouseHandlers.cs | 32 + .../Features/Warehouses/WarehouseModels.cs | 5 + .../Warehouses/WarehouseValidators.cs | 3 + .../InventoryFlow.Application.csproj | 12 + .../src/InventoryFlow.Domain/Common/Entity.cs | 23 + .../Entities/InventoryBalance.cs | 48 + .../Entities/InventoryMovement.cs | 77 ++ .../InventoryFlow.Domain/Entities/Product.cs | 61 + .../Entities/PurchaseReceipt.cs | 34 + .../Entities/RefreshToken.cs | 117 ++ .../Entities/SalesFulfillment.cs | 32 + .../InventoryFlow.Domain/Entities/Supplier.cs | 47 + .../Entities/Warehouse.cs | 4 + .../Entities/WarehouseTransfer.cs | 43 + .../Entities/Workspace.cs | 34 + .../Entities/WorkspaceInvitation.cs | 119 ++ .../Entities/WorkspaceMember.cs | 29 + .../Entities/WorkspaceMemberRole.cs | 11 + .../Exceptions/DomainException.cs | 26 + .../InventoryFlow.Domain.csproj | 9 + .../IdentityAuthenticationService.cs | 205 +++ .../Authentication/JwtAccessTokenIssuer.cs | 41 + .../Authentication/JwtOptions.cs | 22 + .../Authentication/RefreshTokenGenerator.cs | 12 + .../Collaboration/EfCollaborationService.cs | 149 +++ ...frastructureServiceCollectionExtensions.cs | 123 ++ .../Identity/ApplicationUser.cs | 14 + .../Inventory/EfInventoryLedger.cs | 38 + .../Inventory/InventoryLedgerWriter.cs | 41 + .../InventoryFlow.Infrastructure.csproj | 21 + .../Persistence/ApplicationDbContext.cs | 66 + .../ApplicationDbContextFactory.cs | 33 + .../ApplicationUserConfiguration.cs | 19 + .../InventoryBalanceConfiguration.cs | 21 + .../InventoryMovementConfiguration.cs | 26 + .../Configurations/ProductConfiguration.cs | 22 + .../PurchaseReceiptConfiguration.cs | 25 + .../RefreshTokenConfiguration.cs | 51 + .../SalesFulfillmentConfiguration.cs | 24 + .../Configurations/SupplierConfiguration.cs | 21 + .../Configurations/WarehouseConfiguration.cs | 4 + .../WarehouseTransferConfiguration.cs | 27 + .../Configurations/WorkspaceConfiguration.cs | 18 + .../WorkspaceInvitationConfiguration.cs | 33 + .../WorkspaceMemberConfiguration.cs | 24 + ...20260717170006_InitialIdentity.Designer.cs | 341 +++++ .../20260717170006_InitialIdentity.cs | 277 ++++ ...100649_AddRefreshTokenFamilies.Designer.cs | 346 +++++ .../20260718100649_AddRefreshTokenFamilies.cs | 49 + .../20260718105902_AddWorkspaces.Designer.cs | 412 ++++++ .../20260718105902_AddWorkspaces.cs | 99 ++ .../20260718112223_AddProducts.Designer.cs | 456 +++++++ .../Migrations/20260718112223_AddProducts.cs | 55 + .../20260718115041_AddWarehouses.Designer.cs | 495 +++++++ .../20260718115041_AddWarehouses.cs | 54 + ...60718122029_AddInventoryLedger.Designer.cs | 609 +++++++++ .../20260718122029_AddInventoryLedger.cs | 125 ++ ...ProtectInventorySourceArchival.Designer.cs | 611 +++++++++ ...18123000_ProtectInventorySourceArchival.cs | 26 + .../20260718130340_AddSuppliers.Designer.cs | 650 ++++++++++ .../Migrations/20260718130340_AddSuppliers.cs | 54 + ...0718132213_AddPurchaseReceipts.Designer.cs | 735 +++++++++++ .../20260718132213_AddPurchaseReceipts.cs | 103 ++ ...718135438_AddSalesFulfillments.Designer.cs | 809 ++++++++++++ .../20260718135438_AddSalesFulfillments.cs | 91 ++ ...18141104_AddWarehouseTransfers.Designer.cs | 906 +++++++++++++ .../20260718141104_AddWarehouseTransfers.cs | 116 ++ ...620_AddCollaborationFoundation.Designer.cs | 999 ++++++++++++++ ...260719073620_AddCollaborationFoundation.cs | 154 +++ .../ApplicationDbContextModelSnapshot.cs | 996 ++++++++++++++ .../Products/EfProductCatalog.cs | 71 + .../Purchases/EfPurchaseReceiptService.cs | 59 + .../Sales/EfSalesFulfillmentService.cs | 52 + .../Suppliers/EfSupplierCatalog.cs | 52 + .../Tenancy/CurrentWorkspaceResolver.cs | 25 + .../Transfers/EfWarehouseTransferService.cs | 101 ++ .../Warehouses/EfWarehouseCatalog.cs | 73 ++ .../InventoryFlow.Shared.csproj | 9 + .../Api/AuthenticatedApiFixture.cs | 92 ++ .../Api/AuthenticationEndpointsTests.cs | 242 ++++ .../Api/CollaborationEndpointsTests.cs | 332 +++++ .../Api/GlobalExceptionHandlerTests.cs | 55 + .../Api/HealthEndpointTests.cs | 37 + .../Api/InventoryEndpointsTests.cs | 222 ++++ .../Api/InventoryFlowApiFactory.cs | 78 ++ .../Api/ProductEndpointsTests.cs | 108 ++ .../Api/PurchaseReceiptEndpointsTests.cs | 233 ++++ .../Api/SalesFulfillmentEndpointsTests.cs | 220 ++++ .../Api/SupplierEndpointsTests.cs | 113 ++ .../Api/WarehouseEndpointsTests.cs | 116 ++ .../Api/WarehouseTransferEndpointsTests.cs | 254 ++++ .../Api/WorkspaceMigrationAndTenancyTests.cs | 266 ++++ .../InventoryFlow.IntegrationTests.csproj | 30 + .../TestCollectionBehavior.cs | 3 + .../Domain/DomainExceptionTests.cs | 25 + .../Domain/InventoryBalanceTests.cs | 37 + .../Domain/ProductTests.cs | 37 + .../Domain/PurchaseReceiptTests.cs | 28 + .../Domain/RefreshTokenTests.cs | 112 ++ .../Domain/SupplierTests.cs | 36 + .../Domain/WarehouseTests.cs | 31 + .../Domain/WorkspaceInvitationTests.cs | 170 +++ .../Domain/WorkspaceMemberTests.cs | 29 + .../Domain/WorkspaceTests.cs | 28 + .../InventoryFlow.UnitTests.csproj | 27 + docker-compose.yml | 57 + frontend/.dockerignore | 9 + frontend/.env.example | 2 + frontend/.gitignore | 24 + frontend/.prettierignore | 6 + frontend/.prettierrc | 11 + frontend/Dockerfile | 14 + frontend/README.md | 30 + frontend/bun.lock | 1150 +++++++++++++++++ frontend/components.json | 25 + frontend/eslint.config.js | 28 + frontend/index.html | 16 + frontend/nginx.conf | 28 + frontend/package.json | 56 + frontend/src/App.tsx | 13 + frontend/src/app/config/environment.ts | 5 + frontend/src/app/providers/AppProviders.tsx | 22 + frontend/src/app/router/index.tsx | 88 ++ frontend/src/components/layout/Sidebar.tsx | 109 ++ frontend/src/components/layout/Topbar.tsx | 167 +++ frontend/src/components/shared/PageHeader.tsx | 21 + frontend/src/components/theme-provider.tsx | 230 ++++ frontend/src/components/ui/avatar.tsx | 109 ++ frontend/src/components/ui/badge.tsx | 52 + frontend/src/components/ui/button.tsx | 56 + frontend/src/components/ui/card.tsx | 100 ++ frontend/src/components/ui/dropdown-menu.tsx | 277 ++++ frontend/src/components/ui/separator.tsx | 23 + frontend/src/components/ui/sheet.tsx | 136 ++ frontend/src/components/ui/skeleton.tsx | 15 + frontend/src/components/ui/tooltip.tsx | 64 + frontend/src/features/auth/auth-api.ts | 31 + frontend/src/features/auth/auth-redirect.ts | 17 + frontend/src/features/auth/auth-schema.ts | 9 + frontend/src/features/auth/auth-store.ts | 37 + .../features/auth/components/PublicOnly.tsx | 12 + .../features/auth/components/RequireAuth.tsx | 21 + .../src/features/auth/pages/LoginPage.tsx | 100 ++ .../src/features/auth/pages/RegisterPage.tsx | 114 ++ .../src/features/auth/session-bootstrap.tsx | 9 + frontend/src/features/auth/types.ts | 18 + .../dashboard/components/MetricCard.tsx | 66 + .../dashboard/pages/DashboardPage.tsx | 268 ++++ .../src/features/inventory/inventory-api.ts | 26 + .../features/inventory/inventory-queries.ts | 19 + .../features/inventory/inventory-schema.ts | 15 + .../inventory/pages/InventoryPage.tsx | 273 ++++ frontend/src/features/inventory/types.ts | 22 + .../features/products/pages/ProductsPage.tsx | 151 +++ .../src/features/products/products-api.ts | 11 + .../src/features/products/products-schema.ts | 6 + frontend/src/features/products/types.ts | 11 + .../purchases/pages/PurchasesPage.tsx | 247 ++++ .../src/features/purchases/purchases-api.ts | 17 + .../features/purchases/purchases-schema.ts | 16 + frontend/src/features/purchases/types.ts | 17 + .../features/reports/pages/ReportsPage.tsx | 191 +++ .../src/features/sales/pages/SalesPage.tsx | 219 ++++ frontend/src/features/sales/sales-api.ts | 19 + frontend/src/features/sales/sales-schema.ts | 15 + frontend/src/features/sales/types.ts | 15 + .../suppliers/pages/SuppliersPage.tsx | 138 ++ .../src/features/suppliers/suppliers-api.ts | 14 + .../features/suppliers/suppliers-schema.ts | 5 + frontend/src/features/suppliers/types.ts | 9 + .../transfers/pages/TransfersPage.tsx | 239 ++++ .../src/features/transfers/transfers-api.ts | 19 + .../features/transfers/transfers-schema.ts | 21 + frontend/src/features/transfers/types.ts | 18 + .../warehouses/pages/WarehousesPage.tsx | 151 +++ frontend/src/features/warehouses/types.ts | 2 + .../src/features/warehouses/warehouses-api.ts | 8 + .../features/warehouses/warehouses-schema.ts | 4 + frontend/src/index.css | 130 ++ frontend/src/layouts/DashboardLayout.tsx | 22 + frontend/src/lib/api-client.ts | 78 ++ frontend/src/lib/query-client.ts | 11 + frontend/src/lib/utils.ts | 6 + frontend/src/main.tsx | 14 + frontend/src/store/ui-store.ts | 13 + frontend/tsconfig.app.json | 29 + frontend/tsconfig.json | 12 + frontend/tsconfig.node.json | 24 + frontend/vite.config.ts | 45 + global.json | 6 + mise.toml | 2 + 250 files changed, 22598 insertions(+) create mode 100644 .env.example create mode 100644 backend/.config/dotnet-tools.json create mode 100644 backend/.dockerignore create mode 100644 backend/Directory.Build.props create mode 100644 backend/Directory.Packages.props create mode 100644 backend/Dockerfile create mode 100644 backend/InventoryFlow.sln create mode 100644 backend/README.md create mode 100644 backend/src/InventoryFlow.Api/Controllers/AuthController.cs create mode 100644 backend/src/InventoryFlow.Api/Controllers/CollaborationController.cs create mode 100644 backend/src/InventoryFlow.Api/Controllers/InventoryController.cs create mode 100644 backend/src/InventoryFlow.Api/Controllers/ProductsController.cs create mode 100644 backend/src/InventoryFlow.Api/Controllers/PurchaseReceiptsController.cs create mode 100644 backend/src/InventoryFlow.Api/Controllers/SalesFulfillmentsController.cs create mode 100644 backend/src/InventoryFlow.Api/Controllers/SuppliersController.cs create mode 100644 backend/src/InventoryFlow.Api/Controllers/WarehouseTransfersController.cs create mode 100644 backend/src/InventoryFlow.Api/Controllers/WarehousesController.cs create mode 100644 backend/src/InventoryFlow.Api/ExceptionHandling/GlobalExceptionHandler.cs create mode 100644 backend/src/InventoryFlow.Api/InventoryFlow.Api.csproj create mode 100644 backend/src/InventoryFlow.Api/InventoryFlow.Api.http create mode 100644 backend/src/InventoryFlow.Api/Program.cs create mode 100644 backend/src/InventoryFlow.Api/Properties/launchSettings.json create mode 100644 backend/src/InventoryFlow.Api/appsettings.json create mode 100644 backend/src/InventoryFlow.Application/Common/Behaviors/ValidationBehavior.cs create mode 100644 backend/src/InventoryFlow.Application/Common/Tenancy/CurrentWorkspace.cs create mode 100644 backend/src/InventoryFlow.Application/Common/Tenancy/ICurrentWorkspace.cs create mode 100644 backend/src/InventoryFlow.Application/DependencyInjection/ApplicationServiceCollectionExtensions.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Authentication/AuthenticationHandlers.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Authentication/AuthenticationModels.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Authentication/AuthenticationValidators.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Authentication/IAuthenticationService.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Collaboration/CollaborationHandlers.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Collaboration/CollaborationModels.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Collaboration/ICollaborationService.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Inventory/IInventoryLedger.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Inventory/InventoryArchiveConflictException.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Inventory/InventoryHandlers.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Inventory/InventoryModels.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Inventory/InventoryMovementCommands.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Inventory/InventoryQueries.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Inventory/InventoryValidators.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Products/IProductCatalog.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Products/ProductHandlers.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Products/ProductModels.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Products/ProductValidators.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Purchases/PurchaseReceiptHandlers.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Purchases/PurchaseReceiptModels.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Purchases/PurchaseReceiptValidators.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Sales/SalesFulfillmentHandlers.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Sales/SalesFulfillmentModels.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Sales/SalesFulfillmentValidators.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Suppliers/ISupplierCatalog.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Suppliers/SupplierHandlers.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Suppliers/SupplierModels.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Suppliers/SupplierValidators.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Transfers/WarehouseTransferHandlers.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Transfers/WarehouseTransferModels.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Transfers/WarehouseTransferValidators.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Warehouses/IWarehouseCatalog.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Warehouses/WarehouseHandlers.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Warehouses/WarehouseModels.cs create mode 100644 backend/src/InventoryFlow.Application/Features/Warehouses/WarehouseValidators.cs create mode 100644 backend/src/InventoryFlow.Application/InventoryFlow.Application.csproj create mode 100644 backend/src/InventoryFlow.Domain/Common/Entity.cs create mode 100644 backend/src/InventoryFlow.Domain/Entities/InventoryBalance.cs create mode 100644 backend/src/InventoryFlow.Domain/Entities/InventoryMovement.cs create mode 100644 backend/src/InventoryFlow.Domain/Entities/Product.cs create mode 100644 backend/src/InventoryFlow.Domain/Entities/PurchaseReceipt.cs create mode 100644 backend/src/InventoryFlow.Domain/Entities/RefreshToken.cs create mode 100644 backend/src/InventoryFlow.Domain/Entities/SalesFulfillment.cs create mode 100644 backend/src/InventoryFlow.Domain/Entities/Supplier.cs create mode 100644 backend/src/InventoryFlow.Domain/Entities/Warehouse.cs create mode 100644 backend/src/InventoryFlow.Domain/Entities/WarehouseTransfer.cs create mode 100644 backend/src/InventoryFlow.Domain/Entities/Workspace.cs create mode 100644 backend/src/InventoryFlow.Domain/Entities/WorkspaceInvitation.cs create mode 100644 backend/src/InventoryFlow.Domain/Entities/WorkspaceMember.cs create mode 100644 backend/src/InventoryFlow.Domain/Entities/WorkspaceMemberRole.cs create mode 100644 backend/src/InventoryFlow.Domain/Exceptions/DomainException.cs create mode 100644 backend/src/InventoryFlow.Domain/InventoryFlow.Domain.csproj create mode 100644 backend/src/InventoryFlow.Infrastructure/Authentication/IdentityAuthenticationService.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Authentication/JwtAccessTokenIssuer.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Authentication/JwtOptions.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Authentication/RefreshTokenGenerator.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Collaboration/EfCollaborationService.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/DependencyInjection/InfrastructureServiceCollectionExtensions.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Identity/ApplicationUser.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Inventory/EfInventoryLedger.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Inventory/InventoryLedgerWriter.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/InventoryFlow.Infrastructure.csproj create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/ApplicationDbContext.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/ApplicationDbContextFactory.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/ApplicationUserConfiguration.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/InventoryBalanceConfiguration.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/InventoryMovementConfiguration.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/ProductConfiguration.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/PurchaseReceiptConfiguration.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/SalesFulfillmentConfiguration.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/SupplierConfiguration.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WarehouseConfiguration.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WarehouseTransferConfiguration.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WorkspaceConfiguration.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WorkspaceInvitationConfiguration.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WorkspaceMemberConfiguration.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260717170006_InitialIdentity.Designer.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260717170006_InitialIdentity.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718100649_AddRefreshTokenFamilies.Designer.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718100649_AddRefreshTokenFamilies.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718105902_AddWorkspaces.Designer.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718105902_AddWorkspaces.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718112223_AddProducts.Designer.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718112223_AddProducts.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718115041_AddWarehouses.Designer.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718115041_AddWarehouses.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718122029_AddInventoryLedger.Designer.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718122029_AddInventoryLedger.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718123000_ProtectInventorySourceArchival.Designer.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718123000_ProtectInventorySourceArchival.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718130340_AddSuppliers.Designer.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718130340_AddSuppliers.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718132213_AddPurchaseReceipts.Designer.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718132213_AddPurchaseReceipts.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718135438_AddSalesFulfillments.Designer.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718135438_AddSalesFulfillments.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718141104_AddWarehouseTransfers.Designer.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718141104_AddWarehouseTransfers.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260719073620_AddCollaborationFoundation.Designer.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260719073620_AddCollaborationFoundation.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Products/EfProductCatalog.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Purchases/EfPurchaseReceiptService.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Sales/EfSalesFulfillmentService.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Suppliers/EfSupplierCatalog.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Tenancy/CurrentWorkspaceResolver.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Transfers/EfWarehouseTransferService.cs create mode 100644 backend/src/InventoryFlow.Infrastructure/Warehouses/EfWarehouseCatalog.cs create mode 100644 backend/src/InventoryFlow.Shared/InventoryFlow.Shared.csproj create mode 100644 backend/tests/InventoryFlow.IntegrationTests/Api/AuthenticatedApiFixture.cs create mode 100644 backend/tests/InventoryFlow.IntegrationTests/Api/AuthenticationEndpointsTests.cs create mode 100644 backend/tests/InventoryFlow.IntegrationTests/Api/CollaborationEndpointsTests.cs create mode 100644 backend/tests/InventoryFlow.IntegrationTests/Api/GlobalExceptionHandlerTests.cs create mode 100644 backend/tests/InventoryFlow.IntegrationTests/Api/HealthEndpointTests.cs create mode 100644 backend/tests/InventoryFlow.IntegrationTests/Api/InventoryEndpointsTests.cs create mode 100644 backend/tests/InventoryFlow.IntegrationTests/Api/InventoryFlowApiFactory.cs create mode 100644 backend/tests/InventoryFlow.IntegrationTests/Api/ProductEndpointsTests.cs create mode 100644 backend/tests/InventoryFlow.IntegrationTests/Api/PurchaseReceiptEndpointsTests.cs create mode 100644 backend/tests/InventoryFlow.IntegrationTests/Api/SalesFulfillmentEndpointsTests.cs create mode 100644 backend/tests/InventoryFlow.IntegrationTests/Api/SupplierEndpointsTests.cs create mode 100644 backend/tests/InventoryFlow.IntegrationTests/Api/WarehouseEndpointsTests.cs create mode 100644 backend/tests/InventoryFlow.IntegrationTests/Api/WarehouseTransferEndpointsTests.cs create mode 100644 backend/tests/InventoryFlow.IntegrationTests/Api/WorkspaceMigrationAndTenancyTests.cs create mode 100644 backend/tests/InventoryFlow.IntegrationTests/InventoryFlow.IntegrationTests.csproj create mode 100644 backend/tests/InventoryFlow.IntegrationTests/TestCollectionBehavior.cs create mode 100644 backend/tests/InventoryFlow.UnitTests/Domain/DomainExceptionTests.cs create mode 100644 backend/tests/InventoryFlow.UnitTests/Domain/InventoryBalanceTests.cs create mode 100644 backend/tests/InventoryFlow.UnitTests/Domain/ProductTests.cs create mode 100644 backend/tests/InventoryFlow.UnitTests/Domain/PurchaseReceiptTests.cs create mode 100644 backend/tests/InventoryFlow.UnitTests/Domain/RefreshTokenTests.cs create mode 100644 backend/tests/InventoryFlow.UnitTests/Domain/SupplierTests.cs create mode 100644 backend/tests/InventoryFlow.UnitTests/Domain/WarehouseTests.cs create mode 100644 backend/tests/InventoryFlow.UnitTests/Domain/WorkspaceInvitationTests.cs create mode 100644 backend/tests/InventoryFlow.UnitTests/Domain/WorkspaceMemberTests.cs create mode 100644 backend/tests/InventoryFlow.UnitTests/Domain/WorkspaceTests.cs create mode 100644 backend/tests/InventoryFlow.UnitTests/InventoryFlow.UnitTests.csproj create mode 100644 docker-compose.yml create mode 100644 frontend/.dockerignore create mode 100644 frontend/.env.example create mode 100644 frontend/.gitignore create mode 100644 frontend/.prettierignore create mode 100644 frontend/.prettierrc create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/bun.lock create mode 100644 frontend/components.json create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/app/config/environment.ts create mode 100644 frontend/src/app/providers/AppProviders.tsx create mode 100644 frontend/src/app/router/index.tsx create mode 100644 frontend/src/components/layout/Sidebar.tsx create mode 100644 frontend/src/components/layout/Topbar.tsx create mode 100644 frontend/src/components/shared/PageHeader.tsx create mode 100644 frontend/src/components/theme-provider.tsx create mode 100644 frontend/src/components/ui/avatar.tsx create mode 100644 frontend/src/components/ui/badge.tsx create mode 100644 frontend/src/components/ui/button.tsx create mode 100644 frontend/src/components/ui/card.tsx create mode 100644 frontend/src/components/ui/dropdown-menu.tsx create mode 100644 frontend/src/components/ui/separator.tsx create mode 100644 frontend/src/components/ui/sheet.tsx create mode 100644 frontend/src/components/ui/skeleton.tsx create mode 100644 frontend/src/components/ui/tooltip.tsx create mode 100644 frontend/src/features/auth/auth-api.ts create mode 100644 frontend/src/features/auth/auth-redirect.ts create mode 100644 frontend/src/features/auth/auth-schema.ts create mode 100644 frontend/src/features/auth/auth-store.ts create mode 100644 frontend/src/features/auth/components/PublicOnly.tsx create mode 100644 frontend/src/features/auth/components/RequireAuth.tsx create mode 100644 frontend/src/features/auth/pages/LoginPage.tsx create mode 100644 frontend/src/features/auth/pages/RegisterPage.tsx create mode 100644 frontend/src/features/auth/session-bootstrap.tsx create mode 100644 frontend/src/features/auth/types.ts create mode 100644 frontend/src/features/dashboard/components/MetricCard.tsx create mode 100644 frontend/src/features/dashboard/pages/DashboardPage.tsx create mode 100644 frontend/src/features/inventory/inventory-api.ts create mode 100644 frontend/src/features/inventory/inventory-queries.ts create mode 100644 frontend/src/features/inventory/inventory-schema.ts create mode 100644 frontend/src/features/inventory/pages/InventoryPage.tsx create mode 100644 frontend/src/features/inventory/types.ts create mode 100644 frontend/src/features/products/pages/ProductsPage.tsx create mode 100644 frontend/src/features/products/products-api.ts create mode 100644 frontend/src/features/products/products-schema.ts create mode 100644 frontend/src/features/products/types.ts create mode 100644 frontend/src/features/purchases/pages/PurchasesPage.tsx create mode 100644 frontend/src/features/purchases/purchases-api.ts create mode 100644 frontend/src/features/purchases/purchases-schema.ts create mode 100644 frontend/src/features/purchases/types.ts create mode 100644 frontend/src/features/reports/pages/ReportsPage.tsx create mode 100644 frontend/src/features/sales/pages/SalesPage.tsx create mode 100644 frontend/src/features/sales/sales-api.ts create mode 100644 frontend/src/features/sales/sales-schema.ts create mode 100644 frontend/src/features/sales/types.ts create mode 100644 frontend/src/features/suppliers/pages/SuppliersPage.tsx create mode 100644 frontend/src/features/suppliers/suppliers-api.ts create mode 100644 frontend/src/features/suppliers/suppliers-schema.ts create mode 100644 frontend/src/features/suppliers/types.ts create mode 100644 frontend/src/features/transfers/pages/TransfersPage.tsx create mode 100644 frontend/src/features/transfers/transfers-api.ts create mode 100644 frontend/src/features/transfers/transfers-schema.ts create mode 100644 frontend/src/features/transfers/types.ts create mode 100644 frontend/src/features/warehouses/pages/WarehousesPage.tsx create mode 100644 frontend/src/features/warehouses/types.ts create mode 100644 frontend/src/features/warehouses/warehouses-api.ts create mode 100644 frontend/src/features/warehouses/warehouses-schema.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/layouts/DashboardLayout.tsx create mode 100644 frontend/src/lib/api-client.ts create mode 100644 frontend/src/lib/query-client.ts create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/store/ui-store.ts create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 global.json create mode 100644 mise.toml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9d2d73a --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# Copy to .env and replace both values with local development secrets. +# These values are required; no credentials or JWT signing key defaults are committed. +MSSQL_SA_PASSWORD= +Jwt__SigningKey= diff --git a/backend/.config/dotnet-tools.json b/backend/.config/dotnet-tools.json new file mode 100644 index 0000000..2fb3663 --- /dev/null +++ b/backend/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "9.0.18", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..d761642 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,11 @@ +**/bin/ +**/obj/ +**/TestResults/ +tests/ +.git/ +.github/ +.env +.env.* +!*.env.example +appsettings.Development.json +appsettings.*.local.json diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props new file mode 100644 index 0000000..d62e07f --- /dev/null +++ b/backend/Directory.Build.props @@ -0,0 +1,10 @@ + + + net9.0 + enable + enable + latest + true + true + + diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props new file mode 100644 index 0000000..7adcf0d --- /dev/null +++ b/backend/Directory.Packages.props @@ -0,0 +1,30 @@ + + + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..cae344e --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,46 @@ +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS base +ARG APP_UID=10001 +ARG APP_GID=10001 +RUN groupadd --system --gid ${APP_GID} inventoryflow \ + && useradd --uid ${APP_UID} --gid inventoryflow --create-home --home-dir /home/inventoryflow --shell /usr/sbin/nologin inventoryflow \ + && mkdir -p /src /app /home/inventoryflow/.nuget/packages \ + && chown -R inventoryflow:inventoryflow /src /app /home/inventoryflow +ENV DOTNET_CLI_HOME=/home/inventoryflow \ + NUGET_PACKAGES=/home/inventoryflow/.nuget/packages +WORKDIR /src +USER inventoryflow + +FROM base AS restore +COPY --chown=inventoryflow:inventoryflow Directory.Build.props Directory.Packages.props InventoryFlow.sln ./ +COPY --chown=inventoryflow:inventoryflow src/InventoryFlow.Api/InventoryFlow.Api.csproj src/InventoryFlow.Api/ +COPY --chown=inventoryflow:inventoryflow src/InventoryFlow.Application/InventoryFlow.Application.csproj src/InventoryFlow.Application/ +COPY --chown=inventoryflow:inventoryflow src/InventoryFlow.Domain/InventoryFlow.Domain.csproj src/InventoryFlow.Domain/ +COPY --chown=inventoryflow:inventoryflow src/InventoryFlow.Infrastructure/InventoryFlow.Infrastructure.csproj src/InventoryFlow.Infrastructure/ +COPY --chown=inventoryflow:inventoryflow src/InventoryFlow.Shared/InventoryFlow.Shared.csproj src/InventoryFlow.Shared/ +RUN dotnet restore src/InventoryFlow.Api/InventoryFlow.Api.csproj + +FROM restore AS build +COPY --chown=inventoryflow:inventoryflow src ./src +RUN dotnet build src/InventoryFlow.Api/InventoryFlow.Api.csproj --configuration Release --no-restore + +FROM build AS publish +RUN dotnet publish src/InventoryFlow.Api/InventoryFlow.Api.csproj --configuration Release --no-build --output /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final +ARG APP_UID=10001 +ARG APP_GID=10001 +RUN groupadd --system --gid ${APP_GID} inventoryflow \ + && useradd --uid ${APP_UID} --gid inventoryflow --create-home --home-dir /home/inventoryflow --shell /usr/sbin/nologin inventoryflow \ + && mkdir -p /app \ + && chown inventoryflow:inventoryflow /app +WORKDIR /app +EXPOSE 8080 +ENV ASPNETCORE_URLS=http://+:8080 +COPY --from=publish --chown=inventoryflow:inventoryflow /app/publish . +USER inventoryflow +ENTRYPOINT ["dotnet", "InventoryFlow.Api.dll"] + +FROM build AS migrator +COPY --chown=inventoryflow:inventoryflow .config .config +RUN dotnet tool restore +ENTRYPOINT ["dotnet", "ef", "database", "update", "--project", "src/InventoryFlow.Infrastructure", "--startup-project", "src/InventoryFlow.Api"] diff --git a/backend/InventoryFlow.sln b/backend/InventoryFlow.sln new file mode 100644 index 0000000..e68854a --- /dev/null +++ b/backend/InventoryFlow.sln @@ -0,0 +1,129 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InventoryFlow.Domain", "src\InventoryFlow.Domain\InventoryFlow.Domain.csproj", "{D6637A17-757E-4C0E-82A8-4080C183B775}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InventoryFlow.Application", "src\InventoryFlow.Application\InventoryFlow.Application.csproj", "{3436993A-54EE-4E1C-A601-EDD2BEE33712}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InventoryFlow.Infrastructure", "src\InventoryFlow.Infrastructure\InventoryFlow.Infrastructure.csproj", "{20CB6ECB-430E-4CD9-B392-C196CFD6D7AE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InventoryFlow.Shared", "src\InventoryFlow.Shared\InventoryFlow.Shared.csproj", "{526EF02F-24EE-49E0-AC41-0F7174C5B961}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InventoryFlow.Api", "src\InventoryFlow.Api\InventoryFlow.Api.csproj", "{D0C3B3C5-8FA6-4B19-8BE9-CB2E3FBD6245}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InventoryFlow.UnitTests", "tests\InventoryFlow.UnitTests\InventoryFlow.UnitTests.csproj", "{A1976E8B-EC99-4F3F-A5C1-7B196893A477}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InventoryFlow.IntegrationTests", "tests\InventoryFlow.IntegrationTests\InventoryFlow.IntegrationTests.csproj", "{5C4062B2-3592-421D-B956-DACAB8F01B42}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D6637A17-757E-4C0E-82A8-4080C183B775}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D6637A17-757E-4C0E-82A8-4080C183B775}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D6637A17-757E-4C0E-82A8-4080C183B775}.Debug|x64.ActiveCfg = Debug|Any CPU + {D6637A17-757E-4C0E-82A8-4080C183B775}.Debug|x64.Build.0 = Debug|Any CPU + {D6637A17-757E-4C0E-82A8-4080C183B775}.Debug|x86.ActiveCfg = Debug|Any CPU + {D6637A17-757E-4C0E-82A8-4080C183B775}.Debug|x86.Build.0 = Debug|Any CPU + {D6637A17-757E-4C0E-82A8-4080C183B775}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D6637A17-757E-4C0E-82A8-4080C183B775}.Release|Any CPU.Build.0 = Release|Any CPU + {D6637A17-757E-4C0E-82A8-4080C183B775}.Release|x64.ActiveCfg = Release|Any CPU + {D6637A17-757E-4C0E-82A8-4080C183B775}.Release|x64.Build.0 = Release|Any CPU + {D6637A17-757E-4C0E-82A8-4080C183B775}.Release|x86.ActiveCfg = Release|Any CPU + {D6637A17-757E-4C0E-82A8-4080C183B775}.Release|x86.Build.0 = Release|Any CPU + {3436993A-54EE-4E1C-A601-EDD2BEE33712}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3436993A-54EE-4E1C-A601-EDD2BEE33712}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3436993A-54EE-4E1C-A601-EDD2BEE33712}.Debug|x64.ActiveCfg = Debug|Any CPU + {3436993A-54EE-4E1C-A601-EDD2BEE33712}.Debug|x64.Build.0 = Debug|Any CPU + {3436993A-54EE-4E1C-A601-EDD2BEE33712}.Debug|x86.ActiveCfg = Debug|Any CPU + {3436993A-54EE-4E1C-A601-EDD2BEE33712}.Debug|x86.Build.0 = Debug|Any CPU + {3436993A-54EE-4E1C-A601-EDD2BEE33712}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3436993A-54EE-4E1C-A601-EDD2BEE33712}.Release|Any CPU.Build.0 = Release|Any CPU + {3436993A-54EE-4E1C-A601-EDD2BEE33712}.Release|x64.ActiveCfg = Release|Any CPU + {3436993A-54EE-4E1C-A601-EDD2BEE33712}.Release|x64.Build.0 = Release|Any CPU + {3436993A-54EE-4E1C-A601-EDD2BEE33712}.Release|x86.ActiveCfg = Release|Any CPU + {3436993A-54EE-4E1C-A601-EDD2BEE33712}.Release|x86.Build.0 = Release|Any CPU + {20CB6ECB-430E-4CD9-B392-C196CFD6D7AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {20CB6ECB-430E-4CD9-B392-C196CFD6D7AE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {20CB6ECB-430E-4CD9-B392-C196CFD6D7AE}.Debug|x64.ActiveCfg = Debug|Any CPU + {20CB6ECB-430E-4CD9-B392-C196CFD6D7AE}.Debug|x64.Build.0 = Debug|Any CPU + {20CB6ECB-430E-4CD9-B392-C196CFD6D7AE}.Debug|x86.ActiveCfg = Debug|Any CPU + {20CB6ECB-430E-4CD9-B392-C196CFD6D7AE}.Debug|x86.Build.0 = Debug|Any CPU + {20CB6ECB-430E-4CD9-B392-C196CFD6D7AE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {20CB6ECB-430E-4CD9-B392-C196CFD6D7AE}.Release|Any CPU.Build.0 = Release|Any CPU + {20CB6ECB-430E-4CD9-B392-C196CFD6D7AE}.Release|x64.ActiveCfg = Release|Any CPU + {20CB6ECB-430E-4CD9-B392-C196CFD6D7AE}.Release|x64.Build.0 = Release|Any CPU + {20CB6ECB-430E-4CD9-B392-C196CFD6D7AE}.Release|x86.ActiveCfg = Release|Any CPU + {20CB6ECB-430E-4CD9-B392-C196CFD6D7AE}.Release|x86.Build.0 = Release|Any CPU + {526EF02F-24EE-49E0-AC41-0F7174C5B961}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {526EF02F-24EE-49E0-AC41-0F7174C5B961}.Debug|Any CPU.Build.0 = Debug|Any CPU + {526EF02F-24EE-49E0-AC41-0F7174C5B961}.Debug|x64.ActiveCfg = Debug|Any CPU + {526EF02F-24EE-49E0-AC41-0F7174C5B961}.Debug|x64.Build.0 = Debug|Any CPU + {526EF02F-24EE-49E0-AC41-0F7174C5B961}.Debug|x86.ActiveCfg = Debug|Any CPU + {526EF02F-24EE-49E0-AC41-0F7174C5B961}.Debug|x86.Build.0 = Debug|Any CPU + {526EF02F-24EE-49E0-AC41-0F7174C5B961}.Release|Any CPU.ActiveCfg = Release|Any CPU + {526EF02F-24EE-49E0-AC41-0F7174C5B961}.Release|Any CPU.Build.0 = Release|Any CPU + {526EF02F-24EE-49E0-AC41-0F7174C5B961}.Release|x64.ActiveCfg = Release|Any CPU + {526EF02F-24EE-49E0-AC41-0F7174C5B961}.Release|x64.Build.0 = Release|Any CPU + {526EF02F-24EE-49E0-AC41-0F7174C5B961}.Release|x86.ActiveCfg = Release|Any CPU + {526EF02F-24EE-49E0-AC41-0F7174C5B961}.Release|x86.Build.0 = Release|Any CPU + {D0C3B3C5-8FA6-4B19-8BE9-CB2E3FBD6245}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D0C3B3C5-8FA6-4B19-8BE9-CB2E3FBD6245}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D0C3B3C5-8FA6-4B19-8BE9-CB2E3FBD6245}.Debug|x64.ActiveCfg = Debug|Any CPU + {D0C3B3C5-8FA6-4B19-8BE9-CB2E3FBD6245}.Debug|x64.Build.0 = Debug|Any CPU + {D0C3B3C5-8FA6-4B19-8BE9-CB2E3FBD6245}.Debug|x86.ActiveCfg = Debug|Any CPU + {D0C3B3C5-8FA6-4B19-8BE9-CB2E3FBD6245}.Debug|x86.Build.0 = Debug|Any CPU + {D0C3B3C5-8FA6-4B19-8BE9-CB2E3FBD6245}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D0C3B3C5-8FA6-4B19-8BE9-CB2E3FBD6245}.Release|Any CPU.Build.0 = Release|Any CPU + {D0C3B3C5-8FA6-4B19-8BE9-CB2E3FBD6245}.Release|x64.ActiveCfg = Release|Any CPU + {D0C3B3C5-8FA6-4B19-8BE9-CB2E3FBD6245}.Release|x64.Build.0 = Release|Any CPU + {D0C3B3C5-8FA6-4B19-8BE9-CB2E3FBD6245}.Release|x86.ActiveCfg = Release|Any CPU + {D0C3B3C5-8FA6-4B19-8BE9-CB2E3FBD6245}.Release|x86.Build.0 = Release|Any CPU + {A1976E8B-EC99-4F3F-A5C1-7B196893A477}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1976E8B-EC99-4F3F-A5C1-7B196893A477}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1976E8B-EC99-4F3F-A5C1-7B196893A477}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1976E8B-EC99-4F3F-A5C1-7B196893A477}.Debug|x64.Build.0 = Debug|Any CPU + {A1976E8B-EC99-4F3F-A5C1-7B196893A477}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1976E8B-EC99-4F3F-A5C1-7B196893A477}.Debug|x86.Build.0 = Debug|Any CPU + {A1976E8B-EC99-4F3F-A5C1-7B196893A477}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1976E8B-EC99-4F3F-A5C1-7B196893A477}.Release|Any CPU.Build.0 = Release|Any CPU + {A1976E8B-EC99-4F3F-A5C1-7B196893A477}.Release|x64.ActiveCfg = Release|Any CPU + {A1976E8B-EC99-4F3F-A5C1-7B196893A477}.Release|x64.Build.0 = Release|Any CPU + {A1976E8B-EC99-4F3F-A5C1-7B196893A477}.Release|x86.ActiveCfg = Release|Any CPU + {A1976E8B-EC99-4F3F-A5C1-7B196893A477}.Release|x86.Build.0 = Release|Any CPU + {5C4062B2-3592-421D-B956-DACAB8F01B42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5C4062B2-3592-421D-B956-DACAB8F01B42}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5C4062B2-3592-421D-B956-DACAB8F01B42}.Debug|x64.ActiveCfg = Debug|Any CPU + {5C4062B2-3592-421D-B956-DACAB8F01B42}.Debug|x64.Build.0 = Debug|Any CPU + {5C4062B2-3592-421D-B956-DACAB8F01B42}.Debug|x86.ActiveCfg = Debug|Any CPU + {5C4062B2-3592-421D-B956-DACAB8F01B42}.Debug|x86.Build.0 = Debug|Any CPU + {5C4062B2-3592-421D-B956-DACAB8F01B42}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5C4062B2-3592-421D-B956-DACAB8F01B42}.Release|Any CPU.Build.0 = Release|Any CPU + {5C4062B2-3592-421D-B956-DACAB8F01B42}.Release|x64.ActiveCfg = Release|Any CPU + {5C4062B2-3592-421D-B956-DACAB8F01B42}.Release|x64.Build.0 = Release|Any CPU + {5C4062B2-3592-421D-B956-DACAB8F01B42}.Release|x86.ActiveCfg = Release|Any CPU + {5C4062B2-3592-421D-B956-DACAB8F01B42}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {D6637A17-757E-4C0E-82A8-4080C183B775} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {3436993A-54EE-4E1C-A601-EDD2BEE33712} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {20CB6ECB-430E-4CD9-B392-C196CFD6D7AE} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {526EF02F-24EE-49E0-AC41-0F7174C5B961} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {D0C3B3C5-8FA6-4B19-8BE9-CB2E3FBD6245} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {A1976E8B-EC99-4F3F-A5C1-7B196893A477} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {5C4062B2-3592-421D-B956-DACAB8F01B42} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + EndGlobalSection +EndGlobal diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..c346947 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,15 @@ +# Inventory Flow API + +Set the database connection string and JWT signing secret outside tracked configuration before running the API: + +```bash +cd backend/src/InventoryFlow.Api +dotnet user-secrets set "Jwt:SigningKey" "replace-with-at-least-32-random-bytes" +export ConnectionStrings__InventoryFlowDatabase='Server=localhost,1433;Database=InventoryFlow;User Id=sa;Password=;TrustServerCertificate=True' +``` + +Production deployments must supply `Jwt__SigningKey` from a secret manager and set exact `Cors__AllowedOrigins__0` values. The browser refresh cookie is `HttpOnly`, `SameSite=Strict`, and requires same-site SPA/API deployment. Apply migrations with `dotnet ef database update --project src/InventoryFlow.Infrastructure --startup-project src/InventoryFlow.Api` from this directory. + +## Workspace tenancy + +Self-registration creates a personal workspace and immutable Owner membership. Authentication responses include that server-resolved workspace; workspace IDs are never accepted from the refresh cookie. Future multi-workspace switching and invitations are intentionally not yet implemented. diff --git a/backend/src/InventoryFlow.Api/Controllers/AuthController.cs b/backend/src/InventoryFlow.Api/Controllers/AuthController.cs new file mode 100644 index 0000000..d770990 --- /dev/null +++ b/backend/src/InventoryFlow.Api/Controllers/AuthController.cs @@ -0,0 +1,99 @@ +using System.Security.Claims; +using InventoryFlow.Application.Features.Authentication; +using InventoryFlow.Infrastructure.Authentication; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace InventoryFlow.Api.Controllers; + +/// Provides browser authentication endpoints. +[ApiController] +[Route("api/auth")] +public sealed class AuthController(ISender sender, IOptions options) : ControllerBase +{ + private readonly JwtOptions _options = options.Value; + + /// Registers a user and starts a browser session. + [HttpPost("register")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Register(RegisterUserCommand command, CancellationToken cancellationToken) + { + var session = await sender.Send(command, cancellationToken); + SetRefreshCookie(session.RefreshToken); + return Ok(session.Response); + } + + /// Authenticates a user and starts a browser session. + [HttpPost("login")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> Login(LoginUserCommand command, CancellationToken cancellationToken) + { + var session = await sender.Send(command, cancellationToken); + SetRefreshCookie(session.RefreshToken); + return Ok(session.Response); + } + + /// Rotates the HttpOnly refresh cookie and returns a new access token. + [HttpPost("refresh")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> Refresh(CancellationToken cancellationToken) + { + var refreshToken = Request.Cookies[_options.RefreshCookieName]; + if (string.IsNullOrWhiteSpace(refreshToken)) { DeleteRefreshCookie(); return Unauthorized(); } + var session = await sender.Send(new RefreshSessionCommand(refreshToken), cancellationToken); + if (session is null) { DeleteRefreshCookie(); return Unauthorized(); } + SetRefreshCookie(session.RefreshToken); + return Ok(session.Response); + } + + /// Switches the active workspace, rotating the persisted refresh session. + [Authorize] + [HttpPost("workspace/switch")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> SwitchWorkspace(SwitchWorkspaceRequest request, CancellationToken cancellationToken) + { + var refreshToken = Request.Cookies[_options.RefreshCookieName]; + if (string.IsNullOrWhiteSpace(refreshToken)) { DeleteRefreshCookie(); return Unauthorized(); } + var value = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!Guid.TryParse(value, out var userId)) return Unauthorized(); + var session = await sender.Send(new SwitchWorkspaceCommand(userId, request.WorkspaceId, refreshToken), cancellationToken); + if (session is null) return Forbid(); + SetRefreshCookie(session.RefreshToken); + return Ok(session.Response); + } + + /// Revokes the refresh family held by the browser. + [HttpPost("logout")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task Logout(CancellationToken cancellationToken) + { + await sender.Send(new LogoutSessionCommand(Request.Cookies[_options.RefreshCookieName]), cancellationToken); + DeleteRefreshCookie(); + return NoContent(); + } + + /// Gets the current user from a valid bearer token. + [Authorize] + [HttpGet("me")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Me(CancellationToken cancellationToken) + { + var value = User.FindFirstValue(ClaimTypes.NameIdentifier); + var workspaceValue = User.FindFirstValue(JwtAccessTokenIssuer.WorkspaceIdClaimType); + if (!Guid.TryParse(value, out var userId) || !Guid.TryParse(workspaceValue, out var workspaceId)) return Unauthorized(); + var user = await sender.Send(new GetCurrentUserQuery(userId, workspaceId), cancellationToken); + return user is null ? Unauthorized() : Ok(user); + } + + private void SetRefreshCookie(string value) => Response.Cookies.Append(_options.RefreshCookieName, value, new CookieOptions + { HttpOnly = true, SameSite = SameSiteMode.Strict, Secure = !HttpContext.RequestServices.GetRequiredService().IsDevelopment(), Path = "/api/auth", MaxAge = TimeSpan.FromDays(_options.RefreshTokenLifetimeDays), Expires = DateTimeOffset.UtcNow.AddDays(_options.RefreshTokenLifetimeDays), IsEssential = true }); + private void DeleteRefreshCookie() => Response.Cookies.Delete(_options.RefreshCookieName, new CookieOptions { Path = "/api/auth", SameSite = SameSiteMode.Strict, Secure = !HttpContext.RequestServices.GetRequiredService().IsDevelopment() }); +} + +/// Supplies an active-workspace switch request. +public sealed record SwitchWorkspaceRequest(Guid WorkspaceId); diff --git a/backend/src/InventoryFlow.Api/Controllers/CollaborationController.cs b/backend/src/InventoryFlow.Api/Controllers/CollaborationController.cs new file mode 100644 index 0000000..ff3af13 --- /dev/null +++ b/backend/src/InventoryFlow.Api/Controllers/CollaborationController.cs @@ -0,0 +1,81 @@ +using System.Security.Claims; +using InventoryFlow.Application.Common.Tenancy; +using InventoryFlow.Application.Features.Collaboration; +using InventoryFlow.Domain.Entities; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace InventoryFlow.Api.Controllers; + +/// Provides workspace collaboration endpoints. +[ApiController] +[Authorize] +[Route("api/collaboration")] +public sealed class CollaborationController(ISender sender, ICurrentWorkspace currentWorkspace) : ControllerBase +{ + /// Lists members in the active workspace. Owner-only. + [HttpGet("members")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> ListMembers(CancellationToken cancellationToken) + { + var workspace = await ResolveWorkspaceAsync(cancellationToken); + if (workspace is null) return Forbid(); + if (workspace.Role != WorkspaceMemberRole.Owner) return Forbid(); + return Ok(await sender.Send(new ListWorkspaceMembersQuery(workspace.Id, workspace.Role), cancellationToken)); + } + + /// Lists invitations in the active workspace. Owner-only. + [HttpGet("invitations")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> ListInvitations(CancellationToken cancellationToken) + { + var workspace = await ResolveWorkspaceAsync(cancellationToken); + if (workspace is null) return Forbid(); + if (workspace.Role != WorkspaceMemberRole.Owner) return Forbid(); + return Ok(await sender.Send(new ListWorkspaceInvitationsQuery(workspace.Id, workspace.Role), cancellationToken)); + } + + /// Creates a Member invitation for an existing registered email. Owner-only. + [HttpPost("invitations")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> CreateInvitation(CreateWorkspaceInvitationRequest request, CancellationToken cancellationToken) + { + var workspace = await ResolveWorkspaceAsync(cancellationToken); + if (workspace is null) return Forbid(); + if (workspace.Role != WorkspaceMemberRole.Owner) return Forbid(); + var userId = GetAuthenticatedUserId(); + if (userId is null) return Unauthorized(); + return Ok(await sender.Send(new CreateWorkspaceInvitationCommand(workspace.Id, userId.Value, workspace.Role, request.Email), cancellationToken)); + } + + /// Revokes a pending invitation. Owner-only. + [HttpPost("invitations/{invitationId:guid}/revoke")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task RevokeInvitation(Guid invitationId, CancellationToken cancellationToken) + { + var workspace = await ResolveWorkspaceAsync(cancellationToken); + if (workspace is null) return Forbid(); + if (workspace.Role != WorkspaceMemberRole.Owner) return Forbid(); + await sender.Send(new RevokeWorkspaceInvitationCommand(workspace.Id, invitationId, workspace.Role), cancellationToken); + return NoContent(); + } + + /// Accepts an invitation as the authenticated registered user. + [HttpPost("invitations/accept")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> AcceptInvitation(AcceptWorkspaceInvitationRequest request, CancellationToken cancellationToken) + { + var userId = GetAuthenticatedUserId(); + if (userId is null) return Unauthorized(); + return Ok(await sender.Send(new AcceptWorkspaceInvitationCommand(userId.Value, request.Token), cancellationToken)); + } + + private async Task ResolveWorkspaceAsync(CancellationToken cancellationToken) => await currentWorkspace.GetAsync(cancellationToken); + + private Guid? GetAuthenticatedUserId() + { + var value = User.FindFirstValue(ClaimTypes.NameIdentifier); + return Guid.TryParse(value, out var userId) ? userId : null; + } +} diff --git a/backend/src/InventoryFlow.Api/Controllers/InventoryController.cs b/backend/src/InventoryFlow.Api/Controllers/InventoryController.cs new file mode 100644 index 0000000..0a406db --- /dev/null +++ b/backend/src/InventoryFlow.Api/Controllers/InventoryController.cs @@ -0,0 +1,57 @@ +using InventoryFlow.Application.Common.Tenancy; +using InventoryFlow.Application.Features.Inventory; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace InventoryFlow.Api.Controllers; + +/// Provides workspace-scoped inventory receipt, issue, and balance endpoints. +[ApiController] +[Authorize] +[Route("api/inventory")] +public sealed class InventoryController(ISender sender, ICurrentWorkspace currentWorkspace) : ControllerBase +{ + /// Records a stock receipt exactly once for an idempotency key. + [HttpPost("receipts")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Receive(RecordInventoryMovementRequest request, + [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey, CancellationToken cancellationToken) + { + var workspace = await currentWorkspace.GetAsync(cancellationToken); + if (workspace is null) return Forbid(); + var movement = await sender.Send(new RecordReceiptCommand(workspace.Id, request.WarehouseId, request.ProductId, + request.Quantity, idempotencyKey ?? request.IdempotencyKey), cancellationToken); + return movement is null ? NotFound() : Created($"/api/inventory/movements/{movement.Id}", movement); + } + + /// Records a stock issue exactly once for an idempotency key. + [HttpPost("issues")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Issue(RecordInventoryMovementRequest request, + [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey, CancellationToken cancellationToken) + { + var workspace = await currentWorkspace.GetAsync(cancellationToken); + if (workspace is null) return Forbid(); + var movement = await sender.Send(new RecordIssueCommand(workspace.Id, request.WarehouseId, request.ProductId, + request.Quantity, idempotencyKey ?? request.IdempotencyKey), cancellationToken); + return movement is null ? NotFound() : Created($"/api/inventory/movements/{movement.Id}", movement); + } + + /// Lists current on-hand balances for the current workspace. + [HttpGet("balances")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task>> ListBalances(Guid? warehouseId, Guid? productId, + CancellationToken cancellationToken) + { + var workspace = await currentWorkspace.GetAsync(cancellationToken); + if (workspace is null) return Forbid(); + return Ok(await sender.Send(new ListInventoryBalancesQuery(workspace.Id, warehouseId, productId), cancellationToken)); + } +} diff --git a/backend/src/InventoryFlow.Api/Controllers/ProductsController.cs b/backend/src/InventoryFlow.Api/Controllers/ProductsController.cs new file mode 100644 index 0000000..5ac48c7 --- /dev/null +++ b/backend/src/InventoryFlow.Api/Controllers/ProductsController.cs @@ -0,0 +1,50 @@ +using InventoryFlow.Application.Common.Tenancy; +using InventoryFlow.Application.Features.Products; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace InventoryFlow.Api.Controllers; + +/// Provides workspace-scoped product catalog endpoints. +[ApiController] +[Authorize] +[Route("api/products")] +public sealed class ProductsController(ISender sender, ICurrentWorkspace currentWorkspace) : ControllerBase +{ + /// Creates a product in the current workspace. + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> Create(CreateProductRequest request, CancellationToken cancellationToken) + { + var workspace = await currentWorkspace.GetAsync(cancellationToken); + if (workspace is null) return Forbid(); + var product = await sender.Send(new CreateProductCommand(workspace.Id, request.Name, request.Sku), cancellationToken); + return Created($"/api/products/{product.Id}", product); + } + + /// Lists active products in the current workspace. + [HttpGet] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task>> List(CancellationToken cancellationToken) + { + var workspace = await currentWorkspace.GetAsync(cancellationToken); + if (workspace is null) return Forbid(); + return Ok(await sender.Send(new ListProductsQuery(workspace.Id), cancellationToken)); + } + + /// Idempotently archives a product with no stock on hand. + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Archive(Guid id, CancellationToken cancellationToken) + { + var workspace = await currentWorkspace.GetAsync(cancellationToken); + if (workspace is null) return Forbid(); + return await sender.Send(new ArchiveProductCommand(workspace.Id, id), cancellationToken) ? NoContent() : NotFound(); + } +} diff --git a/backend/src/InventoryFlow.Api/Controllers/PurchaseReceiptsController.cs b/backend/src/InventoryFlow.Api/Controllers/PurchaseReceiptsController.cs new file mode 100644 index 0000000..e8e1331 --- /dev/null +++ b/backend/src/InventoryFlow.Api/Controllers/PurchaseReceiptsController.cs @@ -0,0 +1,37 @@ +using InventoryFlow.Application.Common.Tenancy; +using InventoryFlow.Application.Features.Purchases; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace InventoryFlow.Api.Controllers; + +[ApiController] +[Authorize] +[Route("api/purchases/receipts")] +public sealed class PurchaseReceiptsController(ISender sender, ICurrentWorkspace currentWorkspace) : ControllerBase +{ + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Record(RecordPurchaseReceiptRequest request, + [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey, CancellationToken cancellationToken) + { + var workspace = await currentWorkspace.GetAsync(cancellationToken); + if (workspace is null) return Forbid(); + var receipt = await sender.Send(new RecordPurchaseReceiptCommand(workspace.Id, request.SupplierId, request.WarehouseId, + request.ProductId, request.Quantity, idempotencyKey ?? request.IdempotencyKey), cancellationToken); + return receipt is null ? NotFound() : Created($"/api/purchases/receipts/{receipt.Id}", receipt); + } + + [HttpGet] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task>> List(CancellationToken cancellationToken) + { + var workspace = await currentWorkspace.GetAsync(cancellationToken); + if (workspace is null) return Forbid(); + return Ok(await sender.Send(new ListPurchaseReceiptsQuery(workspace.Id), cancellationToken)); + } +} diff --git a/backend/src/InventoryFlow.Api/Controllers/SalesFulfillmentsController.cs b/backend/src/InventoryFlow.Api/Controllers/SalesFulfillmentsController.cs new file mode 100644 index 0000000..3350f83 --- /dev/null +++ b/backend/src/InventoryFlow.Api/Controllers/SalesFulfillmentsController.cs @@ -0,0 +1,37 @@ +using InventoryFlow.Application.Common.Tenancy; +using InventoryFlow.Application.Features.Sales; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace InventoryFlow.Api.Controllers; + +[ApiController] +[Authorize] +[Route("api/sales/fulfillments")] +public sealed class SalesFulfillmentsController(ISender sender, ICurrentWorkspace currentWorkspace) : ControllerBase +{ + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Record(RecordSalesFulfillmentRequest request, + [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey, CancellationToken cancellationToken) + { + var workspace = await currentWorkspace.GetAsync(cancellationToken); + if (workspace is null) return Forbid(); + var fulfillment = await sender.Send(new RecordSalesFulfillmentCommand(workspace.Id, request.WarehouseId, request.ProductId, + request.Quantity, idempotencyKey ?? request.IdempotencyKey), cancellationToken); + return fulfillment is null ? NotFound() : Created($"/api/sales/fulfillments/{fulfillment.Id}", fulfillment); + } + + [HttpGet] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task>> List(CancellationToken cancellationToken) + { + var workspace = await currentWorkspace.GetAsync(cancellationToken); + if (workspace is null) return Forbid(); + return Ok(await sender.Send(new ListSalesFulfillmentsQuery(workspace.Id), cancellationToken)); + } +} diff --git a/backend/src/InventoryFlow.Api/Controllers/SuppliersController.cs b/backend/src/InventoryFlow.Api/Controllers/SuppliersController.cs new file mode 100644 index 0000000..13b802a --- /dev/null +++ b/backend/src/InventoryFlow.Api/Controllers/SuppliersController.cs @@ -0,0 +1,49 @@ +using InventoryFlow.Application.Common.Tenancy; +using InventoryFlow.Application.Features.Suppliers; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace InventoryFlow.Api.Controllers; + +/// Provides workspace-scoped supplier catalog endpoints. +[ApiController] +[Authorize] +[Route("api/suppliers")] +public sealed class SuppliersController(ISender sender, ICurrentWorkspace currentWorkspace) : ControllerBase +{ + /// Creates a supplier in the current workspace. + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> Create(CreateSupplierRequest request, CancellationToken cancellationToken) + { + var workspace = await currentWorkspace.GetAsync(cancellationToken); + if (workspace is null) return Forbid(); + var supplier = await sender.Send(new CreateSupplierCommand(workspace.Id, request.Name), cancellationToken); + return Created($"/api/suppliers/{supplier.Id}", supplier); + } + + /// Lists active suppliers in the current workspace. + [HttpGet] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task>> List(CancellationToken cancellationToken) + { + var workspace = await currentWorkspace.GetAsync(cancellationToken); + if (workspace is null) return Forbid(); + return Ok(await sender.Send(new ListSuppliersQuery(workspace.Id), cancellationToken)); + } + + /// Idempotently archives a supplier. + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Archive(Guid id, CancellationToken cancellationToken) + { + var workspace = await currentWorkspace.GetAsync(cancellationToken); + if (workspace is null) return Forbid(); + return await sender.Send(new ArchiveSupplierCommand(workspace.Id, id), cancellationToken) ? NoContent() : NotFound(); + } +} diff --git a/backend/src/InventoryFlow.Api/Controllers/WarehouseTransfersController.cs b/backend/src/InventoryFlow.Api/Controllers/WarehouseTransfersController.cs new file mode 100644 index 0000000..848e98a --- /dev/null +++ b/backend/src/InventoryFlow.Api/Controllers/WarehouseTransfersController.cs @@ -0,0 +1,37 @@ +using InventoryFlow.Application.Common.Tenancy; +using InventoryFlow.Application.Features.Transfers; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace InventoryFlow.Api.Controllers; + +[ApiController] +[Authorize] +[Route("api/transfers")] +public sealed class WarehouseTransfersController(ISender sender, ICurrentWorkspace currentWorkspace) : ControllerBase +{ + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Record(RecordWarehouseTransferRequest request, + [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey, CancellationToken cancellationToken) + { + var workspace = await currentWorkspace.GetAsync(cancellationToken); + if (workspace is null) return Forbid(); + var transfer = await sender.Send(new RecordWarehouseTransferCommand(workspace.Id, request.SourceWarehouseId, + request.DestinationWarehouseId, request.ProductId, request.Quantity, idempotencyKey ?? request.IdempotencyKey), cancellationToken); + return transfer is null ? NotFound() : Created($"/api/transfers/{transfer.Id}", transfer); + } + + [HttpGet] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task>> List(CancellationToken cancellationToken) + { + var workspace = await currentWorkspace.GetAsync(cancellationToken); + if (workspace is null) return Forbid(); + return Ok(await sender.Send(new ListWarehouseTransfersQuery(workspace.Id), cancellationToken)); + } +} diff --git a/backend/src/InventoryFlow.Api/Controllers/WarehousesController.cs b/backend/src/InventoryFlow.Api/Controllers/WarehousesController.cs new file mode 100644 index 0000000..1e8d90a --- /dev/null +++ b/backend/src/InventoryFlow.Api/Controllers/WarehousesController.cs @@ -0,0 +1,50 @@ +using InventoryFlow.Application.Common.Tenancy; +using InventoryFlow.Application.Features.Warehouses; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace InventoryFlow.Api.Controllers; + +/// Provides workspace-scoped warehouse catalog endpoints. +[ApiController] +[Authorize] +[Route("api/warehouses")] +public sealed class WarehousesController(ISender sender, ICurrentWorkspace currentWorkspace) : ControllerBase +{ + /// Creates a warehouse in the current workspace. + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> Create(CreateWarehouseRequest request, CancellationToken cancellationToken) + { + var workspace = await currentWorkspace.GetAsync(cancellationToken); + if (workspace is null) return Forbid(); + var warehouse = await sender.Send(new CreateWarehouseCommand(workspace.Id, request.Name), cancellationToken); + return Created($"/api/warehouses/{warehouse.Id}", warehouse); + } + + /// Lists active warehouses in the current workspace. + [HttpGet] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task>> List(CancellationToken cancellationToken) + { + var workspace = await currentWorkspace.GetAsync(cancellationToken); + if (workspace is null) return Forbid(); + return Ok(await sender.Send(new ListWarehousesQuery(workspace.Id), cancellationToken)); + } + + /// Idempotently archives a warehouse with no stock on hand. + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Archive(Guid id, CancellationToken cancellationToken) + { + var workspace = await currentWorkspace.GetAsync(cancellationToken); + if (workspace is null) return Forbid(); + return await sender.Send(new ArchiveWarehouseCommand(workspace.Id, id), cancellationToken) ? NoContent() : NotFound(); + } +} diff --git a/backend/src/InventoryFlow.Api/ExceptionHandling/GlobalExceptionHandler.cs b/backend/src/InventoryFlow.Api/ExceptionHandling/GlobalExceptionHandler.cs new file mode 100644 index 0000000..3d115a7 --- /dev/null +++ b/backend/src/InventoryFlow.Api/ExceptionHandling/GlobalExceptionHandler.cs @@ -0,0 +1,94 @@ +using FluentValidation; +using InventoryFlow.Application.Features.Authentication; +using InventoryFlow.Application.Features.Collaboration; +using InventoryFlow.Application.Features.Inventory; +using InventoryFlow.Application.Features.Products; +using InventoryFlow.Application.Features.Suppliers; +using InventoryFlow.Application.Features.Warehouses; +using InventoryFlow.Domain.Exceptions; +using InventoryFlow.Domain.Entities; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; + +namespace InventoryFlow.Api.ExceptionHandling; + +/// +/// Converts unhandled application exceptions into RFC 7807 problem details responses. +/// +/// The logger used to capture unexpected failures. +public sealed class GlobalExceptionHandler( + ILogger logger) : IExceptionHandler +{ + /// + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken) + { + if (httpContext.Response.HasStarted) + { + logger.LogWarning( + exception, + "The response has already started for {RequestMethod} {RequestPath}", + httpContext.Request.Method, + httpContext.Request.Path); + + return false; + } + + var statusCode = exception switch + { + ValidationException => StatusCodes.Status400BadRequest, + DomainException => StatusCodes.Status400BadRequest, + AuthenticationException => StatusCodes.Status401Unauthorized, + UnauthorizedAccessException => StatusCodes.Status403Forbidden, + ProductSkuConflictException => StatusCodes.Status409Conflict, + SupplierNameConflictException => StatusCodes.Status409Conflict, + WarehouseNameConflictException => StatusCodes.Status409Conflict, + InsufficientInventoryException => StatusCodes.Status409Conflict, + InventoryArchiveConflictException => StatusCodes.Status409Conflict, + CollaborationConflictException => StatusCodes.Status409Conflict, + _ => StatusCodes.Status500InternalServerError, + }; + + logger.LogError( + exception, + "Unhandled exception while processing {RequestMethod} {RequestPath}", + httpContext.Request.Method, + httpContext.Request.Path); + + if (exception is ValidationException validationException) + { + var validation = new ValidationProblemDetails(validationException.Errors.GroupBy(error => error.PropertyName).ToDictionary(group => group.Key, group => group.Select(error => error.ErrorMessage).ToArray())) + { Status = statusCode, Title = "One or more validation errors occurred.", Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1" }; + httpContext.Response.StatusCode = statusCode; + await httpContext.Response.WriteAsJsonAsync(validation, options: null, contentType: "application/problem+json", cancellationToken: cancellationToken); + return true; + } + + var problemDetails = new ProblemDetails + { + Status = statusCode, + Title = statusCode switch { StatusCodes.Status400BadRequest => "A business rule was violated.", StatusCodes.Status401Unauthorized => "Authentication failed.", StatusCodes.Status403Forbidden => "Access is forbidden.", StatusCodes.Status409Conflict => "A conflicting value or insufficient inventory exists.", _ => "An unexpected error occurred." }, + Type = statusCode switch + { + StatusCodes.Status400BadRequest => "https://tools.ietf.org/html/rfc9110#section-15.5.1", + StatusCodes.Status403Forbidden => "https://tools.ietf.org/html/rfc9110#section-15.5.4", + StatusCodes.Status409Conflict => "https://tools.ietf.org/html/rfc9110#section-15.5.10", + _ => "https://tools.ietf.org/html/rfc9110#section-15.6.1", + }, + Detail = exception is DomainException or InsufficientInventoryException or InventoryArchiveConflictException + ? exception.Message + : null, + }; + + httpContext.Response.StatusCode = statusCode; + await httpContext.Response.WriteAsJsonAsync( + problemDetails, + options: null, + contentType: "application/problem+json", + cancellationToken: cancellationToken); + + return true; + } +} diff --git a/backend/src/InventoryFlow.Api/InventoryFlow.Api.csproj b/backend/src/InventoryFlow.Api/InventoryFlow.Api.csproj new file mode 100644 index 0000000..9a4b8e7 --- /dev/null +++ b/backend/src/InventoryFlow.Api/InventoryFlow.Api.csproj @@ -0,0 +1,20 @@ + + + inventory-flow-api-development + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/backend/src/InventoryFlow.Api/InventoryFlow.Api.http b/backend/src/InventoryFlow.Api/InventoryFlow.Api.http new file mode 100644 index 0000000..bab68c0 --- /dev/null +++ b/backend/src/InventoryFlow.Api/InventoryFlow.Api.http @@ -0,0 +1,30 @@ +@InventoryFlow.Api_HostAddress = http://localhost:5255 + +GET {{InventoryFlow.Api_HostAddress}}/health +Accept: application/json + +### + +### Register (the API writes the refresh cookie; do not place one in request bodies) +POST {{InventoryFlow.Api_HostAddress}}/api/auth/register +Content-Type: application/json + +{ + "displayName": "Example User", + "email": "example@example.test", + "password": "Example-password-123!" +} + +### Current user (replace only in local scratch requests) +GET {{InventoryFlow.Api_HostAddress}}/api/auth/me +Authorization: Bearer {{$processEnv ACCESS_TOKEN}} + +### Registration returns the server-resolved personal workspace +POST {{InventoryFlow.Api_HostAddress}}/api/auth/register +Content-Type: application/json + +{ + "displayName": "Workspace Owner", + "email": "owner@example.test", + "password": "Password!12345" +} diff --git a/backend/src/InventoryFlow.Api/Program.cs b/backend/src/InventoryFlow.Api/Program.cs new file mode 100644 index 0000000..528ccd0 --- /dev/null +++ b/backend/src/InventoryFlow.Api/Program.cs @@ -0,0 +1,66 @@ +using System.Threading.RateLimiting; +using InventoryFlow.Api.ExceptionHandling; +using Microsoft.AspNetCore.RateLimiting; +using InventoryFlow.Application.DependencyInjection; +using InventoryFlow.Infrastructure.DependencyInjection; +using Serilog; + +var builder = WebApplication.CreateBuilder(args); + +builder.Host.UseSerilog((context, services, loggerConfiguration) => loggerConfiguration + .ReadFrom.Configuration(context.Configuration) + .ReadFrom.Services(services) + .Enrich.FromLogContext()); + +builder.Services.AddApplication(); +builder.Services.AddInfrastructure(builder.Configuration); +builder.Services.AddControllers(); +builder.Services.AddAuthorization(); +builder.Services.AddOpenApi(); +builder.Services.AddProblemDetails(); +builder.Services.AddExceptionHandler(); +builder.Services.AddHealthChecks(); +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.AddFixedWindowLimiter("api", limiterOptions => + { + limiterOptions.PermitLimit = 100; + limiterOptions.Window = TimeSpan.FromMinutes(1); + limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; + limiterOptions.QueueLimit = 0; + }); +}); +builder.Services.AddCors(options => options.AddPolicy("Frontend", policy => +{ + var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? []; + policy.WithOrigins(allowedOrigins) + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials(); +})); + +var app = builder.Build(); + +app.UseExceptionHandler(); +app.UseSerilogRequestLogging(); +app.UseHttpsRedirection(); +app.UseRateLimiter(); +app.UseCors("Frontend"); +app.UseAuthentication(); +app.UseAuthorization(); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.MapHealthChecks("/health").RequireRateLimiting("api"); +app.MapControllers().RequireRateLimiting("api"); + +app.Run(); + +/// +/// Exposes the generated top-level program class to integration tests. +/// +public partial class Program; diff --git a/backend/src/InventoryFlow.Api/Properties/launchSettings.json b/backend/src/InventoryFlow.Api/Properties/launchSettings.json new file mode 100644 index 0000000..a9db21b --- /dev/null +++ b/backend/src/InventoryFlow.Api/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5255", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/backend/src/InventoryFlow.Api/appsettings.json b/backend/src/InventoryFlow.Api/appsettings.json new file mode 100644 index 0000000..656e113 --- /dev/null +++ b/backend/src/InventoryFlow.Api/appsettings.json @@ -0,0 +1,33 @@ +{ + "AllowedHosts": "*", + "Cors": { + "AllowedOrigins": [ + "http://localhost:5173" + ] + }, + "Jwt": { + "Issuer": "InventoryFlow", + "Audience": "InventoryFlow.Web", + "AccessTokenLifetimeMinutes": 10, + "RefreshTokenLifetimeDays": 7, + "RefreshCookieName": "inventory_flow_refresh" + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Console" + } + ], + "Enrich": [ "FromLogContext" ], + "Properties": { + "Application": "InventoryFlow.Api" + } + } +} diff --git a/backend/src/InventoryFlow.Application/Common/Behaviors/ValidationBehavior.cs b/backend/src/InventoryFlow.Application/Common/Behaviors/ValidationBehavior.cs new file mode 100644 index 0000000..b3541b1 --- /dev/null +++ b/backend/src/InventoryFlow.Application/Common/Behaviors/ValidationBehavior.cs @@ -0,0 +1,19 @@ +using FluentValidation; +using MediatR; + +namespace InventoryFlow.Application.Common.Behaviors; + +/// Runs FluentValidation before MediatR handlers. +public sealed class ValidationBehavior(IEnumerable> validators) : IPipelineBehavior + where TRequest : notnull +{ + /// + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + var context = new ValidationContext(request); + var failures = (await Task.WhenAll(validators.Select(validator => validator.ValidateAsync(context, cancellationToken)))) + .SelectMany(result => result.Errors).Where(error => error is not null).ToList(); + if (failures.Count != 0) throw new ValidationException(failures); + return await next(cancellationToken); + } +} diff --git a/backend/src/InventoryFlow.Application/Common/Tenancy/CurrentWorkspace.cs b/backend/src/InventoryFlow.Application/Common/Tenancy/CurrentWorkspace.cs new file mode 100644 index 0000000..554695f --- /dev/null +++ b/backend/src/InventoryFlow.Application/Common/Tenancy/CurrentWorkspace.cs @@ -0,0 +1,6 @@ +using InventoryFlow.Domain.Entities; + +namespace InventoryFlow.Application.Common.Tenancy; + +/// Represents the server-resolved workspace for the current request. +public sealed record CurrentWorkspace(Guid Id, string Name, WorkspaceMemberRole Role); diff --git a/backend/src/InventoryFlow.Application/Common/Tenancy/ICurrentWorkspace.cs b/backend/src/InventoryFlow.Application/Common/Tenancy/ICurrentWorkspace.cs new file mode 100644 index 0000000..e26f190 --- /dev/null +++ b/backend/src/InventoryFlow.Application/Common/Tenancy/ICurrentWorkspace.cs @@ -0,0 +1,8 @@ +namespace InventoryFlow.Application.Common.Tenancy; + +/// Resolves the authenticated request's unambiguous current workspace. +public interface ICurrentWorkspace +{ + /// Returns the current workspace or null when unauthenticated, missing, or ambiguous. + Task GetAsync(CancellationToken cancellationToken = default); +} diff --git a/backend/src/InventoryFlow.Application/DependencyInjection/ApplicationServiceCollectionExtensions.cs b/backend/src/InventoryFlow.Application/DependencyInjection/ApplicationServiceCollectionExtensions.cs new file mode 100644 index 0000000..11bbe22 --- /dev/null +++ b/backend/src/InventoryFlow.Application/DependencyInjection/ApplicationServiceCollectionExtensions.cs @@ -0,0 +1,32 @@ +using FluentValidation; +using InventoryFlow.Application.Common.Behaviors; +using MediatR; +using Microsoft.Extensions.DependencyInjection; + +namespace InventoryFlow.Application.DependencyInjection; + +/// +/// Registers application-layer services. +/// +public static class ApplicationServiceCollectionExtensions +{ + /// + /// Adds MediatR handlers and FluentValidation validators from the application assembly. + /// + /// The dependency-injection service collection. + /// The supplied instance. + public static IServiceCollection AddApplication(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + var assembly = typeof(ApplicationServiceCollectionExtensions).Assembly; + services.AddMediatR(configuration => + { + configuration.RegisterServicesFromAssembly(assembly); + configuration.AddOpenBehavior(typeof(ValidationBehavior<,>)); + }); + services.AddValidatorsFromAssembly(assembly); + + return services; + } +} diff --git a/backend/src/InventoryFlow.Application/Features/Authentication/AuthenticationHandlers.cs b/backend/src/InventoryFlow.Application/Features/Authentication/AuthenticationHandlers.cs new file mode 100644 index 0000000..10abf8e --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Authentication/AuthenticationHandlers.cs @@ -0,0 +1,31 @@ +using MediatR; + +namespace InventoryFlow.Application.Features.Authentication; + +/// Delegates registration to the authentication port. +public sealed class RegisterUserHandler(IAuthenticationService service) : IRequestHandler +{ public Task Handle(RegisterUserCommand request, CancellationToken cancellationToken) => service.RegisterAsync(request, cancellationToken); } +/// Delegates login to the authentication port. +public sealed class LoginUserHandler(IAuthenticationService service) : IRequestHandler +{ public async Task Handle(LoginUserCommand request, CancellationToken cancellationToken) => await service.LoginAsync(request, cancellationToken) ?? throw new AuthenticationException(); } +/// Delegates refresh to the authentication port. +public sealed class RefreshSessionHandler(IAuthenticationService service) : IRequestHandler +{ public Task Handle(RefreshSessionCommand request, CancellationToken cancellationToken) => service.RefreshAsync(request.RefreshToken, cancellationToken); } +/// Delegates workspace switching to the authentication port. +public sealed class SwitchWorkspaceHandler(IAuthenticationService service) : IRequestHandler +{ public Task Handle(SwitchWorkspaceCommand request, CancellationToken cancellationToken) => service.SwitchWorkspaceAsync(request.UserId, request.WorkspaceId, request.RefreshToken, cancellationToken); } +/// Delegates logout to the authentication port. +public sealed class LogoutSessionHandler(IAuthenticationService service) : IRequestHandler +{ public async Task Handle(LogoutSessionCommand request, CancellationToken cancellationToken) => await service.LogoutAsync(request.RefreshToken, cancellationToken); } +/// Delegates current-user lookup to the authentication port. +public sealed class GetCurrentUserHandler(IAuthenticationService service) : IRequestHandler +{ public Task Handle(GetCurrentUserQuery request, CancellationToken cancellationToken) => service.GetUserAsync(request.UserId, request.WorkspaceId, cancellationToken); } + +/// Represents an externally-uniform authentication failure. +public sealed class AuthenticationException : Exception +{ + /// Initializes the exception. + public AuthenticationException() : base("Authentication failed.") + { + } +} diff --git a/backend/src/InventoryFlow.Application/Features/Authentication/AuthenticationModels.cs b/backend/src/InventoryFlow.Application/Features/Authentication/AuthenticationModels.cs new file mode 100644 index 0000000..74b51d9 --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Authentication/AuthenticationModels.cs @@ -0,0 +1,26 @@ +namespace InventoryFlow.Application.Features.Authentication; + +/// Represents an authenticated workspace exposed to clients. +public sealed record AuthenticatedWorkspace(Guid Id, string Name, string Role); + +/// Represents the authenticated user exposed to clients. +public sealed record AuthenticatedUser(Guid Id, string Email, string DisplayName, AuthenticatedWorkspace Workspace, IReadOnlyCollection Workspaces); + +/// Represents a browser session response. +public sealed record AuthenticationResponse(string AccessToken, DateTimeOffset AccessTokenExpiresAtUtc, AuthenticatedUser User); + +/// Represents the internal result used to write a refresh cookie. +public sealed record AuthenticationSession(AuthenticationResponse Response, string RefreshToken); + +/// Supplies registration input. +public sealed record RegisterUserCommand(string DisplayName, string Email, string Password) : MediatR.IRequest; +/// Supplies login input. +public sealed record LoginUserCommand(string Email, string Password) : MediatR.IRequest; +/// Refreshes a browser session. +public sealed record RefreshSessionCommand(string RefreshToken) : MediatR.IRequest; +/// Switches the active workspace for the browser session. +public sealed record SwitchWorkspaceCommand(Guid UserId, Guid WorkspaceId, string RefreshToken) : MediatR.IRequest; +/// Ends a browser session. +public sealed record LogoutSessionCommand(string? RefreshToken) : MediatR.IRequest; +/// Retrieves the current user. +public sealed record GetCurrentUserQuery(Guid UserId, Guid WorkspaceId) : MediatR.IRequest; diff --git a/backend/src/InventoryFlow.Application/Features/Authentication/AuthenticationValidators.cs b/backend/src/InventoryFlow.Application/Features/Authentication/AuthenticationValidators.cs new file mode 100644 index 0000000..1a2953d --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Authentication/AuthenticationValidators.cs @@ -0,0 +1,24 @@ +using FluentValidation; + +namespace InventoryFlow.Application.Features.Authentication; + +/// Validates registration input. +public sealed class RegisterUserCommandValidator : AbstractValidator +{ + /// Initializes validation rules. + public RegisterUserCommandValidator() + { + RuleFor(command => command.DisplayName).NotEmpty().MaximumLength(200).Must(name => name == name.Trim()).WithMessage("Display name cannot have leading or trailing whitespace."); + RuleFor(command => command.Email).NotEmpty().EmailAddress().MaximumLength(256); + RuleFor(command => command.Password).NotEmpty(); + } +} +/// Validates login input. +public sealed class LoginUserCommandValidator : AbstractValidator +{ public LoginUserCommandValidator() { RuleFor(command => command.Email).NotEmpty().EmailAddress().MaximumLength(256); RuleFor(command => command.Password).NotEmpty(); } } +/// Validates refresh input. +public sealed class RefreshSessionCommandValidator : AbstractValidator +{ public RefreshSessionCommandValidator() => RuleFor(command => command.RefreshToken).NotEmpty(); } +/// Validates current-user lookup input. +public sealed class GetCurrentUserQueryValidator : AbstractValidator +{ public GetCurrentUserQueryValidator() => RuleFor(query => query.UserId).NotEmpty(); } diff --git a/backend/src/InventoryFlow.Application/Features/Authentication/IAuthenticationService.cs b/backend/src/InventoryFlow.Application/Features/Authentication/IAuthenticationService.cs new file mode 100644 index 0000000..7b8ef83 --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Authentication/IAuthenticationService.cs @@ -0,0 +1,18 @@ +namespace InventoryFlow.Application.Features.Authentication; + +/// Defines authentication operations implemented by infrastructure. +public interface IAuthenticationService +{ + /// Registers a user and creates a session. + Task RegisterAsync(RegisterUserCommand command, CancellationToken cancellationToken); + /// Authenticates a user and creates a session. + Task LoginAsync(LoginUserCommand command, CancellationToken cancellationToken); + /// Rotates a refresh token. + Task RefreshAsync(string refreshToken, CancellationToken cancellationToken); + /// Switches the active workspace and rotates the refresh token. + Task SwitchWorkspaceAsync(Guid userId, Guid workspaceId, string refreshToken, CancellationToken cancellationToken); + /// Revokes the token family represented by a refresh token. + Task LogoutAsync(string? refreshToken, CancellationToken cancellationToken); + /// Gets a current user by identifier and active workspace. + Task GetUserAsync(Guid userId, Guid workspaceId, CancellationToken cancellationToken); +} diff --git a/backend/src/InventoryFlow.Application/Features/Collaboration/CollaborationHandlers.cs b/backend/src/InventoryFlow.Application/Features/Collaboration/CollaborationHandlers.cs new file mode 100644 index 0000000..f1f52a9 --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Collaboration/CollaborationHandlers.cs @@ -0,0 +1,23 @@ +using MediatR; + +namespace InventoryFlow.Application.Features.Collaboration; + +/// Delegates member listing to the collaboration port. +public sealed class ListWorkspaceMembersHandler(ICollaborationService service) : IRequestHandler> +{ public Task> Handle(ListWorkspaceMembersQuery request, CancellationToken cancellationToken) => service.ListMembersAsync(request, cancellationToken); } + +/// Delegates invitation listing to the collaboration port. +public sealed class ListWorkspaceInvitationsHandler(ICollaborationService service) : IRequestHandler> +{ public Task> Handle(ListWorkspaceInvitationsQuery request, CancellationToken cancellationToken) => service.ListInvitationsAsync(request, cancellationToken); } + +/// Delegates invitation creation to the collaboration port. +public sealed class CreateWorkspaceInvitationHandler(ICollaborationService service) : IRequestHandler +{ public Task Handle(CreateWorkspaceInvitationCommand request, CancellationToken cancellationToken) => service.CreateInvitationAsync(request, cancellationToken); } + +/// Delegates invitation revocation to the collaboration port. +public sealed class RevokeWorkspaceInvitationHandler(ICollaborationService service) : IRequestHandler +{ public Task Handle(RevokeWorkspaceInvitationCommand request, CancellationToken cancellationToken) => service.RevokeInvitationAsync(request, cancellationToken); } + +/// Delegates invitation acceptance to the collaboration port. +public sealed class AcceptWorkspaceInvitationHandler(ICollaborationService service) : IRequestHandler +{ public Task Handle(AcceptWorkspaceInvitationCommand request, CancellationToken cancellationToken) => service.AcceptInvitationAsync(request, cancellationToken); } diff --git a/backend/src/InventoryFlow.Application/Features/Collaboration/CollaborationModels.cs b/backend/src/InventoryFlow.Application/Features/Collaboration/CollaborationModels.cs new file mode 100644 index 0000000..5f65599 --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Collaboration/CollaborationModels.cs @@ -0,0 +1,43 @@ +using InventoryFlow.Domain.Entities; +using MediatR; + +namespace InventoryFlow.Application.Features.Collaboration; + +/// Represents a workspace member returned to administrators. +public sealed record WorkspaceMemberResponse(Guid UserId, string Email, string DisplayName, string Role, DateTimeOffset CreatedAtUtc); + +/// Represents a workspace invitation returned to administrators. +public sealed record WorkspaceInvitationResponse( + Guid Id, + string Email, + string Role, + DateTimeOffset ExpiresAtUtc, + DateTimeOffset CreatedAtUtc, + Guid CreatedByUserId, + DateTimeOffset? AcceptedAtUtc, + Guid? AcceptedByUserId, + DateTimeOffset? RevokedAtUtc); + +/// Represents a newly-created invitation with its plaintext token shown once. +public sealed record CreatedWorkspaceInvitationResponse(WorkspaceInvitationResponse Invitation, string Token); + +/// Represents a request to create an invitation. +public sealed record CreateWorkspaceInvitationRequest(string Email); + +/// Represents a request to accept an invitation. +public sealed record AcceptWorkspaceInvitationRequest(string Token); + +/// Lists members for the active workspace. +public sealed record ListWorkspaceMembersQuery(Guid WorkspaceId, WorkspaceMemberRole CurrentUserRole) : IRequest>; + +/// Lists invitations for the active workspace. +public sealed record ListWorkspaceInvitationsQuery(Guid WorkspaceId, WorkspaceMemberRole CurrentUserRole) : IRequest>; + +/// Creates a member invitation for the active workspace. +public sealed record CreateWorkspaceInvitationCommand(Guid WorkspaceId, Guid CreatedByUserId, WorkspaceMemberRole CurrentUserRole, string Email) : IRequest; + +/// Revokes a pending invitation for the active workspace. +public sealed record RevokeWorkspaceInvitationCommand(Guid WorkspaceId, Guid InvitationId, WorkspaceMemberRole CurrentUserRole) : IRequest; + +/// Accepts an invitation for the authenticated user. +public sealed record AcceptWorkspaceInvitationCommand(Guid UserId, string Token) : IRequest; diff --git a/backend/src/InventoryFlow.Application/Features/Collaboration/ICollaborationService.cs b/backend/src/InventoryFlow.Application/Features/Collaboration/ICollaborationService.cs new file mode 100644 index 0000000..f2e24df --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Collaboration/ICollaborationService.cs @@ -0,0 +1,23 @@ +namespace InventoryFlow.Application.Features.Collaboration; + +/// Defines workspace collaboration operations. +public interface ICollaborationService +{ + /// Lists active workspace members. + Task> ListMembersAsync(ListWorkspaceMembersQuery query, CancellationToken cancellationToken); + + /// Lists active workspace invitations. + Task> ListInvitationsAsync(ListWorkspaceInvitationsQuery query, CancellationToken cancellationToken); + + /// Creates a member invitation. + Task CreateInvitationAsync(CreateWorkspaceInvitationCommand command, CancellationToken cancellationToken); + + /// Revokes a pending invitation. + Task RevokeInvitationAsync(RevokeWorkspaceInvitationCommand command, CancellationToken cancellationToken); + + /// Accepts an invitation. + Task AcceptInvitationAsync(AcceptWorkspaceInvitationCommand command, CancellationToken cancellationToken); +} + +/// Represents a collaboration conflict. +public sealed class CollaborationConflictException(string message) : Exception(message); diff --git a/backend/src/InventoryFlow.Application/Features/Inventory/IInventoryLedger.cs b/backend/src/InventoryFlow.Application/Features/Inventory/IInventoryLedger.cs new file mode 100644 index 0000000..4471b70 --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Inventory/IInventoryLedger.cs @@ -0,0 +1,15 @@ +using InventoryFlow.Domain.Entities; + +namespace InventoryFlow.Application.Features.Inventory; + +/// Provides atomic workspace-scoped inventory ledger persistence. +public interface IInventoryLedger +{ + /// Records a movement once for an idempotency key, returning null when its product or warehouse is unavailable. + Task RecordAsync(Guid workspaceId, Guid warehouseId, Guid productId, InventoryMovementType type, + decimal quantity, string idempotencyKey, DateTimeOffset occurredAtUtc, CancellationToken cancellationToken); + + /// Lists current balances, optionally restricted to a warehouse or product. + Task> ListBalancesAsync(Guid workspaceId, Guid? warehouseId, Guid? productId, + CancellationToken cancellationToken); +} diff --git a/backend/src/InventoryFlow.Application/Features/Inventory/InventoryArchiveConflictException.cs b/backend/src/InventoryFlow.Application/Features/Inventory/InventoryArchiveConflictException.cs new file mode 100644 index 0000000..ce39d93 --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Inventory/InventoryArchiveConflictException.cs @@ -0,0 +1,8 @@ +namespace InventoryFlow.Application.Features.Inventory; + +/// Indicates an inventory source cannot be archived while stock remains on hand. +public sealed class InventoryArchiveConflictException : Exception +{ + /// Initializes the exception with a safe client-facing message. + public InventoryArchiveConflictException() : base("This product or warehouse cannot be archived while it has inventory on hand.") { } +} diff --git a/backend/src/InventoryFlow.Application/Features/Inventory/InventoryHandlers.cs b/backend/src/InventoryFlow.Application/Features/Inventory/InventoryHandlers.cs new file mode 100644 index 0000000..85bf2d8 --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Inventory/InventoryHandlers.cs @@ -0,0 +1,40 @@ +using InventoryFlow.Domain.Entities; +using MediatR; + +namespace InventoryFlow.Application.Features.Inventory; + +/// Handles receipt recording. +public sealed class RecordReceiptHandler(IInventoryLedger ledger, TimeProvider timeProvider) + : IRequestHandler +{ + /// + public async Task Handle(RecordReceiptCommand request, CancellationToken cancellationToken) + { + var movement = await ledger.RecordAsync(request.WorkspaceId, request.WarehouseId, request.ProductId, + InventoryMovementType.Receipt, request.Quantity, request.IdempotencyKey, timeProvider.GetUtcNow(), cancellationToken); + return movement is null ? null : InventoryMovementResponse.From(movement); + } +} + +/// Handles issue recording. +public sealed class RecordIssueHandler(IInventoryLedger ledger, TimeProvider timeProvider) + : IRequestHandler +{ + /// + public async Task Handle(RecordIssueCommand request, CancellationToken cancellationToken) + { + var movement = await ledger.RecordAsync(request.WorkspaceId, request.WarehouseId, request.ProductId, + InventoryMovementType.Issue, request.Quantity, request.IdempotencyKey, timeProvider.GetUtcNow(), cancellationToken); + return movement is null ? null : InventoryMovementResponse.From(movement); + } +} + +/// Handles current balance listing. +public sealed class ListInventoryBalancesHandler(IInventoryLedger ledger) + : IRequestHandler> +{ + /// + public async Task> Handle(ListInventoryBalancesQuery request, CancellationToken cancellationToken) => + (await ledger.ListBalancesAsync(request.WorkspaceId, request.WarehouseId, request.ProductId, cancellationToken)) + .Select(InventoryBalanceResponse.From).ToArray(); +} diff --git a/backend/src/InventoryFlow.Application/Features/Inventory/InventoryModels.cs b/backend/src/InventoryFlow.Application/Features/Inventory/InventoryModels.cs new file mode 100644 index 0000000..7eb4d8b --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Inventory/InventoryModels.cs @@ -0,0 +1,19 @@ +using InventoryFlow.Domain.Entities; + +namespace InventoryFlow.Application.Features.Inventory; + +/// Represents a recorded inventory movement exposed to clients. +public sealed record InventoryMovementResponse(Guid Id, Guid WarehouseId, Guid ProductId, InventoryMovementType Type, + decimal Quantity, decimal BalanceAfterQuantity, DateTimeOffset OccurredAtUtc) +{ + /// Maps a ledger entry to a response. + public static InventoryMovementResponse From(InventoryMovement movement) => new(movement.Id, movement.WarehouseId, + movement.ProductId, movement.Type, movement.Quantity, movement.BalanceAfterQuantity, movement.OccurredAtUtc); +} + +/// Represents current on-hand inventory exposed to clients. +public sealed record InventoryBalanceResponse(Guid WarehouseId, Guid ProductId, decimal Quantity) +{ + /// Maps a balance to a response. + public static InventoryBalanceResponse From(InventoryBalance balance) => new(balance.WarehouseId, balance.ProductId, balance.Quantity); +} diff --git a/backend/src/InventoryFlow.Application/Features/Inventory/InventoryMovementCommands.cs b/backend/src/InventoryFlow.Application/Features/Inventory/InventoryMovementCommands.cs new file mode 100644 index 0000000..e339183 --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Inventory/InventoryMovementCommands.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; +using MediatR; +using InventoryFlow.Domain.Entities; + +namespace InventoryFlow.Application.Features.Inventory; + +/// Describes a request that records an immutable inventory movement. +public interface IInventoryMovementCommand +{ + /// Gets the workspace owning the movement. + Guid WorkspaceId { get; } + /// Gets the warehouse receiving or issuing stock. + Guid WarehouseId { get; } + /// Gets the product receiving or issuing stock. + Guid ProductId { get; } + /// Gets the positive movement quantity. + decimal Quantity { get; } + /// Gets the retry-stable client idempotency key. + string IdempotencyKey { get; } +} + +/// Supplies browser input for recording a movement. +public sealed record RecordInventoryMovementRequest(Guid WarehouseId, Guid ProductId, + [property: JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] decimal Quantity, string IdempotencyKey); + +/// Records a stock receipt in a server-resolved workspace. +public sealed record RecordReceiptCommand(Guid WorkspaceId, Guid WarehouseId, Guid ProductId, decimal Quantity, + string IdempotencyKey) : IRequest, IInventoryMovementCommand; + +/// Records a stock issue in a server-resolved workspace. +public sealed record RecordIssueCommand(Guid WorkspaceId, Guid WarehouseId, Guid ProductId, decimal Quantity, + string IdempotencyKey) : IRequest, IInventoryMovementCommand; + diff --git a/backend/src/InventoryFlow.Application/Features/Inventory/InventoryQueries.cs b/backend/src/InventoryFlow.Application/Features/Inventory/InventoryQueries.cs new file mode 100644 index 0000000..d6133a9 --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Inventory/InventoryQueries.cs @@ -0,0 +1,7 @@ +using MediatR; + +namespace InventoryFlow.Application.Features.Inventory; + +/// Lists current workspace inventory balances. +public sealed record ListInventoryBalancesQuery(Guid WorkspaceId, Guid? WarehouseId, Guid? ProductId) + : IRequest>; diff --git a/backend/src/InventoryFlow.Application/Features/Inventory/InventoryValidators.cs b/backend/src/InventoryFlow.Application/Features/Inventory/InventoryValidators.cs new file mode 100644 index 0000000..61c296d --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Inventory/InventoryValidators.cs @@ -0,0 +1,33 @@ +using FluentValidation; +using InventoryFlow.Domain.Entities; + +namespace InventoryFlow.Application.Features.Inventory; + +/// Validates inventory recording input. +public sealed class RecordReceiptCommandValidator : AbstractValidator +{ + /// Initializes validation rules. + public RecordReceiptCommandValidator() => InventoryValidation.Configure(this); +} + +/// Validates inventory issue input. +public sealed class RecordIssueCommandValidator : AbstractValidator +{ + /// Initializes validation rules. + public RecordIssueCommandValidator() => InventoryValidation.Configure(this); +} + +internal static class InventoryValidation +{ + internal static void Configure(AbstractValidator validator) where T : class, IInventoryMovementCommand + { + validator.RuleFor(x => x.WorkspaceId).NotEmpty(); + validator.RuleFor(x => x.WarehouseId).NotEmpty(); + validator.RuleFor(x => x.ProductId).NotEmpty(); + validator.RuleFor(x => x.Quantity).GreaterThan(0m).Must(quantity => decimal.Round(quantity, 4) == quantity) + .WithMessage("Quantity must have at most four decimal places.") + .LessThanOrEqualTo(InventoryMovement.MaxQuantity) + .WithMessage("Quantity must not exceed 99999999999999.9999."); + validator.RuleFor(x => x.IdempotencyKey).NotEmpty().MaximumLength(InventoryMovement.IdempotencyKeyMaxLength); + } +} diff --git a/backend/src/InventoryFlow.Application/Features/Products/IProductCatalog.cs b/backend/src/InventoryFlow.Application/Features/Products/IProductCatalog.cs new file mode 100644 index 0000000..ddcba60 --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Products/IProductCatalog.cs @@ -0,0 +1,25 @@ +using InventoryFlow.Domain.Entities; + +namespace InventoryFlow.Application.Features.Products; + +/// Provides workspace-scoped persistence operations for products. +public interface IProductCatalog +{ + /// Creates a product. + Task CreateAsync(Product product, CancellationToken cancellationToken); + /// Lists active products. + Task> ListActiveAsync(Guid workspaceId, CancellationToken cancellationToken); + /// Finds a product only within its workspace. + Task FindAsync(Guid workspaceId, Guid productId, CancellationToken cancellationToken); + /// Archives a product only when no nonzero workspace balance references it. + Task ArchiveAsync(Guid workspaceId, Guid productId, DateTimeOffset archivedAtUtc, CancellationToken cancellationToken); + /// Persists pending changes. + Task SaveChangesAsync(CancellationToken cancellationToken); +} + +/// Represents a duplicate canonical SKU within a workspace. +public sealed class ProductSkuConflictException : Exception +{ + /// Initializes the exception. + public ProductSkuConflictException() : base("A product with this SKU already exists in the workspace.") { } +} diff --git a/backend/src/InventoryFlow.Application/Features/Products/ProductHandlers.cs b/backend/src/InventoryFlow.Application/Features/Products/ProductHandlers.cs new file mode 100644 index 0000000..1747f93 --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Products/ProductHandlers.cs @@ -0,0 +1,29 @@ +using InventoryFlow.Domain.Entities; +using MediatR; + +namespace InventoryFlow.Application.Features.Products; + +/// Handles product creation. +public sealed class CreateProductHandler(IProductCatalog catalog, TimeProvider timeProvider) : IRequestHandler +{ + /// + public async Task Handle(CreateProductCommand request, CancellationToken cancellationToken) + { + var product = new Product(Guid.NewGuid(), request.WorkspaceId, request.Name, request.Sku, timeProvider.GetUtcNow()); + return ProductResponse.From(await catalog.CreateAsync(product, cancellationToken)); + } +} +/// Handles active-product listing. +public sealed class ListProductsHandler(IProductCatalog catalog) : IRequestHandler> +{ + /// + public async Task> Handle(ListProductsQuery request, CancellationToken cancellationToken) => + (await catalog.ListActiveAsync(request.WorkspaceId, cancellationToken)).Select(ProductResponse.From).ToArray(); +} +/// Handles idempotent product archive. +public sealed class ArchiveProductHandler(IProductCatalog catalog, TimeProvider timeProvider) : IRequestHandler +{ + /// + public Task Handle(ArchiveProductCommand request, CancellationToken cancellationToken) => + catalog.ArchiveAsync(request.WorkspaceId, request.ProductId, timeProvider.GetUtcNow(), cancellationToken); +} diff --git a/backend/src/InventoryFlow.Application/Features/Products/ProductModels.cs b/backend/src/InventoryFlow.Application/Features/Products/ProductModels.cs new file mode 100644 index 0000000..8b32c0d --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Products/ProductModels.cs @@ -0,0 +1,19 @@ +using InventoryFlow.Domain.Entities; +using MediatR; + +namespace InventoryFlow.Application.Features.Products; + +/// Supplies browser input for product creation. +public sealed record CreateProductRequest(string Name, string Sku); +/// Represents a product exposed to clients. +public sealed record ProductResponse(Guid Id, string Name, string Sku, DateTimeOffset CreatedAtUtc) +{ + /// Maps a domain product to a response. + public static ProductResponse From(Product product) => new(product.Id, product.Name, product.Sku, product.CreatedAtUtc); +} +/// Creates a product in a server-resolved workspace. +public sealed record CreateProductCommand(Guid WorkspaceId, string Name, string Sku) : IRequest; +/// Lists active products in a server-resolved workspace. +public sealed record ListProductsQuery(Guid WorkspaceId) : IRequest>; +/// Archives a product in a server-resolved workspace. +public sealed record ArchiveProductCommand(Guid WorkspaceId, Guid ProductId) : IRequest; diff --git a/backend/src/InventoryFlow.Application/Features/Products/ProductValidators.cs b/backend/src/InventoryFlow.Application/Features/Products/ProductValidators.cs new file mode 100644 index 0000000..133bfb8 --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Products/ProductValidators.cs @@ -0,0 +1,16 @@ +using FluentValidation; +using InventoryFlow.Domain.Entities; + +namespace InventoryFlow.Application.Features.Products; + +/// Validates product creation input. +public sealed class CreateProductCommandValidator : AbstractValidator +{ + /// Initializes validation rules. + public CreateProductCommandValidator() + { + RuleFor(command => command.WorkspaceId).NotEmpty(); + RuleFor(command => command.Name).NotEmpty().MaximumLength(Product.NameMaxLength); + RuleFor(command => command.Sku).NotEmpty().MaximumLength(Product.SkuMaxLength); + } +} diff --git a/backend/src/InventoryFlow.Application/Features/Purchases/PurchaseReceiptHandlers.cs b/backend/src/InventoryFlow.Application/Features/Purchases/PurchaseReceiptHandlers.cs new file mode 100644 index 0000000..e7069d9 --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Purchases/PurchaseReceiptHandlers.cs @@ -0,0 +1,21 @@ +using MediatR; + +namespace InventoryFlow.Application.Features.Purchases; + +public sealed class RecordPurchaseReceiptHandler(IPurchaseReceiptService service, TimeProvider timeProvider) + : IRequestHandler +{ + public async Task Handle(RecordPurchaseReceiptCommand request, CancellationToken cancellationToken) + { + var receipt = await service.RecordAsync(request.WorkspaceId, request.SupplierId, request.WarehouseId, request.ProductId, + request.Quantity, request.IdempotencyKey, timeProvider.GetUtcNow(), cancellationToken); + return receipt is null ? null : PurchaseReceiptResponse.From(receipt); + } +} + +public sealed class ListPurchaseReceiptsHandler(IPurchaseReceiptService service) + : IRequestHandler> +{ + public async Task> Handle(ListPurchaseReceiptsQuery request, CancellationToken cancellationToken) => + (await service.ListAsync(request.WorkspaceId, cancellationToken)).Select(PurchaseReceiptResponse.From).ToArray(); +} diff --git a/backend/src/InventoryFlow.Application/Features/Purchases/PurchaseReceiptModels.cs b/backend/src/InventoryFlow.Application/Features/Purchases/PurchaseReceiptModels.cs new file mode 100644 index 0000000..f06e040 --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Purchases/PurchaseReceiptModels.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; +using InventoryFlow.Domain.Entities; + +namespace InventoryFlow.Application.Features.Purchases; + +public sealed record RecordPurchaseReceiptRequest(Guid SupplierId, Guid WarehouseId, Guid ProductId, + [property: JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] decimal Quantity, string IdempotencyKey); + +public sealed record RecordPurchaseReceiptCommand(Guid WorkspaceId, Guid SupplierId, Guid WarehouseId, Guid ProductId, + decimal Quantity, string IdempotencyKey) : MediatR.IRequest; + +public sealed record ListPurchaseReceiptsQuery(Guid WorkspaceId) : MediatR.IRequest>; + +public sealed record PurchaseReceiptResponse(Guid Id, Guid SupplierId, Guid WarehouseId, Guid ProductId, decimal Quantity, + Guid InventoryMovementId, DateTimeOffset ReceivedAtUtc) +{ + public static PurchaseReceiptResponse From(PurchaseReceipt receipt) => new(receipt.Id, receipt.SupplierId, receipt.WarehouseId, + receipt.ProductId, receipt.Quantity, receipt.InventoryMovementId, receipt.ReceivedAtUtc); +} + +public interface IPurchaseReceiptService +{ + Task RecordAsync(Guid workspaceId, Guid supplierId, Guid warehouseId, Guid productId, decimal quantity, + string idempotencyKey, DateTimeOffset receivedAtUtc, CancellationToken cancellationToken); + Task> ListAsync(Guid workspaceId, CancellationToken cancellationToken); +} diff --git a/backend/src/InventoryFlow.Application/Features/Purchases/PurchaseReceiptValidators.cs b/backend/src/InventoryFlow.Application/Features/Purchases/PurchaseReceiptValidators.cs new file mode 100644 index 0000000..f0db5ab --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Purchases/PurchaseReceiptValidators.cs @@ -0,0 +1,19 @@ +using FluentValidation; +using InventoryFlow.Domain.Entities; + +namespace InventoryFlow.Application.Features.Purchases; + +public sealed class RecordPurchaseReceiptCommandValidator : AbstractValidator +{ + public RecordPurchaseReceiptCommandValidator() + { + RuleFor(x => x.WorkspaceId).NotEmpty(); + RuleFor(x => x.SupplierId).NotEmpty(); + RuleFor(x => x.WarehouseId).NotEmpty(); + RuleFor(x => x.ProductId).NotEmpty(); + RuleFor(x => x.Quantity).GreaterThan(0m).Must(quantity => decimal.Round(quantity, 4) == quantity) + .WithMessage("Quantity must have at most four decimal places.") + .LessThanOrEqualTo(InventoryMovement.MaxQuantity).WithMessage("Quantity must not exceed 99999999999999.9999."); + RuleFor(x => x.IdempotencyKey).NotEmpty().MaximumLength(InventoryMovement.IdempotencyKeyMaxLength); + } +} diff --git a/backend/src/InventoryFlow.Application/Features/Sales/SalesFulfillmentHandlers.cs b/backend/src/InventoryFlow.Application/Features/Sales/SalesFulfillmentHandlers.cs new file mode 100644 index 0000000..cc28023 --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Sales/SalesFulfillmentHandlers.cs @@ -0,0 +1,22 @@ +using MediatR; + +namespace InventoryFlow.Application.Features.Sales; + +public sealed class RecordSalesFulfillmentHandler(ISalesFulfillmentService service, TimeProvider timeProvider) + : IRequestHandler +{ + public async Task Handle(RecordSalesFulfillmentCommand request, CancellationToken cancellationToken) + { + var fulfillment = await service.RecordAsync(request.WorkspaceId, request.WarehouseId, request.ProductId, request.Quantity, + request.IdempotencyKey, timeProvider.GetUtcNow(), cancellationToken); + return fulfillment is null ? null : SalesFulfillmentResponse.From(fulfillment); + } +} + +public sealed class ListSalesFulfillmentsHandler(ISalesFulfillmentService service) + : IRequestHandler> +{ + public async Task> Handle(ListSalesFulfillmentsQuery request, + CancellationToken cancellationToken) => + (await service.ListAsync(request.WorkspaceId, cancellationToken)).Select(SalesFulfillmentResponse.From).ToArray(); +} diff --git a/backend/src/InventoryFlow.Application/Features/Sales/SalesFulfillmentModels.cs b/backend/src/InventoryFlow.Application/Features/Sales/SalesFulfillmentModels.cs new file mode 100644 index 0000000..b41d4b7 --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Sales/SalesFulfillmentModels.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; +using InventoryFlow.Domain.Entities; + +namespace InventoryFlow.Application.Features.Sales; + +public sealed record RecordSalesFulfillmentRequest(Guid WarehouseId, Guid ProductId, + [property: JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] decimal Quantity, string IdempotencyKey); + +public sealed record RecordSalesFulfillmentCommand(Guid WorkspaceId, Guid WarehouseId, Guid ProductId, decimal Quantity, + string IdempotencyKey) : MediatR.IRequest; + +public sealed record ListSalesFulfillmentsQuery(Guid WorkspaceId) : MediatR.IRequest>; + +public sealed record SalesFulfillmentResponse(Guid Id, Guid WarehouseId, Guid ProductId, decimal Quantity, + Guid InventoryMovementId, DateTimeOffset FulfilledAtUtc) +{ + public static SalesFulfillmentResponse From(SalesFulfillment fulfillment) => new(fulfillment.Id, fulfillment.WarehouseId, + fulfillment.ProductId, fulfillment.Quantity, fulfillment.InventoryMovementId, fulfillment.FulfilledAtUtc); +} + +public interface ISalesFulfillmentService +{ + Task RecordAsync(Guid workspaceId, Guid warehouseId, Guid productId, decimal quantity, string idempotencyKey, + DateTimeOffset fulfilledAtUtc, CancellationToken cancellationToken); + Task> ListAsync(Guid workspaceId, CancellationToken cancellationToken); +} diff --git a/backend/src/InventoryFlow.Application/Features/Sales/SalesFulfillmentValidators.cs b/backend/src/InventoryFlow.Application/Features/Sales/SalesFulfillmentValidators.cs new file mode 100644 index 0000000..8ec8987 --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Sales/SalesFulfillmentValidators.cs @@ -0,0 +1,18 @@ +using FluentValidation; +using InventoryFlow.Domain.Entities; + +namespace InventoryFlow.Application.Features.Sales; + +public sealed class RecordSalesFulfillmentCommandValidator : AbstractValidator +{ + public RecordSalesFulfillmentCommandValidator() + { + RuleFor(x => x.WorkspaceId).NotEmpty(); + RuleFor(x => x.WarehouseId).NotEmpty(); + RuleFor(x => x.ProductId).NotEmpty(); + RuleFor(x => x.Quantity).GreaterThan(0m).Must(quantity => decimal.Round(quantity, 4) == quantity) + .WithMessage("Quantity must have at most four decimal places.") + .LessThanOrEqualTo(InventoryMovement.MaxQuantity).WithMessage("Quantity must not exceed 99999999999999.9999."); + RuleFor(x => x.IdempotencyKey).NotEmpty().MaximumLength(InventoryMovement.IdempotencyKeyMaxLength); + } +} diff --git a/backend/src/InventoryFlow.Application/Features/Suppliers/ISupplierCatalog.cs b/backend/src/InventoryFlow.Application/Features/Suppliers/ISupplierCatalog.cs new file mode 100644 index 0000000..ad1f0f7 --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Suppliers/ISupplierCatalog.cs @@ -0,0 +1,25 @@ +using InventoryFlow.Domain.Entities; + +namespace InventoryFlow.Application.Features.Suppliers; + +/// Provides workspace-scoped persistence operations for suppliers. +public interface ISupplierCatalog +{ + /// Creates a supplier. + Task CreateAsync(Supplier supplier, CancellationToken cancellationToken); + /// Lists active suppliers. + Task> ListActiveAsync(Guid workspaceId, CancellationToken cancellationToken); + /// Finds a supplier only within its workspace. + Task FindAsync(Guid workspaceId, Guid supplierId, CancellationToken cancellationToken); + /// Idempotently archives a supplier only within its workspace. + Task ArchiveAsync(Guid workspaceId, Guid supplierId, DateTimeOffset archivedAtUtc, CancellationToken cancellationToken); + /// Persists pending changes. + Task SaveChangesAsync(CancellationToken cancellationToken); +} + +/// Represents a duplicate normalized supplier name within a workspace. +public sealed class SupplierNameConflictException : Exception +{ + /// Initializes the exception. + public SupplierNameConflictException() : base("A supplier with this name already exists in the workspace.") { } +} diff --git a/backend/src/InventoryFlow.Application/Features/Suppliers/SupplierHandlers.cs b/backend/src/InventoryFlow.Application/Features/Suppliers/SupplierHandlers.cs new file mode 100644 index 0000000..eae4bf9 --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Suppliers/SupplierHandlers.cs @@ -0,0 +1,32 @@ +using InventoryFlow.Domain.Entities; +using MediatR; + +namespace InventoryFlow.Application.Features.Suppliers; + +/// Handles supplier creation. +public sealed class CreateSupplierHandler(ISupplierCatalog catalog, TimeProvider timeProvider) + : IRequestHandler +{ + /// + public async Task Handle(CreateSupplierCommand request, CancellationToken cancellationToken) => + SupplierResponse.From(await catalog.CreateAsync(new Supplier(Guid.NewGuid(), request.WorkspaceId, request.Name, + timeProvider.GetUtcNow()), cancellationToken)); +} + +/// Handles active supplier listing. +public sealed class ListSuppliersHandler(ISupplierCatalog catalog) + : IRequestHandler> +{ + /// + public async Task> Handle(ListSuppliersQuery request, CancellationToken cancellationToken) => + (await catalog.ListActiveAsync(request.WorkspaceId, cancellationToken)).Select(SupplierResponse.From).ToArray(); +} + +/// Handles idempotent supplier archival. +public sealed class ArchiveSupplierHandler(ISupplierCatalog catalog, TimeProvider timeProvider) + : IRequestHandler +{ + /// + public Task Handle(ArchiveSupplierCommand request, CancellationToken cancellationToken) => + catalog.ArchiveAsync(request.WorkspaceId, request.SupplierId, timeProvider.GetUtcNow(), cancellationToken); +} diff --git a/backend/src/InventoryFlow.Application/Features/Suppliers/SupplierModels.cs b/backend/src/InventoryFlow.Application/Features/Suppliers/SupplierModels.cs new file mode 100644 index 0000000..e566b2b --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Suppliers/SupplierModels.cs @@ -0,0 +1,19 @@ +using InventoryFlow.Domain.Entities; +using MediatR; + +namespace InventoryFlow.Application.Features.Suppliers; + +/// Supplies browser input for supplier creation. +public sealed record CreateSupplierRequest(string Name); +/// Represents a supplier exposed to clients. +public sealed record SupplierResponse(Guid Id, string Name, DateTimeOffset CreatedAtUtc) +{ + /// Maps a domain supplier to a response. + public static SupplierResponse From(Supplier supplier) => new(supplier.Id, supplier.Name, supplier.CreatedAtUtc); +} +/// Creates a supplier in a server-resolved workspace. +public sealed record CreateSupplierCommand(Guid WorkspaceId, string Name) : IRequest; +/// Lists active suppliers in a server-resolved workspace. +public sealed record ListSuppliersQuery(Guid WorkspaceId) : IRequest>; +/// Archives a supplier in a server-resolved workspace. +public sealed record ArchiveSupplierCommand(Guid WorkspaceId, Guid SupplierId) : IRequest; diff --git a/backend/src/InventoryFlow.Application/Features/Suppliers/SupplierValidators.cs b/backend/src/InventoryFlow.Application/Features/Suppliers/SupplierValidators.cs new file mode 100644 index 0000000..a55e807 --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Suppliers/SupplierValidators.cs @@ -0,0 +1,15 @@ +using FluentValidation; +using InventoryFlow.Domain.Entities; + +namespace InventoryFlow.Application.Features.Suppliers; + +/// Validates supplier creation input. +public sealed class CreateSupplierCommandValidator : AbstractValidator +{ + /// Initializes validation rules. + public CreateSupplierCommandValidator() + { + RuleFor(command => command.WorkspaceId).NotEmpty(); + RuleFor(command => command.Name).NotEmpty().MaximumLength(Supplier.NameMaxLength); + } +} diff --git a/backend/src/InventoryFlow.Application/Features/Transfers/WarehouseTransferHandlers.cs b/backend/src/InventoryFlow.Application/Features/Transfers/WarehouseTransferHandlers.cs new file mode 100644 index 0000000..3bd8f56 --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Transfers/WarehouseTransferHandlers.cs @@ -0,0 +1,22 @@ +using MediatR; + +namespace InventoryFlow.Application.Features.Transfers; + +public sealed class RecordWarehouseTransferHandler(IWarehouseTransferService service, TimeProvider timeProvider) + : IRequestHandler +{ + public async Task Handle(RecordWarehouseTransferCommand request, CancellationToken cancellationToken) + { + var transfer = await service.RecordAsync(request.WorkspaceId, request.SourceWarehouseId, request.DestinationWarehouseId, + request.ProductId, request.Quantity, request.IdempotencyKey, timeProvider.GetUtcNow(), cancellationToken); + return transfer is null ? null : WarehouseTransferResponse.From(transfer); + } +} + +public sealed class ListWarehouseTransfersHandler(IWarehouseTransferService service) + : IRequestHandler> +{ + public async Task> Handle(ListWarehouseTransfersQuery request, + CancellationToken cancellationToken) => + (await service.ListAsync(request.WorkspaceId, cancellationToken)).Select(WarehouseTransferResponse.From).ToArray(); +} diff --git a/backend/src/InventoryFlow.Application/Features/Transfers/WarehouseTransferModels.cs b/backend/src/InventoryFlow.Application/Features/Transfers/WarehouseTransferModels.cs new file mode 100644 index 0000000..7b51cae --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Transfers/WarehouseTransferModels.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; +using InventoryFlow.Domain.Entities; + +namespace InventoryFlow.Application.Features.Transfers; + +public sealed record RecordWarehouseTransferRequest(Guid SourceWarehouseId, Guid DestinationWarehouseId, Guid ProductId, + [property: JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] decimal Quantity, string IdempotencyKey); + +public sealed record RecordWarehouseTransferCommand(Guid WorkspaceId, Guid SourceWarehouseId, Guid DestinationWarehouseId, + Guid ProductId, decimal Quantity, string IdempotencyKey) : MediatR.IRequest; + +public sealed record ListWarehouseTransfersQuery(Guid WorkspaceId) : MediatR.IRequest>; + +public sealed record WarehouseTransferResponse(Guid Id, Guid SourceWarehouseId, Guid DestinationWarehouseId, Guid ProductId, + decimal Quantity, Guid SourceInventoryMovementId, Guid DestinationInventoryMovementId, DateTimeOffset TransferredAtUtc) +{ + public static WarehouseTransferResponse From(WarehouseTransfer transfer) => new(transfer.Id, transfer.SourceWarehouseId, + transfer.DestinationWarehouseId, transfer.ProductId, transfer.Quantity, transfer.SourceInventoryMovementId, + transfer.DestinationInventoryMovementId, transfer.TransferredAtUtc); +} + +public interface IWarehouseTransferService +{ + Task RecordAsync(Guid workspaceId, Guid sourceWarehouseId, Guid destinationWarehouseId, Guid productId, + decimal quantity, string idempotencyKey, DateTimeOffset transferredAtUtc, CancellationToken cancellationToken); + Task> ListAsync(Guid workspaceId, CancellationToken cancellationToken); +} diff --git a/backend/src/InventoryFlow.Application/Features/Transfers/WarehouseTransferValidators.cs b/backend/src/InventoryFlow.Application/Features/Transfers/WarehouseTransferValidators.cs new file mode 100644 index 0000000..02d57f9 --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Transfers/WarehouseTransferValidators.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using InventoryFlow.Domain.Entities; + +namespace InventoryFlow.Application.Features.Transfers; + +public sealed class RecordWarehouseTransferCommandValidator : AbstractValidator +{ + public RecordWarehouseTransferCommandValidator() + { + RuleFor(x => x.WorkspaceId).NotEmpty(); + RuleFor(x => x.SourceWarehouseId).NotEmpty(); + RuleFor(x => x.DestinationWarehouseId).NotEmpty().NotEqual(x => x.SourceWarehouseId) + .WithMessage("Source and destination warehouses must be different."); + RuleFor(x => x.ProductId).NotEmpty(); + RuleFor(x => x.Quantity).GreaterThan(0m).Must(quantity => decimal.Round(quantity, 4) == quantity) + .WithMessage("Quantity must have at most four decimal places.") + .LessThanOrEqualTo(InventoryMovement.MaxQuantity).WithMessage("Quantity must not exceed 99999999999999.9999."); + RuleFor(x => x.IdempotencyKey).NotEmpty().MaximumLength(InventoryMovement.IdempotencyKeyMaxLength); + } +} diff --git a/backend/src/InventoryFlow.Application/Features/Warehouses/IWarehouseCatalog.cs b/backend/src/InventoryFlow.Application/Features/Warehouses/IWarehouseCatalog.cs new file mode 100644 index 0000000..a7c4643 --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Warehouses/IWarehouseCatalog.cs @@ -0,0 +1,25 @@ +using InventoryFlow.Domain.Entities; + +namespace InventoryFlow.Application.Features.Warehouses; + +/// Provides workspace-scoped persistence operations for warehouses. +public interface IWarehouseCatalog +{ + /// Creates a warehouse. + Task CreateAsync(Warehouse warehouse, CancellationToken cancellationToken); + /// Lists active warehouses. + Task> ListActiveAsync(Guid workspaceId, CancellationToken cancellationToken); + /// Finds a warehouse only within its workspace. + Task FindAsync(Guid workspaceId, Guid warehouseId, CancellationToken cancellationToken); + /// Archives a warehouse only when no nonzero workspace balance references it. + Task ArchiveAsync(Guid workspaceId, Guid warehouseId, DateTimeOffset archivedAtUtc, CancellationToken cancellationToken); + /// Persists pending changes. + Task SaveChangesAsync(CancellationToken cancellationToken); +} + +/// Represents a duplicate canonical warehouse name within a workspace. +public sealed class WarehouseNameConflictException : Exception +{ + /// Initializes the exception. + public WarehouseNameConflictException() : base("A warehouse with this name already exists in the workspace.") { } +} diff --git a/backend/src/InventoryFlow.Application/Features/Warehouses/WarehouseHandlers.cs b/backend/src/InventoryFlow.Application/Features/Warehouses/WarehouseHandlers.cs new file mode 100644 index 0000000..67c3d3f --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Warehouses/WarehouseHandlers.cs @@ -0,0 +1,32 @@ +using InventoryFlow.Domain.Entities; +using MediatR; + +namespace InventoryFlow.Application.Features.Warehouses; + +/// Handles warehouse creation. +public sealed class CreateWarehouseHandler(IWarehouseCatalog catalog, TimeProvider timeProvider) + : IRequestHandler +{ + /// + public async Task Handle(CreateWarehouseCommand request, CancellationToken cancellationToken) => + WarehouseResponse.From(await catalog.CreateAsync(new Warehouse(Guid.NewGuid(), request.WorkspaceId, request.Name, + timeProvider.GetUtcNow()), cancellationToken)); +} + +/// Handles active warehouse listing. +public sealed class ListWarehousesHandler(IWarehouseCatalog catalog) + : IRequestHandler> +{ + /// + public async Task> Handle(ListWarehousesQuery request, CancellationToken cancellationToken) => + (await catalog.ListActiveAsync(request.WorkspaceId, cancellationToken)).Select(WarehouseResponse.From).ToArray(); +} + +/// Handles warehouse archival. +public sealed class ArchiveWarehouseHandler(IWarehouseCatalog catalog, TimeProvider timeProvider) + : IRequestHandler +{ + /// + public Task Handle(ArchiveWarehouseCommand request, CancellationToken cancellationToken) => + catalog.ArchiveAsync(request.WorkspaceId, request.WarehouseId, timeProvider.GetUtcNow(), cancellationToken); +} diff --git a/backend/src/InventoryFlow.Application/Features/Warehouses/WarehouseModels.cs b/backend/src/InventoryFlow.Application/Features/Warehouses/WarehouseModels.cs new file mode 100644 index 0000000..59e17ee --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Warehouses/WarehouseModels.cs @@ -0,0 +1,5 @@ +using InventoryFlow.Domain.Entities; +using MediatR; +namespace InventoryFlow.Application.Features.Warehouses; +public sealed record CreateWarehouseRequest(string Name); public sealed record WarehouseResponse(Guid Id, string Name, DateTimeOffset CreatedAtUtc) { public static WarehouseResponse From(Warehouse x) => new(x.Id, x.Name, x.CreatedAtUtc); } +public sealed record CreateWarehouseCommand(Guid WorkspaceId, string Name) : IRequest; public sealed record ListWarehousesQuery(Guid WorkspaceId) : IRequest>; public sealed record ArchiveWarehouseCommand(Guid WorkspaceId, Guid WarehouseId) : IRequest; diff --git a/backend/src/InventoryFlow.Application/Features/Warehouses/WarehouseValidators.cs b/backend/src/InventoryFlow.Application/Features/Warehouses/WarehouseValidators.cs new file mode 100644 index 0000000..67f3990 --- /dev/null +++ b/backend/src/InventoryFlow.Application/Features/Warehouses/WarehouseValidators.cs @@ -0,0 +1,3 @@ +using FluentValidation; +using InventoryFlow.Domain.Entities; +namespace InventoryFlow.Application.Features.Warehouses; public sealed class CreateWarehouseCommandValidator : AbstractValidator { public CreateWarehouseCommandValidator() { RuleFor(x => x.WorkspaceId).NotEmpty(); RuleFor(x => x.Name).NotEmpty().MaximumLength(Warehouse.NameMaxLength); } } diff --git a/backend/src/InventoryFlow.Application/InventoryFlow.Application.csproj b/backend/src/InventoryFlow.Application/InventoryFlow.Application.csproj new file mode 100644 index 0000000..839bc21 --- /dev/null +++ b/backend/src/InventoryFlow.Application/InventoryFlow.Application.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/backend/src/InventoryFlow.Domain/Common/Entity.cs b/backend/src/InventoryFlow.Domain/Common/Entity.cs new file mode 100644 index 0000000..dae89f4 --- /dev/null +++ b/backend/src/InventoryFlow.Domain/Common/Entity.cs @@ -0,0 +1,23 @@ +namespace InventoryFlow.Domain.Common; + +/// +/// Represents a domain entity with an identity. +/// +/// The type used to identify the entity. +public abstract class Entity + where TId : notnull +{ + /// + /// Initializes a new instance of the class. + /// + /// The entity identifier. + protected Entity(TId id) + { + Id = id; + } + + /// + /// Gets the entity identifier. + /// + public TId Id { get; protected set; } +} diff --git a/backend/src/InventoryFlow.Domain/Entities/InventoryBalance.cs b/backend/src/InventoryFlow.Domain/Entities/InventoryBalance.cs new file mode 100644 index 0000000..2745dc9 --- /dev/null +++ b/backend/src/InventoryFlow.Domain/Entities/InventoryBalance.cs @@ -0,0 +1,48 @@ +using InventoryFlow.Domain.Exceptions; + +namespace InventoryFlow.Domain.Entities; + +/// Represents the current on-hand quantity for a product at a warehouse. +public sealed class InventoryBalance +{ + /// Initializes a zero or positive inventory balance. + public InventoryBalance(Guid workspaceId, Guid warehouseId, Guid productId, decimal quantity) + { + if (workspaceId == Guid.Empty || warehouseId == Guid.Empty || productId == Guid.Empty) + throw new DomainException("Inventory balance identifiers are required."); + if (quantity < 0m || decimal.Round(quantity, 4) != quantity || quantity > InventoryMovement.MaxQuantity) + throw new DomainException("Inventory balance must be nonnegative, have at most four decimal places, and not exceed 99999999999999.9999."); + WorkspaceId = workspaceId; + WarehouseId = warehouseId; + ProductId = productId; + Quantity = quantity; + } + + /// Gets the owning workspace identifier. + public Guid WorkspaceId { get; private set; } + /// Gets the warehouse identifier. + public Guid WarehouseId { get; private set; } + /// Gets the product identifier. + public Guid ProductId { get; private set; } + /// Gets the current on-hand quantity. + public decimal Quantity { get; private set; } + + /// Applies a signed quantity change without allowing negative stock. + public void Apply(decimal quantityDelta) + { + if (decimal.Round(quantityDelta, 4) != quantityDelta) + throw new DomainException("Inventory quantity must have at most four decimal places."); + var next = Quantity + quantityDelta; + if (next < 0m) throw new InsufficientInventoryException(); + if (next > InventoryMovement.MaxQuantity) + throw new DomainException("Inventory balance cannot exceed 99999999999999.9999."); + Quantity = next; + } +} + +/// Indicates that an issue would make inventory negative. +public sealed class InsufficientInventoryException : Exception +{ + /// Initializes the exception. + public InsufficientInventoryException() : base("Insufficient inventory is available for this issue.") { } +} diff --git a/backend/src/InventoryFlow.Domain/Entities/InventoryMovement.cs b/backend/src/InventoryFlow.Domain/Entities/InventoryMovement.cs new file mode 100644 index 0000000..3162cec --- /dev/null +++ b/backend/src/InventoryFlow.Domain/Entities/InventoryMovement.cs @@ -0,0 +1,77 @@ +using InventoryFlow.Domain.Common; +using InventoryFlow.Domain.Exceptions; + +namespace InventoryFlow.Domain.Entities; + +/// Identifies the direction of an inventory ledger entry. +public enum InventoryMovementType +{ + Receipt = 1, + Issue = 2 +} + +/// Represents an immutable, idempotent inventory ledger entry. +public sealed class InventoryMovement : Entity +{ + /// Maximum quantity representable by the inventory decimal(18,4) columns. + public const decimal MaxQuantity = 99999999999999.9999m; + + /// Maximum length of a client supplied idempotency key. + public const int IdempotencyKeyMaxLength = 100; + + /// Initializes an inventory movement. + public InventoryMovement(Guid id, Guid workspaceId, Guid warehouseId, Guid productId, InventoryMovementType type, + decimal quantity, string idempotencyKey, decimal balanceAfterQuantity, DateTimeOffset occurredAtUtc) : base(id) + { + if (id == Guid.Empty || workspaceId == Guid.Empty || warehouseId == Guid.Empty || productId == Guid.Empty) + throw new DomainException("Inventory movement identifiers are required."); + if (!Enum.IsDefined(type)) throw new DomainException("Inventory movement type is invalid."); + Quantity = ValidateQuantity(quantity); + IdempotencyKey = NormalizeIdempotencyKey(idempotencyKey); + if (balanceAfterQuantity < 0m || decimal.Round(balanceAfterQuantity, 4) != balanceAfterQuantity || + balanceAfterQuantity > MaxQuantity) + throw new DomainException("Inventory balance must be nonnegative, have at most four decimal places, and not exceed 99999999999999.9999."); + if (occurredAtUtc.Offset != TimeSpan.Zero) throw new DomainException("Inventory movement time must be in UTC."); + + WorkspaceId = workspaceId; + WarehouseId = warehouseId; + ProductId = productId; + Type = type; + BalanceAfterQuantity = balanceAfterQuantity; + OccurredAtUtc = occurredAtUtc; + } + + /// Gets the owning workspace identifier. + public Guid WorkspaceId { get; private set; } + /// Gets the warehouse identifier. + public Guid WarehouseId { get; private set; } + /// Gets the product identifier. + public Guid ProductId { get; private set; } + /// Gets the direction of the movement. + public InventoryMovementType Type { get; private set; } + /// Gets the positive movement quantity. + public decimal Quantity { get; private set; } + /// Gets the client-supplied idempotency key. + public string IdempotencyKey { get; private set; } + /// Gets the resulting balance immediately after this movement. + public decimal BalanceAfterQuantity { get; private set; } + /// Gets the UTC instant at which the movement was recorded. + public DateTimeOffset OccurredAtUtc { get; private set; } + + /// Validates a positive decimal quantity representable by inventory decimal(18,4) columns. + public static decimal ValidateQuantity(decimal quantity) + { + if (quantity <= 0m || decimal.Round(quantity, 4) != quantity || quantity > MaxQuantity) + throw new DomainException("Inventory quantity must be positive, have at most four decimal places, and not exceed 99999999999999.9999."); + return quantity; + } + + /// Normalizes and validates an idempotency key. + public static string NormalizeIdempotencyKey(string idempotencyKey) + { + var value = idempotencyKey?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(value) || value.Length > IdempotencyKeyMaxLength) + throw new DomainException($"Idempotency key must contain between 1 and {IdempotencyKeyMaxLength} characters."); + return value; + } +} diff --git a/backend/src/InventoryFlow.Domain/Entities/Product.cs b/backend/src/InventoryFlow.Domain/Entities/Product.cs new file mode 100644 index 0000000..bd783a8 --- /dev/null +++ b/backend/src/InventoryFlow.Domain/Entities/Product.cs @@ -0,0 +1,61 @@ +using InventoryFlow.Domain.Common; +using InventoryFlow.Domain.Exceptions; + +namespace InventoryFlow.Domain.Entities; + +/// Represents a workspace-owned catalog product. +public sealed class Product : Entity +{ + /// Maximum permitted product-name length. + public const int NameMaxLength = 200; + /// Maximum permitted canonical SKU length. + public const int SkuMaxLength = 100; + + /// Initializes a product. + public Product(Guid id, Guid workspaceId, string name, string sku, DateTimeOffset createdAtUtc) : base(id) + { + if (id == Guid.Empty) throw new DomainException("Product identifier is required."); + if (workspaceId == Guid.Empty) throw new DomainException("Workspace identifier is required."); + if (createdAtUtc.Offset != TimeSpan.Zero) throw new DomainException("Product creation time must be in UTC."); + WorkspaceId = workspaceId; + Name = NormalizeName(name); + Sku = NormalizeSku(sku); + CreatedAtUtc = createdAtUtc; + } + + /// Gets the owning workspace identifier. + public Guid WorkspaceId { get; private set; } + /// Gets the display name. + public string Name { get; private set; } + /// Gets the canonical stock-keeping unit. + public string Sku { get; private set; } + /// Gets the UTC creation instant. + public DateTimeOffset CreatedAtUtc { get; private set; } + /// Gets the UTC archive instant, if archived. + public DateTimeOffset? ArchivedAtUtc { get; private set; } + + /// Normalizes and validates a name. + public static string NormalizeName(string name) + { + var normalized = name?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(normalized) || normalized.Length > NameMaxLength) + throw new DomainException($"Product name must contain between 1 and {NameMaxLength} characters."); + return normalized; + } + + /// Normalizes and validates a SKU. + public static string NormalizeSku(string sku) + { + var normalized = sku?.Trim().ToUpperInvariant() ?? string.Empty; + if (string.IsNullOrWhiteSpace(normalized) || normalized.Length > SkuMaxLength) + throw new DomainException($"Product SKU must contain between 1 and {SkuMaxLength} characters."); + return normalized; + } + + /// Archives the product once. + public void Archive(DateTimeOffset archivedAtUtc) + { + if (archivedAtUtc.Offset != TimeSpan.Zero) throw new DomainException("Product archive time must be in UTC."); + ArchivedAtUtc ??= archivedAtUtc; + } +} diff --git a/backend/src/InventoryFlow.Domain/Entities/PurchaseReceipt.cs b/backend/src/InventoryFlow.Domain/Entities/PurchaseReceipt.cs new file mode 100644 index 0000000..4716667 --- /dev/null +++ b/backend/src/InventoryFlow.Domain/Entities/PurchaseReceipt.cs @@ -0,0 +1,34 @@ +using InventoryFlow.Domain.Common; +using InventoryFlow.Domain.Exceptions; + +namespace InventoryFlow.Domain.Entities; + +/// An immutable, supplier-linked posted goods receipt. +public sealed class PurchaseReceipt : Entity +{ + public PurchaseReceipt(Guid id, Guid workspaceId, Guid supplierId, Guid warehouseId, Guid productId, decimal quantity, + string idempotencyKey, Guid inventoryMovementId, DateTimeOffset receivedAtUtc) : base(id) + { + if (id == Guid.Empty || workspaceId == Guid.Empty || supplierId == Guid.Empty || warehouseId == Guid.Empty || + productId == Guid.Empty || inventoryMovementId == Guid.Empty) + throw new DomainException("Purchase receipt identifiers are required."); + if (receivedAtUtc.Offset != TimeSpan.Zero) throw new DomainException("Purchase receipt time must be in UTC."); + WorkspaceId = workspaceId; + SupplierId = supplierId; + WarehouseId = warehouseId; + ProductId = productId; + Quantity = InventoryMovement.ValidateQuantity(quantity); + IdempotencyKey = InventoryMovement.NormalizeIdempotencyKey(idempotencyKey); + InventoryMovementId = inventoryMovementId; + ReceivedAtUtc = receivedAtUtc; + } + + public Guid WorkspaceId { get; private set; } + public Guid SupplierId { get; private set; } + public Guid WarehouseId { get; private set; } + public Guid ProductId { get; private set; } + public decimal Quantity { get; private set; } + public string IdempotencyKey { get; private set; } + public Guid InventoryMovementId { get; private set; } + public DateTimeOffset ReceivedAtUtc { get; private set; } +} diff --git a/backend/src/InventoryFlow.Domain/Entities/RefreshToken.cs b/backend/src/InventoryFlow.Domain/Entities/RefreshToken.cs new file mode 100644 index 0000000..25cb9bf --- /dev/null +++ b/backend/src/InventoryFlow.Domain/Entities/RefreshToken.cs @@ -0,0 +1,117 @@ +using InventoryFlow.Domain.Common; +using InventoryFlow.Domain.Exceptions; + +namespace InventoryFlow.Domain.Entities; + +/// +/// Represents a hashed refresh token issued to an authenticated user. +/// +public sealed class RefreshToken : Entity +{ + /// + /// Initializes a new instance of the class. + /// + /// The refresh-token identifier. + /// The identifier of the token owner. + /// The one-way hash of the issued refresh token. + /// The UTC instant after which the token cannot be used. + /// Thrown when an identifier or token hash is empty, or the expiration is not UTC. + public RefreshToken( + Guid id, + Guid userId, + Guid familyId, + Guid workspaceId, + string tokenHash, + DateTimeOffset expiresAtUtc) + : base(id) + { + if (id == Guid.Empty) + { + throw new DomainException("Refresh token identifier is required."); + } + + if (userId == Guid.Empty) + { + throw new DomainException("Refresh token user identifier is required."); + } + + if (familyId == Guid.Empty) + { + throw new DomainException("Refresh token family identifier is required."); + } + + if (workspaceId == Guid.Empty) + { + throw new DomainException("Refresh token workspace identifier is required."); + } + + if (string.IsNullOrWhiteSpace(tokenHash)) + { + throw new DomainException("Refresh token hash is required."); + } + + if (expiresAtUtc.Offset != TimeSpan.Zero) + { + throw new DomainException("Refresh token expiration must be in UTC."); + } + + UserId = userId; + FamilyId = familyId; + WorkspaceId = workspaceId; + TokenHash = tokenHash; + ExpiresAtUtc = expiresAtUtc; + } + + /// + /// Gets the identifier of the user that owns this token. + /// + public Guid UserId { get; private set; } + + /// + /// Gets the identifier shared by all tokens created from this session. + /// + public Guid FamilyId { get; private set; } + + /// + /// Gets the active workspace for this refresh session. + /// + public Guid WorkspaceId { get; private set; } + + /// + /// Gets the one-way hash of the issued token. + /// + public string TokenHash { get; private set; } + + /// + /// Gets the UTC instant at which this token expires. + /// + public DateTimeOffset ExpiresAtUtc { get; private set; } + + /// + /// Gets the UTC instant at which this token was revoked, if applicable. + /// + public DateTimeOffset? RevokedAtUtc { get; private set; } + + /// + /// Gets a value indicating whether this token is valid at the supplied UTC instant. + /// + /// The current UTC instant. + /// when the token is unrevoked and unexpired; otherwise, . + public bool IsActive(DateTimeOffset utcNow) => + RevokedAtUtc is null && ExpiresAtUtc > utcNow; + + /// + /// Revokes this token at the supplied UTC instant. + /// + /// The UTC instant at which the token was revoked. + /// Thrown when the supplied instant is not UTC. + public void Revoke(DateTimeOffset revokedAtUtc) + { + if (revokedAtUtc.Offset != TimeSpan.Zero) + { + throw new DomainException("Refresh token revocation time must be in UTC."); + } + + RevokedAtUtc ??= revokedAtUtc; + } +} diff --git a/backend/src/InventoryFlow.Domain/Entities/SalesFulfillment.cs b/backend/src/InventoryFlow.Domain/Entities/SalesFulfillment.cs new file mode 100644 index 0000000..f503fba --- /dev/null +++ b/backend/src/InventoryFlow.Domain/Entities/SalesFulfillment.cs @@ -0,0 +1,32 @@ +using InventoryFlow.Domain.Common; +using InventoryFlow.Domain.Exceptions; + +namespace InventoryFlow.Domain.Entities; + +/// An immutable posted single-line sales fulfillment. +public sealed class SalesFulfillment : Entity +{ + public SalesFulfillment(Guid id, Guid workspaceId, Guid warehouseId, Guid productId, decimal quantity, + string idempotencyKey, Guid inventoryMovementId, DateTimeOffset fulfilledAtUtc) : base(id) + { + if (id == Guid.Empty || workspaceId == Guid.Empty || warehouseId == Guid.Empty || productId == Guid.Empty || + inventoryMovementId == Guid.Empty) + throw new DomainException("Sales fulfillment identifiers are required."); + if (fulfilledAtUtc.Offset != TimeSpan.Zero) throw new DomainException("Sales fulfillment time must be in UTC."); + WorkspaceId = workspaceId; + WarehouseId = warehouseId; + ProductId = productId; + Quantity = InventoryMovement.ValidateQuantity(quantity); + IdempotencyKey = InventoryMovement.NormalizeIdempotencyKey(idempotencyKey); + InventoryMovementId = inventoryMovementId; + FulfilledAtUtc = fulfilledAtUtc; + } + + public Guid WorkspaceId { get; private set; } + public Guid WarehouseId { get; private set; } + public Guid ProductId { get; private set; } + public decimal Quantity { get; private set; } + public string IdempotencyKey { get; private set; } + public Guid InventoryMovementId { get; private set; } + public DateTimeOffset FulfilledAtUtc { get; private set; } +} diff --git a/backend/src/InventoryFlow.Domain/Entities/Supplier.cs b/backend/src/InventoryFlow.Domain/Entities/Supplier.cs new file mode 100644 index 0000000..6a3db8b --- /dev/null +++ b/backend/src/InventoryFlow.Domain/Entities/Supplier.cs @@ -0,0 +1,47 @@ +using InventoryFlow.Domain.Common; +using InventoryFlow.Domain.Exceptions; + +namespace InventoryFlow.Domain.Entities; + +/// Represents a workspace-owned supplier catalog entry. +public sealed class Supplier : Entity +{ + /// Maximum permitted supplier-name length. + public const int NameMaxLength = 200; + + /// Initializes a supplier. + public Supplier(Guid id, Guid workspaceId, string name, DateTimeOffset createdAtUtc) : base(id) + { + if (id == Guid.Empty) throw new DomainException("Supplier identifier is required."); + if (workspaceId == Guid.Empty) throw new DomainException("Workspace identifier is required."); + if (createdAtUtc.Offset != TimeSpan.Zero) throw new DomainException("Supplier creation time must be in UTC."); + WorkspaceId = workspaceId; + Name = NormalizeName(name); + CreatedAtUtc = createdAtUtc; + } + + /// Gets the owning workspace identifier. + public Guid WorkspaceId { get; private set; } + /// Gets the normalized display name. + public string Name { get; private set; } + /// Gets the UTC creation instant. + public DateTimeOffset CreatedAtUtc { get; private set; } + /// Gets the UTC archive instant, if archived. + public DateTimeOffset? ArchivedAtUtc { get; private set; } + + /// Normalizes and validates a name. + public static string NormalizeName(string name) + { + var normalized = name?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(normalized) || normalized.Length > NameMaxLength) + throw new DomainException($"Supplier name must contain between 1 and {NameMaxLength} characters."); + return normalized; + } + + /// Archives the supplier once. + public void Archive(DateTimeOffset archivedAtUtc) + { + if (archivedAtUtc.Offset != TimeSpan.Zero) throw new DomainException("Supplier archive time must be in UTC."); + ArchivedAtUtc ??= archivedAtUtc; + } +} diff --git a/backend/src/InventoryFlow.Domain/Entities/Warehouse.cs b/backend/src/InventoryFlow.Domain/Entities/Warehouse.cs new file mode 100644 index 0000000..2b34183 --- /dev/null +++ b/backend/src/InventoryFlow.Domain/Entities/Warehouse.cs @@ -0,0 +1,4 @@ +using InventoryFlow.Domain.Common; +using InventoryFlow.Domain.Exceptions; +namespace InventoryFlow.Domain.Entities; +public sealed class Warehouse : Entity { public const int NameMaxLength = 200; public Warehouse(Guid id, Guid workspaceId, string name, DateTimeOffset createdAtUtc) : base(id) { if (id == Guid.Empty || workspaceId == Guid.Empty || createdAtUtc.Offset != TimeSpan.Zero) throw new DomainException("Warehouse values are invalid."); WorkspaceId = workspaceId; Name = NormalizeName(name); CreatedAtUtc = createdAtUtc; } public Guid WorkspaceId { get; private set; } public string Name { get; private set; } public DateTimeOffset CreatedAtUtc { get; private set; } public DateTimeOffset? ArchivedAtUtc { get; private set; } public static string NormalizeName(string name) { var value = name?.Trim() ?? string.Empty; if (string.IsNullOrWhiteSpace(value) || value.Length > NameMaxLength) throw new DomainException("Warehouse name is invalid."); return value; } public void Archive(DateTimeOffset value) { if (value.Offset != TimeSpan.Zero) throw new DomainException("Warehouse archive time must be UTC."); ArchivedAtUtc ??= value; } } diff --git a/backend/src/InventoryFlow.Domain/Entities/WarehouseTransfer.cs b/backend/src/InventoryFlow.Domain/Entities/WarehouseTransfer.cs new file mode 100644 index 0000000..6b4407c --- /dev/null +++ b/backend/src/InventoryFlow.Domain/Entities/WarehouseTransfer.cs @@ -0,0 +1,43 @@ +using InventoryFlow.Domain.Common; +using InventoryFlow.Domain.Exceptions; + +namespace InventoryFlow.Domain.Entities; + +/// An immutable posted single-product transfer between two warehouses. +public sealed class WarehouseTransfer : Entity +{ + public WarehouseTransfer(Guid id, Guid workspaceId, Guid sourceWarehouseId, Guid destinationWarehouseId, Guid productId, + decimal quantity, string idempotencyKey, Guid sourceInventoryMovementId, Guid destinationInventoryMovementId, + DateTimeOffset transferredAtUtc) : base(id) + { + if (id == Guid.Empty || workspaceId == Guid.Empty || sourceWarehouseId == Guid.Empty || destinationWarehouseId == Guid.Empty || + productId == Guid.Empty || sourceInventoryMovementId == Guid.Empty || destinationInventoryMovementId == Guid.Empty) + throw new DomainException("Warehouse transfer identifiers are required."); + if (sourceWarehouseId == destinationWarehouseId) + throw new DomainException("Source and destination warehouses must be different."); + if (sourceInventoryMovementId == destinationInventoryMovementId) + throw new DomainException("Warehouse transfer movements must be different."); + if (transferredAtUtc.Offset != TimeSpan.Zero) + throw new DomainException("Warehouse transfer time must be in UTC."); + + WorkspaceId = workspaceId; + SourceWarehouseId = sourceWarehouseId; + DestinationWarehouseId = destinationWarehouseId; + ProductId = productId; + Quantity = InventoryMovement.ValidateQuantity(quantity); + IdempotencyKey = InventoryMovement.NormalizeIdempotencyKey(idempotencyKey); + SourceInventoryMovementId = sourceInventoryMovementId; + DestinationInventoryMovementId = destinationInventoryMovementId; + TransferredAtUtc = transferredAtUtc; + } + + public Guid WorkspaceId { get; private set; } + public Guid SourceWarehouseId { get; private set; } + public Guid DestinationWarehouseId { get; private set; } + public Guid ProductId { get; private set; } + public decimal Quantity { get; private set; } + public string IdempotencyKey { get; private set; } + public Guid SourceInventoryMovementId { get; private set; } + public Guid DestinationInventoryMovementId { get; private set; } + public DateTimeOffset TransferredAtUtc { get; private set; } +} diff --git a/backend/src/InventoryFlow.Domain/Entities/Workspace.cs b/backend/src/InventoryFlow.Domain/Entities/Workspace.cs new file mode 100644 index 0000000..0491db6 --- /dev/null +++ b/backend/src/InventoryFlow.Domain/Entities/Workspace.cs @@ -0,0 +1,34 @@ +using InventoryFlow.Domain.Common; +using InventoryFlow.Domain.Exceptions; + +namespace InventoryFlow.Domain.Entities; + +/// Represents a workspace that owns future Inventory Flow data. +public sealed class Workspace : Entity +{ + /// Maximum permitted workspace-name length. + public const int NameMaxLength = 120; + + /// Initializes a workspace. + public Workspace(Guid id, string name, DateTimeOffset createdAtUtc) : base(id) + { + if (id == Guid.Empty) throw new DomainException("Workspace identifier is required."); + Name = NormalizeName(name); + if (createdAtUtc.Offset != TimeSpan.Zero) throw new DomainException("Workspace creation time must be in UTC."); + CreatedAtUtc = createdAtUtc; + } + + /// Gets the workspace display name. + public string Name { get; private set; } + /// Gets the UTC creation instant. + public DateTimeOffset CreatedAtUtc { get; private set; } + + /// Normalizes and validates a workspace display name. + public static string NormalizeName(string name) + { + var normalized = name?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(normalized) || normalized.Length > NameMaxLength) + throw new DomainException($"Workspace name must contain between 1 and {NameMaxLength} characters."); + return normalized; + } +} diff --git a/backend/src/InventoryFlow.Domain/Entities/WorkspaceInvitation.cs b/backend/src/InventoryFlow.Domain/Entities/WorkspaceInvitation.cs new file mode 100644 index 0000000..32c3535 --- /dev/null +++ b/backend/src/InventoryFlow.Domain/Entities/WorkspaceInvitation.cs @@ -0,0 +1,119 @@ +using InventoryFlow.Domain.Common; +using InventoryFlow.Domain.Exceptions; + +namespace InventoryFlow.Domain.Entities; + +/// Represents a single-use invitation to join a workspace. +public sealed class WorkspaceInvitation : Entity +{ + /// Maximum length for stored normalized invite email addresses. + public const int NormalizedEmailMaxLength = 256; + + /// Maximum length for SHA-256 token hashes encoded as hexadecimal. + public const int TokenHashMaxLength = 64; + + /// Initializes a workspace invitation. + public WorkspaceInvitation( + Guid id, + Guid workspaceId, + string normalizedEmail, + WorkspaceMemberRole role, + string tokenHash, + DateTimeOffset expiresAtUtc, + Guid createdByUserId, + DateTimeOffset createdAtUtc) + : base(id) + { + if (id == Guid.Empty || workspaceId == Guid.Empty || createdByUserId == Guid.Empty) + throw new DomainException("Invitation, workspace, and creator identifiers are required."); + if (role != WorkspaceMemberRole.Member) + throw new DomainException("Only Member invitations are supported."); + NormalizedEmail = NormalizeEmail(normalizedEmail); + TokenHash = NormalizeTokenHash(tokenHash); + if (createdAtUtc.Offset != TimeSpan.Zero || expiresAtUtc.Offset != TimeSpan.Zero) + throw new DomainException("Invitation timestamps must be in UTC."); + if (expiresAtUtc <= createdAtUtc) + throw new DomainException("Invitation expiration must be after creation time."); + + WorkspaceId = workspaceId; + Role = role; + ExpiresAtUtc = expiresAtUtc; + CreatedByUserId = createdByUserId; + CreatedAtUtc = createdAtUtc; + } + + /// Gets the workspace identifier. + public Guid WorkspaceId { get; private set; } + + /// Gets the normalized invited email address. + public string NormalizedEmail { get; private set; } + + /// Gets the invited role. + public WorkspaceMemberRole Role { get; private set; } + + /// Gets the one-way hash of the invitation token. + public string TokenHash { get; private set; } + + /// Gets the UTC instant at which the invitation expires. + public DateTimeOffset ExpiresAtUtc { get; private set; } + + /// Gets the user identifier that created the invitation. + public Guid CreatedByUserId { get; private set; } + + /// Gets the UTC creation instant. + public DateTimeOffset CreatedAtUtc { get; private set; } + + /// Gets the UTC instant at which the invitation was accepted, if any. + public DateTimeOffset? AcceptedAtUtc { get; private set; } + + /// Gets the user identifier that accepted the invitation, if any. + public Guid? AcceptedByUserId { get; private set; } + + /// Gets the UTC instant at which the invitation was revoked, if any. + public DateTimeOffset? RevokedAtUtc { get; private set; } + + /// Returns whether the invitation can be accepted at the supplied time. + public bool IsPending(DateTimeOffset utcNow) => + AcceptedAtUtc is null && RevokedAtUtc is null && ExpiresAtUtc > utcNow; + + /// Marks the invitation as revoked. + public void Revoke(DateTimeOffset revokedAtUtc) + { + if (revokedAtUtc.Offset != TimeSpan.Zero) + throw new DomainException("Invitation revocation time must be in UTC."); + if (AcceptedAtUtc is not null) + throw new DomainException("Accepted invitations cannot be revoked."); + if (RevokedAtUtc is not null) + throw new DomainException("Invitation has already been revoked."); + RevokedAtUtc = revokedAtUtc; + } + + /// Marks the invitation as accepted by the supplied user. + public void Accept(Guid acceptedByUserId, DateTimeOffset acceptedAtUtc) + { + if (acceptedByUserId == Guid.Empty) + throw new DomainException("Invitation accepter identifier is required."); + if (acceptedAtUtc.Offset != TimeSpan.Zero) + throw new DomainException("Invitation acceptance time must be in UTC."); + if (!IsPending(acceptedAtUtc)) + throw new DomainException("Invitation is not pending."); + AcceptedByUserId = acceptedByUserId; + AcceptedAtUtc = acceptedAtUtc; + } + + private static string NormalizeEmail(string normalizedEmail) + { + var value = normalizedEmail?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(value) || value.Length > NormalizedEmailMaxLength) + throw new DomainException($"Invitation email must contain between 1 and {NormalizedEmailMaxLength} characters."); + return value; + } + + private static string NormalizeTokenHash(string tokenHash) + { + var value = tokenHash?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(value) || value.Length > TokenHashMaxLength) + throw new DomainException("Invitation token hash is required."); + return value; + } +} diff --git a/backend/src/InventoryFlow.Domain/Entities/WorkspaceMember.cs b/backend/src/InventoryFlow.Domain/Entities/WorkspaceMember.cs new file mode 100644 index 0000000..1cc2d37 --- /dev/null +++ b/backend/src/InventoryFlow.Domain/Entities/WorkspaceMember.cs @@ -0,0 +1,29 @@ +using InventoryFlow.Domain.Common; +using InventoryFlow.Domain.Exceptions; + +namespace InventoryFlow.Domain.Entities; + +/// Represents a user's membership in a workspace. +public sealed class WorkspaceMember : Entity +{ + /// Initializes a workspace member. + public WorkspaceMember(Guid id, Guid workspaceId, Guid userId, WorkspaceMemberRole role, DateTimeOffset createdAtUtc) : base(id) + { + if (id == Guid.Empty || workspaceId == Guid.Empty || userId == Guid.Empty) + throw new DomainException("Workspace member, workspace, and user identifiers are required."); + if (!Enum.IsDefined(role)) throw new DomainException("Workspace membership role is not supported."); + if (createdAtUtc.Offset != TimeSpan.Zero) throw new DomainException("Workspace membership creation time must be in UTC."); + WorkspaceId = workspaceId; + UserId = userId; + Role = role; + CreatedAtUtc = createdAtUtc; + } + /// Gets the workspace identifier. + public Guid WorkspaceId { get; private set; } + /// Gets the Identity user identifier. + public Guid UserId { get; private set; } + /// Gets the membership role. + public WorkspaceMemberRole Role { get; private set; } + /// Gets the UTC creation instant. + public DateTimeOffset CreatedAtUtc { get; private set; } +} diff --git a/backend/src/InventoryFlow.Domain/Entities/WorkspaceMemberRole.cs b/backend/src/InventoryFlow.Domain/Entities/WorkspaceMemberRole.cs new file mode 100644 index 0000000..5a6d125 --- /dev/null +++ b/backend/src/InventoryFlow.Domain/Entities/WorkspaceMemberRole.cs @@ -0,0 +1,11 @@ +namespace InventoryFlow.Domain.Entities; + +/// Defines the supported workspace membership roles. +public enum WorkspaceMemberRole +{ + /// Owns and administers a workspace. + Owner = 1, + + /// Can perform operational work in a workspace. + Member = 2, +} diff --git a/backend/src/InventoryFlow.Domain/Exceptions/DomainException.cs b/backend/src/InventoryFlow.Domain/Exceptions/DomainException.cs new file mode 100644 index 0000000..0e0ccd9 --- /dev/null +++ b/backend/src/InventoryFlow.Domain/Exceptions/DomainException.cs @@ -0,0 +1,26 @@ +namespace InventoryFlow.Domain.Exceptions; + +/// +/// Represents a violated domain invariant. +/// +public sealed class DomainException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + /// A description of the violated invariant. + public DomainException(string message) + : base(message) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// A description of the violated invariant. + /// The exception that caused this exception. + public DomainException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/backend/src/InventoryFlow.Domain/InventoryFlow.Domain.csproj b/backend/src/InventoryFlow.Domain/InventoryFlow.Domain.csproj new file mode 100644 index 0000000..125f4c9 --- /dev/null +++ b/backend/src/InventoryFlow.Domain/InventoryFlow.Domain.csproj @@ -0,0 +1,9 @@ + + + + net9.0 + enable + enable + + + diff --git a/backend/src/InventoryFlow.Infrastructure/Authentication/IdentityAuthenticationService.cs b/backend/src/InventoryFlow.Infrastructure/Authentication/IdentityAuthenticationService.cs new file mode 100644 index 0000000..54db052 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Authentication/IdentityAuthenticationService.cs @@ -0,0 +1,205 @@ +using System.Data; +using InventoryFlow.Application.Features.Authentication; +using InventoryFlow.Domain.Entities; +using InventoryFlow.Infrastructure.Identity; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace InventoryFlow.Infrastructure.Authentication; + +/// Implements identity, workspace provisioning, session issuance, and refresh rotation. +public sealed class IdentityAuthenticationService( + UserManager userManager, + SignInManager signInManager, + ApplicationDbContext dbContext, + JwtAccessTokenIssuer accessTokenIssuer, + RefreshTokenGenerator refreshTokenGenerator, + IOptions options, + TimeProvider timeProvider) : IAuthenticationService +{ + private readonly JwtOptions _options = options.Value; + + /// + public async Task RegisterAsync(RegisterUserCommand command, CancellationToken cancellationToken) + { + var strategy = dbContext.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => + { + await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + var now = timeProvider.GetUtcNow(); + var user = new ApplicationUser { Id = Guid.NewGuid(), UserName = command.Email.Trim(), Email = command.Email.Trim(), DisplayName = command.DisplayName.Trim() }; + var result = await userManager.CreateAsync(user, command.Password); + if (!result.Succeeded) throw new AuthenticationException(); + var workspace = new Workspace(Guid.NewGuid(), CreatePersonalWorkspaceName(user.DisplayName), now); + var membership = new WorkspaceMember(Guid.NewGuid(), workspace.Id, user.Id, WorkspaceMemberRole.Owner, now); + dbContext.AddRange(workspace, membership); + var activeWorkspace = ToWorkspace(workspace.Id, workspace.Name, membership.Role); + var session = CreateSession(user, activeWorkspace, [activeWorkspace], Guid.NewGuid(), now); + dbContext.RefreshTokens.Add(session.Token); + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return session.Session; + }); + } + + /// + public async Task LoginAsync(LoginUserCommand command, CancellationToken cancellationToken) + { + var user = await userManager.FindByEmailAsync(command.Email.Trim()); + if (user is null) return null; + var result = await signInManager.CheckPasswordSignInAsync(user, command.Password, lockoutOnFailure: true); + if (!result.Succeeded) return null; + return await IssueSessionAsync(user, Guid.NewGuid(), preferredWorkspaceId: null, cancellationToken); + } + + /// + public async Task RefreshAsync(string refreshToken, CancellationToken cancellationToken) + { + var hash = refreshTokenGenerator.Hash(refreshToken); + var strategy = dbContext.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => + { + var now = timeProvider.GetUtcNow(); + await using var transaction = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken); + var token = await dbContext.RefreshTokens.SingleOrDefaultAsync(item => item.TokenHash == hash, cancellationToken); + if (token is null) { await transaction.CommitAsync(cancellationToken); return null; } + if (!token.IsActive(now)) + { + await RevokeFamilyAsync(token.FamilyId, now, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return null; + } + token.Revoke(now); + var user = await userManager.FindByIdAsync(token.UserId.ToString()); + var session = user is null ? null : await CreateSessionForWorkspaceAsync(user, token.WorkspaceId, token.FamilyId, now, cancellationToken); + if (session is null) + { + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return null; + } + dbContext.RefreshTokens.Add(session.Value.Token); + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return session.Value.Session; + }); + } + + /// + public async Task SwitchWorkspaceAsync(Guid userId, Guid workspaceId, string refreshToken, CancellationToken cancellationToken) + { + var hash = refreshTokenGenerator.Hash(refreshToken); + var strategy = dbContext.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => + { + var now = timeProvider.GetUtcNow(); + await using var transaction = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken); + var token = await dbContext.RefreshTokens.SingleOrDefaultAsync(item => item.TokenHash == hash, cancellationToken); + if (token is null || token.UserId != userId) { await transaction.CommitAsync(cancellationToken); return null; } + if (!token.IsActive(now)) + { + await RevokeFamilyAsync(token.FamilyId, now, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return null; + } + + var user = await userManager.FindByIdAsync(userId.ToString()); + var session = user is null ? null : await CreateSessionForWorkspaceAsync(user, workspaceId, token.FamilyId, now, cancellationToken); + if (session is null) + { + await transaction.CommitAsync(cancellationToken); + return null; + } + + token.Revoke(now); + dbContext.RefreshTokens.Add(session.Value.Token); + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return session.Value.Session; + }); + } + + /// + public async Task LogoutAsync(string? refreshToken, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(refreshToken)) return; + var hash = refreshTokenGenerator.Hash(refreshToken); + var strategy = dbContext.Database.CreateExecutionStrategy(); + await strategy.ExecuteAsync(async () => + { + var now = timeProvider.GetUtcNow(); + await using var transaction = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken); + var token = await dbContext.RefreshTokens.SingleOrDefaultAsync(item => item.TokenHash == hash, cancellationToken); + if (token is not null) { await RevokeFamilyAsync(token.FamilyId, now, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); } + await transaction.CommitAsync(cancellationToken); + }); + } + + /// + public async Task GetUserAsync(Guid userId, Guid workspaceId, CancellationToken cancellationToken) + { + var user = await userManager.FindByIdAsync(userId.ToString()); + if (user is null) return null; + var workspaces = await GetWorkspacesAsync(user.Id, cancellationToken); + var active = workspaces.SingleOrDefault(workspace => workspace.Id == workspaceId); + return active is null ? null : ToUser(user, active, workspaces); + } + + private async Task IssueSessionAsync(ApplicationUser user, Guid familyId, Guid? preferredWorkspaceId, CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + var workspaces = await GetWorkspacesAsync(user.Id, cancellationToken); + var active = preferredWorkspaceId is null + ? workspaces.FirstOrDefault() + : workspaces.SingleOrDefault(workspace => workspace.Id == preferredWorkspaceId.Value); + if (active is null) return null; + var created = CreateSession(user, active, workspaces, familyId, now); + dbContext.RefreshTokens.Add(created.Token); + await dbContext.SaveChangesAsync(cancellationToken); + return created.Session; + } + + private async Task<(AuthenticationSession Session, RefreshToken Token)?> CreateSessionForWorkspaceAsync(ApplicationUser user, Guid workspaceId, Guid familyId, DateTimeOffset now, CancellationToken cancellationToken) + { + var workspaces = await GetWorkspacesAsync(user.Id, cancellationToken); + var active = workspaces.SingleOrDefault(workspace => workspace.Id == workspaceId); + return active is null ? null : CreateSession(user, active, workspaces, familyId, now); + } + + private async Task> GetWorkspacesAsync(Guid userId, CancellationToken cancellationToken) + { + return await (from member in dbContext.WorkspaceMembers.AsNoTracking() + join workspace in dbContext.Workspaces.AsNoTracking() on member.WorkspaceId equals workspace.Id + where member.UserId == userId + orderby member.Role == WorkspaceMemberRole.Owner descending, member.CreatedAtUtc, workspace.Name + select new AuthenticatedWorkspace(workspace.Id, workspace.Name, member.Role.ToString())).ToListAsync(cancellationToken); + } + + private (AuthenticationSession Session, RefreshToken Token) CreateSession(ApplicationUser user, AuthenticatedWorkspace activeWorkspace, IReadOnlyCollection workspaces, Guid familyId, DateTimeOffset now) + { + var refreshValue = refreshTokenGenerator.Create(); + var token = new RefreshToken(Guid.NewGuid(), user.Id, familyId, activeWorkspace.Id, refreshTokenGenerator.Hash(refreshValue), now.AddDays(_options.RefreshTokenLifetimeDays)); + var authenticatedUser = ToUser(user, activeWorkspace, workspaces); + var access = accessTokenIssuer.Issue(authenticatedUser); + return (new AuthenticationSession(new AuthenticationResponse(access.Token, access.ExpiresAtUtc, authenticatedUser), refreshValue), token); + } + + private async Task RevokeFamilyAsync(Guid familyId, DateTimeOffset now, CancellationToken cancellationToken) + { + var activeTokens = await dbContext.RefreshTokens.Where(item => item.FamilyId == familyId && item.RevokedAtUtc == null && item.ExpiresAtUtc > now).ToListAsync(cancellationToken); + foreach (var token in activeTokens) token.Revoke(now); + } + + private static string CreatePersonalWorkspaceName(string displayName) + { + const string suffix = "'s workspace"; + return string.Concat(displayName.AsSpan(0, Math.Min(displayName.Length, Workspace.NameMaxLength - suffix.Length)), suffix); + } + + private static AuthenticatedWorkspace ToWorkspace(Guid id, string name, WorkspaceMemberRole role) => new(id, name, role.ToString()); + private static AuthenticatedUser ToUser(ApplicationUser user, AuthenticatedWorkspace activeWorkspace, IReadOnlyCollection workspaces) => new(user.Id, user.Email ?? string.Empty, user.DisplayName, activeWorkspace, workspaces); +} diff --git a/backend/src/InventoryFlow.Infrastructure/Authentication/JwtAccessTokenIssuer.cs b/backend/src/InventoryFlow.Infrastructure/Authentication/JwtAccessTokenIssuer.cs new file mode 100644 index 0000000..97aeb67 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Authentication/JwtAccessTokenIssuer.cs @@ -0,0 +1,41 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using InventoryFlow.Application.Features.Authentication; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace InventoryFlow.Infrastructure.Authentication; + +/// Issues short-lived signed access tokens. +public sealed class JwtAccessTokenIssuer(IOptions options, TimeProvider timeProvider) +{ + /// Claim type that carries the active workspace identifier. + public const string WorkspaceIdClaimType = "workspace_id"; + + /// + /// Claim type that carries the active workspace role. This is a NON-AUTHORITATIVE UI hint only; + /// never use it for access control. The effective role is always revalidated from the database + /// membership row per request by CurrentWorkspaceResolver. + /// + public const string WorkspaceRoleClaimType = "workspace_role"; + + private readonly JwtOptions _options = options.Value; + /// Creates an access token for a user. + public (string Token, DateTimeOffset ExpiresAtUtc) Issue(AuthenticatedUser user) + { + var expiresAtUtc = timeProvider.GetUtcNow().AddMinutes(_options.AccessTokenLifetimeMinutes); + var credentials = new SigningCredentials(new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey)), SecurityAlgorithms.HmacSha256); + var claims = new[] + { + new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Email, user.Email), + new Claim("display_name", user.DisplayName), + new Claim(WorkspaceIdClaimType, user.Workspace.Id.ToString()), + new Claim(WorkspaceRoleClaimType, user.Workspace.Role) + }; + var token = new JwtSecurityToken(_options.Issuer, _options.Audience, claims, notBefore: timeProvider.GetUtcNow().UtcDateTime, expires: expiresAtUtc.UtcDateTime, signingCredentials: credentials); + return (new JwtSecurityTokenHandler().WriteToken(token), expiresAtUtc); + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Authentication/JwtOptions.cs b/backend/src/InventoryFlow.Infrastructure/Authentication/JwtOptions.cs new file mode 100644 index 0000000..71ee2ef --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Authentication/JwtOptions.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; + +namespace InventoryFlow.Infrastructure.Authentication; + +/// Contains JWT and refresh-session settings. +public sealed class JwtOptions +{ + /// Configuration section name. + public const string SectionName = "Jwt"; + /// Gets or sets the trusted token issuer. + [Required] public string Issuer { get; set; } = string.Empty; + /// Gets or sets the intended token audience. + [Required] public string Audience { get; set; } = string.Empty; + /// Gets or sets the secret signing key, supplied outside tracked configuration. + [Required, MinLength(32)] public string SigningKey { get; set; } = string.Empty; + /// Gets or sets access-token lifetime in minutes. + [Range(1, 60)] public int AccessTokenLifetimeMinutes { get; set; } = 10; + /// Gets or sets refresh-token lifetime in days. + [Range(1, 30)] public int RefreshTokenLifetimeDays { get; set; } = 7; + /// Gets or sets the browser refresh cookie name. + [Required] public string RefreshCookieName { get; set; } = "inventory_flow_refresh"; +} diff --git a/backend/src/InventoryFlow.Infrastructure/Authentication/RefreshTokenGenerator.cs b/backend/src/InventoryFlow.Infrastructure/Authentication/RefreshTokenGenerator.cs new file mode 100644 index 0000000..89dbb65 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Authentication/RefreshTokenGenerator.cs @@ -0,0 +1,12 @@ +using System.Security.Cryptography; + +namespace InventoryFlow.Infrastructure.Authentication; + +/// Generates opaque refresh values and their one-way hashes. +public sealed class RefreshTokenGenerator +{ + /// Generates a 256-bit URL-safe opaque value. + public string Create() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + /// Hashes an opaque value for persistence. + public string Hash(string token) => Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token))); +} diff --git a/backend/src/InventoryFlow.Infrastructure/Collaboration/EfCollaborationService.cs b/backend/src/InventoryFlow.Infrastructure/Collaboration/EfCollaborationService.cs new file mode 100644 index 0000000..56cccee --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Collaboration/EfCollaborationService.cs @@ -0,0 +1,149 @@ +using System.Data; +using InventoryFlow.Application.Features.Collaboration; +using InventoryFlow.Domain.Entities; +using InventoryFlow.Domain.Exceptions; +using InventoryFlow.Infrastructure.Authentication; +using InventoryFlow.Infrastructure.Identity; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace InventoryFlow.Infrastructure.Collaboration; + +/// Implements workspace collaboration operations with EF Core. +public sealed class EfCollaborationService( + ApplicationDbContext dbContext, + UserManager userManager, + RefreshTokenGenerator tokenGenerator, + TimeProvider timeProvider) : ICollaborationService +{ + private static readonly TimeSpan InvitationLifetime = TimeSpan.FromDays(7); + + /// + public async Task> ListMembersAsync(ListWorkspaceMembersQuery query, CancellationToken cancellationToken) + { + EnsureOwner(query.CurrentUserRole); + return await (from member in dbContext.WorkspaceMembers.AsNoTracking() + join user in dbContext.Users.AsNoTracking() on member.UserId equals user.Id + where member.WorkspaceId == query.WorkspaceId + orderby member.Role == WorkspaceMemberRole.Owner descending, user.DisplayName + select new WorkspaceMemberResponse(user.Id, user.Email ?? string.Empty, user.DisplayName, member.Role.ToString(), member.CreatedAtUtc)).ToListAsync(cancellationToken); + } + + /// + public async Task> ListInvitationsAsync(ListWorkspaceInvitationsQuery query, CancellationToken cancellationToken) + { + EnsureOwner(query.CurrentUserRole); + return await dbContext.WorkspaceInvitations.AsNoTracking() + .Where(invitation => invitation.WorkspaceId == query.WorkspaceId) + .OrderByDescending(invitation => invitation.CreatedAtUtc) + .Select(invitation => ToResponse(invitation)) + .ToListAsync(cancellationToken); + } + + /// + public async Task CreateInvitationAsync(CreateWorkspaceInvitationCommand command, CancellationToken cancellationToken) + { + EnsureOwner(command.CurrentUserRole); + var email = command.Email?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(email)) throw new DomainException("Invitation email is required."); + var normalizedEmail = userManager.NormalizeEmail(email); + var invitedUser = await userManager.FindByEmailAsync(email); + if (invitedUser is null) throw new DomainException("Invitation email must belong to an existing registered user."); + if (await dbContext.WorkspaceMembers.AsNoTracking().AnyAsync(member => member.WorkspaceId == command.WorkspaceId && member.UserId == invitedUser.Id, cancellationToken)) + throw new CollaborationConflictException("The invited user is already a workspace member."); + + var token = tokenGenerator.Create(); + var now = timeProvider.GetUtcNow(); + var invitation = new WorkspaceInvitation( + Guid.NewGuid(), + command.WorkspaceId, + normalizedEmail, + WorkspaceMemberRole.Member, + tokenGenerator.Hash(token), + now.Add(InvitationLifetime), + command.CreatedByUserId, + now); + dbContext.WorkspaceInvitations.Add(invitation); + try + { + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException exception) when (IsUniqueConstraint(exception)) + { + throw new CollaborationConflictException("A pending invitation already exists for this email address."); + } + return new CreatedWorkspaceInvitationResponse(ToResponse(invitation), token); + } + + /// + public async Task RevokeInvitationAsync(RevokeWorkspaceInvitationCommand command, CancellationToken cancellationToken) + { + EnsureOwner(command.CurrentUserRole); + var strategy = dbContext.Database.CreateExecutionStrategy(); + await strategy.ExecuteAsync(async () => + { + var now = timeProvider.GetUtcNow(); + await using var transaction = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken); + var invitation = await dbContext.WorkspaceInvitations.SingleOrDefaultAsync(item => item.Id == command.InvitationId && item.WorkspaceId == command.WorkspaceId, cancellationToken); + if (invitation is null) throw new DomainException("Invitation was not found."); + if (!invitation.IsPending(now)) throw new DomainException("Only pending invitations can be revoked."); + invitation.Revoke(now); + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + }); + } + + /// + public async Task AcceptInvitationAsync(AcceptWorkspaceInvitationCommand command, CancellationToken cancellationToken) + { + var hash = tokenGenerator.Hash(command.Token); + var strategy = dbContext.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => + { + var now = timeProvider.GetUtcNow(); + await using var transaction = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken); + var invitation = await dbContext.WorkspaceInvitations.SingleOrDefaultAsync(item => item.TokenHash == hash, cancellationToken); + if (invitation is null || !invitation.IsPending(now)) throw new DomainException("Invitation is invalid or expired."); + var user = await userManager.FindByIdAsync(command.UserId.ToString()); + if (user is null) throw new DomainException("Authenticated user was not found."); + var normalizedEmail = userManager.NormalizeEmail(user.Email ?? string.Empty); + if (!string.Equals(invitation.NormalizedEmail, normalizedEmail, StringComparison.Ordinal)) + throw new DomainException("Invitation email does not match the authenticated user."); + if (await dbContext.WorkspaceMembers.AnyAsync(member => member.WorkspaceId == invitation.WorkspaceId && member.UserId == command.UserId, cancellationToken)) + throw new CollaborationConflictException("The authenticated user is already a workspace member."); + + dbContext.WorkspaceMembers.Add(new WorkspaceMember(Guid.NewGuid(), invitation.WorkspaceId, command.UserId, WorkspaceMemberRole.Member, now)); + invitation.Accept(command.UserId, now); + try + { + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + catch (DbUpdateException exception) when (IsUniqueConstraint(exception)) + { + throw new CollaborationConflictException("The authenticated user is already a workspace member."); + } + return ToResponse(invitation); + }); + } + + private static void EnsureOwner(WorkspaceMemberRole role) + { + if (role != WorkspaceMemberRole.Owner) throw new UnauthorizedAccessException("Owner role is required."); + } + + private static WorkspaceInvitationResponse ToResponse(WorkspaceInvitation invitation) => new( + invitation.Id, + invitation.NormalizedEmail, + invitation.Role.ToString(), + invitation.ExpiresAtUtc, + invitation.CreatedAtUtc, + invitation.CreatedByUserId, + invitation.AcceptedAtUtc, + invitation.AcceptedByUserId, + invitation.RevokedAtUtc); + + private static bool IsUniqueConstraint(DbUpdateException exception) => exception.InnerException?.Message.Contains("duplicate", StringComparison.OrdinalIgnoreCase) == true + || exception.InnerException?.Message.Contains("unique", StringComparison.OrdinalIgnoreCase) == true; +} diff --git a/backend/src/InventoryFlow.Infrastructure/DependencyInjection/InfrastructureServiceCollectionExtensions.cs b/backend/src/InventoryFlow.Infrastructure/DependencyInjection/InfrastructureServiceCollectionExtensions.cs new file mode 100644 index 0000000..aca9198 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/DependencyInjection/InfrastructureServiceCollectionExtensions.cs @@ -0,0 +1,123 @@ +using System.Text; +using InventoryFlow.Application.Features.Authentication; +using InventoryFlow.Application.Features.Products; +using InventoryFlow.Application.Features.Collaboration; +using InventoryFlow.Infrastructure.Authentication; +using InventoryFlow.Infrastructure.Collaboration; +using InventoryFlow.Infrastructure.Identity; +using InventoryFlow.Application.Features.Warehouses; +using InventoryFlow.Infrastructure.Warehouses; +using InventoryFlow.Application.Features.Inventory; +using InventoryFlow.Infrastructure.Inventory; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using Microsoft.Extensions.Options; +using InventoryFlow.Infrastructure.Persistence; +using InventoryFlow.Infrastructure.Products; +using InventoryFlow.Infrastructure.Tenancy; +using InventoryFlow.Application.Common.Tenancy; +using InventoryFlow.Application.Features.Suppliers; +using InventoryFlow.Infrastructure.Suppliers; +using InventoryFlow.Application.Features.Purchases; +using InventoryFlow.Infrastructure.Purchases; +using InventoryFlow.Application.Features.Sales; +using InventoryFlow.Infrastructure.Sales; +using InventoryFlow.Application.Features.Transfers; +using InventoryFlow.Infrastructure.Transfers; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace InventoryFlow.Infrastructure.DependencyInjection; + +/// +/// Registers infrastructure-layer services. +/// +public static class InfrastructureServiceCollectionExtensions +{ + /// + /// Adds SQL Server persistence and ASP.NET Core Identity services. + /// + /// The dependency-injection service collection. + /// The application configuration. + /// The supplied instance. + /// Thrown when the database connection string is missing. + public static IServiceCollection AddInfrastructure( + this IServiceCollection services, + IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + var connectionString = configuration.GetConnectionString("InventoryFlowDatabase"); + ArgumentException.ThrowIfNullOrWhiteSpace( + connectionString, + "ConnectionStrings:InventoryFlowDatabase"); + + services.AddOptions() + .Bind(configuration.GetSection(JwtOptions.SectionName)) + .ValidateDataAnnotations() + .Validate(options => Encoding.UTF8.GetByteCount(options.SigningKey) >= 32, "Jwt signing key must be at least 32 bytes.") + .ValidateOnStart(); + var jwt = configuration.GetSection(JwtOptions.SectionName).Get() ?? new JwtOptions(); + + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = jwt.Issuer, + ValidateAudience = true, + ValidAudience = jwt.Audience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.SigningKey)), + ValidateLifetime = true, + ClockSkew = TimeSpan.Zero, + NameClaimType = "display_name" + }; + }); + services.AddHttpContextAccessor(); + services.AddSingleton(TimeProvider.System); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddDbContext(options => options.UseSqlServer( + connectionString, + sqlServerOptions => sqlServerOptions.EnableRetryOnFailure())); + services.AddDataProtection() + .SetApplicationName("InventoryFlow") + .PersistKeysToDbContext(); + + services.AddIdentityCore(options => + { + options.User.RequireUniqueEmail = true; + options.Password.RequiredLength = 12; + options.Password.RequireDigit = true; + options.Password.RequireLowercase = true; + options.Password.RequireUppercase = true; + options.Password.RequireNonAlphanumeric = true; + options.Lockout.AllowedForNewUsers = true; + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); + options.Lockout.MaxFailedAccessAttempts = 5; + }) + .AddRoles>() + .AddEntityFrameworkStores() + .AddSignInManager() + .AddDefaultTokenProviders(); + + return services; + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Identity/ApplicationUser.cs b/backend/src/InventoryFlow.Infrastructure/Identity/ApplicationUser.cs new file mode 100644 index 0000000..413c916 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Identity/ApplicationUser.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Identity; + +namespace InventoryFlow.Infrastructure.Identity; + +/// +/// Represents an authenticated Inventory Flow user. +/// +public sealed class ApplicationUser : IdentityUser +{ + /// + /// Gets or sets the user's display name. + /// + public string DisplayName { get; set; } = string.Empty; +} diff --git a/backend/src/InventoryFlow.Infrastructure/Inventory/EfInventoryLedger.cs b/backend/src/InventoryFlow.Infrastructure/Inventory/EfInventoryLedger.cs new file mode 100644 index 0000000..7ed7aab --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Inventory/EfInventoryLedger.cs @@ -0,0 +1,38 @@ +using System.Data; +using InventoryFlow.Application.Features.Inventory; +using InventoryFlow.Domain.Entities; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace InventoryFlow.Infrastructure.Inventory; + +/// Persists inventory movements and materialized balances atomically. +public sealed class EfInventoryLedger(ApplicationDbContext dbContext) : IInventoryLedger +{ + public async Task RecordAsync(Guid workspaceId, Guid warehouseId, Guid productId, InventoryMovementType type, + decimal quantity, string idempotencyKey, DateTimeOffset occurredAtUtc, CancellationToken cancellationToken) + { + quantity = InventoryMovement.ValidateQuantity(quantity); + idempotencyKey = InventoryMovement.NormalizeIdempotencyKey(idempotencyKey); + var strategy = dbContext.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => + { + await using var transaction = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken); + var movement = await new InventoryLedgerWriter(dbContext).RecordAsync(workspaceId, warehouseId, productId, type, quantity, + idempotencyKey, occurredAtUtc, Guid.NewGuid(), cancellationToken); + if (movement is not null && dbContext.Entry(movement).State == EntityState.Added) + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return movement; + }); + } + + public async Task> ListBalancesAsync(Guid workspaceId, Guid? warehouseId, Guid? productId, + CancellationToken cancellationToken) + { + var query = dbContext.InventoryBalances.AsNoTracking().Where(balance => balance.WorkspaceId == workspaceId); + if (warehouseId.HasValue) query = query.Where(balance => balance.WarehouseId == warehouseId.Value); + if (productId.HasValue) query = query.Where(balance => balance.ProductId == productId.Value); + return await query.OrderBy(balance => balance.WarehouseId).ThenBy(balance => balance.ProductId).ToListAsync(cancellationToken); + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Inventory/InventoryLedgerWriter.cs b/backend/src/InventoryFlow.Infrastructure/Inventory/InventoryLedgerWriter.cs new file mode 100644 index 0000000..1cec13d --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Inventory/InventoryLedgerWriter.cs @@ -0,0 +1,41 @@ +using InventoryFlow.Domain.Entities; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace InventoryFlow.Infrastructure.Inventory; + +/// Shared transaction-aware inventory balance and movement writer. +internal sealed class InventoryLedgerWriter(ApplicationDbContext dbContext) +{ + internal async Task RecordAsync(Guid workspaceId, Guid warehouseId, Guid productId, InventoryMovementType type, + decimal quantity, string idempotencyKey, DateTimeOffset occurredAtUtc, Guid movementId, CancellationToken cancellationToken) + { + quantity = InventoryMovement.ValidateQuantity(quantity); + idempotencyKey = InventoryMovement.NormalizeIdempotencyKey(idempotencyKey); + if (!Enum.IsDefined(type)) throw new ArgumentOutOfRangeException(nameof(type)); + + var existing = await dbContext.InventoryMovements.SingleOrDefaultAsync(movement => movement.WorkspaceId == workspaceId && + movement.IdempotencyKey == idempotencyKey, cancellationToken); + if (existing is not null) return existing; + + var warehouseExists = await dbContext.Warehouses.AnyAsync(warehouse => warehouse.Id == warehouseId && + warehouse.WorkspaceId == workspaceId && warehouse.ArchivedAtUtc == null, cancellationToken); + var productExists = await dbContext.Products.AnyAsync(product => product.Id == productId && + product.WorkspaceId == workspaceId && product.ArchivedAtUtc == null, cancellationToken); + if (!warehouseExists || !productExists) return null; + + var balance = await dbContext.InventoryBalances.SingleOrDefaultAsync(item => item.WorkspaceId == workspaceId && + item.WarehouseId == warehouseId && item.ProductId == productId, cancellationToken); + if (balance is null) + { + balance = new InventoryBalance(workspaceId, warehouseId, productId, 0m); + dbContext.InventoryBalances.Add(balance); + } + + balance.Apply(type == InventoryMovementType.Receipt ? quantity : -quantity); + var movement = new InventoryMovement(movementId, workspaceId, warehouseId, productId, type, quantity, idempotencyKey, + balance.Quantity, occurredAtUtc); + dbContext.InventoryMovements.Add(movement); + return movement; + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/InventoryFlow.Infrastructure.csproj b/backend/src/InventoryFlow.Infrastructure/InventoryFlow.Infrastructure.csproj new file mode 100644 index 0000000..582f863 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/InventoryFlow.Infrastructure.csproj @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/ApplicationDbContext.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/ApplicationDbContext.cs new file mode 100644 index 0000000..9f82019 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/ApplicationDbContext.cs @@ -0,0 +1,66 @@ +using InventoryFlow.Domain.Entities; +using InventoryFlow.Infrastructure.Identity; +using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace InventoryFlow.Infrastructure.Persistence; + +/// +/// Provides SQL Server persistence for Identity and Inventory Flow data. +/// +/// The options used to configure the context. +public sealed class ApplicationDbContext( + DbContextOptions options) + : IdentityDbContext, Guid>(options), IDataProtectionKeyContext +{ + /// + /// Gets the issued refresh tokens. + /// + public DbSet RefreshTokens => Set(); + public DbSet Warehouses => Set(); + + /// Gets workspace-scoped suppliers. + public DbSet Suppliers => Set(); + + /// Gets immutable inventory ledger movements. + public DbSet InventoryMovements => Set(); + + /// Gets immutable supplier-linked purchase receipts. + public DbSet PurchaseReceipts => Set(); + + /// Gets immutable sales fulfillments. + public DbSet SalesFulfillments => Set(); + + /// Gets immutable warehouse transfers. + public DbSet WarehouseTransfers => Set(); + + /// Gets current inventory balances. + public DbSet InventoryBalances => Set(); + + /// Gets workspaces. + public DbSet Workspaces => Set(); + + /// Gets workspace memberships. + public DbSet WorkspaceMembers => Set(); + + /// Gets workspace invitations. + public DbSet WorkspaceInvitations => Set(); + + /// Gets workspace-scoped products. + public DbSet Products => Set(); + + /// + /// Gets the shared data-protection keys used by Identity token providers. + /// + public DbSet DataProtectionKeys => Set(); + + /// + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + builder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly); + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/ApplicationDbContextFactory.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/ApplicationDbContextFactory.cs new file mode 100644 index 0000000..9a40076 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/ApplicationDbContextFactory.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace InventoryFlow.Infrastructure.Persistence; + +/// +/// Creates instances for EF Core design-time operations. +/// +public sealed class ApplicationDbContextFactory : IDesignTimeDbContextFactory +{ + /// + /// Creates a context using the database connection string supplied through the environment. + /// + /// Unused command-line arguments supplied by the EF Core tools. + /// A configured database context. + /// Thrown when the database connection string is missing. + public ApplicationDbContext CreateDbContext(string[] args) + { + ArgumentNullException.ThrowIfNull(args); + + var connectionString = Environment.GetEnvironmentVariable( + "ConnectionStrings__InventoryFlowDatabase"); + ArgumentException.ThrowIfNullOrWhiteSpace( + connectionString, + "ConnectionStrings__InventoryFlowDatabase"); + + var options = new DbContextOptionsBuilder() + .UseSqlServer(connectionString) + .Options; + + return new ApplicationDbContext(options); + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/ApplicationUserConfiguration.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/ApplicationUserConfiguration.cs new file mode 100644 index 0000000..366d47f --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/ApplicationUserConfiguration.cs @@ -0,0 +1,19 @@ +using InventoryFlow.Infrastructure.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace InventoryFlow.Infrastructure.Persistence.Configurations; + +/// +/// Configures persistence for entities. +/// +public sealed class ApplicationUserConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.Property(user => user.DisplayName) + .HasMaxLength(200) + .IsRequired(); + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/InventoryBalanceConfiguration.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/InventoryBalanceConfiguration.cs new file mode 100644 index 0000000..73eea2f --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/InventoryBalanceConfiguration.cs @@ -0,0 +1,21 @@ +using InventoryFlow.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace InventoryFlow.Infrastructure.Persistence.Configurations; + +/// Configures current warehouse-product inventory balances. +public sealed class InventoryBalanceConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("InventoryBalances"); + builder.HasKey(balance => new { balance.WorkspaceId, balance.WarehouseId, balance.ProductId }); + builder.Property(balance => balance.Quantity).HasPrecision(18, 4).IsRequired(); + builder.HasOne().WithMany().HasForeignKey(balance => balance.WorkspaceId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany().HasForeignKey(balance => balance.WarehouseId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(balance => balance.ProductId).OnDelete(DeleteBehavior.Restrict); + builder.HasIndex(balance => new { balance.WorkspaceId, balance.ProductId }); + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/InventoryMovementConfiguration.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/InventoryMovementConfiguration.cs new file mode 100644 index 0000000..dedee91 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/InventoryMovementConfiguration.cs @@ -0,0 +1,26 @@ +using InventoryFlow.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace InventoryFlow.Infrastructure.Persistence.Configurations; + +/// Configures immutable inventory ledger entries. +public sealed class InventoryMovementConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("InventoryMovements"); + builder.HasKey(movement => movement.Id); + builder.Property(movement => movement.Type).HasConversion().IsRequired(); + builder.Property(movement => movement.Quantity).HasPrecision(18, 4).IsRequired(); + builder.Property(movement => movement.BalanceAfterQuantity).HasPrecision(18, 4).IsRequired(); + builder.Property(movement => movement.IdempotencyKey).HasMaxLength(InventoryMovement.IdempotencyKeyMaxLength).IsRequired(); + builder.Property(movement => movement.OccurredAtUtc).IsRequired(); + builder.HasIndex(movement => new { movement.WorkspaceId, movement.IdempotencyKey }).IsUnique(); + builder.HasIndex(movement => new { movement.WorkspaceId, movement.WarehouseId, movement.ProductId, movement.OccurredAtUtc }); + builder.HasOne().WithMany().HasForeignKey(movement => movement.WorkspaceId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany().HasForeignKey(movement => movement.WarehouseId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(movement => movement.ProductId).OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/ProductConfiguration.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/ProductConfiguration.cs new file mode 100644 index 0000000..f1d382f --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/ProductConfiguration.cs @@ -0,0 +1,22 @@ +using InventoryFlow.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace InventoryFlow.Infrastructure.Persistence.Configurations; + +/// Configures product persistence. +public sealed class ProductConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Products"); + builder.HasKey(product => product.Id); + builder.Property(product => product.Name).HasMaxLength(Product.NameMaxLength).IsRequired(); + builder.Property(product => product.Sku).HasMaxLength(Product.SkuMaxLength).IsRequired(); + builder.Property(product => product.CreatedAtUtc).IsRequired(); + builder.HasIndex(product => new { product.WorkspaceId, product.Sku }).IsUnique(); + builder.HasIndex(product => new { product.WorkspaceId, product.ArchivedAtUtc, product.Name, product.Id }); + builder.HasOne().WithMany().HasForeignKey(product => product.WorkspaceId).OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/PurchaseReceiptConfiguration.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/PurchaseReceiptConfiguration.cs new file mode 100644 index 0000000..6cf7b83 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/PurchaseReceiptConfiguration.cs @@ -0,0 +1,25 @@ +using InventoryFlow.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace InventoryFlow.Infrastructure.Persistence.Configurations; + +public sealed class PurchaseReceiptConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("PurchaseReceipts"); + builder.HasKey(receipt => receipt.Id); + builder.Property(receipt => receipt.Quantity).HasPrecision(18, 4).IsRequired(); + builder.Property(receipt => receipt.IdempotencyKey).HasMaxLength(InventoryMovement.IdempotencyKeyMaxLength).IsRequired(); + builder.Property(receipt => receipt.ReceivedAtUtc).IsRequired(); + builder.HasIndex(receipt => new { receipt.WorkspaceId, receipt.IdempotencyKey }).IsUnique(); + builder.HasIndex(receipt => receipt.InventoryMovementId).IsUnique(); + builder.HasIndex(receipt => new { receipt.WorkspaceId, receipt.ReceivedAtUtc, receipt.Id }); + builder.HasOne().WithMany().HasForeignKey(receipt => receipt.WorkspaceId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany().HasForeignKey(receipt => receipt.SupplierId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(receipt => receipt.WarehouseId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(receipt => receipt.ProductId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(receipt => receipt.InventoryMovementId).OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs new file mode 100644 index 0000000..0a52c05 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs @@ -0,0 +1,51 @@ +using InventoryFlow.Domain.Entities; +using InventoryFlow.Infrastructure.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace InventoryFlow.Infrastructure.Persistence.Configurations; + +/// +/// Configures persistence for entities. +/// +public sealed class RefreshTokenConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("RefreshTokens"); + builder.HasKey(token => token.Id); + + builder.Property(token => token.FamilyId) + .IsRequired(); + + builder.Property(token => token.WorkspaceId) + .IsRequired(); + + builder.Property(token => token.TokenHash) + .HasMaxLength(128) + .IsRequired(); + + builder.Property(token => token.ExpiresAtUtc) + .IsRequired(); + + builder.HasIndex(token => token.TokenHash) + .IsUnique(); + + builder.HasIndex(token => new { token.UserId, token.ExpiresAtUtc }); + + builder.HasIndex(token => token.FamilyId); + + builder.HasIndex(token => new { token.UserId, token.WorkspaceId }); + + builder.HasOne() + .WithMany() + .HasForeignKey(token => token.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne() + .WithMany() + .HasForeignKey(token => token.WorkspaceId) + .OnDelete(DeleteBehavior.NoAction); + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/SalesFulfillmentConfiguration.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/SalesFulfillmentConfiguration.cs new file mode 100644 index 0000000..a90f254 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/SalesFulfillmentConfiguration.cs @@ -0,0 +1,24 @@ +using InventoryFlow.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace InventoryFlow.Infrastructure.Persistence.Configurations; + +public sealed class SalesFulfillmentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("SalesFulfillments"); + builder.HasKey(fulfillment => fulfillment.Id); + builder.Property(fulfillment => fulfillment.Quantity).HasPrecision(18, 4).IsRequired(); + builder.Property(fulfillment => fulfillment.IdempotencyKey).HasMaxLength(InventoryMovement.IdempotencyKeyMaxLength).IsRequired(); + builder.Property(fulfillment => fulfillment.FulfilledAtUtc).IsRequired(); + builder.HasIndex(fulfillment => new { fulfillment.WorkspaceId, fulfillment.IdempotencyKey }).IsUnique(); + builder.HasIndex(fulfillment => fulfillment.InventoryMovementId).IsUnique(); + builder.HasIndex(fulfillment => new { fulfillment.WorkspaceId, fulfillment.FulfilledAtUtc, fulfillment.Id }); + builder.HasOne().WithMany().HasForeignKey(fulfillment => fulfillment.WorkspaceId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany().HasForeignKey(fulfillment => fulfillment.WarehouseId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(fulfillment => fulfillment.ProductId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(fulfillment => fulfillment.InventoryMovementId).OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/SupplierConfiguration.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/SupplierConfiguration.cs new file mode 100644 index 0000000..7c72392 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/SupplierConfiguration.cs @@ -0,0 +1,21 @@ +using InventoryFlow.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace InventoryFlow.Infrastructure.Persistence.Configurations; + +/// Configures supplier persistence. +public sealed class SupplierConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Suppliers"); + builder.HasKey(supplier => supplier.Id); + builder.Property(supplier => supplier.Name).HasMaxLength(Supplier.NameMaxLength).IsRequired(); + builder.Property(supplier => supplier.CreatedAtUtc).IsRequired(); + builder.HasIndex(supplier => new { supplier.WorkspaceId, supplier.Name }).IsUnique(); + builder.HasIndex(supplier => new { supplier.WorkspaceId, supplier.ArchivedAtUtc, supplier.Name, supplier.Id }); + builder.HasOne().WithMany().HasForeignKey(supplier => supplier.WorkspaceId).OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WarehouseConfiguration.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WarehouseConfiguration.cs new file mode 100644 index 0000000..e178cf0 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WarehouseConfiguration.cs @@ -0,0 +1,4 @@ +using InventoryFlow.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +namespace InventoryFlow.Infrastructure.Persistence.Configurations; public sealed class WarehouseConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder b) { b.ToTable("Warehouses"); b.HasKey(x => x.Id); b.Property(x => x.Name).HasMaxLength(Warehouse.NameMaxLength).IsRequired(); b.HasIndex(x => new { x.WorkspaceId, x.Name }).IsUnique(); b.HasIndex(x => new { x.WorkspaceId, x.ArchivedAtUtc, x.Name }); b.HasOne().WithMany().HasForeignKey(x => x.WorkspaceId).OnDelete(DeleteBehavior.Cascade); } } diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WarehouseTransferConfiguration.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WarehouseTransferConfiguration.cs new file mode 100644 index 0000000..e7d54f4 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WarehouseTransferConfiguration.cs @@ -0,0 +1,27 @@ +using InventoryFlow.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace InventoryFlow.Infrastructure.Persistence.Configurations; + +public sealed class WarehouseTransferConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("WarehouseTransfers"); + builder.HasKey(transfer => transfer.Id); + builder.Property(transfer => transfer.Quantity).HasPrecision(18, 4).IsRequired(); + builder.Property(transfer => transfer.IdempotencyKey).HasMaxLength(InventoryMovement.IdempotencyKeyMaxLength).IsRequired(); + builder.Property(transfer => transfer.TransferredAtUtc).IsRequired(); + builder.HasIndex(transfer => new { transfer.WorkspaceId, transfer.IdempotencyKey }).IsUnique(); + builder.HasIndex(transfer => transfer.SourceInventoryMovementId).IsUnique(); + builder.HasIndex(transfer => transfer.DestinationInventoryMovementId).IsUnique(); + builder.HasIndex(transfer => new { transfer.WorkspaceId, transfer.TransferredAtUtc, transfer.Id }); + builder.HasOne().WithMany().HasForeignKey(transfer => transfer.WorkspaceId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany().HasForeignKey(transfer => transfer.SourceWarehouseId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(transfer => transfer.DestinationWarehouseId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(transfer => transfer.ProductId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(transfer => transfer.SourceInventoryMovementId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(transfer => transfer.DestinationInventoryMovementId).OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WorkspaceConfiguration.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WorkspaceConfiguration.cs new file mode 100644 index 0000000..30132b8 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WorkspaceConfiguration.cs @@ -0,0 +1,18 @@ +using InventoryFlow.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace InventoryFlow.Infrastructure.Persistence.Configurations; + +/// Configures workspace persistence. +public sealed class WorkspaceConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Workspaces"); + builder.HasKey(workspace => workspace.Id); + builder.Property(workspace => workspace.Name).HasMaxLength(Workspace.NameMaxLength).IsRequired(); + builder.Property(workspace => workspace.CreatedAtUtc).IsRequired(); + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WorkspaceInvitationConfiguration.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WorkspaceInvitationConfiguration.cs new file mode 100644 index 0000000..9a395f2 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WorkspaceInvitationConfiguration.cs @@ -0,0 +1,33 @@ +using InventoryFlow.Domain.Entities; +using InventoryFlow.Infrastructure.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace InventoryFlow.Infrastructure.Persistence.Configurations; + +/// Configures workspace invitation persistence. +public sealed class WorkspaceInvitationConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("WorkspaceInvitations"); + builder.HasKey(invitation => invitation.Id); + builder.Property(invitation => invitation.NormalizedEmail).HasMaxLength(WorkspaceInvitation.NormalizedEmailMaxLength).IsRequired(); + builder.Property(invitation => invitation.Role).HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(invitation => invitation.TokenHash).HasMaxLength(WorkspaceInvitation.TokenHashMaxLength).IsRequired(); + builder.Property(invitation => invitation.ExpiresAtUtc).IsRequired(); + builder.Property(invitation => invitation.CreatedAtUtc).IsRequired(); + builder.Property(invitation => invitation.AcceptedAtUtc); + builder.Property(invitation => invitation.AcceptedByUserId); + builder.Property(invitation => invitation.RevokedAtUtc); + builder.HasIndex(invitation => invitation.TokenHash).IsUnique(); + builder.HasIndex(invitation => new { invitation.WorkspaceId, invitation.NormalizedEmail }) + .IsUnique() + .HasFilter("[AcceptedAtUtc] IS NULL AND [RevokedAtUtc] IS NULL"); + builder.HasIndex(invitation => new { invitation.WorkspaceId, invitation.CreatedAtUtc }); + builder.HasOne().WithMany().HasForeignKey(invitation => invitation.WorkspaceId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany().HasForeignKey(invitation => invitation.CreatedByUserId).OnDelete(DeleteBehavior.NoAction); + builder.HasOne().WithMany().HasForeignKey(invitation => invitation.AcceptedByUserId).OnDelete(DeleteBehavior.NoAction); + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WorkspaceMemberConfiguration.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WorkspaceMemberConfiguration.cs new file mode 100644 index 0000000..9199757 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WorkspaceMemberConfiguration.cs @@ -0,0 +1,24 @@ +using InventoryFlow.Domain.Entities; +using InventoryFlow.Infrastructure.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace InventoryFlow.Infrastructure.Persistence.Configurations; + +/// Configures workspace membership persistence. +public sealed class WorkspaceMemberConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("WorkspaceMembers"); + builder.HasKey(member => member.Id); + builder.Property(member => member.Role).HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(member => member.CreatedAtUtc).IsRequired(); + builder.HasIndex(member => new { member.WorkspaceId, member.UserId }).IsUnique(); + builder.HasIndex(member => member.UserId); + builder.HasIndex(member => member.WorkspaceId); + builder.HasOne().WithMany().HasForeignKey(member => member.WorkspaceId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany().HasForeignKey(member => member.UserId).OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260717170006_InitialIdentity.Designer.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260717170006_InitialIdentity.Designer.cs new file mode 100644 index 0000000..a209407 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260717170006_InitialIdentity.Designer.cs @@ -0,0 +1,341 @@ +// +using System; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace InventoryFlow.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260717170006_InitialIdentity")] + partial class InitialIdentity + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("RevokedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId", "ExpiresAtUtc"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.RefreshToken", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260717170006_InitialIdentity.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260717170006_InitialIdentity.cs new file mode 100644 index 0000000..c0b20a8 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260717170006_InitialIdentity.cs @@ -0,0 +1,277 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace InventoryFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class InitialIdentity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + DisplayName = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + UserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "bit", nullable: false), + PasswordHash = table.Column(type: "nvarchar(max)", nullable: true), + SecurityStamp = table.Column(type: "nvarchar(max)", nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumber = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumberConfirmed = table.Column(type: "bit", nullable: false), + TwoFactorEnabled = table.Column(type: "bit", nullable: false), + LockoutEnd = table.Column(type: "datetimeoffset", nullable: true), + LockoutEnabled = table.Column(type: "bit", nullable: false), + AccessFailedCount = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "DataProtectionKeys", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + FriendlyName = table.Column(type: "nvarchar(max)", nullable: true), + Xml = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_DataProtectionKeys", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + RoleId = table.Column(type: "uniqueidentifier", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "uniqueidentifier", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), + ProviderKey = table.Column(type: "nvarchar(450)", nullable: false), + ProviderDisplayName = table.Column(type: "nvarchar(max)", nullable: true), + UserId = table.Column(type: "uniqueidentifier", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "uniqueidentifier", nullable: false), + RoleId = table.Column(type: "uniqueidentifier", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(type: "uniqueidentifier", nullable: false), + LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(450)", nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "RefreshTokens", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + UserId = table.Column(type: "uniqueidentifier", nullable: false), + TokenHash = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + ExpiresAtUtc = table.Column(type: "datetimeoffset", nullable: false), + RevokedAtUtc = table.Column(type: "datetimeoffset", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RefreshTokens", x => x.Id); + table.ForeignKey( + name: "FK_RefreshTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true, + filter: "[NormalizedName] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true, + filter: "[NormalizedUserName] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_TokenHash", + table: "RefreshTokens", + column: "TokenHash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_UserId_ExpiresAtUtc", + table: "RefreshTokens", + columns: new[] { "UserId", "ExpiresAtUtc" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "DataProtectionKeys"); + + migrationBuilder.DropTable( + name: "RefreshTokens"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + } + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718100649_AddRefreshTokenFamilies.Designer.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718100649_AddRefreshTokenFamilies.Designer.cs new file mode 100644 index 0000000..15de008 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718100649_AddRefreshTokenFamilies.Designer.cs @@ -0,0 +1,346 @@ +// +using System; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace InventoryFlow.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260718100649_AddRefreshTokenFamilies")] + partial class AddRefreshTokenFamilies + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("FamilyId") + .HasColumnType("uniqueidentifier"); + + b.Property("RevokedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId", "ExpiresAtUtc"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.RefreshToken", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718100649_AddRefreshTokenFamilies.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718100649_AddRefreshTokenFamilies.cs new file mode 100644 index 0000000..5c88ace --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718100649_AddRefreshTokenFamilies.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace InventoryFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddRefreshTokenFamilies : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FamilyId", + table: "RefreshTokens", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.Sql("UPDATE [RefreshTokens] SET [FamilyId] = NEWID() WHERE [FamilyId] IS NULL;"); + + migrationBuilder.AlterColumn( + name: "FamilyId", + table: "RefreshTokens", + type: "uniqueidentifier", + nullable: false, + oldClrType: typeof(Guid), + oldType: "uniqueidentifier", + oldNullable: true); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_FamilyId", + table: "RefreshTokens", + column: "FamilyId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_RefreshTokens_FamilyId", + table: "RefreshTokens"); + + migrationBuilder.DropColumn( + name: "FamilyId", + table: "RefreshTokens"); + } + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718105902_AddWorkspaces.Designer.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718105902_AddWorkspaces.Designer.cs new file mode 100644 index 0000000..ef3b6e4 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718105902_AddWorkspaces.Designer.cs @@ -0,0 +1,412 @@ +// +using System; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace InventoryFlow.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260718105902_AddWorkspaces")] + partial class AddWorkspaces + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("FamilyId") + .HasColumnType("uniqueidentifier"); + + b.Property("RevokedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId", "ExpiresAtUtc"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Workspace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.HasKey("Id"); + + b.ToTable("Workspaces", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WorkspaceMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "UserId") + .IsUnique(); + + b.ToTable("WorkspaceMembers", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.RefreshToken", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WorkspaceMember", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718105902_AddWorkspaces.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718105902_AddWorkspaces.cs new file mode 100644 index 0000000..0b99d67 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718105902_AddWorkspaces.cs @@ -0,0 +1,99 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace InventoryFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddWorkspaces : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Workspaces", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(120)", maxLength: 120, nullable: false), + CreatedAtUtc = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Workspaces", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "WorkspaceMembers", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + WorkspaceId = table.Column(type: "uniqueidentifier", nullable: false), + UserId = table.Column(type: "uniqueidentifier", nullable: false), + Role = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + CreatedAtUtc = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_WorkspaceMembers", x => x.Id); + table.ForeignKey( + name: "FK_WorkspaceMembers_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_WorkspaceMembers_Workspaces_WorkspaceId", + column: x => x.WorkspaceId, + principalTable: "Workspaces", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.Sql(""" + DECLARE @Backfill TABLE (UserId uniqueidentifier NOT NULL, WorkspaceId uniqueidentifier NOT NULL, MemberId uniqueidentifier NOT NULL); + + INSERT INTO @Backfill (UserId, WorkspaceId, MemberId) + SELECT [Id], NEWID(), NEWID() + FROM [AspNetUsers] AS [u] + WHERE NOT EXISTS (SELECT 1 FROM [WorkspaceMembers] AS [m] WHERE [m].[UserId] = [u].[Id]); + + INSERT INTO [Workspaces] ([Id], [Name], [CreatedAtUtc]) + SELECT [WorkspaceId], LEFT(CONCAT(COALESCE(NULLIF(LTRIM(RTRIM([u].[DisplayName])), ''), 'Personal'), ' workspace'), 120), SYSUTCDATETIME() + FROM @Backfill AS [b] + INNER JOIN [AspNetUsers] AS [u] ON [u].[Id] = [b].[UserId]; + + INSERT INTO [WorkspaceMembers] ([Id], [WorkspaceId], [UserId], [Role], [CreatedAtUtc]) + SELECT [MemberId], [WorkspaceId], [UserId], 'Owner', SYSUTCDATETIME() + FROM @Backfill; + """); + + migrationBuilder.CreateIndex( + name: "IX_WorkspaceMembers_UserId", + table: "WorkspaceMembers", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_WorkspaceMembers_WorkspaceId", + table: "WorkspaceMembers", + column: "WorkspaceId"); + + migrationBuilder.CreateIndex( + name: "IX_WorkspaceMembers_WorkspaceId_UserId", + table: "WorkspaceMembers", + columns: new[] { "WorkspaceId", "UserId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "WorkspaceMembers"); + + migrationBuilder.DropTable( + name: "Workspaces"); + } + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718112223_AddProducts.Designer.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718112223_AddProducts.Designer.cs new file mode 100644 index 0000000..8bdc4c9 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718112223_AddProducts.Designer.cs @@ -0,0 +1,456 @@ +// +using System; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace InventoryFlow.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260718112223_AddProducts")] + partial class AddProducts + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ArchivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId", "Sku") + .IsUnique(); + + b.HasIndex("WorkspaceId", "ArchivedAtUtc", "Name", "Id"); + + b.ToTable("Products", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("FamilyId") + .HasColumnType("uniqueidentifier"); + + b.Property("RevokedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId", "ExpiresAtUtc"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Workspace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.HasKey("Id"); + + b.ToTable("Workspaces", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WorkspaceMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "UserId") + .IsUnique(); + + b.ToTable("WorkspaceMembers", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Product", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.RefreshToken", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WorkspaceMember", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718112223_AddProducts.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718112223_AddProducts.cs new file mode 100644 index 0000000..cf02c70 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718112223_AddProducts.cs @@ -0,0 +1,55 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace InventoryFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddProducts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Products", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + WorkspaceId = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + Sku = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + CreatedAtUtc = table.Column(type: "datetimeoffset", nullable: false), + ArchivedAtUtc = table.Column(type: "datetimeoffset", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Products", x => x.Id); + table.ForeignKey( + name: "FK_Products_Workspaces_WorkspaceId", + column: x => x.WorkspaceId, + principalTable: "Workspaces", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Products_WorkspaceId_ArchivedAtUtc_Name_Id", + table: "Products", + columns: new[] { "WorkspaceId", "ArchivedAtUtc", "Name", "Id" }); + + migrationBuilder.CreateIndex( + name: "IX_Products_WorkspaceId_Sku", + table: "Products", + columns: new[] { "WorkspaceId", "Sku" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Products"); + } + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718115041_AddWarehouses.Designer.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718115041_AddWarehouses.Designer.cs new file mode 100644 index 0000000..22e6cbc --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718115041_AddWarehouses.Designer.cs @@ -0,0 +1,495 @@ +// +using System; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace InventoryFlow.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260718115041_AddWarehouses")] + partial class AddWarehouses + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ArchivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId", "Sku") + .IsUnique(); + + b.HasIndex("WorkspaceId", "ArchivedAtUtc", "Name", "Id"); + + b.ToTable("Products", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("FamilyId") + .HasColumnType("uniqueidentifier"); + + b.Property("RevokedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId", "ExpiresAtUtc"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Warehouse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ArchivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId", "Name") + .IsUnique(); + + b.HasIndex("WorkspaceId", "ArchivedAtUtc", "Name"); + + b.ToTable("Warehouses", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Workspace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.HasKey("Id"); + + b.ToTable("Workspaces", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WorkspaceMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "UserId") + .IsUnique(); + + b.ToTable("WorkspaceMembers", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Product", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.RefreshToken", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Warehouse", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WorkspaceMember", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718115041_AddWarehouses.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718115041_AddWarehouses.cs new file mode 100644 index 0000000..0467262 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718115041_AddWarehouses.cs @@ -0,0 +1,54 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace InventoryFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddWarehouses : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Warehouses", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + WorkspaceId = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + CreatedAtUtc = table.Column(type: "datetimeoffset", nullable: false), + ArchivedAtUtc = table.Column(type: "datetimeoffset", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Warehouses", x => x.Id); + table.ForeignKey( + name: "FK_Warehouses_Workspaces_WorkspaceId", + column: x => x.WorkspaceId, + principalTable: "Workspaces", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Warehouses_WorkspaceId_ArchivedAtUtc_Name", + table: "Warehouses", + columns: new[] { "WorkspaceId", "ArchivedAtUtc", "Name" }); + + migrationBuilder.CreateIndex( + name: "IX_Warehouses_WorkspaceId_Name", + table: "Warehouses", + columns: new[] { "WorkspaceId", "Name" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Warehouses"); + } + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718122029_AddInventoryLedger.Designer.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718122029_AddInventoryLedger.Designer.cs new file mode 100644 index 0000000..955ac62 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718122029_AddInventoryLedger.Designer.cs @@ -0,0 +1,609 @@ +// +using System; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace InventoryFlow.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260718122029_AddInventoryLedger")] + partial class AddInventoryLedger + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryBalance", b => + { + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.Property("WarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("ProductId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.HasKey("WorkspaceId", "WarehouseId", "ProductId"); + + b.HasIndex("ProductId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("InventoryBalances", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BalanceAfterQuantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("ProductId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("WarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("WorkspaceId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("WorkspaceId", "WarehouseId", "ProductId", "OccurredAtUtc"); + + b.ToTable("InventoryMovements", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ArchivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId", "Sku") + .IsUnique(); + + b.HasIndex("WorkspaceId", "ArchivedAtUtc", "Name", "Id"); + + b.ToTable("Products", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("FamilyId") + .HasColumnType("uniqueidentifier"); + + b.Property("RevokedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId", "ExpiresAtUtc"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Warehouse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ArchivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId", "Name") + .IsUnique(); + + b.HasIndex("WorkspaceId", "ArchivedAtUtc", "Name"); + + b.ToTable("Warehouses", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Workspace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.HasKey("Id"); + + b.ToTable("Workspaces", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WorkspaceMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "UserId") + .IsUnique(); + + b.ToTable("WorkspaceMembers", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryBalance", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryMovement", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Product", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.RefreshToken", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Warehouse", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WorkspaceMember", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718122029_AddInventoryLedger.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718122029_AddInventoryLedger.cs new file mode 100644 index 0000000..fb489dc --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718122029_AddInventoryLedger.cs @@ -0,0 +1,125 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace InventoryFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddInventoryLedger : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "InventoryBalances", + columns: table => new + { + WorkspaceId = table.Column(type: "uniqueidentifier", nullable: false), + WarehouseId = table.Column(type: "uniqueidentifier", nullable: false), + ProductId = table.Column(type: "uniqueidentifier", nullable: false), + Quantity = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_InventoryBalances", x => new { x.WorkspaceId, x.WarehouseId, x.ProductId }); + table.ForeignKey( + name: "FK_InventoryBalances_Products_ProductId", + column: x => x.ProductId, + principalTable: "Products", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_InventoryBalances_Warehouses_WarehouseId", + column: x => x.WarehouseId, + principalTable: "Warehouses", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_InventoryBalances_Workspaces_WorkspaceId", + column: x => x.WorkspaceId, + principalTable: "Workspaces", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "InventoryMovements", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + WorkspaceId = table.Column(type: "uniqueidentifier", nullable: false), + WarehouseId = table.Column(type: "uniqueidentifier", nullable: false), + ProductId = table.Column(type: "uniqueidentifier", nullable: false), + Type = table.Column(type: "int", nullable: false), + Quantity = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false), + IdempotencyKey = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + BalanceAfterQuantity = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false), + OccurredAtUtc = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_InventoryMovements", x => x.Id); + table.ForeignKey( + name: "FK_InventoryMovements_Products_ProductId", + column: x => x.ProductId, + principalTable: "Products", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_InventoryMovements_Warehouses_WarehouseId", + column: x => x.WarehouseId, + principalTable: "Warehouses", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_InventoryMovements_Workspaces_WorkspaceId", + column: x => x.WorkspaceId, + principalTable: "Workspaces", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_InventoryBalances_ProductId", + table: "InventoryBalances", + column: "ProductId"); + + migrationBuilder.CreateIndex( + name: "IX_InventoryBalances_WarehouseId", + table: "InventoryBalances", + column: "WarehouseId"); + + migrationBuilder.CreateIndex( + name: "IX_InventoryMovements_ProductId", + table: "InventoryMovements", + column: "ProductId"); + + migrationBuilder.CreateIndex( + name: "IX_InventoryMovements_WarehouseId", + table: "InventoryMovements", + column: "WarehouseId"); + + migrationBuilder.CreateIndex( + name: "IX_InventoryMovements_WorkspaceId_IdempotencyKey", + table: "InventoryMovements", + columns: new[] { "WorkspaceId", "IdempotencyKey" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_InventoryMovements_WorkspaceId_WarehouseId_ProductId_OccurredAtUtc", + table: "InventoryMovements", + columns: new[] { "WorkspaceId", "WarehouseId", "ProductId", "OccurredAtUtc" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "InventoryBalances"); + + migrationBuilder.DropTable( + name: "InventoryMovements"); + } + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718123000_ProtectInventorySourceArchival.Designer.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718123000_ProtectInventorySourceArchival.Designer.cs new file mode 100644 index 0000000..416e326 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718123000_ProtectInventorySourceArchival.Designer.cs @@ -0,0 +1,611 @@ +// +using System; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace InventoryFlow.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260718123000_ProtectInventorySourceArchival")] + partial class ProtectInventorySourceArchival + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryBalance", b => + { + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.Property("WarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("ProductId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.HasKey("WorkspaceId", "WarehouseId", "ProductId"); + + b.HasIndex("ProductId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("WorkspaceId", "ProductId"); + + b.ToTable("InventoryBalances", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BalanceAfterQuantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("ProductId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("WarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("WorkspaceId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("WorkspaceId", "WarehouseId", "ProductId", "OccurredAtUtc"); + + b.ToTable("InventoryMovements", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ArchivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId", "Sku") + .IsUnique(); + + b.HasIndex("WorkspaceId", "ArchivedAtUtc", "Name", "Id"); + + b.ToTable("Products", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("FamilyId") + .HasColumnType("uniqueidentifier"); + + b.Property("RevokedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId", "ExpiresAtUtc"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Warehouse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ArchivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId", "Name") + .IsUnique(); + + b.HasIndex("WorkspaceId", "ArchivedAtUtc", "Name"); + + b.ToTable("Warehouses", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Workspace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.HasKey("Id"); + + b.ToTable("Workspaces", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WorkspaceMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "UserId") + .IsUnique(); + + b.ToTable("WorkspaceMembers", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryBalance", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryMovement", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Product", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.RefreshToken", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Warehouse", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WorkspaceMember", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718123000_ProtectInventorySourceArchival.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718123000_ProtectInventorySourceArchival.cs new file mode 100644 index 0000000..a6abe74 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718123000_ProtectInventorySourceArchival.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace InventoryFlow.Infrastructure.Persistence.Migrations; + +/// +public partial class ProtectInventorySourceArchival : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_InventoryBalances_WorkspaceId_ProductId", + table: "InventoryBalances", + columns: new[] { "WorkspaceId", "ProductId" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_InventoryBalances_WorkspaceId_ProductId", + table: "InventoryBalances"); + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718130340_AddSuppliers.Designer.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718130340_AddSuppliers.Designer.cs new file mode 100644 index 0000000..86a70ba --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718130340_AddSuppliers.Designer.cs @@ -0,0 +1,650 @@ +// +using System; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace InventoryFlow.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260718130340_AddSuppliers")] + partial class AddSuppliers + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryBalance", b => + { + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.Property("WarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("ProductId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.HasKey("WorkspaceId", "WarehouseId", "ProductId"); + + b.HasIndex("ProductId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("WorkspaceId", "ProductId"); + + b.ToTable("InventoryBalances", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BalanceAfterQuantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("ProductId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("WarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("WorkspaceId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("WorkspaceId", "WarehouseId", "ProductId", "OccurredAtUtc"); + + b.ToTable("InventoryMovements", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ArchivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId", "Sku") + .IsUnique(); + + b.HasIndex("WorkspaceId", "ArchivedAtUtc", "Name", "Id"); + + b.ToTable("Products", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("FamilyId") + .HasColumnType("uniqueidentifier"); + + b.Property("RevokedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId", "ExpiresAtUtc"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ArchivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId", "Name") + .IsUnique(); + + b.HasIndex("WorkspaceId", "ArchivedAtUtc", "Name", "Id"); + + b.ToTable("Suppliers", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Warehouse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ArchivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId", "Name") + .IsUnique(); + + b.HasIndex("WorkspaceId", "ArchivedAtUtc", "Name"); + + b.ToTable("Warehouses", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Workspace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.HasKey("Id"); + + b.ToTable("Workspaces", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WorkspaceMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "UserId") + .IsUnique(); + + b.ToTable("WorkspaceMembers", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryBalance", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryMovement", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Product", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.RefreshToken", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Supplier", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Warehouse", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WorkspaceMember", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718130340_AddSuppliers.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718130340_AddSuppliers.cs new file mode 100644 index 0000000..c06757c --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718130340_AddSuppliers.cs @@ -0,0 +1,54 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace InventoryFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddSuppliers : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Suppliers", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + WorkspaceId = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + CreatedAtUtc = table.Column(type: "datetimeoffset", nullable: false), + ArchivedAtUtc = table.Column(type: "datetimeoffset", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Suppliers", x => x.Id); + table.ForeignKey( + name: "FK_Suppliers_Workspaces_WorkspaceId", + column: x => x.WorkspaceId, + principalTable: "Workspaces", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Suppliers_WorkspaceId_ArchivedAtUtc_Name_Id", + table: "Suppliers", + columns: new[] { "WorkspaceId", "ArchivedAtUtc", "Name", "Id" }); + + migrationBuilder.CreateIndex( + name: "IX_Suppliers_WorkspaceId_Name", + table: "Suppliers", + columns: new[] { "WorkspaceId", "Name" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Suppliers"); + } + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718132213_AddPurchaseReceipts.Designer.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718132213_AddPurchaseReceipts.Designer.cs new file mode 100644 index 0000000..dbd78a9 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718132213_AddPurchaseReceipts.Designer.cs @@ -0,0 +1,735 @@ +// +using System; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace InventoryFlow.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260718132213_AddPurchaseReceipts")] + partial class AddPurchaseReceipts + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryBalance", b => + { + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.Property("WarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("ProductId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.HasKey("WorkspaceId", "WarehouseId", "ProductId"); + + b.HasIndex("ProductId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("WorkspaceId", "ProductId"); + + b.ToTable("InventoryBalances", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BalanceAfterQuantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("ProductId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("WarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("WorkspaceId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("WorkspaceId", "WarehouseId", "ProductId", "OccurredAtUtc"); + + b.ToTable("InventoryMovements", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ArchivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId", "Sku") + .IsUnique(); + + b.HasIndex("WorkspaceId", "ArchivedAtUtc", "Name", "Id"); + + b.ToTable("Products", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.PurchaseReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InventoryMovementId") + .HasColumnType("uniqueidentifier"); + + b.Property("ProductId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ReceivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("SupplierId") + .HasColumnType("uniqueidentifier"); + + b.Property("WarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("InventoryMovementId") + .IsUnique(); + + b.HasIndex("ProductId"); + + b.HasIndex("SupplierId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("WorkspaceId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("WorkspaceId", "ReceivedAtUtc", "Id"); + + b.ToTable("PurchaseReceipts", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("FamilyId") + .HasColumnType("uniqueidentifier"); + + b.Property("RevokedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId", "ExpiresAtUtc"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ArchivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId", "Name") + .IsUnique(); + + b.HasIndex("WorkspaceId", "ArchivedAtUtc", "Name", "Id"); + + b.ToTable("Suppliers", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Warehouse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ArchivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId", "Name") + .IsUnique(); + + b.HasIndex("WorkspaceId", "ArchivedAtUtc", "Name"); + + b.ToTable("Warehouses", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Workspace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.HasKey("Id"); + + b.ToTable("Workspaces", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WorkspaceMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "UserId") + .IsUnique(); + + b.ToTable("WorkspaceMembers", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryBalance", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryMovement", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Product", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.PurchaseReceipt", b => + { + b.HasOne("InventoryFlow.Domain.Entities.InventoryMovement", null) + .WithMany() + .HasForeignKey("InventoryMovementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Supplier", null) + .WithMany() + .HasForeignKey("SupplierId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.RefreshToken", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Supplier", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Warehouse", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WorkspaceMember", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718132213_AddPurchaseReceipts.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718132213_AddPurchaseReceipts.cs new file mode 100644 index 0000000..4bca2cf --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718132213_AddPurchaseReceipts.cs @@ -0,0 +1,103 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace InventoryFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddPurchaseReceipts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PurchaseReceipts", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + WorkspaceId = table.Column(type: "uniqueidentifier", nullable: false), + SupplierId = table.Column(type: "uniqueidentifier", nullable: false), + WarehouseId = table.Column(type: "uniqueidentifier", nullable: false), + ProductId = table.Column(type: "uniqueidentifier", nullable: false), + Quantity = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false), + IdempotencyKey = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + InventoryMovementId = table.Column(type: "uniqueidentifier", nullable: false), + ReceivedAtUtc = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PurchaseReceipts", x => x.Id); + table.ForeignKey( + name: "FK_PurchaseReceipts_InventoryMovements_InventoryMovementId", + column: x => x.InventoryMovementId, + principalTable: "InventoryMovements", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_PurchaseReceipts_Products_ProductId", + column: x => x.ProductId, + principalTable: "Products", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_PurchaseReceipts_Suppliers_SupplierId", + column: x => x.SupplierId, + principalTable: "Suppliers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_PurchaseReceipts_Warehouses_WarehouseId", + column: x => x.WarehouseId, + principalTable: "Warehouses", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_PurchaseReceipts_Workspaces_WorkspaceId", + column: x => x.WorkspaceId, + principalTable: "Workspaces", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_PurchaseReceipts_InventoryMovementId", + table: "PurchaseReceipts", + column: "InventoryMovementId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PurchaseReceipts_ProductId", + table: "PurchaseReceipts", + column: "ProductId"); + + migrationBuilder.CreateIndex( + name: "IX_PurchaseReceipts_SupplierId", + table: "PurchaseReceipts", + column: "SupplierId"); + + migrationBuilder.CreateIndex( + name: "IX_PurchaseReceipts_WarehouseId", + table: "PurchaseReceipts", + column: "WarehouseId"); + + migrationBuilder.CreateIndex( + name: "IX_PurchaseReceipts_WorkspaceId_IdempotencyKey", + table: "PurchaseReceipts", + columns: new[] { "WorkspaceId", "IdempotencyKey" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PurchaseReceipts_WorkspaceId_ReceivedAtUtc_Id", + table: "PurchaseReceipts", + columns: new[] { "WorkspaceId", "ReceivedAtUtc", "Id" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PurchaseReceipts"); + } + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718135438_AddSalesFulfillments.Designer.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718135438_AddSalesFulfillments.Designer.cs new file mode 100644 index 0000000..f35965f --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718135438_AddSalesFulfillments.Designer.cs @@ -0,0 +1,809 @@ +// +using System; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace InventoryFlow.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260718135438_AddSalesFulfillments")] + partial class AddSalesFulfillments + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryBalance", b => + { + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.Property("WarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("ProductId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.HasKey("WorkspaceId", "WarehouseId", "ProductId"); + + b.HasIndex("ProductId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("WorkspaceId", "ProductId"); + + b.ToTable("InventoryBalances", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BalanceAfterQuantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("ProductId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("WarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("WorkspaceId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("WorkspaceId", "WarehouseId", "ProductId", "OccurredAtUtc"); + + b.ToTable("InventoryMovements", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ArchivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId", "Sku") + .IsUnique(); + + b.HasIndex("WorkspaceId", "ArchivedAtUtc", "Name", "Id"); + + b.ToTable("Products", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.PurchaseReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InventoryMovementId") + .HasColumnType("uniqueidentifier"); + + b.Property("ProductId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ReceivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("SupplierId") + .HasColumnType("uniqueidentifier"); + + b.Property("WarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("InventoryMovementId") + .IsUnique(); + + b.HasIndex("ProductId"); + + b.HasIndex("SupplierId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("WorkspaceId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("WorkspaceId", "ReceivedAtUtc", "Id"); + + b.ToTable("PurchaseReceipts", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("FamilyId") + .HasColumnType("uniqueidentifier"); + + b.Property("RevokedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId", "ExpiresAtUtc"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.SalesFulfillment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("FulfilledAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InventoryMovementId") + .HasColumnType("uniqueidentifier"); + + b.Property("ProductId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("InventoryMovementId") + .IsUnique(); + + b.HasIndex("ProductId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("WorkspaceId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("WorkspaceId", "FulfilledAtUtc", "Id"); + + b.ToTable("SalesFulfillments", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ArchivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId", "Name") + .IsUnique(); + + b.HasIndex("WorkspaceId", "ArchivedAtUtc", "Name", "Id"); + + b.ToTable("Suppliers", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Warehouse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ArchivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId", "Name") + .IsUnique(); + + b.HasIndex("WorkspaceId", "ArchivedAtUtc", "Name"); + + b.ToTable("Warehouses", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Workspace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.HasKey("Id"); + + b.ToTable("Workspaces", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WorkspaceMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "UserId") + .IsUnique(); + + b.ToTable("WorkspaceMembers", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryBalance", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryMovement", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Product", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.PurchaseReceipt", b => + { + b.HasOne("InventoryFlow.Domain.Entities.InventoryMovement", null) + .WithMany() + .HasForeignKey("InventoryMovementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Supplier", null) + .WithMany() + .HasForeignKey("SupplierId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.RefreshToken", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.SalesFulfillment", b => + { + b.HasOne("InventoryFlow.Domain.Entities.InventoryMovement", null) + .WithMany() + .HasForeignKey("InventoryMovementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Supplier", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Warehouse", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WorkspaceMember", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718135438_AddSalesFulfillments.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718135438_AddSalesFulfillments.cs new file mode 100644 index 0000000..0f2245c --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718135438_AddSalesFulfillments.cs @@ -0,0 +1,91 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace InventoryFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddSalesFulfillments : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "SalesFulfillments", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + WorkspaceId = table.Column(type: "uniqueidentifier", nullable: false), + WarehouseId = table.Column(type: "uniqueidentifier", nullable: false), + ProductId = table.Column(type: "uniqueidentifier", nullable: false), + Quantity = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false), + IdempotencyKey = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + InventoryMovementId = table.Column(type: "uniqueidentifier", nullable: false), + FulfilledAtUtc = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SalesFulfillments", x => x.Id); + table.ForeignKey( + name: "FK_SalesFulfillments_InventoryMovements_InventoryMovementId", + column: x => x.InventoryMovementId, + principalTable: "InventoryMovements", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_SalesFulfillments_Products_ProductId", + column: x => x.ProductId, + principalTable: "Products", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_SalesFulfillments_Warehouses_WarehouseId", + column: x => x.WarehouseId, + principalTable: "Warehouses", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_SalesFulfillments_Workspaces_WorkspaceId", + column: x => x.WorkspaceId, + principalTable: "Workspaces", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_SalesFulfillments_InventoryMovementId", + table: "SalesFulfillments", + column: "InventoryMovementId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SalesFulfillments_ProductId", + table: "SalesFulfillments", + column: "ProductId"); + + migrationBuilder.CreateIndex( + name: "IX_SalesFulfillments_WarehouseId", + table: "SalesFulfillments", + column: "WarehouseId"); + + migrationBuilder.CreateIndex( + name: "IX_SalesFulfillments_WorkspaceId_FulfilledAtUtc_Id", + table: "SalesFulfillments", + columns: new[] { "WorkspaceId", "FulfilledAtUtc", "Id" }); + + migrationBuilder.CreateIndex( + name: "IX_SalesFulfillments_WorkspaceId_IdempotencyKey", + table: "SalesFulfillments", + columns: new[] { "WorkspaceId", "IdempotencyKey" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SalesFulfillments"); + } + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718141104_AddWarehouseTransfers.Designer.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718141104_AddWarehouseTransfers.Designer.cs new file mode 100644 index 0000000..0842bde --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718141104_AddWarehouseTransfers.Designer.cs @@ -0,0 +1,906 @@ +// +using System; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace InventoryFlow.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260718141104_AddWarehouseTransfers")] + partial class AddWarehouseTransfers + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryBalance", b => + { + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.Property("WarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("ProductId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.HasKey("WorkspaceId", "WarehouseId", "ProductId"); + + b.HasIndex("ProductId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("WorkspaceId", "ProductId"); + + b.ToTable("InventoryBalances", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BalanceAfterQuantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("ProductId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("WarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("WorkspaceId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("WorkspaceId", "WarehouseId", "ProductId", "OccurredAtUtc"); + + b.ToTable("InventoryMovements", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ArchivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId", "Sku") + .IsUnique(); + + b.HasIndex("WorkspaceId", "ArchivedAtUtc", "Name", "Id"); + + b.ToTable("Products", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.PurchaseReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InventoryMovementId") + .HasColumnType("uniqueidentifier"); + + b.Property("ProductId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ReceivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("SupplierId") + .HasColumnType("uniqueidentifier"); + + b.Property("WarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("InventoryMovementId") + .IsUnique(); + + b.HasIndex("ProductId"); + + b.HasIndex("SupplierId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("WorkspaceId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("WorkspaceId", "ReceivedAtUtc", "Id"); + + b.ToTable("PurchaseReceipts", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("FamilyId") + .HasColumnType("uniqueidentifier"); + + b.Property("RevokedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId", "ExpiresAtUtc"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.SalesFulfillment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("FulfilledAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InventoryMovementId") + .HasColumnType("uniqueidentifier"); + + b.Property("ProductId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("InventoryMovementId") + .IsUnique(); + + b.HasIndex("ProductId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("WorkspaceId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("WorkspaceId", "FulfilledAtUtc", "Id"); + + b.ToTable("SalesFulfillments", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ArchivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId", "Name") + .IsUnique(); + + b.HasIndex("WorkspaceId", "ArchivedAtUtc", "Name", "Id"); + + b.ToTable("Suppliers", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Warehouse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ArchivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId", "Name") + .IsUnique(); + + b.HasIndex("WorkspaceId", "ArchivedAtUtc", "Name"); + + b.ToTable("Warehouses", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WarehouseTransfer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("DestinationInventoryMovementId") + .HasColumnType("uniqueidentifier"); + + b.Property("DestinationWarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ProductId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("SourceInventoryMovementId") + .HasColumnType("uniqueidentifier"); + + b.Property("SourceWarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("TransferredAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("DestinationInventoryMovementId") + .IsUnique(); + + b.HasIndex("DestinationWarehouseId"); + + b.HasIndex("ProductId"); + + b.HasIndex("SourceInventoryMovementId") + .IsUnique(); + + b.HasIndex("SourceWarehouseId"); + + b.HasIndex("WorkspaceId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("WorkspaceId", "TransferredAtUtc", "Id"); + + b.ToTable("WarehouseTransfers", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Workspace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.HasKey("Id"); + + b.ToTable("Workspaces", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WorkspaceMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "UserId") + .IsUnique(); + + b.ToTable("WorkspaceMembers", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryBalance", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryMovement", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Product", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.PurchaseReceipt", b => + { + b.HasOne("InventoryFlow.Domain.Entities.InventoryMovement", null) + .WithMany() + .HasForeignKey("InventoryMovementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Supplier", null) + .WithMany() + .HasForeignKey("SupplierId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.RefreshToken", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.SalesFulfillment", b => + { + b.HasOne("InventoryFlow.Domain.Entities.InventoryMovement", null) + .WithMany() + .HasForeignKey("InventoryMovementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Supplier", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Warehouse", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WarehouseTransfer", b => + { + b.HasOne("InventoryFlow.Domain.Entities.InventoryMovement", null) + .WithMany() + .HasForeignKey("DestinationInventoryMovementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("DestinationWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.InventoryMovement", null) + .WithMany() + .HasForeignKey("SourceInventoryMovementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("SourceWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WorkspaceMember", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718141104_AddWarehouseTransfers.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718141104_AddWarehouseTransfers.cs new file mode 100644 index 0000000..7df58a5 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260718141104_AddWarehouseTransfers.cs @@ -0,0 +1,116 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace InventoryFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddWarehouseTransfers : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "WarehouseTransfers", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + WorkspaceId = table.Column(type: "uniqueidentifier", nullable: false), + SourceWarehouseId = table.Column(type: "uniqueidentifier", nullable: false), + DestinationWarehouseId = table.Column(type: "uniqueidentifier", nullable: false), + ProductId = table.Column(type: "uniqueidentifier", nullable: false), + Quantity = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false), + IdempotencyKey = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + SourceInventoryMovementId = table.Column(type: "uniqueidentifier", nullable: false), + DestinationInventoryMovementId = table.Column(type: "uniqueidentifier", nullable: false), + TransferredAtUtc = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_WarehouseTransfers", x => x.Id); + table.ForeignKey( + name: "FK_WarehouseTransfers_InventoryMovements_DestinationInventoryMovementId", + column: x => x.DestinationInventoryMovementId, + principalTable: "InventoryMovements", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_WarehouseTransfers_InventoryMovements_SourceInventoryMovementId", + column: x => x.SourceInventoryMovementId, + principalTable: "InventoryMovements", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_WarehouseTransfers_Products_ProductId", + column: x => x.ProductId, + principalTable: "Products", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_WarehouseTransfers_Warehouses_DestinationWarehouseId", + column: x => x.DestinationWarehouseId, + principalTable: "Warehouses", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_WarehouseTransfers_Warehouses_SourceWarehouseId", + column: x => x.SourceWarehouseId, + principalTable: "Warehouses", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_WarehouseTransfers_Workspaces_WorkspaceId", + column: x => x.WorkspaceId, + principalTable: "Workspaces", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_WarehouseTransfers_DestinationInventoryMovementId", + table: "WarehouseTransfers", + column: "DestinationInventoryMovementId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_WarehouseTransfers_DestinationWarehouseId", + table: "WarehouseTransfers", + column: "DestinationWarehouseId"); + + migrationBuilder.CreateIndex( + name: "IX_WarehouseTransfers_ProductId", + table: "WarehouseTransfers", + column: "ProductId"); + + migrationBuilder.CreateIndex( + name: "IX_WarehouseTransfers_SourceInventoryMovementId", + table: "WarehouseTransfers", + column: "SourceInventoryMovementId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_WarehouseTransfers_SourceWarehouseId", + table: "WarehouseTransfers", + column: "SourceWarehouseId"); + + migrationBuilder.CreateIndex( + name: "IX_WarehouseTransfers_WorkspaceId_IdempotencyKey", + table: "WarehouseTransfers", + columns: new[] { "WorkspaceId", "IdempotencyKey" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_WarehouseTransfers_WorkspaceId_TransferredAtUtc_Id", + table: "WarehouseTransfers", + columns: new[] { "WorkspaceId", "TransferredAtUtc", "Id" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "WarehouseTransfers"); + } + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260719073620_AddCollaborationFoundation.Designer.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260719073620_AddCollaborationFoundation.Designer.cs new file mode 100644 index 0000000..ebd13b5 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260719073620_AddCollaborationFoundation.Designer.cs @@ -0,0 +1,999 @@ +// +using System; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace InventoryFlow.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260719073620_AddCollaborationFoundation")] + partial class AddCollaborationFoundation + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryBalance", b => + { + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.Property("WarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("ProductId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.HasKey("WorkspaceId", "WarehouseId", "ProductId"); + + b.HasIndex("ProductId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("WorkspaceId", "ProductId"); + + b.ToTable("InventoryBalances", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BalanceAfterQuantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("ProductId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("WarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("WorkspaceId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("WorkspaceId", "WarehouseId", "ProductId", "OccurredAtUtc"); + + b.ToTable("InventoryMovements", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ArchivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId", "Sku") + .IsUnique(); + + b.HasIndex("WorkspaceId", "ArchivedAtUtc", "Name", "Id"); + + b.ToTable("Products", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.PurchaseReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InventoryMovementId") + .HasColumnType("uniqueidentifier"); + + b.Property("ProductId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ReceivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("SupplierId") + .HasColumnType("uniqueidentifier"); + + b.Property("WarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("InventoryMovementId") + .IsUnique(); + + b.HasIndex("ProductId"); + + b.HasIndex("SupplierId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("WorkspaceId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("WorkspaceId", "ReceivedAtUtc", "Id"); + + b.ToTable("PurchaseReceipts", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("FamilyId") + .HasColumnType("uniqueidentifier"); + + b.Property("RevokedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("UserId", "ExpiresAtUtc"); + + b.HasIndex("UserId", "WorkspaceId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.SalesFulfillment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("FulfilledAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InventoryMovementId") + .HasColumnType("uniqueidentifier"); + + b.Property("ProductId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("InventoryMovementId") + .IsUnique(); + + b.HasIndex("ProductId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("WorkspaceId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("WorkspaceId", "FulfilledAtUtc", "Id"); + + b.ToTable("SalesFulfillments", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ArchivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId", "Name") + .IsUnique(); + + b.HasIndex("WorkspaceId", "ArchivedAtUtc", "Name", "Id"); + + b.ToTable("Suppliers", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Warehouse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ArchivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId", "Name") + .IsUnique(); + + b.HasIndex("WorkspaceId", "ArchivedAtUtc", "Name"); + + b.ToTable("Warehouses", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WarehouseTransfer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("DestinationInventoryMovementId") + .HasColumnType("uniqueidentifier"); + + b.Property("DestinationWarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ProductId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("SourceInventoryMovementId") + .HasColumnType("uniqueidentifier"); + + b.Property("SourceWarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("TransferredAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("DestinationInventoryMovementId") + .IsUnique(); + + b.HasIndex("DestinationWarehouseId"); + + b.HasIndex("ProductId"); + + b.HasIndex("SourceInventoryMovementId") + .IsUnique(); + + b.HasIndex("SourceWarehouseId"); + + b.HasIndex("WorkspaceId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("WorkspaceId", "TransferredAtUtc", "Id"); + + b.ToTable("WarehouseTransfers", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Workspace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.HasKey("Id"); + + b.ToTable("Workspaces", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WorkspaceInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AcceptedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("AcceptedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("RevokedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedByUserId"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("WorkspaceId", "CreatedAtUtc"); + + b.HasIndex("WorkspaceId", "NormalizedEmail") + .IsUnique() + .HasFilter("[AcceptedAtUtc] IS NULL AND [RevokedAtUtc] IS NULL"); + + b.ToTable("WorkspaceInvitations", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WorkspaceMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "UserId") + .IsUnique(); + + b.ToTable("WorkspaceMembers", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryBalance", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryMovement", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Product", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.PurchaseReceipt", b => + { + b.HasOne("InventoryFlow.Domain.Entities.InventoryMovement", null) + .WithMany() + .HasForeignKey("InventoryMovementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Supplier", null) + .WithMany() + .HasForeignKey("SupplierId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.RefreshToken", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.SalesFulfillment", b => + { + b.HasOne("InventoryFlow.Domain.Entities.InventoryMovement", null) + .WithMany() + .HasForeignKey("InventoryMovementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Supplier", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Warehouse", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WarehouseTransfer", b => + { + b.HasOne("InventoryFlow.Domain.Entities.InventoryMovement", null) + .WithMany() + .HasForeignKey("DestinationInventoryMovementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("DestinationWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.InventoryMovement", null) + .WithMany() + .HasForeignKey("SourceInventoryMovementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("SourceWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WorkspaceInvitation", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("AcceptedByUserId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WorkspaceMember", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260719073620_AddCollaborationFoundation.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260719073620_AddCollaborationFoundation.cs new file mode 100644 index 0000000..a4d3ca2 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/20260719073620_AddCollaborationFoundation.cs @@ -0,0 +1,154 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace InventoryFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddCollaborationFoundation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "WorkspaceId", + table: "RefreshTokens", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.Sql(""" + UPDATE refreshToken + SET WorkspaceId = membership.WorkspaceId + FROM RefreshTokens AS refreshToken + CROSS APPLY ( + SELECT TOP (1) WorkspaceId + FROM WorkspaceMembers AS member + WHERE member.UserId = refreshToken.UserId + ORDER BY CASE WHEN member.Role = 'Owner' THEN 0 ELSE 1 END, member.CreatedAtUtc, member.Id + ) AS membership; + """); + + migrationBuilder.Sql(""" + DELETE r + FROM RefreshTokens AS r + LEFT JOIN WorkspaceMembers AS m ON m.UserId = r.UserId + WHERE r.WorkspaceId IS NULL AND m.UserId IS NULL; + """); + + migrationBuilder.AlterColumn( + name: "WorkspaceId", + table: "RefreshTokens", + type: "uniqueidentifier", + nullable: false, + oldClrType: typeof(Guid), + oldType: "uniqueidentifier", + oldNullable: true); + + migrationBuilder.CreateTable( + name: "WorkspaceInvitations", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + WorkspaceId = table.Column(type: "uniqueidentifier", nullable: false), + NormalizedEmail = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + Role = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + TokenHash = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + ExpiresAtUtc = table.Column(type: "datetimeoffset", nullable: false), + CreatedByUserId = table.Column(type: "uniqueidentifier", nullable: false), + CreatedAtUtc = table.Column(type: "datetimeoffset", nullable: false), + AcceptedAtUtc = table.Column(type: "datetimeoffset", nullable: true), + AcceptedByUserId = table.Column(type: "uniqueidentifier", nullable: true), + RevokedAtUtc = table.Column(type: "datetimeoffset", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_WorkspaceInvitations", x => x.Id); + table.ForeignKey( + name: "FK_WorkspaceInvitations_AspNetUsers_AcceptedByUserId", + column: x => x.AcceptedByUserId, + principalTable: "AspNetUsers", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_WorkspaceInvitations_AspNetUsers_CreatedByUserId", + column: x => x.CreatedByUserId, + principalTable: "AspNetUsers", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_WorkspaceInvitations_Workspaces_WorkspaceId", + column: x => x.WorkspaceId, + principalTable: "Workspaces", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_UserId_WorkspaceId", + table: "RefreshTokens", + columns: new[] { "UserId", "WorkspaceId" }); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_WorkspaceId", + table: "RefreshTokens", + column: "WorkspaceId"); + + migrationBuilder.CreateIndex( + name: "IX_WorkspaceInvitations_AcceptedByUserId", + table: "WorkspaceInvitations", + column: "AcceptedByUserId"); + + migrationBuilder.CreateIndex( + name: "IX_WorkspaceInvitations_CreatedByUserId", + table: "WorkspaceInvitations", + column: "CreatedByUserId"); + + migrationBuilder.CreateIndex( + name: "IX_WorkspaceInvitations_TokenHash", + table: "WorkspaceInvitations", + column: "TokenHash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_WorkspaceInvitations_WorkspaceId_CreatedAtUtc", + table: "WorkspaceInvitations", + columns: new[] { "WorkspaceId", "CreatedAtUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_WorkspaceInvitations_WorkspaceId_NormalizedEmail", + table: "WorkspaceInvitations", + columns: new[] { "WorkspaceId", "NormalizedEmail" }, + unique: true, + filter: "[AcceptedAtUtc] IS NULL AND [RevokedAtUtc] IS NULL"); + + migrationBuilder.AddForeignKey( + name: "FK_RefreshTokens_Workspaces_WorkspaceId", + table: "RefreshTokens", + column: "WorkspaceId", + principalTable: "Workspaces", + principalColumn: "Id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_RefreshTokens_Workspaces_WorkspaceId", + table: "RefreshTokens"); + + migrationBuilder.DropTable( + name: "WorkspaceInvitations"); + + migrationBuilder.DropIndex( + name: "IX_RefreshTokens_UserId_WorkspaceId", + table: "RefreshTokens"); + + migrationBuilder.DropIndex( + name: "IX_RefreshTokens_WorkspaceId", + table: "RefreshTokens"); + + migrationBuilder.DropColumn( + name: "WorkspaceId", + table: "RefreshTokens"); + } + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 0000000..539ac21 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,996 @@ +// +using System; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace InventoryFlow.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + partial class ApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryBalance", b => + { + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.Property("WarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("ProductId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.HasKey("WorkspaceId", "WarehouseId", "ProductId"); + + b.HasIndex("ProductId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("WorkspaceId", "ProductId"); + + b.ToTable("InventoryBalances", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BalanceAfterQuantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("ProductId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("WarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("WorkspaceId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("WorkspaceId", "WarehouseId", "ProductId", "OccurredAtUtc"); + + b.ToTable("InventoryMovements", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ArchivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId", "Sku") + .IsUnique(); + + b.HasIndex("WorkspaceId", "ArchivedAtUtc", "Name", "Id"); + + b.ToTable("Products", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.PurchaseReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InventoryMovementId") + .HasColumnType("uniqueidentifier"); + + b.Property("ProductId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ReceivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("SupplierId") + .HasColumnType("uniqueidentifier"); + + b.Property("WarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("InventoryMovementId") + .IsUnique(); + + b.HasIndex("ProductId"); + + b.HasIndex("SupplierId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("WorkspaceId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("WorkspaceId", "ReceivedAtUtc", "Id"); + + b.ToTable("PurchaseReceipts", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("FamilyId") + .HasColumnType("uniqueidentifier"); + + b.Property("RevokedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("UserId", "ExpiresAtUtc"); + + b.HasIndex("UserId", "WorkspaceId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.SalesFulfillment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("FulfilledAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InventoryMovementId") + .HasColumnType("uniqueidentifier"); + + b.Property("ProductId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("InventoryMovementId") + .IsUnique(); + + b.HasIndex("ProductId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("WorkspaceId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("WorkspaceId", "FulfilledAtUtc", "Id"); + + b.ToTable("SalesFulfillments", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ArchivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId", "Name") + .IsUnique(); + + b.HasIndex("WorkspaceId", "ArchivedAtUtc", "Name", "Id"); + + b.ToTable("Suppliers", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Warehouse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ArchivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId", "Name") + .IsUnique(); + + b.HasIndex("WorkspaceId", "ArchivedAtUtc", "Name"); + + b.ToTable("Warehouses", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WarehouseTransfer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("DestinationInventoryMovementId") + .HasColumnType("uniqueidentifier"); + + b.Property("DestinationWarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ProductId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("SourceInventoryMovementId") + .HasColumnType("uniqueidentifier"); + + b.Property("SourceWarehouseId") + .HasColumnType("uniqueidentifier"); + + b.Property("TransferredAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("DestinationInventoryMovementId") + .IsUnique(); + + b.HasIndex("DestinationWarehouseId"); + + b.HasIndex("ProductId"); + + b.HasIndex("SourceInventoryMovementId") + .IsUnique(); + + b.HasIndex("SourceWarehouseId"); + + b.HasIndex("WorkspaceId", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("WorkspaceId", "TransferredAtUtc", "Id"); + + b.ToTable("WarehouseTransfers", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Workspace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.HasKey("Id"); + + b.ToTable("Workspaces", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WorkspaceInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AcceptedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("AcceptedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("RevokedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedByUserId"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("WorkspaceId", "CreatedAtUtc"); + + b.HasIndex("WorkspaceId", "NormalizedEmail") + .IsUnique() + .HasFilter("[AcceptedAtUtc] IS NULL AND [RevokedAtUtc] IS NULL"); + + b.ToTable("WorkspaceInvitations", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WorkspaceMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "UserId") + .IsUnique(); + + b.ToTable("WorkspaceMembers", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryBalance", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.InventoryMovement", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Product", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.PurchaseReceipt", b => + { + b.HasOne("InventoryFlow.Domain.Entities.InventoryMovement", null) + .WithMany() + .HasForeignKey("InventoryMovementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Supplier", null) + .WithMany() + .HasForeignKey("SupplierId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.RefreshToken", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.SalesFulfillment", b => + { + b.HasOne("InventoryFlow.Domain.Entities.InventoryMovement", null) + .WithMany() + .HasForeignKey("InventoryMovementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Supplier", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.Warehouse", b => + { + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WarehouseTransfer", b => + { + b.HasOne("InventoryFlow.Domain.Entities.InventoryMovement", null) + .WithMany() + .HasForeignKey("DestinationInventoryMovementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("DestinationWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.InventoryMovement", null) + .WithMany() + .HasForeignKey("SourceInventoryMovementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("SourceWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WorkspaceInvitation", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("AcceptedByUserId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InventoryFlow.Domain.Entities.WorkspaceMember", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InventoryFlow.Domain.Entities.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("InventoryFlow.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Products/EfProductCatalog.cs b/backend/src/InventoryFlow.Infrastructure/Products/EfProductCatalog.cs new file mode 100644 index 0000000..b06c116 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Products/EfProductCatalog.cs @@ -0,0 +1,71 @@ +using System.Data; +using InventoryFlow.Application.Features.Inventory; +using InventoryFlow.Application.Features.Products; +using InventoryFlow.Domain.Entities; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; + +namespace InventoryFlow.Infrastructure.Products; + +/// Persists workspace-scoped products with SQL Server uniqueness and lifecycle handling. +public sealed class EfProductCatalog(ApplicationDbContext dbContext) : IProductCatalog +{ + /// + public async Task CreateAsync(Product product, CancellationToken cancellationToken) + { + dbContext.Products.Add(product); + try + { + await dbContext.SaveChangesAsync(cancellationToken); + return product; + } + catch (DbUpdateException exception) when (exception.InnerException is SqlException { Number: 2601 or 2627 }) + { + throw new ProductSkuConflictException(); + } + } + + /// + public async Task> ListActiveAsync(Guid workspaceId, CancellationToken cancellationToken) => + await dbContext.Products.AsNoTracking().Where(item => item.WorkspaceId == workspaceId && item.ArchivedAtUtc == null) + .OrderBy(item => item.Name).ThenBy(item => item.Id).ToListAsync(cancellationToken); + + /// + public Task FindAsync(Guid workspaceId, Guid productId, CancellationToken cancellationToken) => + dbContext.Products.SingleOrDefaultAsync(item => item.WorkspaceId == workspaceId && item.Id == productId, cancellationToken); + + /// + public async Task ArchiveAsync(Guid workspaceId, Guid productId, DateTimeOffset archivedAtUtc, + CancellationToken cancellationToken) + { + var strategy = dbContext.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => + { + await using var transaction = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken); + var product = await dbContext.Products.SingleOrDefaultAsync(item => item.WorkspaceId == workspaceId && + item.Id == productId, cancellationToken); + if (product is null) + { + await transaction.RollbackAsync(cancellationToken); + return false; + } + + var hasOnHandBalance = await dbContext.InventoryBalances.AnyAsync(balance => balance.WorkspaceId == workspaceId && + balance.ProductId == productId && balance.Quantity != 0m, cancellationToken); + if (hasOnHandBalance) + { + await transaction.RollbackAsync(cancellationToken); + throw new InventoryArchiveConflictException(); + } + + product.Archive(archivedAtUtc); + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return true; + }); + } + + /// + public async Task SaveChangesAsync(CancellationToken cancellationToken) => await dbContext.SaveChangesAsync(cancellationToken); +} diff --git a/backend/src/InventoryFlow.Infrastructure/Purchases/EfPurchaseReceiptService.cs b/backend/src/InventoryFlow.Infrastructure/Purchases/EfPurchaseReceiptService.cs new file mode 100644 index 0000000..b64f27b --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Purchases/EfPurchaseReceiptService.cs @@ -0,0 +1,59 @@ +using System.Data; +using InventoryFlow.Application.Features.Purchases; +using InventoryFlow.Domain.Entities; +using InventoryFlow.Infrastructure.Inventory; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace InventoryFlow.Infrastructure.Purchases; + +/// Posts immutable purchase receipts and their ledger entries in one transaction. +public sealed class EfPurchaseReceiptService(ApplicationDbContext dbContext) : IPurchaseReceiptService +{ + public async Task RecordAsync(Guid workspaceId, Guid supplierId, Guid warehouseId, Guid productId, decimal quantity, + string idempotencyKey, DateTimeOffset receivedAtUtc, CancellationToken cancellationToken) + { + quantity = InventoryMovement.ValidateQuantity(quantity); + idempotencyKey = InventoryMovement.NormalizeIdempotencyKey(idempotencyKey); + var strategy = dbContext.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => + { + await using var transaction = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken); + var existing = await dbContext.PurchaseReceipts.SingleOrDefaultAsync(receipt => receipt.WorkspaceId == workspaceId && + receipt.IdempotencyKey == idempotencyKey, cancellationToken); + if (existing is not null) + { + await transaction.CommitAsync(cancellationToken); + return existing; + } + + var supplierExists = await dbContext.Suppliers.AnyAsync(supplier => supplier.Id == supplierId && + supplier.WorkspaceId == workspaceId && supplier.ArchivedAtUtc == null, cancellationToken); + if (!supplierExists) + { + await transaction.RollbackAsync(cancellationToken); + return null; + } + + var receiptId = Guid.NewGuid(); + var movement = await new InventoryLedgerWriter(dbContext).RecordAsync(workspaceId, warehouseId, productId, + InventoryMovementType.Receipt, quantity, receiptId.ToString("N"), receivedAtUtc, Guid.NewGuid(), cancellationToken); + if (movement is null) + { + await transaction.RollbackAsync(cancellationToken); + return null; + } + + var receipt = new PurchaseReceipt(receiptId, workspaceId, supplierId, warehouseId, productId, quantity, idempotencyKey, + movement.Id, receivedAtUtc); + dbContext.PurchaseReceipts.Add(receipt); + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return receipt; + }); + } + + public async Task> ListAsync(Guid workspaceId, CancellationToken cancellationToken) => + await dbContext.PurchaseReceipts.AsNoTracking().Where(receipt => receipt.WorkspaceId == workspaceId) + .OrderByDescending(receipt => receipt.ReceivedAtUtc).ThenByDescending(receipt => receipt.Id).ToListAsync(cancellationToken); +} diff --git a/backend/src/InventoryFlow.Infrastructure/Sales/EfSalesFulfillmentService.cs b/backend/src/InventoryFlow.Infrastructure/Sales/EfSalesFulfillmentService.cs new file mode 100644 index 0000000..fe46e03 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Sales/EfSalesFulfillmentService.cs @@ -0,0 +1,52 @@ +using System.Data; +using InventoryFlow.Application.Features.Sales; +using InventoryFlow.Domain.Entities; +using InventoryFlow.Infrastructure.Inventory; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace InventoryFlow.Infrastructure.Sales; + +/// Posts immutable sales fulfillments and their issue ledger entries in one transaction. +public sealed class EfSalesFulfillmentService(ApplicationDbContext dbContext) : ISalesFulfillmentService +{ + public async Task RecordAsync(Guid workspaceId, Guid warehouseId, Guid productId, decimal quantity, + string idempotencyKey, DateTimeOffset fulfilledAtUtc, CancellationToken cancellationToken) + { + quantity = InventoryMovement.ValidateQuantity(quantity); + idempotencyKey = InventoryMovement.NormalizeIdempotencyKey(idempotencyKey); + var strategy = dbContext.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => + { + await using var transaction = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken); + var existing = await dbContext.SalesFulfillments.SingleOrDefaultAsync(fulfillment => fulfillment.WorkspaceId == workspaceId && + fulfillment.IdempotencyKey == idempotencyKey, cancellationToken); + if (existing is not null) + { + await transaction.CommitAsync(cancellationToken); + return existing; + } + + var fulfillmentId = Guid.NewGuid(); + var movement = await new InventoryLedgerWriter(dbContext).RecordAsync(workspaceId, warehouseId, productId, + InventoryMovementType.Issue, quantity, fulfillmentId.ToString("N"), fulfilledAtUtc, Guid.NewGuid(), cancellationToken); + if (movement is null) + { + await transaction.RollbackAsync(cancellationToken); + return null; + } + + var fulfillment = new SalesFulfillment(fulfillmentId, workspaceId, warehouseId, productId, quantity, idempotencyKey, + movement.Id, fulfilledAtUtc); + dbContext.SalesFulfillments.Add(fulfillment); + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return fulfillment; + }); + } + + public async Task> ListAsync(Guid workspaceId, CancellationToken cancellationToken) => + await dbContext.SalesFulfillments.AsNoTracking().Where(fulfillment => fulfillment.WorkspaceId == workspaceId) + .OrderByDescending(fulfillment => fulfillment.FulfilledAtUtc).ThenByDescending(fulfillment => fulfillment.Id) + .ToListAsync(cancellationToken); +} diff --git a/backend/src/InventoryFlow.Infrastructure/Suppliers/EfSupplierCatalog.cs b/backend/src/InventoryFlow.Infrastructure/Suppliers/EfSupplierCatalog.cs new file mode 100644 index 0000000..9932d67 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Suppliers/EfSupplierCatalog.cs @@ -0,0 +1,52 @@ +using InventoryFlow.Application.Features.Suppliers; +using InventoryFlow.Domain.Entities; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; + +namespace InventoryFlow.Infrastructure.Suppliers; + +/// Persists workspace-scoped suppliers with SQL Server uniqueness and lifecycle handling. +public sealed class EfSupplierCatalog(ApplicationDbContext dbContext) : ISupplierCatalog +{ + /// + public async Task CreateAsync(Supplier supplier, CancellationToken cancellationToken) + { + dbContext.Suppliers.Add(supplier); + try + { + await dbContext.SaveChangesAsync(cancellationToken); + return supplier; + } + catch (DbUpdateException exception) when (exception.InnerException is SqlException { Number: 2601 or 2627 }) + { + throw new SupplierNameConflictException(); + } + } + + /// + public async Task> ListActiveAsync(Guid workspaceId, CancellationToken cancellationToken) => + await dbContext.Suppliers.AsNoTracking().Where(supplier => supplier.WorkspaceId == workspaceId && + supplier.ArchivedAtUtc == null).OrderBy(supplier => supplier.Name).ThenBy(supplier => supplier.Id) + .ToListAsync(cancellationToken); + + /// + public Task FindAsync(Guid workspaceId, Guid supplierId, CancellationToken cancellationToken) => + dbContext.Suppliers.SingleOrDefaultAsync(supplier => supplier.WorkspaceId == workspaceId && supplier.Id == supplierId, + cancellationToken); + + /// + public async Task ArchiveAsync(Guid workspaceId, Guid supplierId, DateTimeOffset archivedAtUtc, + CancellationToken cancellationToken) + { + var supplier = await dbContext.Suppliers.SingleOrDefaultAsync(item => item.WorkspaceId == workspaceId && + item.Id == supplierId, cancellationToken); + if (supplier is null) return false; + supplier.Archive(archivedAtUtc); + await dbContext.SaveChangesAsync(cancellationToken); + return true; + } + + /// + public async Task SaveChangesAsync(CancellationToken cancellationToken) => await dbContext.SaveChangesAsync(cancellationToken); +} diff --git a/backend/src/InventoryFlow.Infrastructure/Tenancy/CurrentWorkspaceResolver.cs b/backend/src/InventoryFlow.Infrastructure/Tenancy/CurrentWorkspaceResolver.cs new file mode 100644 index 0000000..9fbd4af --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Tenancy/CurrentWorkspaceResolver.cs @@ -0,0 +1,25 @@ +using System.Security.Claims; +using InventoryFlow.Application.Common.Tenancy; +using InventoryFlow.Infrastructure.Authentication; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; + +namespace InventoryFlow.Infrastructure.Tenancy; + +/// Resolves a request workspace exclusively from authenticated membership data. +public sealed class CurrentWorkspaceResolver(IHttpContextAccessor httpContextAccessor, ApplicationDbContext dbContext) : ICurrentWorkspace +{ + /// + public async Task GetAsync(CancellationToken cancellationToken = default) + { + var userIdValue = httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier); + var workspaceIdValue = httpContextAccessor.HttpContext?.User.FindFirstValue(JwtAccessTokenIssuer.WorkspaceIdClaimType); + if (!Guid.TryParse(userIdValue, out var userId) || !Guid.TryParse(workspaceIdValue, out var workspaceId)) return null; + var matches = await (from member in dbContext.WorkspaceMembers.AsNoTracking() + join workspace in dbContext.Workspaces.AsNoTracking() on member.WorkspaceId equals workspace.Id + where member.UserId == userId && member.WorkspaceId == workspaceId + select new CurrentWorkspace(workspace.Id, workspace.Name, member.Role)).Take(2).ToListAsync(cancellationToken); + return matches.Count == 1 ? matches[0] : null; + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Transfers/EfWarehouseTransferService.cs b/backend/src/InventoryFlow.Infrastructure/Transfers/EfWarehouseTransferService.cs new file mode 100644 index 0000000..6bb5430 --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Transfers/EfWarehouseTransferService.cs @@ -0,0 +1,101 @@ +using System.Data; +using InventoryFlow.Application.Features.Transfers; +using InventoryFlow.Domain.Entities; +using InventoryFlow.Domain.Exceptions; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace InventoryFlow.Infrastructure.Transfers; + +/// Posts immutable warehouse transfers with a serializable, two-balance ledger update. +public sealed class EfWarehouseTransferService(ApplicationDbContext dbContext, IServiceScopeFactory scopeFactory) : IWarehouseTransferService +{ + public async Task RecordAsync(Guid workspaceId, Guid sourceWarehouseId, Guid destinationWarehouseId, + Guid productId, decimal quantity, string idempotencyKey, DateTimeOffset transferredAtUtc, CancellationToken cancellationToken) + { + quantity = InventoryMovement.ValidateQuantity(quantity); + idempotencyKey = InventoryMovement.NormalizeIdempotencyKey(idempotencyKey); + if (sourceWarehouseId == destinationWarehouseId) + throw new DomainException("Source and destination warehouses must be different."); + + var strategy = dbContext.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => + { + await using var scope = scopeFactory.CreateAsyncScope(); + var attemptDbContext = scope.ServiceProvider.GetRequiredService(); + return await RecordAttemptAsync(attemptDbContext, workspaceId, sourceWarehouseId, destinationWarehouseId, productId, + quantity, idempotencyKey, transferredAtUtc, cancellationToken); + }); + } + + public async Task> ListAsync(Guid workspaceId, CancellationToken cancellationToken) => + await dbContext.WarehouseTransfers.AsNoTracking().Where(transfer => transfer.WorkspaceId == workspaceId) + .OrderByDescending(transfer => transfer.TransferredAtUtc).ThenByDescending(transfer => transfer.Id) + .ToListAsync(cancellationToken); + + private static async Task RecordAttemptAsync(ApplicationDbContext dbContext, Guid workspaceId, + Guid sourceWarehouseId, Guid destinationWarehouseId, Guid productId, decimal quantity, string idempotencyKey, + DateTimeOffset transferredAtUtc, CancellationToken cancellationToken) + { + await using var transaction = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken); + var existing = await dbContext.WarehouseTransfers.SingleOrDefaultAsync(transfer => transfer.WorkspaceId == workspaceId && + transfer.IdempotencyKey == idempotencyKey, cancellationToken); + if (existing is not null) + { + await transaction.CommitAsync(cancellationToken); + return existing; + } + + var productExists = await dbContext.Products.AnyAsync(product => product.Id == productId && + product.WorkspaceId == workspaceId && product.ArchivedAtUtc == null, cancellationToken); + var sourceExists = await dbContext.Warehouses.AnyAsync(warehouse => warehouse.Id == sourceWarehouseId && + warehouse.WorkspaceId == workspaceId && warehouse.ArchivedAtUtc == null, cancellationToken); + var destinationExists = await dbContext.Warehouses.AnyAsync(warehouse => warehouse.Id == destinationWarehouseId && + warehouse.WorkspaceId == workspaceId && warehouse.ArchivedAtUtc == null, cancellationToken); + if (!productExists || !sourceExists || !destinationExists) + { + await transaction.RollbackAsync(cancellationToken); + return null; + } + + // Every transfer acquires both key/range locks in the same warehouse-ID order, including reverse transfers. + var firstWarehouseId = sourceWarehouseId.CompareTo(destinationWarehouseId) < 0 ? sourceWarehouseId : destinationWarehouseId; + var secondWarehouseId = firstWarehouseId == sourceWarehouseId ? destinationWarehouseId : sourceWarehouseId; + var firstBalance = await LockBalanceAsync(dbContext, workspaceId, firstWarehouseId, productId, cancellationToken); + var secondBalance = await LockBalanceAsync(dbContext, workspaceId, secondWarehouseId, productId, cancellationToken); + + var sourceBalance = sourceWarehouseId == firstWarehouseId ? firstBalance : secondBalance; + var destinationBalance = destinationWarehouseId == firstWarehouseId ? firstBalance : secondBalance; + sourceBalance.Apply(-quantity); + destinationBalance.Apply(quantity); + + var transferId = Guid.NewGuid(); + var sourceMovement = new InventoryMovement(Guid.NewGuid(), workspaceId, sourceWarehouseId, productId, + InventoryMovementType.Issue, quantity, $"{transferId:N}:issue", sourceBalance.Quantity, transferredAtUtc); + var destinationMovement = new InventoryMovement(Guid.NewGuid(), workspaceId, destinationWarehouseId, productId, + InventoryMovementType.Receipt, quantity, $"{transferId:N}:receipt", destinationBalance.Quantity, transferredAtUtc); + var transfer = new WarehouseTransfer(transferId, workspaceId, sourceWarehouseId, destinationWarehouseId, productId, + quantity, idempotencyKey, sourceMovement.Id, destinationMovement.Id, transferredAtUtc); + + dbContext.InventoryMovements.AddRange(sourceMovement, destinationMovement); + dbContext.WarehouseTransfers.Add(transfer); + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return transfer; + } + + private static async Task LockBalanceAsync(ApplicationDbContext dbContext, Guid workspaceId, + Guid warehouseId, Guid productId, CancellationToken cancellationToken) + { + var balance = await dbContext.InventoryBalances.FromSqlInterpolated($""" + SELECT * FROM [InventoryBalances] WITH (UPDLOCK, HOLDLOCK) + WHERE [WorkspaceId] = {workspaceId} AND [WarehouseId] = {warehouseId} AND [ProductId] = {productId} + """).SingleOrDefaultAsync(cancellationToken); + if (balance is not null) return balance; + + balance = new InventoryBalance(workspaceId, warehouseId, productId, 0m); + dbContext.InventoryBalances.Add(balance); + return balance; + } +} diff --git a/backend/src/InventoryFlow.Infrastructure/Warehouses/EfWarehouseCatalog.cs b/backend/src/InventoryFlow.Infrastructure/Warehouses/EfWarehouseCatalog.cs new file mode 100644 index 0000000..1c3ca9b --- /dev/null +++ b/backend/src/InventoryFlow.Infrastructure/Warehouses/EfWarehouseCatalog.cs @@ -0,0 +1,73 @@ +using System.Data; +using InventoryFlow.Application.Features.Inventory; +using InventoryFlow.Application.Features.Warehouses; +using InventoryFlow.Domain.Entities; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; + +namespace InventoryFlow.Infrastructure.Warehouses; + +/// Persists workspace-scoped warehouses with SQL Server lifecycle handling. +public sealed class EfWarehouseCatalog(ApplicationDbContext dbContext) : IWarehouseCatalog +{ + /// + public async Task CreateAsync(Warehouse warehouse, CancellationToken cancellationToken) + { + dbContext.Warehouses.Add(warehouse); + try + { + await dbContext.SaveChangesAsync(cancellationToken); + return warehouse; + } + catch (DbUpdateException exception) when (exception.InnerException is SqlException { Number: 2601 or 2627 }) + { + throw new WarehouseNameConflictException(); + } + } + + /// + public async Task> ListActiveAsync(Guid workspaceId, CancellationToken cancellationToken) => + await dbContext.Warehouses.AsNoTracking().Where(warehouse => warehouse.WorkspaceId == workspaceId && + warehouse.ArchivedAtUtc == null).OrderBy(warehouse => warehouse.Name).ThenBy(warehouse => warehouse.Id) + .ToListAsync(cancellationToken); + + /// + public Task FindAsync(Guid workspaceId, Guid warehouseId, CancellationToken cancellationToken) => + dbContext.Warehouses.SingleOrDefaultAsync(item => item.WorkspaceId == workspaceId && item.Id == warehouseId, + cancellationToken); + + /// + public async Task ArchiveAsync(Guid workspaceId, Guid warehouseId, DateTimeOffset archivedAtUtc, + CancellationToken cancellationToken) + { + var strategy = dbContext.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => + { + await using var transaction = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken); + var warehouse = await dbContext.Warehouses.SingleOrDefaultAsync(item => item.WorkspaceId == workspaceId && + item.Id == warehouseId, cancellationToken); + if (warehouse is null) + { + await transaction.RollbackAsync(cancellationToken); + return false; + } + + var hasOnHandBalance = await dbContext.InventoryBalances.AnyAsync(balance => balance.WorkspaceId == workspaceId && + balance.WarehouseId == warehouseId && balance.Quantity != 0m, cancellationToken); + if (hasOnHandBalance) + { + await transaction.RollbackAsync(cancellationToken); + throw new InventoryArchiveConflictException(); + } + + warehouse.Archive(archivedAtUtc); + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return true; + }); + } + + /// + public async Task SaveChangesAsync(CancellationToken cancellationToken) => await dbContext.SaveChangesAsync(cancellationToken); +} diff --git a/backend/src/InventoryFlow.Shared/InventoryFlow.Shared.csproj b/backend/src/InventoryFlow.Shared/InventoryFlow.Shared.csproj new file mode 100644 index 0000000..125f4c9 --- /dev/null +++ b/backend/src/InventoryFlow.Shared/InventoryFlow.Shared.csproj @@ -0,0 +1,9 @@ + + + + net9.0 + enable + enable + + + diff --git a/backend/tests/InventoryFlow.IntegrationTests/Api/AuthenticatedApiFixture.cs b/backend/tests/InventoryFlow.IntegrationTests/Api/AuthenticatedApiFixture.cs new file mode 100644 index 0000000..b8a3c5d --- /dev/null +++ b/backend/tests/InventoryFlow.IntegrationTests/Api/AuthenticatedApiFixture.cs @@ -0,0 +1,92 @@ +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Testcontainers.MsSql; + +namespace InventoryFlow.IntegrationTests.Api; + +/// +/// Provides an API host backed by an isolated, migrated SQL Server database. +/// +public sealed class AuthenticatedApiFixture : IAsyncLifetime +{ + private const string ConnectionStringEnvironmentVariable = + "ConnectionStrings__InventoryFlowDatabase"; + private const string SigningKeyEnvironmentVariable = "Jwt__SigningKey"; + private const string IssuerEnvironmentVariable = "Jwt__Issuer"; + private const string AudienceEnvironmentVariable = "Jwt__Audience"; + + private readonly MsSqlContainer _sqlServer = new MsSqlBuilder( + "mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04").Build(); + private readonly string? _originalConnectionString = Environment.GetEnvironmentVariable( + ConnectionStringEnvironmentVariable); + private readonly string? _originalSigningKey = Environment.GetEnvironmentVariable( + SigningKeyEnvironmentVariable); + private readonly string? _originalIssuer = Environment.GetEnvironmentVariable( + IssuerEnvironmentVariable); + private readonly string? _originalAudience = Environment.GetEnvironmentVariable( + AudienceEnvironmentVariable); + + /// + /// Gets the hosted API factory. + /// + public WebApplicationFactory Factory { get; private set; } = null!; + + /// + public async Task InitializeAsync() + { + await _sqlServer.StartAsync(); + + var databaseName = $"InventoryFlowTests_{Guid.NewGuid():N}"; + await using (var connection = new SqlConnection(_sqlServer.GetConnectionString())) + { + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = $"CREATE DATABASE [{databaseName}]"; + await command.ExecuteNonQueryAsync(); + } + + var connectionStringBuilder = new SqlConnectionStringBuilder(_sqlServer.GetConnectionString()) + { + InitialCatalog = databaseName, + }; + Environment.SetEnvironmentVariable( + ConnectionStringEnvironmentVariable, + connectionStringBuilder.ConnectionString); + Environment.SetEnvironmentVariable( + SigningKeyEnvironmentVariable, + "test-signing-key-that-is-at-least-thirty-two-bytes-long"); + Environment.SetEnvironmentVariable(IssuerEnvironmentVariable, "InventoryFlow.Test"); + Environment.SetEnvironmentVariable(AudienceEnvironmentVariable, "InventoryFlow.Test.Web"); + Factory = new AuthenticatedApiFactory(); + + await using var scope = Factory.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.MigrateAsync(); + } + + /// + public async Task DisposeAsync() + { + Factory?.Dispose(); + Environment.SetEnvironmentVariable( + ConnectionStringEnvironmentVariable, + _originalConnectionString); + Environment.SetEnvironmentVariable(SigningKeyEnvironmentVariable, _originalSigningKey); + Environment.SetEnvironmentVariable(IssuerEnvironmentVariable, _originalIssuer); + Environment.SetEnvironmentVariable(AudienceEnvironmentVariable, _originalAudience); + await _sqlServer.DisposeAsync(); + } + + private sealed class AuthenticatedApiFactory : WebApplicationFactory + { + /// + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Development"); + } + } +} diff --git a/backend/tests/InventoryFlow.IntegrationTests/Api/AuthenticationEndpointsTests.cs b/backend/tests/InventoryFlow.IntegrationTests/Api/AuthenticationEndpointsTests.cs new file mode 100644 index 0000000..9e76705 --- /dev/null +++ b/backend/tests/InventoryFlow.IntegrationTests/Api/AuthenticationEndpointsTests.cs @@ -0,0 +1,242 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using InventoryFlow.Application.Features.Authentication; +using Microsoft.AspNetCore.Mvc.Testing; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace InventoryFlow.IntegrationTests.Api; + +/// +/// Verifies authentication against an isolated SQL Server instance. +/// +public sealed class AuthenticationEndpointsTests : IClassFixture +{ + private const string RefreshCookieName = "inventory_flow_refresh"; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly HttpClient _client; + private readonly AuthenticatedApiFixture _fixture; + + /// + /// Initializes a new instance of the class. + /// + /// The SQL Server-backed API fixture. + public AuthenticationEndpointsTests(AuthenticatedApiFixture fixture) + { + _fixture = fixture; + _client = fixture.Factory.CreateClient(new WebApplicationFactoryClientOptions + { + HandleCookies = false, + }); + } + + /// + /// Registers a user and writes a secure browser-scoped refresh cookie. + /// + [Fact] + public async Task Register_WritesExpectedRefreshCookie() + { + // Act + using var response = await RegisterAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var cookie = GetRefreshCookieHeader(response); + Assert.Contains("httponly", cookie, StringComparison.OrdinalIgnoreCase); + Assert.Contains("samesite=strict", cookie, StringComparison.OrdinalIgnoreCase); + Assert.Contains("path=/api/auth", cookie, StringComparison.OrdinalIgnoreCase); + Assert.Contains("max-age=", cookie, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Authorizes the current-user endpoint with the registration JWT. + /// + [Fact] + public async Task Me_WithIssuedAccessToken_ReturnsCurrentUser() + { + // Arrange + using var registration = await RegisterAsync(); + var session = await ReadSessionAsync(registration); + using var request = new HttpRequestMessage(HttpMethod.Get, "/api/auth/me"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken); + + // Act + using var response = await _client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var user = await response.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(user); + Assert.Equal(session.User.Email, user.Email); + Assert.Equal(session.User.Workspace, user.Workspace); + } + + /// Provisions exactly one Owner workspace with registration. + [Fact] + public async Task Register_ProvisionsOwnerWorkspace() + { + using var registration = await RegisterAsync(); + var session = await ReadSessionAsync(registration); + await using var scope = _fixture.Factory.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + Assert.Equal(1, await dbContext.Workspaces.CountAsync(workspace => workspace.Id == session.User.Workspace.Id)); + Assert.Equal(1, await dbContext.WorkspaceMembers.CountAsync(member => member.UserId == session.User.Id && member.WorkspaceId == session.User.Workspace.Id)); + } + + /// + /// Rotates refresh tokens and invalidates an entire family after replay. + /// + [Fact] + public async Task Refresh_ReplayRevokesEntireTokenFamily() + { + // Arrange + using var registration = await RegisterAsync(); + var originalCookie = GetRefreshCookieValue(registration); + + // Act + using var rotated = await RefreshAsync(originalCookie); + var rotatedCookie = GetRefreshCookieValue(rotated); + using var replay = await RefreshAsync(originalCookie); + using var descendant = await RefreshAsync(rotatedCookie); + + // Assert + Assert.Equal(HttpStatusCode.OK, rotated.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, replay.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, descendant.StatusCode); + } + + /// + /// Revokes the browser's refresh-token family on logout. + /// + [Fact] + public async Task Logout_RevokesRefreshTokenFamily() + { + // Arrange + using var registration = await RegisterAsync(); + var refreshCookie = GetRefreshCookieValue(registration); + + // Act + using var logout = await SendWithRefreshCookieAsync(HttpMethod.Post, "/api/auth/logout", refreshCookie); + using var refresh = await RefreshAsync(refreshCookie); + + // Assert + Assert.Equal(HttpStatusCode.NoContent, logout.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, refresh.StatusCode); + } + + /// + /// Permits only one concurrent refresh and treats the other request as replay. + /// + [Fact] + public async Task Refresh_ConcurrentRequests_OnlyOneSucceedsAndRevokesFamily() + { + // Arrange + using var registration = await RegisterAsync(); + var originalCookie = GetRefreshCookieValue(registration); + + // Act + var refreshes = await Task.WhenAll( + RefreshAsync(originalCookie), + RefreshAsync(originalCookie)); + using var first = refreshes[0]; + using var second = refreshes[1]; + var successfulRefresh = refreshes.Single(response => response.StatusCode == HttpStatusCode.OK); + var replacementCookie = GetRefreshCookieValue(successfulRefresh); + using var descendant = await RefreshAsync(replacementCookie); + + // Assert + Assert.Single(refreshes, response => response.StatusCode == HttpStatusCode.OK); + Assert.Single(refreshes, response => response.StatusCode == HttpStatusCode.Unauthorized); + Assert.Equal(HttpStatusCode.Unauthorized, descendant.StatusCode); + } + + /// + /// Revokes the original refresh token and any replacement issued while logout and refresh overlap. + /// + [Fact] + public async Task Logout_ConcurrentWithRefresh_RevokesOriginalAndAnyReplacement() + { + // Arrange + using var registration = await RegisterAsync(); + var originalCookie = GetRefreshCookieValue(registration); + var start = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + // Act + var logoutTask = StartWhenReleasedAsync( + start.Task, + () => SendWithRefreshCookieAsync(HttpMethod.Post, "/api/auth/logout", originalCookie)); + var refreshTask = StartWhenReleasedAsync(start.Task, () => RefreshAsync(originalCookie)); + start.SetResult(); + + using var logout = await logoutTask; + using var refresh = await refreshTask; + var issuedCookies = new List { originalCookie }; + if (refresh.StatusCode == HttpStatusCode.OK) + { + issuedCookies.Add(GetRefreshCookieValue(refresh)); + } + + var refreshAttempts = await Task.WhenAll(issuedCookies.Select(RefreshAsync)); + using var originalAttempt = refreshAttempts[0]; + if (refreshAttempts.Length > 1) + { + using var replacementAttempt = refreshAttempts[1]; + Assert.Equal(HttpStatusCode.Unauthorized, replacementAttempt.StatusCode); + } + + // Assert + Assert.Equal(HttpStatusCode.NoContent, logout.StatusCode); + Assert.True( + refresh.StatusCode is HttpStatusCode.OK or HttpStatusCode.Unauthorized, + $"Expected refresh to succeed or be unauthorized, but received {refresh.StatusCode}."); + Assert.Equal(HttpStatusCode.Unauthorized, originalAttempt.StatusCode); + } + + private static async Task StartWhenReleasedAsync(Task start, Func> operation) + { + await start; + return await operation(); + } + + private async Task RegisterAsync() + { + var suffix = Guid.NewGuid().ToString("N"); + return await _client.PostAsJsonAsync( + "/api/auth/register", + new RegisterUserCommand($"User {suffix}", $"user-{suffix}@example.test", "Password!12345")); + } + + private async Task RefreshAsync(string refreshCookie) => + await SendWithRefreshCookieAsync(HttpMethod.Post, "/api/auth/refresh", refreshCookie); + + private async Task SendWithRefreshCookieAsync( + HttpMethod method, + string path, + string refreshCookie) + { + using var request = new HttpRequestMessage(method, path); + request.Headers.Add("Cookie", $"{RefreshCookieName}={refreshCookie}"); + return await _client.SendAsync(request); + } + + private static async Task ReadSessionAsync(HttpResponseMessage response) + { + var session = await response.Content.ReadFromJsonAsync(JsonOptions); + return Assert.IsType(session); + } + + private static string GetRefreshCookieHeader(HttpResponseMessage response) => + Assert.Single(response.Headers.GetValues("Set-Cookie")); + + private static string GetRefreshCookieValue(HttpResponseMessage response) + { + var header = GetRefreshCookieHeader(response); + var cookie = header.Split(';', 2)[0].Split('=', 2); + Assert.Equal(RefreshCookieName, cookie[0]); + return Uri.UnescapeDataString(cookie[1]); + } +} diff --git a/backend/tests/InventoryFlow.IntegrationTests/Api/CollaborationEndpointsTests.cs b/backend/tests/InventoryFlow.IntegrationTests/Api/CollaborationEndpointsTests.cs new file mode 100644 index 0000000..6c39569 --- /dev/null +++ b/backend/tests/InventoryFlow.IntegrationTests/Api/CollaborationEndpointsTests.cs @@ -0,0 +1,332 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Security.Claims; +using System.Text; +using System.Text.Json; +using InventoryFlow.Application.Features.Authentication; +using InventoryFlow.Application.Features.Collaboration; +using InventoryFlow.Application.Features.Products; +using InventoryFlow.Domain.Entities; +using InventoryFlow.Infrastructure.Authentication; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; + +namespace InventoryFlow.IntegrationTests.Api; + +/// Verifies workspace collaboration endpoints against SQL Server. +public sealed class CollaborationEndpointsTests : IClassFixture +{ + private const string RefreshCookieName = "inventory_flow_refresh"; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly HttpClient _client; + private readonly AuthenticatedApiFixture _fixture; + + /// Initializes the test class. + public CollaborationEndpointsTests(AuthenticatedApiFixture fixture) + { + _fixture = fixture; + _client = fixture.Factory.CreateClient(new WebApplicationFactoryClientOptions { HandleCookies = false }); + } + + /// Owner can invite an existing user, stores only token hash, and invited user can accept. + [Fact] + public async Task Invitation_CreateAndAccept_StoresHashOnlyAndCreatesMember() + { + var owner = await RegisterAsync("owner"); + var invited = await RegisterAsync("invited"); + var created = await CreateInvitationAsync(owner.Session.AccessToken, invited.Session.User.Email); + + Assert.False(string.IsNullOrWhiteSpace(created.Token)); + await using (var scope = _fixture.Factory.Services.CreateAsyncScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + var invitation = await dbContext.WorkspaceInvitations.SingleAsync(item => item.Id == created.Invitation.Id); + Assert.NotEqual(created.Token, invitation.TokenHash); + Assert.Equal(64, invitation.TokenHash.Length); + Assert.Equal(WorkspaceMemberRole.Member, invitation.Role); + } + + using var accept = await SendJsonWithBearerAsync(HttpMethod.Post, "/api/collaboration/invitations/accept", invited.Session.AccessToken, new AcceptWorkspaceInvitationRequest(created.Token)); + Assert.Equal(HttpStatusCode.OK, accept.StatusCode); + + await using var verifyScope = _fixture.Factory.Services.CreateAsyncScope(); + var verifyDb = verifyScope.ServiceProvider.GetRequiredService(); + var membership = await verifyDb.WorkspaceMembers.SingleAsync(member => member.WorkspaceId == owner.Session.User.Workspace.Id && member.UserId == invited.Session.User.Id); + Assert.Equal(WorkspaceMemberRole.Member, membership.Role); + } + + /// Members cannot use owner-only collaboration admin endpoints. + [Fact] + public async Task CollaborationAdmin_WithMember_ReturnsForbidden() + { + var (owner, invited, _) = await InviteAcceptAndSwitchMemberAsync(); + + using var response = await SendWithBearerAsync(HttpMethod.Get, "/api/collaboration/invitations", invited.Session.AccessToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Equal(owner.Session.User.Workspace.Id, invited.Session.User.Workspace.Id); + Assert.Equal("Member", invited.Session.User.Workspace.Role); + } + + /// Wrong email and revoked invitations cannot be accepted. + [Fact] + public async Task Invitation_AcceptWithWrongEmailOrRevokedToken_ReturnsBadRequest() + { + var owner = await RegisterAsync("owner"); + var invited = await RegisterAsync("invited"); + var other = await RegisterAsync("other"); + var wrongEmailInvitation = await CreateInvitationAsync(owner.Session.AccessToken, invited.Session.User.Email); + + using var wrongEmail = await SendJsonWithBearerAsync(HttpMethod.Post, "/api/collaboration/invitations/accept", other.Session.AccessToken, new AcceptWorkspaceInvitationRequest(wrongEmailInvitation.Token)); + Assert.Equal(HttpStatusCode.BadRequest, wrongEmail.StatusCode); + + var revokedInvitation = await CreateInvitationAsync(owner.Session.AccessToken, other.Session.User.Email); + using var revoke = await SendWithBearerAsync(HttpMethod.Post, $"/api/collaboration/invitations/{revokedInvitation.Invitation.Id}/revoke", owner.Session.AccessToken); + Assert.Equal(HttpStatusCode.NoContent, revoke.StatusCode); + + using var revokedAccept = await SendJsonWithBearerAsync(HttpMethod.Post, "/api/collaboration/invitations/accept", other.Session.AccessToken, new AcceptWorkspaceInvitationRequest(revokedInvitation.Token)); + Assert.Equal(HttpStatusCode.BadRequest, revokedAccept.StatusCode); + } + + /// Workspace switch rotates the refresh token and persists the active workspace across refresh. + [Fact] + public async Task SwitchWorkspace_PersistsActiveWorkspaceAcrossRefresh() + { + var (owner, invited, switchCookie) = await InviteAcceptAndSwitchMemberAsync(); + + using var refresh = await SendWithRefreshCookieAsync(HttpMethod.Post, "/api/auth/refresh", switchCookie); + var refreshedSession = await ReadSessionAsync(refresh); + + Assert.Equal(owner.Session.User.Workspace.Id, refreshedSession.User.Workspace.Id); + Assert.Equal("Member", refreshedSession.User.Workspace.Role); + Assert.Contains(refreshedSession.User.Workspaces, workspace => workspace.Id == invited.OriginalWorkspaceId); + } + + /// Members can use existing operational endpoints in an invited workspace. + [Fact] + public async Task OperationalEndpoint_WithMemberWorkspace_ReturnsSuccess() + { + var (owner, invited, _) = await InviteAcceptAndSwitchMemberAsync(); + var suffix = Guid.NewGuid().ToString("N"); + + using var create = await SendJsonWithBearerAsync(HttpMethod.Post, "/api/products", invited.Session.AccessToken, new CreateProductRequest($"Member Product {suffix}", $"MEM-{suffix}")); + + Assert.Equal(HttpStatusCode.Created, create.StatusCode); + var product = await create.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(product); + await using var scope = _fixture.Factory.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + Assert.True(await dbContext.Products.AnyAsync(item => item.WorkspaceId == owner.Session.User.Workspace.Id && item.Id == product.Id)); + } + + /// A forged active workspace claim without SQL membership fails closed. + [Fact] + public async Task OperationalEndpoint_WithStaleWorkspaceClaim_ReturnsForbidden() + { + var user = await RegisterAsync("forged"); + var forgedToken = CreateAccessToken(user.Session.User.Id, user.Session.User.Email, user.Session.User.DisplayName, Guid.NewGuid(), "Member"); + + using var response = await SendWithBearerAsync(HttpMethod.Get, "/api/products", forgedToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + /// Accepting an already-accepted invitation is rejected (second accept fails). + [Fact] + public async Task Invitation_AcceptTwice_SecondAcceptRejected() + { + var owner = await RegisterAsync("owner"); + var invited = await RegisterAsync("invited"); + var created = await CreateInvitationAsync(owner.Session.AccessToken, invited.Session.User.Email); + + using var first = await SendJsonWithBearerAsync(HttpMethod.Post, "/api/collaboration/invitations/accept", invited.Session.AccessToken, new AcceptWorkspaceInvitationRequest(created.Token)); + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + + using var second = await SendJsonWithBearerAsync(HttpMethod.Post, "/api/collaboration/invitations/accept", invited.Session.AccessToken, new AcceptWorkspaceInvitationRequest(created.Token)); + Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode); + } + + /// Creating an invitation for a non-existent email is rejected. + [Fact] + public async Task Invitation_CreateForNonExistentEmail_ReturnsBadRequest() + { + var owner = await RegisterAsync("owner"); + + using var response = await SendJsonWithBearerAsync(HttpMethod.Post, "/api/collaboration/invitations", owner.Session.AccessToken, new CreateWorkspaceInvitationRequest("nobody@example.test")); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + /// Creating an invitation for an already-member user is a conflict. + [Fact] + public async Task Invitation_CreateForExistingMember_ReturnsConflict() + { + var (owner, invited, _) = await InviteAcceptAndSwitchMemberAsync(); + + using var response = await SendJsonWithBearerAsync(HttpMethod.Post, "/api/collaboration/invitations", owner.Session.AccessToken, new CreateWorkspaceInvitationRequest(invited.Session.User.Email)); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + /// Creating a duplicate pending invitation is a conflict (unique index). + [Fact] + public async Task Invitation_CreateDuplicatePending_ReturnsConflict() + { + var owner = await RegisterAsync("owner"); + var invited = await RegisterAsync("invited"); + await CreateInvitationAsync(owner.Session.AccessToken, invited.Session.User.Email); + + using var response = await SendJsonWithBearerAsync(HttpMethod.Post, "/api/collaboration/invitations", owner.Session.AccessToken, new CreateWorkspaceInvitationRequest(invited.Session.User.Email)); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + /// Switching into a workspace the user is not a member of is forbidden. + [Fact] + public async Task SwitchWorkspace_ToNonMemberWorkspace_ReturnsForbidden() + { + var owner = await RegisterAsync("owner"); + var stranger = await RegisterAsync("stranger"); + + using var response = await SendJsonWithBearerAndCookieAsync(HttpMethod.Post, "/api/auth/workspace/switch", stranger.Session.AccessToken, stranger.RefreshCookie, new { workspaceId = owner.Session.User.Workspace.Id }); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + /// GET /api/auth/me with a forged/stale workspace claim returns 401. + [Fact] + public async Task Me_WithStaleWorkspaceClaim_ReturnsUnauthorized() + { + var user = await RegisterAsync("stale"); + var forgedToken = CreateAccessToken(user.Session.User.Id, user.Session.User.Email, user.Session.User.DisplayName, Guid.NewGuid(), "Member"); + + using var response = await SendWithBearerAsync(HttpMethod.Get, "/api/auth/me", forgedToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + /// Owner can list members and invitations in the active workspace (200). + [Fact] + public async Task CollaborationAdmin_WithOwner_ReturnsMembersAndInvitations() + { + var owner = await RegisterAsync("owner"); + var invited = await RegisterAsync("invited"); + await CreateInvitationAsync(owner.Session.AccessToken, invited.Session.User.Email); + + using var members = await SendWithBearerAsync(HttpMethod.Get, "/api/collaboration/members", owner.Session.AccessToken); + Assert.Equal(HttpStatusCode.OK, members.StatusCode); + var memberList = await members.Content.ReadFromJsonAsync>(JsonOptions); + Assert.NotNull(memberList); + + using var invitations = await SendWithBearerAsync(HttpMethod.Get, "/api/collaboration/invitations", owner.Session.AccessToken); + Assert.Equal(HttpStatusCode.OK, invitations.StatusCode); + var invitationList = await invitations.Content.ReadFromJsonAsync>(JsonOptions); + Assert.NotNull(invitationList); + Assert.Contains(invitationList, item => string.Equals(item.Email, invited.Session.User.Email, StringComparison.OrdinalIgnoreCase)); + } + + private async Task<(RegisteredUser Owner, RegisteredUser Invited, string SwitchCookie)> InviteAcceptAndSwitchMemberAsync() + { + var owner = await RegisterAsync("owner"); + var invited = await RegisterAsync("invited"); + var originalWorkspaceId = invited.Session.User.Workspace.Id; + var invitation = await CreateInvitationAsync(owner.Session.AccessToken, invited.Session.User.Email); + using var accept = await SendJsonWithBearerAsync(HttpMethod.Post, "/api/collaboration/invitations/accept", invited.Session.AccessToken, new AcceptWorkspaceInvitationRequest(invitation.Token)); + Assert.Equal(HttpStatusCode.OK, accept.StatusCode); + + using var switchResponse = await SendJsonWithBearerAndCookieAsync(HttpMethod.Post, "/api/auth/workspace/switch", invited.Session.AccessToken, invited.RefreshCookie, new { workspaceId = owner.Session.User.Workspace.Id }); + var switchedSession = await ReadSessionAsync(switchResponse); + var switchCookie = GetRefreshCookieValue(switchResponse); + return (owner, invited with { Session = switchedSession, RefreshCookie = switchCookie, OriginalWorkspaceId = originalWorkspaceId }, switchCookie); + } + + private async Task RegisterAsync(string prefix) + { + var suffix = Guid.NewGuid().ToString("N"); + using var response = await _client.PostAsJsonAsync( + "/api/auth/register", + new RegisterUserCommand($"{prefix} {suffix}", $"{prefix}-{suffix}@example.test", "Password!12345")); + var session = await ReadSessionAsync(response); + return new RegisteredUser(session, GetRefreshCookieValue(response), session.User.Workspace.Id); + } + + private async Task CreateInvitationAsync(string accessToken, string email) + { + using var response = await SendJsonWithBearerAsync(HttpMethod.Post, "/api/collaboration/invitations", accessToken, new CreateWorkspaceInvitationRequest(email)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var invitation = await response.Content.ReadFromJsonAsync(JsonOptions); + return Assert.IsType(invitation); + } + + private async Task SendWithBearerAsync(HttpMethod method, string path, string accessToken) + { + using var request = new HttpRequestMessage(method, path); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + return await _client.SendAsync(request); + } + + private async Task SendJsonWithBearerAsync(HttpMethod method, string path, string accessToken, T body) + { + using var request = new HttpRequestMessage(method, path) { Content = JsonContent.Create(body) }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + return await _client.SendAsync(request); + } + + private async Task SendJsonWithBearerAndCookieAsync(HttpMethod method, string path, string accessToken, string refreshCookie, T body) + { + using var request = new HttpRequestMessage(method, path) { Content = JsonContent.Create(body) }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + request.Headers.Add("Cookie", $"{RefreshCookieName}={refreshCookie}"); + return await _client.SendAsync(request); + } + + private async Task SendWithRefreshCookieAsync(HttpMethod method, string path, string refreshCookie) + { + using var request = new HttpRequestMessage(method, path); + request.Headers.Add("Cookie", $"{RefreshCookieName}={refreshCookie}"); + return await _client.SendAsync(request); + } + + private static async Task ReadSessionAsync(HttpResponseMessage response) + { + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var session = await response.Content.ReadFromJsonAsync(JsonOptions); + return Assert.IsType(session); + } + + private static string GetRefreshCookieValue(HttpResponseMessage response) + { + var header = Assert.Single(response.Headers.GetValues("Set-Cookie")); + var cookie = header.Split(';', 2)[0].Split('=', 2); + Assert.Equal(RefreshCookieName, cookie[0]); + return Uri.UnescapeDataString(cookie[1]); + } + + private static string CreateAccessToken(Guid userId, string email, string displayName, Guid workspaceId, string role) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("test-signing-key-that-is-at-least-thirty-two-bytes-long")); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + var token = new JwtSecurityToken( + "InventoryFlow.Test", + "InventoryFlow.Test.Web", + [ + new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()), + new Claim(ClaimTypes.NameIdentifier, userId.ToString()), + new Claim(ClaimTypes.Email, email), + new Claim("display_name", displayName), + new Claim(JwtAccessTokenIssuer.WorkspaceIdClaimType, workspaceId.ToString()), + new Claim(JwtAccessTokenIssuer.WorkspaceRoleClaimType, role) + ], + notBefore: DateTime.UtcNow.AddMinutes(-1), + expires: DateTime.UtcNow.AddMinutes(15), + signingCredentials: credentials); + return new JwtSecurityTokenHandler().WriteToken(token); + } + + private sealed record RegisteredUser(AuthenticationResponse Session, string RefreshCookie, Guid OriginalWorkspaceId); +} diff --git a/backend/tests/InventoryFlow.IntegrationTests/Api/GlobalExceptionHandlerTests.cs b/backend/tests/InventoryFlow.IntegrationTests/Api/GlobalExceptionHandlerTests.cs new file mode 100644 index 0000000..6641d9e --- /dev/null +++ b/backend/tests/InventoryFlow.IntegrationTests/Api/GlobalExceptionHandlerTests.cs @@ -0,0 +1,55 @@ +using System.Text.Json; +using InventoryFlow.Api.ExceptionHandling; +using InventoryFlow.Domain.Exceptions; +using InventoryFlow.Application.Features.Products; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; + +namespace InventoryFlow.IntegrationTests.Api; + +/// +/// Verifies exception responses produced by . +/// +public sealed class GlobalExceptionHandlerTests +{ + /// + /// Produces an RFC 7807 response for a domain exception. + /// + [Fact] + public async Task TryHandleAsync_WithDomainException_WritesProblemDetailsMediaType() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Response.Body = new MemoryStream(); + var handler = new GlobalExceptionHandler( + NullLogger.Instance); + + // Act + var handled = await handler.TryHandleAsync( + httpContext, + new DomainException("Product SKU must be unique."), + CancellationToken.None); + + // Assert + Assert.True(handled); + Assert.Equal(StatusCodes.Status400BadRequest, httpContext.Response.StatusCode); + Assert.Equal("application/problem+json", httpContext.Response.ContentType); + + httpContext.Response.Body.Position = 0; + var problemDetails = await JsonSerializer.DeserializeAsync( + httpContext.Response.Body); + Assert.NotNull(problemDetails); + Assert.Equal("Product SKU must be unique.", problemDetails.Detail); + } + /// Produces a conflict response for duplicate SKU exceptions. + [Fact] + public async Task TryHandleAsync_WithProductSkuConflict_WritesConflictProblemDetails() + { + var httpContext = new DefaultHttpContext { Response = { Body = new MemoryStream() } }; + var handler = new GlobalExceptionHandler(NullLogger.Instance); + var handled = await handler.TryHandleAsync(httpContext, new ProductSkuConflictException(), CancellationToken.None); + Assert.True(handled); + Assert.Equal(StatusCodes.Status409Conflict, httpContext.Response.StatusCode); + } +} diff --git a/backend/tests/InventoryFlow.IntegrationTests/Api/HealthEndpointTests.cs b/backend/tests/InventoryFlow.IntegrationTests/Api/HealthEndpointTests.cs new file mode 100644 index 0000000..059bf1b --- /dev/null +++ b/backend/tests/InventoryFlow.IntegrationTests/Api/HealthEndpointTests.cs @@ -0,0 +1,37 @@ +using System.Net; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace InventoryFlow.IntegrationTests.Api; + +/// +/// Verifies the API's operational health endpoint. +/// +public sealed class HealthEndpointTests : IClassFixture +{ + private readonly InventoryFlowApiFactory _factory; + + /// + /// Initializes a new instance of the class. + /// + /// The in-memory API host. + public HealthEndpointTests(InventoryFlowApiFactory factory) + { + _factory = factory; + } + + /// + /// Returns an OK response when the API host is available. + /// + [Fact] + public async Task GetHealth_WhenApiIsRunning_ReturnsOk() + { + // Arrange + using var client = _factory.CreateClient(); + + // Act + using var response = await client.GetAsync("/health"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} diff --git a/backend/tests/InventoryFlow.IntegrationTests/Api/InventoryEndpointsTests.cs b/backend/tests/InventoryFlow.IntegrationTests/Api/InventoryEndpointsTests.cs new file mode 100644 index 0000000..5214b14 --- /dev/null +++ b/backend/tests/InventoryFlow.IntegrationTests/Api/InventoryEndpointsTests.cs @@ -0,0 +1,222 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using InventoryFlow.Application.Features.Authentication; +using InventoryFlow.Application.Features.Inventory; +using InventoryFlow.Application.Features.Products; +using InventoryFlow.Application.Features.Warehouses; +using InventoryFlow.Domain.Entities; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace InventoryFlow.IntegrationTests.Api; + +/// Verifies inventory receipt, issue, balance, and idempotency behavior against SQL Server. +public sealed class InventoryEndpointsTests : IClassFixture +{ + private readonly HttpClient _client; + private readonly AuthenticatedApiFixture _fixture; + + /// Initializes the test client. + public InventoryEndpointsTests(AuthenticatedApiFixture fixture) + { + _fixture = fixture; + _client = fixture.Factory.CreateClient(new WebApplicationFactoryClientOptions { HandleCookies = false }); + } + + /// Records a decimal receipt once, prevents an overdraft, and exposes the resulting balance. + [Fact] + public async Task Inventory_ReceiptIssueAndBalance_AreIdempotentAndNonnegative() + { + var session = await RegisterSessionAsync(); + var suffix = Guid.NewGuid().ToString("N"); + var warehouse = await PostAsync("/api/warehouses", session.AccessToken, new CreateWarehouseRequest($"Warehouse {suffix}")); + var product = await PostAsync("/api/products", session.AccessToken, new CreateProductRequest($"Product {suffix}", $"SKU-{suffix}")); + var receiptRequest = new RecordInventoryMovementRequest(warehouse.Id, product.Id, 2.5000m, $"receipt-{suffix}"); + + using var receipt = await SendAsync(HttpMethod.Post, "/api/inventory/receipts", session.AccessToken, receiptRequest); + using var duplicateReceipt = await SendAsync(HttpMethod.Post, "/api/inventory/receipts", session.AccessToken, receiptRequest); + using var issue = await SendAsync(HttpMethod.Post, "/api/inventory/issues", session.AccessToken, + new RecordInventoryMovementRequest(warehouse.Id, product.Id, 1.2500m, $"issue-{suffix}")); + using var overdraft = await SendAsync(HttpMethod.Post, "/api/inventory/issues", session.AccessToken, + new RecordInventoryMovementRequest(warehouse.Id, product.Id, 1.2501m, $"overdraft-{suffix}")); + using var balances = await SendAsync(HttpMethod.Get, $"/api/inventory/balances?warehouseId={warehouse.Id}", session.AccessToken); + + Assert.Equal(HttpStatusCode.Created, receipt.StatusCode); + Assert.Equal(HttpStatusCode.Created, duplicateReceipt.StatusCode); + Assert.Equal(HttpStatusCode.Created, issue.StatusCode); + Assert.Equal(HttpStatusCode.Conflict, overdraft.StatusCode); + var balance = Assert.Single(await balances.Content.ReadFromJsonAsync>() ?? []); + Assert.Equal(1.2500m, balance.Quantity); + await using var scope = _fixture.Factory.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + Assert.Equal(2, await dbContext.InventoryMovements.CountAsync(movement => movement.WorkspaceId == session.User.Workspace.Id)); + } + + /// Persists the largest decimal(18,4) quantity accepted by the inventory ledger. + [Fact] + public async Task Receipt_WithMaximumDecimalQuantity_IsPersisted() + { + var session = await RegisterSessionAsync(); + var suffix = Guid.NewGuid().ToString("N"); + var warehouse = await PostAsync("/api/warehouses", session.AccessToken, new CreateWarehouseRequest($"Warehouse {suffix}")); + var product = await PostAsync("/api/products", session.AccessToken, new CreateProductRequest($"Product {suffix}", $"SKU-{suffix}")); + + using var response = await SendAsync(HttpMethod.Post, "/api/inventory/receipts", session.AccessToken, + new RecordInventoryMovementRequest(warehouse.Id, product.Id, InventoryMovement.MaxQuantity, $"maximum-{suffix}")); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + await using var scope = _fixture.Factory.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var balance = await dbContext.InventoryBalances.SingleAsync(item => item.WorkspaceId == session.User.Workspace.Id); + var movement = await dbContext.InventoryMovements.SingleAsync(item => item.WorkspaceId == session.User.Workspace.Id); + Assert.Equal(InventoryMovement.MaxQuantity, balance.Quantity); + Assert.Equal(InventoryMovement.MaxQuantity, movement.Quantity); + Assert.Equal(InventoryMovement.MaxQuantity, movement.BalanceAfterQuantity); + } + + /// Rejects a receipt quantity one unit above the decimal(18,4) maximum before persistence. + [Fact] + public async Task Receipt_ExceedingMaximumDecimalQuantity_IsRejectedWithoutPersistence() + { + var session = await RegisterSessionAsync(); + var suffix = Guid.NewGuid().ToString("N"); + var warehouse = await PostAsync("/api/warehouses", session.AccessToken, new CreateWarehouseRequest($"Warehouse {suffix}")); + var product = await PostAsync("/api/products", session.AccessToken, new CreateProductRequest($"Product {suffix}", $"SKU-{suffix}")); + + using var response = await SendAsync(HttpMethod.Post, "/api/inventory/receipts", session.AccessToken, + new RecordInventoryMovementRequest(warehouse.Id, product.Id, 100000000000000m, $"too-large-{suffix}")); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + await using var scope = _fixture.Factory.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + Assert.Equal(0, await dbContext.InventoryMovements.CountAsync(item => item.WorkspaceId == session.User.Workspace.Id)); + Assert.Equal(0, await dbContext.InventoryBalances.CountAsync(item => item.WorkspaceId == session.User.Workspace.Id)); + } + + /// Rejects a receipt that would overflow the persisted decimal(18,4) balance. + [Fact] + public async Task Receipt_OverflowingExistingBalance_IsRejectedWithoutChangingPersistedBalance() + { + var session = await RegisterSessionAsync(); + var suffix = Guid.NewGuid().ToString("N"); + var warehouse = await PostAsync("/api/warehouses", session.AccessToken, new CreateWarehouseRequest($"Warehouse {suffix}")); + var product = await PostAsync("/api/products", session.AccessToken, new CreateProductRequest($"Product {suffix}", $"SKU-{suffix}")); + var initialQuantity = InventoryMovement.MaxQuantity - 0.0001m; + await PostAsync("/api/inventory/receipts", session.AccessToken, + new RecordInventoryMovementRequest(warehouse.Id, product.Id, initialQuantity, $"initial-{suffix}")); + + using var response = await SendAsync(HttpMethod.Post, "/api/inventory/receipts", session.AccessToken, + new RecordInventoryMovementRequest(warehouse.Id, product.Id, 0.0002m, $"overflow-{suffix}")); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + await using var scope = _fixture.Factory.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var balance = await dbContext.InventoryBalances.SingleAsync(item => item.WorkspaceId == session.User.Workspace.Id); + Assert.Equal(initialQuantity, balance.Quantity); + Assert.Equal(1, await dbContext.InventoryMovements.CountAsync(item => item.WorkspaceId == session.User.Workspace.Id)); + } + + /// Prevents archiving either inventory source while scoped stock remains on hand. + [Fact] + public async Task Archive_WithOnHandBalance_ReturnsConflictForProductAndWarehouse() + { + var session = await RegisterSessionAsync(); + var suffix = Guid.NewGuid().ToString("N"); + var warehouse = await PostAsync("/api/warehouses", session.AccessToken, + new CreateWarehouseRequest($"Warehouse {suffix}")); + var product = await PostAsync("/api/products", session.AccessToken, + new CreateProductRequest($"Product {suffix}", $"SKU-{suffix}")); + await PostAsync("/api/inventory/receipts", session.AccessToken, + new RecordInventoryMovementRequest(warehouse.Id, product.Id, 1m, $"receipt-{suffix}")); + + using var productArchive = await SendAsync(HttpMethod.Delete, $"/api/products/{product.Id}", session.AccessToken); + using var warehouseArchive = await SendAsync(HttpMethod.Delete, $"/api/warehouses/{warehouse.Id}", session.AccessToken); + + Assert.Equal(HttpStatusCode.Conflict, productArchive.StatusCode); + Assert.Equal(HttpStatusCode.Conflict, warehouseArchive.StatusCode); + await using var scope = _fixture.Factory.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + Assert.Null((await dbContext.Products.SingleAsync(item => item.Id == product.Id)).ArchivedAtUtc); + Assert.Null((await dbContext.Warehouses.SingleAsync(item => item.Id == warehouse.Id)).ArchivedAtUtc); + } + + /// Serializes source archival against a concurrent receipt so stock cannot enter an archived product. + [Fact] + public async Task ProductArchive_AndConcurrentReceipt_CannotCreateMovementForAnArchivedProduct() + { + var session = await RegisterSessionAsync(); + var suffix = Guid.NewGuid().ToString("N"); + var warehouse = await PostAsync("/api/warehouses", session.AccessToken, + new CreateWarehouseRequest($"Warehouse {suffix}")); + var product = await PostAsync("/api/products", session.AccessToken, + new CreateProductRequest($"Product {suffix}", $"SKU-{suffix}")); + + var receiptTask = SendAsync(HttpMethod.Post, "/api/inventory/receipts", session.AccessToken, + new RecordInventoryMovementRequest(warehouse.Id, product.Id, 1m, $"receipt-{suffix}")); + var archiveTask = SendAsync(HttpMethod.Delete, $"/api/products/{product.Id}", session.AccessToken); + var responses = await Task.WhenAll(receiptTask, archiveTask); + using var receipt = responses[0]; + using var archive = responses[1]; + + Assert.True(receipt.StatusCode is HttpStatusCode.Created or HttpStatusCode.NotFound); + Assert.True(archive.StatusCode is HttpStatusCode.NoContent or HttpStatusCode.Conflict); + await using var scope = _fixture.Factory.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var persistedProduct = await dbContext.Products.SingleAsync(item => item.Id == product.Id); + var movementCount = await dbContext.InventoryMovements.CountAsync(item => item.ProductId == product.Id); + Assert.False(persistedProduct.ArchivedAtUtc.HasValue && movementCount > 0); + } + + /// Serializes source archival against a concurrent receipt so stock cannot enter an archived warehouse. + [Fact] + public async Task WarehouseArchive_AndConcurrentReceipt_CannotCreateMovementForAnArchivedWarehouse() + { + var session = await RegisterSessionAsync(); + var suffix = Guid.NewGuid().ToString("N"); + var warehouse = await PostAsync("/api/warehouses", session.AccessToken, + new CreateWarehouseRequest($"Warehouse {suffix}")); + var product = await PostAsync("/api/products", session.AccessToken, + new CreateProductRequest($"Product {suffix}", $"SKU-{suffix}")); + + var receiptTask = SendAsync(HttpMethod.Post, "/api/inventory/receipts", session.AccessToken, + new RecordInventoryMovementRequest(warehouse.Id, product.Id, 1m, $"receipt-{suffix}")); + var archiveTask = SendAsync(HttpMethod.Delete, $"/api/warehouses/{warehouse.Id}", session.AccessToken); + var responses = await Task.WhenAll(receiptTask, archiveTask); + using var receipt = responses[0]; + using var archive = responses[1]; + + Assert.True(receipt.StatusCode is HttpStatusCode.Created or HttpStatusCode.NotFound); + Assert.True(archive.StatusCode is HttpStatusCode.NoContent or HttpStatusCode.Conflict); + await using var scope = _fixture.Factory.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var persistedWarehouse = await dbContext.Warehouses.SingleAsync(item => item.Id == warehouse.Id); + var movementCount = await dbContext.InventoryMovements.CountAsync(item => item.WarehouseId == warehouse.Id); + Assert.False(persistedWarehouse.ArchivedAtUtc.HasValue && movementCount > 0); + } + + private async Task PostAsync(string path, string token, object content) + { + using var response = await SendAsync(HttpMethod.Post, path, token, content); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + return (await response.Content.ReadFromJsonAsync())!; + } + + private async Task RegisterSessionAsync() + { + var suffix = Guid.NewGuid().ToString("N"); + using var response = await _client.PostAsJsonAsync("/api/auth/register", new RegisterUserCommand($"User {suffix}", $"user-{suffix}@example.test", "Password!12345")); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return (await response.Content.ReadFromJsonAsync())!; + } + + private async Task SendAsync(HttpMethod method, string path, string token, object? content = null) + { + using var request = new HttpRequestMessage(method, path); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + if (content is not null) request.Content = JsonContent.Create(content); + return await _client.SendAsync(request); + } +} diff --git a/backend/tests/InventoryFlow.IntegrationTests/Api/InventoryFlowApiFactory.cs b/backend/tests/InventoryFlow.IntegrationTests/Api/InventoryFlowApiFactory.cs new file mode 100644 index 0000000..f21cc72 --- /dev/null +++ b/backend/tests/InventoryFlow.IntegrationTests/Api/InventoryFlowApiFactory.cs @@ -0,0 +1,78 @@ +using Microsoft.AspNetCore.DataProtection.KeyManagement; +using System.Xml.Linq; +using Microsoft.AspNetCore.DataProtection.Repositories; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace InventoryFlow.IntegrationTests.Api; + +/// +/// Hosts the API with test-only process configuration. +/// +public sealed class InventoryFlowApiFactory : WebApplicationFactory +{ + private const string ConnectionStringEnvironmentVariable = + "ConnectionStrings__InventoryFlowDatabase"; + + private readonly string? _originalConnectionString = Environment.GetEnvironmentVariable( + ConnectionStringEnvironmentVariable); + + /// + /// Initializes a new instance of the class. + /// + public InventoryFlowApiFactory() + { + Environment.SetEnvironmentVariable( + ConnectionStringEnvironmentVariable, + "Server=inventory-flow-test;Database=InventoryFlowTests;Integrated Security=True;TrustServerCertificate=True"); + Environment.SetEnvironmentVariable("Jwt__SigningKey", "test-signing-key-that-is-at-least-thirty-two-bytes-long"); + Environment.SetEnvironmentVariable("Jwt__Issuer", "InventoryFlow.Test"); + Environment.SetEnvironmentVariable("Jwt__Audience", "InventoryFlow.Test.Web"); + } + + /// + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureTestServices(services => services + .PostConfigure(options => + options.XmlRepository = new InMemoryXmlRepository())); + } + + private sealed class InMemoryXmlRepository : IXmlRepository + { + private readonly List _elements = []; + + public IReadOnlyCollection GetAllElements() + { + lock (_elements) + { + return _elements.Select(element => new XElement(element)).ToArray(); + } + } + + public void StoreElement(XElement element, string friendlyName) + { + ArgumentNullException.ThrowIfNull(element); + + lock (_elements) + { + _elements.Add(new XElement(element)); + } + } + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + Environment.SetEnvironmentVariable( + ConnectionStringEnvironmentVariable, + _originalConnectionString); + } + + base.Dispose(disposing); + } +} diff --git a/backend/tests/InventoryFlow.IntegrationTests/Api/ProductEndpointsTests.cs b/backend/tests/InventoryFlow.IntegrationTests/Api/ProductEndpointsTests.cs new file mode 100644 index 0000000..25b7921 --- /dev/null +++ b/backend/tests/InventoryFlow.IntegrationTests/Api/ProductEndpointsTests.cs @@ -0,0 +1,108 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using InventoryFlow.Application.Features.Authentication; +using InventoryFlow.Application.Features.Products; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace InventoryFlow.IntegrationTests.Api; + +/// Verifies workspace-scoped product API behavior against SQL Server. +public sealed class ProductEndpointsTests : IClassFixture +{ + private readonly HttpClient _client; + private readonly AuthenticatedApiFixture _fixture; + + /// Initializes the test client. + public ProductEndpointsTests(AuthenticatedApiFixture fixture) + { + _fixture = fixture; + _client = fixture.Factory.CreateClient(new WebApplicationFactoryClientOptions { HandleCookies = false }); + } + + /// Creates, lists, archives, and permanently reserves a SKU through the database unique index. + [Fact] + public async Task ProductLifecycle_UsesCanonicalSkuAndKeepsArchivedSkuReserved() + { + var session = await RegisterSessionAsync(); + var token = session.AccessToken; + using var created = await SendAsync(HttpMethod.Post, "/api/products", token, new CreateProductRequest(" Widget ", " ab-1 ")); + var product = await created.Content.ReadFromJsonAsync(); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + Assert.Equal("AB-1", product!.Sku); + using var listed = await SendAsync(HttpMethod.Get, "/api/products", token); + Assert.Single(await listed.Content.ReadFromJsonAsync>() ?? []); + using var archived = await SendAsync(HttpMethod.Delete, $"/api/products/{product.Id}", token); + using var archivedAgain = await SendAsync(HttpMethod.Delete, $"/api/products/{product.Id}", token); + using var duplicate = await SendAsync(HttpMethod.Post, "/api/products", token, new CreateProductRequest("Another", "AB-1")); + Assert.Equal(HttpStatusCode.NoContent, archived.StatusCode); + Assert.Equal(HttpStatusCode.NoContent, archivedAgain.StatusCode); + Assert.Equal(HttpStatusCode.Conflict, duplicate.StatusCode); + await using var scope = _fixture.Factory.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + Assert.Equal(1, await dbContext.Products.CountAsync(product => product.WorkspaceId == session.User.Workspace.Id)); + } + + /// Enforces workspace isolation while allowing same SKU in different workspaces. + [Fact] + public async Task Products_AreIsolatedByWorkspace() + { + var firstToken = await RegisterAsync(); + var secondToken = await RegisterAsync(); + using var firstCreate = await SendAsync(HttpMethod.Post, "/api/products", firstToken, new CreateProductRequest("First", "SHARED")); + var firstProduct = await firstCreate.Content.ReadFromJsonAsync(); + using var secondCreate = await SendAsync(HttpMethod.Post, "/api/products", secondToken, new CreateProductRequest("Second", "shared")); + using var secondList = await SendAsync(HttpMethod.Get, "/api/products", secondToken); + using var crossArchive = await SendAsync(HttpMethod.Delete, $"/api/products/{firstProduct!.Id}", secondToken); + Assert.Equal(HttpStatusCode.Created, firstCreate.StatusCode); + Assert.Equal(HttpStatusCode.Created, secondCreate.StatusCode); + Assert.Single(await secondList.Content.ReadFromJsonAsync>() ?? []); + Assert.Equal(HttpStatusCode.NotFound, crossArchive.StatusCode); + } + + /// Maps a same-workspace SKU race through the database unique index to one creation and one conflict. + [Fact] + public async Task Create_ConcurrentCanonicalSkuRequests_CreateOneProductAndReturnOneConflict() + { + // Arrange + var session = await RegisterSessionAsync(); + var suffix = Guid.NewGuid().ToString("N"); + var firstRequest = SendAsync(HttpMethod.Post, "/api/products", session.AccessToken, new CreateProductRequest("First", $"sku-{suffix}")); + var secondRequest = SendAsync(HttpMethod.Post, "/api/products", session.AccessToken, new CreateProductRequest("Second", $" SKU-{suffix.ToUpperInvariant()} ")); + + // Act + var responses = await Task.WhenAll(firstRequest, secondRequest); + using var firstResponse = responses[0]; + using var secondResponse = responses[1]; + + // Assert + Assert.Single(responses, response => response.StatusCode == HttpStatusCode.Created); + Assert.Single(responses, response => response.StatusCode == HttpStatusCode.Conflict); + await using var scope = _fixture.Factory.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + Assert.Equal(1, await dbContext.Products.CountAsync(product => product.WorkspaceId == session.User.Workspace.Id)); + } + + private async Task RegisterAsync() => (await RegisterSessionAsync()).AccessToken; + + private async Task RegisterSessionAsync() + { + var suffix = Guid.NewGuid().ToString("N"); + using var response = await _client.PostAsJsonAsync("/api/auth/register", new RegisterUserCommand($"User {suffix}", $"user-{suffix}@example.test", "Password!12345")); + var session = await response.Content.ReadFromJsonAsync(); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return session!; + } + + private async Task SendAsync(HttpMethod method, string path, string token, object? content = null) + { + using var request = new HttpRequestMessage(method, path); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + if (content is not null) request.Content = JsonContent.Create(content); + return await _client.SendAsync(request); + } +} diff --git a/backend/tests/InventoryFlow.IntegrationTests/Api/PurchaseReceiptEndpointsTests.cs b/backend/tests/InventoryFlow.IntegrationTests/Api/PurchaseReceiptEndpointsTests.cs new file mode 100644 index 0000000..fab2c96 --- /dev/null +++ b/backend/tests/InventoryFlow.IntegrationTests/Api/PurchaseReceiptEndpointsTests.cs @@ -0,0 +1,233 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using InventoryFlow.Application.Features.Authentication; +using InventoryFlow.Application.Features.Products; +using InventoryFlow.Application.Features.Purchases; +using InventoryFlow.Application.Features.Suppliers; +using InventoryFlow.Application.Features.Warehouses; +using InventoryFlow.Domain.Entities; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace InventoryFlow.IntegrationTests.Api; + +public sealed class PurchaseReceiptEndpointsTests : IClassFixture +{ + private readonly AuthenticatedApiFixture _fixture; + private readonly HttpClient _client; + + public PurchaseReceiptEndpointsTests(AuthenticatedApiFixture fixture) + { + _fixture = fixture; + _client = fixture.Factory.CreateClient(new WebApplicationFactoryClientOptions { HandleCookies = false }); + } + + [Fact] + public async Task Receipts_WithoutAccessToken_ReturnUnauthorized() + { + using var post = await _client.PostAsJsonAsync("/api/purchases/receipts", + new RecordPurchaseReceiptRequest(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), 1m, "unauthenticated")); + using var get = await _client.GetAsync("/api/purchases/receipts"); + + Assert.Equal(HttpStatusCode.Unauthorized, post.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, get.StatusCode); + } + + [Fact] + public async Task Receipts_CrossWorkspaceSourcesReturnNotFoundAndHistoryIsIsolated() + { + var firstSession = await RegisterAsync(); + var secondSession = await RegisterAsync(); + var firstSources = await CreateReceiptSourcesAsync(firstSession); + var secondSources = await CreateReceiptSourcesAsync(secondSession); + await PostAsync("/api/purchases/receipts", firstSession.AccessToken, + new RecordPurchaseReceiptRequest(firstSources.Supplier.Id, firstSources.Warehouse.Id, firstSources.Product.Id, 1m, + $"first-{Guid.NewGuid():N}")); + + using var foreignSupplier = await SendAsync(HttpMethod.Post, "/api/purchases/receipts", secondSession.AccessToken, + new RecordPurchaseReceiptRequest(firstSources.Supplier.Id, secondSources.Warehouse.Id, secondSources.Product.Id, 1m, + $"foreign-supplier-{Guid.NewGuid():N}")); + using var foreignWarehouse = await SendAsync(HttpMethod.Post, "/api/purchases/receipts", secondSession.AccessToken, + new RecordPurchaseReceiptRequest(secondSources.Supplier.Id, firstSources.Warehouse.Id, secondSources.Product.Id, 1m, + $"foreign-warehouse-{Guid.NewGuid():N}")); + using var foreignProduct = await SendAsync(HttpMethod.Post, "/api/purchases/receipts", secondSession.AccessToken, + new RecordPurchaseReceiptRequest(secondSources.Supplier.Id, secondSources.Warehouse.Id, firstSources.Product.Id, 1m, + $"foreign-product-{Guid.NewGuid():N}")); + using var firstHistoryResponse = await SendAsync(HttpMethod.Get, "/api/purchases/receipts", firstSession.AccessToken); + using var secondHistoryResponse = await SendAsync(HttpMethod.Get, "/api/purchases/receipts", secondSession.AccessToken); + + Assert.Equal(HttpStatusCode.NotFound, foreignSupplier.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, foreignWarehouse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, foreignProduct.StatusCode); + Assert.Equal(HttpStatusCode.OK, firstHistoryResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, secondHistoryResponse.StatusCode); + Assert.Single((await firstHistoryResponse.Content.ReadFromJsonAsync>())!); + Assert.Empty((await secondHistoryResponse.Content.ReadFromJsonAsync>())!); + } + + [Fact] + public async Task Receipts_WithArchivedSourcesReturnNotFoundWithoutSideEffects() + { + var session = await RegisterAsync(); + + var archivedSupplierSources = await CreateReceiptSourcesAsync(session); + using var archivedSupplier = await SendAsync(HttpMethod.Delete, $"/api/suppliers/{archivedSupplierSources.Supplier.Id}", session.AccessToken); + using var supplierReceipt = await SendAsync(HttpMethod.Post, "/api/purchases/receipts", session.AccessToken, + CreateReceiptRequest(archivedSupplierSources, $"archived-supplier-{Guid.NewGuid():N}")); + + var archivedProductSources = await CreateReceiptSourcesAsync(session); + using var archivedProduct = await SendAsync(HttpMethod.Delete, $"/api/products/{archivedProductSources.Product.Id}", session.AccessToken); + using var productReceipt = await SendAsync(HttpMethod.Post, "/api/purchases/receipts", session.AccessToken, + CreateReceiptRequest(archivedProductSources, $"archived-product-{Guid.NewGuid():N}")); + + var archivedWarehouseSources = await CreateReceiptSourcesAsync(session); + using var archivedWarehouse = await SendAsync(HttpMethod.Delete, $"/api/warehouses/{archivedWarehouseSources.Warehouse.Id}", session.AccessToken); + using var warehouseReceipt = await SendAsync(HttpMethod.Post, "/api/purchases/receipts", session.AccessToken, + CreateReceiptRequest(archivedWarehouseSources, $"archived-warehouse-{Guid.NewGuid():N}")); + + Assert.Equal(HttpStatusCode.NoContent, archivedSupplier.StatusCode); + Assert.Equal(HttpStatusCode.NoContent, archivedProduct.StatusCode); + Assert.Equal(HttpStatusCode.NoContent, archivedWarehouse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, supplierReceipt.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, productReceipt.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, warehouseReceipt.StatusCode); + await AssertNoReceiptSideEffectsAsync(session.User.Workspace.Id); + } + + [Fact] + public async Task Receipts_WithInvalidRequestReturnBadRequestWithoutSideEffects() + { + var session = await RegisterAsync(); + var sources = await CreateReceiptSourcesAsync(session); + + using var response = await SendAsync(HttpMethod.Post, "/api/purchases/receipts", session.AccessToken, + new RecordPurchaseReceiptRequest(sources.Supplier.Id, sources.Warehouse.Id, sources.Product.Id, 0m, + $"invalid-{Guid.NewGuid():N}")); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + await AssertNoReceiptSideEffectsAsync(session.User.Workspace.Id); + } + + [Fact] + public async Task ReceiptHistory_IsOrderedNewestFirst() + { + var session = await RegisterAsync(); + var sources = await CreateReceiptSourcesAsync(session); + var first = await PostAsync("/api/purchases/receipts", session.AccessToken, + CreateReceiptRequest(sources, $"first-{Guid.NewGuid():N}")); + await Task.Delay(TimeSpan.FromMilliseconds(10)); + var second = await PostAsync("/api/purchases/receipts", session.AccessToken, + CreateReceiptRequest(sources, $"second-{Guid.NewGuid():N}")); + using var historyResponse = await SendAsync(HttpMethod.Get, "/api/purchases/receipts", session.AccessToken); + + Assert.Equal(HttpStatusCode.OK, historyResponse.StatusCode); + var history = (await historyResponse.Content.ReadFromJsonAsync>())!; + Assert.Collection(history, + receipt => Assert.Equal(second.Id, receipt.Id), + receipt => Assert.Equal(first.Id, receipt.Id)); + Assert.True(history[0].ReceivedAtUtc > history[1].ReceivedAtUtc); + } + + [Fact] + public async Task Receipt_IsAtomicAndReplayReturnsTheOriginalDocument() + { + var session = await RegisterAsync(); + var suffix = Guid.NewGuid().ToString("N"); + var supplier = await PostAsync("/api/suppliers", session.AccessToken, new CreateSupplierRequest($"Supplier {suffix}")); + var warehouse = await PostAsync("/api/warehouses", session.AccessToken, new CreateWarehouseRequest($"Warehouse {suffix}")); + var product = await PostAsync("/api/products", session.AccessToken, new CreateProductRequest($"Product {suffix}", $"SKU-{suffix}")); + var request = new RecordPurchaseReceiptRequest(supplier.Id, warehouse.Id, product.Id, 2.5m, $"receipt-{suffix}"); + + using var first = await SendAsync(HttpMethod.Post, "/api/purchases/receipts", session.AccessToken, request); + using var replay = await SendAsync(HttpMethod.Post, "/api/purchases/receipts", session.AccessToken, request); + Assert.Equal(HttpStatusCode.Created, first.StatusCode); + Assert.Equal(HttpStatusCode.Created, replay.StatusCode); + var posted = (await first.Content.ReadFromJsonAsync())!; + var replayed = (await replay.Content.ReadFromJsonAsync())!; + Assert.Equal(posted.Id, replayed.Id); + + await using var scope = _fixture.Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var receipt = await db.PurchaseReceipts.SingleAsync(item => item.Id == posted.Id); + var movement = await db.InventoryMovements.SingleAsync(item => item.Id == receipt.InventoryMovementId); + var balance = await db.InventoryBalances.SingleAsync(item => item.WorkspaceId == session.User.Workspace.Id); + Assert.Equal(InventoryMovementType.Receipt, movement.Type); + Assert.Equal(2.5m, balance.Quantity); + Assert.Equal(1, await db.PurchaseReceipts.CountAsync(item => item.WorkspaceId == session.User.Workspace.Id)); + Assert.Equal(1, await db.InventoryMovements.CountAsync(item => item.WorkspaceId == session.User.Workspace.Id)); + } + + [Fact] + public async Task Receipt_ConcurrentReplayCreatesOneDocumentAndMovement() + { + var session = await RegisterAsync(); + var suffix = Guid.NewGuid().ToString("N"); + var supplier = await PostAsync("/api/suppliers", session.AccessToken, new CreateSupplierRequest($"Supplier {suffix}")); + var warehouse = await PostAsync("/api/warehouses", session.AccessToken, new CreateWarehouseRequest($"Warehouse {suffix}")); + var product = await PostAsync("/api/products", session.AccessToken, new CreateProductRequest($"Product {suffix}", $"SKU-{suffix}")); + var request = new RecordPurchaseReceiptRequest(supplier.Id, warehouse.Id, product.Id, 1m, $"receipt-{suffix}"); + + var responses = await Task.WhenAll( + SendAsync(HttpMethod.Post, "/api/purchases/receipts", session.AccessToken, request), + SendAsync(HttpMethod.Post, "/api/purchases/receipts", session.AccessToken, request)); + using var first = responses[0]; + using var second = responses[1]; + Assert.Equal(HttpStatusCode.Created, first.StatusCode); + Assert.Equal(HttpStatusCode.Created, second.StatusCode); + + await using var scope = _fixture.Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + Assert.Equal(1, await db.PurchaseReceipts.CountAsync(item => item.WorkspaceId == session.User.Workspace.Id)); + Assert.Equal(1, await db.InventoryMovements.CountAsync(item => item.WorkspaceId == session.User.Workspace.Id)); + Assert.Equal(1m, await db.InventoryBalances.Where(item => item.WorkspaceId == session.User.Workspace.Id).Select(item => item.Quantity).SingleAsync()); + } + + private async Task CreateReceiptSourcesAsync(AuthenticationResponse session) + { + var suffix = Guid.NewGuid().ToString("N"); + var supplier = await PostAsync("/api/suppliers", session.AccessToken, new CreateSupplierRequest($"Supplier {suffix}")); + var warehouse = await PostAsync("/api/warehouses", session.AccessToken, new CreateWarehouseRequest($"Warehouse {suffix}")); + var product = await PostAsync("/api/products", session.AccessToken, new CreateProductRequest($"Product {suffix}", $"SKU-{suffix}")); + return new ReceiptSources(supplier, warehouse, product); + } + + private static RecordPurchaseReceiptRequest CreateReceiptRequest(ReceiptSources sources, string idempotencyKey) => + new(sources.Supplier.Id, sources.Warehouse.Id, sources.Product.Id, 1m, idempotencyKey); + + private async Task AssertNoReceiptSideEffectsAsync(Guid workspaceId) + { + await using var scope = _fixture.Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + Assert.Equal(0, await db.PurchaseReceipts.CountAsync(item => item.WorkspaceId == workspaceId)); + Assert.Equal(0, await db.InventoryMovements.CountAsync(item => item.WorkspaceId == workspaceId)); + Assert.Equal(0, await db.InventoryBalances.CountAsync(item => item.WorkspaceId == workspaceId)); + } + + private async Task PostAsync(string path, string token, object content) + { + using var response = await SendAsync(HttpMethod.Post, path, token, content); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + return (await response.Content.ReadFromJsonAsync())!; + } + + private async Task RegisterAsync() + { + var suffix = Guid.NewGuid().ToString("N"); + using var response = await _client.PostAsJsonAsync("/api/auth/register", new RegisterUserCommand($"User {suffix}", $"user-{suffix}@example.test", "Password!12345")); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return (await response.Content.ReadFromJsonAsync())!; + } + + private sealed record ReceiptSources(SupplierResponse Supplier, WarehouseResponse Warehouse, ProductResponse Product); + + private async Task SendAsync(HttpMethod method, string path, string token, object? content = null) + { + using var request = new HttpRequestMessage(method, path); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + if (content is not null) request.Content = JsonContent.Create(content); + return await _client.SendAsync(request); + } +} diff --git a/backend/tests/InventoryFlow.IntegrationTests/Api/SalesFulfillmentEndpointsTests.cs b/backend/tests/InventoryFlow.IntegrationTests/Api/SalesFulfillmentEndpointsTests.cs new file mode 100644 index 0000000..948e946 --- /dev/null +++ b/backend/tests/InventoryFlow.IntegrationTests/Api/SalesFulfillmentEndpointsTests.cs @@ -0,0 +1,220 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using InventoryFlow.Application.Features.Authentication; +using InventoryFlow.Application.Features.Products; +using InventoryFlow.Application.Features.Purchases; +using InventoryFlow.Application.Features.Sales; +using InventoryFlow.Application.Features.Suppliers; +using InventoryFlow.Application.Features.Warehouses; +using InventoryFlow.Domain.Entities; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace InventoryFlow.IntegrationTests.Api; + +public sealed class SalesFulfillmentEndpointsTests : IClassFixture +{ + private readonly AuthenticatedApiFixture _fixture; + private readonly HttpClient _client; + + public SalesFulfillmentEndpointsTests(AuthenticatedApiFixture fixture) + { + _fixture = fixture; + _client = fixture.Factory.CreateClient(new WebApplicationFactoryClientOptions { HandleCookies = false }); + } + + [Fact] + public async Task Fulfillments_WithoutAccessToken_ReturnUnauthorized() + { + using var post = await _client.PostAsJsonAsync("/api/sales/fulfillments", + new RecordSalesFulfillmentRequest(Guid.NewGuid(), Guid.NewGuid(), 1m, "unauthenticated")); + using var get = await _client.GetAsync("/api/sales/fulfillments"); + + Assert.Equal(HttpStatusCode.Unauthorized, post.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, get.StatusCode); + } + + [Fact] + public async Task Fulfillments_CrossWorkspaceSourcesReturnNotFoundAndHistoryIsIsolated() + { + var firstSession = await RegisterAsync(); + var secondSession = await RegisterAsync(); + var firstSources = await CreateFulfillmentSourcesAsync(firstSession, 2m); + var secondSources = await CreateFulfillmentSourcesAsync(secondSession, 2m); + await PostAsync("/api/sales/fulfillments", firstSession.AccessToken, + new RecordSalesFulfillmentRequest(firstSources.Warehouse.Id, firstSources.Product.Id, 1m, $"first-{Guid.NewGuid():N}")); + + using var foreignWarehouse = await SendAsync(HttpMethod.Post, "/api/sales/fulfillments", secondSession.AccessToken, + new RecordSalesFulfillmentRequest(firstSources.Warehouse.Id, secondSources.Product.Id, 1m, $"warehouse-{Guid.NewGuid():N}")); + using var foreignProduct = await SendAsync(HttpMethod.Post, "/api/sales/fulfillments", secondSession.AccessToken, + new RecordSalesFulfillmentRequest(secondSources.Warehouse.Id, firstSources.Product.Id, 1m, $"product-{Guid.NewGuid():N}")); + using var firstHistory = await SendAsync(HttpMethod.Get, "/api/sales/fulfillments", firstSession.AccessToken); + using var secondHistory = await SendAsync(HttpMethod.Get, "/api/sales/fulfillments", secondSession.AccessToken); + + Assert.Equal(HttpStatusCode.NotFound, foreignWarehouse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, foreignProduct.StatusCode); + Assert.Single((await firstHistory.Content.ReadFromJsonAsync>())!); + Assert.Empty((await secondHistory.Content.ReadFromJsonAsync>())!); + } + + [Fact] + public async Task Fulfillments_WithArchivedSourcesReturnNotFoundWithoutSideEffects() + { + var session = await RegisterAsync(); + var archivedProductSources = await CreateFulfillmentSourcesAsync(session); + var beforeProduct = await SnapshotAsync(session.User.Workspace.Id); + using var archiveProduct = await SendAsync(HttpMethod.Delete, $"/api/products/{archivedProductSources.Product.Id}", session.AccessToken); + using var productFulfillment = await SendAsync(HttpMethod.Post, "/api/sales/fulfillments", session.AccessToken, + CreateFulfillmentRequest(archivedProductSources, $"archived-product-{Guid.NewGuid():N}")); + + var archivedWarehouseSources = await CreateFulfillmentSourcesAsync(session); + var beforeWarehouse = await SnapshotAsync(session.User.Workspace.Id); + using var archiveWarehouse = await SendAsync(HttpMethod.Delete, $"/api/warehouses/{archivedWarehouseSources.Warehouse.Id}", session.AccessToken); + using var warehouseFulfillment = await SendAsync(HttpMethod.Post, "/api/sales/fulfillments", session.AccessToken, + CreateFulfillmentRequest(archivedWarehouseSources, $"archived-warehouse-{Guid.NewGuid():N}")); + + Assert.Equal(HttpStatusCode.NoContent, archiveProduct.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, productFulfillment.StatusCode); + Assert.Equal(beforeProduct, await SnapshotAsync(session.User.Workspace.Id)); + Assert.Equal(HttpStatusCode.NoContent, archiveWarehouse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, warehouseFulfillment.StatusCode); + Assert.Equal(beforeWarehouse, await SnapshotAsync(session.User.Workspace.Id)); + } + + [Fact] + public async Task Fulfillment_IsAtomicAndReplayReturnsTheOriginalDocument() + { + var session = await RegisterAsync(); + var sources = await CreateFulfillmentSourcesAsync(session, 4m); + var request = CreateFulfillmentRequest(sources, $"fulfillment-{Guid.NewGuid():N}", 2.5m); + + using var first = await SendAsync(HttpMethod.Post, "/api/sales/fulfillments", session.AccessToken, request); + using var replay = await SendAsync(HttpMethod.Post, "/api/sales/fulfillments", session.AccessToken, request); + + Assert.Equal(HttpStatusCode.Created, first.StatusCode); + Assert.Equal(HttpStatusCode.Created, replay.StatusCode); + var posted = (await first.Content.ReadFromJsonAsync())!; + var replayed = (await replay.Content.ReadFromJsonAsync())!; + Assert.Equal(posted.Id, replayed.Id); + + await using var scope = _fixture.Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var fulfillment = await db.SalesFulfillments.SingleAsync(item => item.Id == posted.Id); + var movement = await db.InventoryMovements.SingleAsync(item => item.Id == fulfillment.InventoryMovementId); + var balance = await db.InventoryBalances.SingleAsync(item => item.WorkspaceId == session.User.Workspace.Id && + item.WarehouseId == sources.Warehouse.Id && item.ProductId == sources.Product.Id); + Assert.Equal(InventoryMovementType.Issue, movement.Type); + Assert.Equal(1.5m, balance.Quantity); + Assert.Equal(1, await db.SalesFulfillments.CountAsync(item => item.WorkspaceId == session.User.Workspace.Id)); + Assert.Equal(2, await db.InventoryMovements.CountAsync(item => item.WorkspaceId == session.User.Workspace.Id)); + } + + [Fact] + public async Task Fulfillment_WithInsufficientInventoryReturnsConflictWithoutSideEffects() + { + var session = await RegisterAsync(); + var sources = await CreateFulfillmentSourcesAsync(session, 1m); + var before = await SnapshotAsync(session.User.Workspace.Id); + + using var response = await SendAsync(HttpMethod.Post, "/api/sales/fulfillments", session.AccessToken, + CreateFulfillmentRequest(sources, $"overdraft-{Guid.NewGuid():N}", 1.0001m)); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + Assert.Equal(before, await SnapshotAsync(session.User.Workspace.Id)); + } + + [Fact] + public async Task FulfillmentHistory_IsOrderedNewestFirst() + { + var session = await RegisterAsync(); + var sources = await CreateFulfillmentSourcesAsync(session, 3m); + var first = await PostAsync("/api/sales/fulfillments", session.AccessToken, + CreateFulfillmentRequest(sources, $"first-{Guid.NewGuid():N}", 1m)); + await Task.Delay(TimeSpan.FromMilliseconds(10)); + var second = await PostAsync("/api/sales/fulfillments", session.AccessToken, + CreateFulfillmentRequest(sources, $"second-{Guid.NewGuid():N}", 1m)); + using var response = await SendAsync(HttpMethod.Get, "/api/sales/fulfillments", session.AccessToken); + + var history = (await response.Content.ReadFromJsonAsync>())!; + Assert.Collection(history, item => Assert.Equal(second.Id, item.Id), item => Assert.Equal(first.Id, item.Id)); + Assert.True(history[0].FulfilledAtUtc > history[1].FulfilledAtUtc); + } + + [Fact] + public async Task Fulfillment_ConcurrentReplayCreatesOneDocumentAndIssue() + { + var session = await RegisterAsync(); + var sources = await CreateFulfillmentSourcesAsync(session, 2m); + var request = CreateFulfillmentRequest(sources, $"fulfillment-{Guid.NewGuid():N}"); + + var responses = await Task.WhenAll( + SendAsync(HttpMethod.Post, "/api/sales/fulfillments", session.AccessToken, request), + SendAsync(HttpMethod.Post, "/api/sales/fulfillments", session.AccessToken, request)); + using var first = responses[0]; + using var second = responses[1]; + Assert.Equal(HttpStatusCode.Created, first.StatusCode); + Assert.Equal(HttpStatusCode.Created, second.StatusCode); + + await using var scope = _fixture.Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + Assert.Equal(1, await db.SalesFulfillments.CountAsync(item => item.WorkspaceId == session.User.Workspace.Id)); + Assert.Equal(2, await db.InventoryMovements.CountAsync(item => item.WorkspaceId == session.User.Workspace.Id)); + Assert.Equal(1m, await db.InventoryBalances.Where(item => item.WorkspaceId == session.User.Workspace.Id) + .Select(item => item.Quantity).SingleAsync()); + } + + private async Task CreateFulfillmentSourcesAsync(AuthenticationResponse session, + decimal? receivedQuantity = null) + { + var suffix = Guid.NewGuid().ToString("N"); + var supplier = await PostAsync("/api/suppliers", session.AccessToken, new CreateSupplierRequest($"Supplier {suffix}")); + var warehouse = await PostAsync("/api/warehouses", session.AccessToken, new CreateWarehouseRequest($"Warehouse {suffix}")); + var product = await PostAsync("/api/products", session.AccessToken, new CreateProductRequest($"Product {suffix}", $"SKU-{suffix}")); + if (receivedQuantity.HasValue) + await PostAsync("/api/purchases/receipts", session.AccessToken, + new RecordPurchaseReceiptRequest(supplier.Id, warehouse.Id, product.Id, receivedQuantity.Value, $"receipt-{suffix}")); + return new FulfillmentSources(warehouse, product); + } + + private static RecordSalesFulfillmentRequest CreateFulfillmentRequest(FulfillmentSources sources, string idempotencyKey, + decimal quantity = 1m) => new(sources.Warehouse.Id, sources.Product.Id, quantity, idempotencyKey); + + private async Task SnapshotAsync(Guid workspaceId) + { + await using var scope = _fixture.Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return new Snapshot(await db.SalesFulfillments.CountAsync(item => item.WorkspaceId == workspaceId), + await db.InventoryMovements.CountAsync(item => item.WorkspaceId == workspaceId), + await db.InventoryBalances.Where(item => item.WorkspaceId == workspaceId).Select(item => item.Quantity).SumAsync()); + } + + private async Task PostAsync(string path, string token, object content) + { + using var response = await SendAsync(HttpMethod.Post, path, token, content); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + return (await response.Content.ReadFromJsonAsync())!; + } + + private async Task RegisterAsync() + { + var suffix = Guid.NewGuid().ToString("N"); + using var response = await _client.PostAsJsonAsync("/api/auth/register", new RegisterUserCommand($"User {suffix}", + $"user-{suffix}@example.test", "Password!12345")); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return (await response.Content.ReadFromJsonAsync())!; + } + + private sealed record FulfillmentSources(WarehouseResponse Warehouse, ProductResponse Product); + private sealed record Snapshot(int Fulfillments, int Movements, decimal Quantity); + + private async Task SendAsync(HttpMethod method, string path, string token, object? content = null) + { + using var request = new HttpRequestMessage(method, path); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + if (content is not null) request.Content = JsonContent.Create(content); + return await _client.SendAsync(request); + } +} diff --git a/backend/tests/InventoryFlow.IntegrationTests/Api/SupplierEndpointsTests.cs b/backend/tests/InventoryFlow.IntegrationTests/Api/SupplierEndpointsTests.cs new file mode 100644 index 0000000..fe68490 --- /dev/null +++ b/backend/tests/InventoryFlow.IntegrationTests/Api/SupplierEndpointsTests.cs @@ -0,0 +1,113 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using InventoryFlow.Application.Features.Authentication; +using InventoryFlow.Application.Features.Suppliers; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace InventoryFlow.IntegrationTests.Api; + +/// Verifies workspace-scoped supplier API behavior against SQL Server. +public sealed class SupplierEndpointsTests : IClassFixture +{ + private readonly HttpClient _client; + private readonly AuthenticatedApiFixture _fixture; + + /// Initializes the test client. + public SupplierEndpointsTests(AuthenticatedApiFixture fixture) + { + _fixture = fixture; + _client = fixture.Factory.CreateClient(new WebApplicationFactoryClientOptions { HandleCookies = false }); + } + + /// Rejects unauthenticated supplier requests. + [Fact] + public async Task List_WithoutAccessToken_ReturnsUnauthorized() + { + using var response = await _client.GetAsync("/api/suppliers"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + /// Creates, lists, archives, and permanently reserves a normalized name through the database unique index. + [Fact] + public async Task SupplierLifecycle_UsesNormalizedNameAndKeepsArchivedNameReserved() + { + var session = await RegisterSessionAsync(); + var token = session.AccessToken; + using var created = await SendAsync(HttpMethod.Post, "/api/suppliers", token, new CreateSupplierRequest(" Acme Supply ")); + var supplier = await created.Content.ReadFromJsonAsync(); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + Assert.Equal("Acme Supply", supplier!.Name); + using var listed = await SendAsync(HttpMethod.Get, "/api/suppliers", token); + Assert.Single(await listed.Content.ReadFromJsonAsync>() ?? []); + using var archived = await SendAsync(HttpMethod.Delete, $"/api/suppliers/{supplier.Id}", token); + using var archivedAgain = await SendAsync(HttpMethod.Delete, $"/api/suppliers/{supplier.Id}", token); + using var duplicate = await SendAsync(HttpMethod.Post, "/api/suppliers", token, new CreateSupplierRequest(" acme supply ")); + Assert.Equal(HttpStatusCode.NoContent, archived.StatusCode); + Assert.Equal(HttpStatusCode.NoContent, archivedAgain.StatusCode); + Assert.Equal(HttpStatusCode.Conflict, duplicate.StatusCode); + await using var scope = _fixture.Factory.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + Assert.Equal(1, await dbContext.Suppliers.CountAsync(supplier => supplier.WorkspaceId == session.User.Workspace.Id)); + } + + /// Enforces workspace isolation while allowing the same name in different workspaces. + [Fact] + public async Task Suppliers_AreIsolatedByWorkspace() + { + var firstToken = await RegisterAsync(); + var secondToken = await RegisterAsync(); + using var firstCreate = await SendAsync(HttpMethod.Post, "/api/suppliers", firstToken, new CreateSupplierRequest("Shared")); + var firstSupplier = await firstCreate.Content.ReadFromJsonAsync(); + using var secondCreate = await SendAsync(HttpMethod.Post, "/api/suppliers", secondToken, new CreateSupplierRequest(" shared ")); + using var secondList = await SendAsync(HttpMethod.Get, "/api/suppliers", secondToken); + using var crossArchive = await SendAsync(HttpMethod.Delete, $"/api/suppliers/{firstSupplier!.Id}", secondToken); + Assert.Equal(HttpStatusCode.Created, firstCreate.StatusCode); + Assert.Equal(HttpStatusCode.Created, secondCreate.StatusCode); + Assert.Single(await secondList.Content.ReadFromJsonAsync>() ?? []); + Assert.Equal(HttpStatusCode.NotFound, crossArchive.StatusCode); + } + + /// Maps a same-workspace name race through the database unique index to one creation and one conflict. + [Fact] + public async Task Create_ConcurrentNormalizedNameRequests_CreateOneSupplierAndReturnOneConflict() + { + var session = await RegisterSessionAsync(); + var suffix = Guid.NewGuid().ToString("N"); + var firstRequest = SendAsync(HttpMethod.Post, "/api/suppliers", session.AccessToken, new CreateSupplierRequest($"Supplier-{suffix}")); + var secondRequest = SendAsync(HttpMethod.Post, "/api/suppliers", session.AccessToken, new CreateSupplierRequest($" supplier-{suffix.ToUpperInvariant()} ")); + + var responses = await Task.WhenAll(firstRequest, secondRequest); + using var firstResponse = responses[0]; + using var secondResponse = responses[1]; + + Assert.Single(responses, response => response.StatusCode == HttpStatusCode.Created); + Assert.Single(responses, response => response.StatusCode == HttpStatusCode.Conflict); + await using var scope = _fixture.Factory.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + Assert.Equal(1, await dbContext.Suppliers.CountAsync(supplier => supplier.WorkspaceId == session.User.Workspace.Id)); + } + + private async Task RegisterAsync() => (await RegisterSessionAsync()).AccessToken; + + private async Task RegisterSessionAsync() + { + var suffix = Guid.NewGuid().ToString("N"); + using var response = await _client.PostAsJsonAsync("/api/auth/register", new RegisterUserCommand($"User {suffix}", $"user-{suffix}@example.test", "Password!12345")); + var session = await response.Content.ReadFromJsonAsync(); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return session!; + } + + private async Task SendAsync(HttpMethod method, string path, string token, object? content = null) + { + using var request = new HttpRequestMessage(method, path); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + if (content is not null) request.Content = JsonContent.Create(content); + return await _client.SendAsync(request); + } +} diff --git a/backend/tests/InventoryFlow.IntegrationTests/Api/WarehouseEndpointsTests.cs b/backend/tests/InventoryFlow.IntegrationTests/Api/WarehouseEndpointsTests.cs new file mode 100644 index 0000000..19b6d12 --- /dev/null +++ b/backend/tests/InventoryFlow.IntegrationTests/Api/WarehouseEndpointsTests.cs @@ -0,0 +1,116 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using InventoryFlow.Application.Features.Authentication; +using InventoryFlow.Application.Features.Warehouses; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace InventoryFlow.IntegrationTests.Api; + +/// Verifies workspace-scoped warehouse API behavior against SQL Server. +public sealed class WarehouseEndpointsTests : IClassFixture +{ + private readonly HttpClient _client; + private readonly AuthenticatedApiFixture _fixture; + + /// Initializes the test client. + public WarehouseEndpointsTests(AuthenticatedApiFixture fixture) + { + _fixture = fixture; + _client = fixture.Factory.CreateClient(new WebApplicationFactoryClientOptions { HandleCookies = false }); + } + + /// Rejects unauthenticated warehouse requests. + [Fact] + public async Task List_WithoutAccessToken_ReturnsUnauthorized() + { + using var response = await _client.GetAsync("/api/warehouses"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + /// Creates, lists, archives, and permanently reserves a name through the database unique index. + [Fact] + public async Task WarehouseLifecycle_UsesCanonicalNameAndKeepsArchivedNameReserved() + { + var session = await RegisterSessionAsync(); + var token = session.AccessToken; + using var created = await SendAsync(HttpMethod.Post, "/api/warehouses", token, new CreateWarehouseRequest(" Central Depot ")); + var warehouse = await created.Content.ReadFromJsonAsync(); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + Assert.Equal("Central Depot", warehouse!.Name); + using var listed = await SendAsync(HttpMethod.Get, "/api/warehouses", token); + Assert.Single(await listed.Content.ReadFromJsonAsync>() ?? []); + using var archived = await SendAsync(HttpMethod.Delete, $"/api/warehouses/{warehouse.Id}", token); + using var archivedAgain = await SendAsync(HttpMethod.Delete, $"/api/warehouses/{warehouse.Id}", token); + using var duplicate = await SendAsync(HttpMethod.Post, "/api/warehouses", token, new CreateWarehouseRequest("Central Depot")); + Assert.Equal(HttpStatusCode.NoContent, archived.StatusCode); + Assert.Equal(HttpStatusCode.NoContent, archivedAgain.StatusCode); + Assert.Equal(HttpStatusCode.Conflict, duplicate.StatusCode); + await using var scope = _fixture.Factory.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + Assert.Equal(1, await dbContext.Warehouses.CountAsync(warehouse => warehouse.WorkspaceId == session.User.Workspace.Id)); + } + + /// Enforces workspace isolation while allowing same name in different workspaces. + [Fact] + public async Task Warehouses_AreIsolatedByWorkspace() + { + var firstToken = await RegisterAsync(); + var secondToken = await RegisterAsync(); + using var firstCreate = await SendAsync(HttpMethod.Post, "/api/warehouses", firstToken, new CreateWarehouseRequest("Shared")); + var firstWarehouse = await firstCreate.Content.ReadFromJsonAsync(); + using var secondCreate = await SendAsync(HttpMethod.Post, "/api/warehouses", secondToken, new CreateWarehouseRequest(" shared ")); + using var secondList = await SendAsync(HttpMethod.Get, "/api/warehouses", secondToken); + using var crossArchive = await SendAsync(HttpMethod.Delete, $"/api/warehouses/{firstWarehouse!.Id}", secondToken); + Assert.Equal(HttpStatusCode.Created, firstCreate.StatusCode); + Assert.Equal(HttpStatusCode.Created, secondCreate.StatusCode); + Assert.Single(await secondList.Content.ReadFromJsonAsync>() ?? []); + Assert.Equal(HttpStatusCode.NotFound, crossArchive.StatusCode); + } + + /// Maps a same-workspace name race through the database unique index to one creation and one conflict. + [Fact] + public async Task Create_ConcurrentCanonicalNameRequests_CreateOneWarehouseAndReturnOneConflict() + { + // Arrange + var session = await RegisterSessionAsync(); + var suffix = Guid.NewGuid().ToString("N"); + var firstRequest = SendAsync(HttpMethod.Post, "/api/warehouses", session.AccessToken, new CreateWarehouseRequest($"Depot-{suffix}")); + var secondRequest = SendAsync(HttpMethod.Post, "/api/warehouses", session.AccessToken, new CreateWarehouseRequest($" depot-{suffix.ToUpperInvariant()} ")); + + // Act + var responses = await Task.WhenAll(firstRequest, secondRequest); + using var firstResponse = responses[0]; + using var secondResponse = responses[1]; + + // Assert + Assert.Single(responses, response => response.StatusCode == HttpStatusCode.Created); + Assert.Single(responses, response => response.StatusCode == HttpStatusCode.Conflict); + await using var scope = _fixture.Factory.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + Assert.Equal(1, await dbContext.Warehouses.CountAsync(warehouse => warehouse.WorkspaceId == session.User.Workspace.Id)); + } + + private async Task RegisterAsync() => (await RegisterSessionAsync()).AccessToken; + + private async Task RegisterSessionAsync() + { + var suffix = Guid.NewGuid().ToString("N"); + using var response = await _client.PostAsJsonAsync("/api/auth/register", new RegisterUserCommand($"User {suffix}", $"user-{suffix}@example.test", "Password!12345")); + var session = await response.Content.ReadFromJsonAsync(); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return session!; + } + + private async Task SendAsync(HttpMethod method, string path, string token, object? content = null) + { + using var request = new HttpRequestMessage(method, path); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + if (content is not null) request.Content = JsonContent.Create(content); + return await _client.SendAsync(request); + } +} diff --git a/backend/tests/InventoryFlow.IntegrationTests/Api/WarehouseTransferEndpointsTests.cs b/backend/tests/InventoryFlow.IntegrationTests/Api/WarehouseTransferEndpointsTests.cs new file mode 100644 index 0000000..e7ee90c --- /dev/null +++ b/backend/tests/InventoryFlow.IntegrationTests/Api/WarehouseTransferEndpointsTests.cs @@ -0,0 +1,254 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using InventoryFlow.Application.Features.Authentication; +using InventoryFlow.Application.Features.Products; +using InventoryFlow.Application.Features.Purchases; +using InventoryFlow.Application.Features.Suppliers; +using InventoryFlow.Application.Features.Transfers; +using InventoryFlow.Application.Features.Warehouses; +using InventoryFlow.Domain.Entities; +using InventoryFlow.Infrastructure.Persistence; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace InventoryFlow.IntegrationTests.Api; + +public sealed class WarehouseTransferEndpointsTests : IClassFixture +{ + private readonly AuthenticatedApiFixture _fixture; + private readonly HttpClient _client; + + public WarehouseTransferEndpointsTests(AuthenticatedApiFixture fixture) + { + _fixture = fixture; + _client = fixture.Factory.CreateClient(new WebApplicationFactoryClientOptions { HandleCookies = false }); + } + + [Fact] + public async Task Transfers_WithoutAccessToken_ReturnUnauthorized() + { + using var post = await _client.PostAsJsonAsync("/api/transfers", + new RecordWarehouseTransferRequest(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), 1m, "unauthenticated")); + using var get = await _client.GetAsync("/api/transfers"); + + Assert.Equal(HttpStatusCode.Unauthorized, post.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, get.StatusCode); + } + + [Fact] + public async Task Transfer_IsAtomicConservingAndDurablyReplayed() + { + var session = await RegisterAsync(); + var sources = await CreateSourcesAsync(session, 5m); + var request = new RecordWarehouseTransferRequest(sources.Source.Id, sources.Destination.Id, sources.Product.Id, 2m, + $"transfer-{Guid.NewGuid():N}"); + + using var first = await SendAsync(HttpMethod.Post, "/api/transfers", session.AccessToken, request); + using var replay = await SendAsync(HttpMethod.Post, "/api/transfers", session.AccessToken, request); + Assert.Equal(HttpStatusCode.Created, first.StatusCode); + Assert.Equal(HttpStatusCode.Created, replay.StatusCode); + var posted = (await first.Content.ReadFromJsonAsync())!; + Assert.Equal(posted.Id, (await replay.Content.ReadFromJsonAsync())!.Id); + + await using var scope = _fixture.Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var transfer = await db.WarehouseTransfers.SingleAsync(item => item.Id == posted.Id); + var movements = await db.InventoryMovements.Where(item => item.Id == transfer.SourceInventoryMovementId || + item.Id == transfer.DestinationInventoryMovementId).ToListAsync(); + var balances = await db.InventoryBalances.Where(item => item.WorkspaceId == session.User.Workspace.Id && + item.ProductId == sources.Product.Id).ToListAsync(); + + Assert.Equal(2, movements.Count); + Assert.Contains(movements, item => item.Id == posted.SourceInventoryMovementId && item.Type == InventoryMovementType.Issue && item.Quantity == 2m); + Assert.Contains(movements, item => item.Id == posted.DestinationInventoryMovementId && item.Type == InventoryMovementType.Receipt && item.Quantity == 2m); + Assert.Equal(movements[0].OccurredAtUtc, movements[1].OccurredAtUtc); + Assert.Equal(3m, balances.Single(item => item.WarehouseId == sources.Source.Id).Quantity); + Assert.Equal(2m, balances.Single(item => item.WarehouseId == sources.Destination.Id).Quantity); + Assert.Equal(5m, balances.Sum(item => item.Quantity)); + Assert.Single(await db.WarehouseTransfers.Where(item => item.WorkspaceId == session.User.Workspace.Id).ToListAsync()); + } + + [Fact] + public async Task OppositeDirectionTransfersUseCompletePairsAndConserveInventory() + { + var session = await RegisterAsync(); + var sources = await CreateSourcesAsync(session, 20m); + var reverseSupplier = await PostAsync("/api/suppliers", session.AccessToken, + new CreateSupplierRequest($"Supplier {Guid.NewGuid():N}")); + await PostAsync("/api/purchases/receipts", session.AccessToken, + new RecordPurchaseReceiptRequest(reverseSupplier.Id, sources.Destination.Id, sources.Product.Id, 20m, + $"receipt-{Guid.NewGuid():N}")); + + var forward = SendAsync(HttpMethod.Post, "/api/transfers", session.AccessToken, + new RecordWarehouseTransferRequest(sources.Source.Id, sources.Destination.Id, sources.Product.Id, 3m, $"forward-{Guid.NewGuid():N}")); + var reverse = SendAsync(HttpMethod.Post, "/api/transfers", session.AccessToken, + new RecordWarehouseTransferRequest(sources.Destination.Id, sources.Source.Id, sources.Product.Id, 4m, $"reverse-{Guid.NewGuid():N}")); + var responses = await Task.WhenAll(forward, reverse); + using var first = responses[0]; + using var second = responses[1]; + Assert.Equal(HttpStatusCode.Created, first.StatusCode); + Assert.Equal(HttpStatusCode.Created, second.StatusCode); + + await using var scope = _fixture.Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var transfers = await db.WarehouseTransfers.Where(item => item.WorkspaceId == session.User.Workspace.Id).ToListAsync(); + var balances = await db.InventoryBalances.Where(item => item.WorkspaceId == session.User.Workspace.Id && + item.ProductId == sources.Product.Id).ToListAsync(); + Assert.Equal(2, transfers.Count); + Assert.Equal(6, await db.InventoryMovements.CountAsync(item => item.WorkspaceId == session.User.Workspace.Id)); + Assert.Equal(40m, balances.Sum(item => item.Quantity)); + Assert.All(balances, balance => Assert.True(balance.Quantity >= 0m)); + } + + [Fact] + public async Task Transfer_WithInsufficientInventoryLeavesNoPairOrDestinationBalance() + { + var session = await RegisterAsync(); + var sources = await CreateSourcesAsync(session, 1m); + using var response = await SendAsync(HttpMethod.Post, "/api/transfers", session.AccessToken, + new RecordWarehouseTransferRequest(sources.Source.Id, sources.Destination.Id, sources.Product.Id, 1.0001m, + $"overdraft-{Guid.NewGuid():N}")); + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + + await using var scope = _fixture.Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + Assert.Empty(await db.WarehouseTransfers.Where(item => item.WorkspaceId == session.User.Workspace.Id).ToListAsync()); + Assert.Empty(await db.InventoryBalances.Where(item => item.WorkspaceId == session.User.Workspace.Id && + item.WarehouseId == sources.Destination.Id && item.ProductId == sources.Product.Id).ToListAsync()); + Assert.Equal(1m, await db.InventoryBalances.Where(item => item.WorkspaceId == session.User.Workspace.Id && + item.WarehouseId == sources.Source.Id && item.ProductId == sources.Product.Id).Select(item => item.Quantity).SingleAsync()); + } + + [Fact] + public async Task Transfer_ConcurrentSameKeyReplayCreatesOneTransferAndPair() + { + var session = await RegisterAsync(); + var sources = await CreateSourcesAsync(session, 5m); + var request = new RecordWarehouseTransferRequest(sources.Source.Id, sources.Destination.Id, sources.Product.Id, 2m, + $"concurrent-replay-{Guid.NewGuid():N}"); + + var responses = await Task.WhenAll( + SendAsync(HttpMethod.Post, "/api/transfers", session.AccessToken, request), + SendAsync(HttpMethod.Post, "/api/transfers", session.AccessToken, request)); + using var first = responses[0]; + using var second = responses[1]; + Assert.Equal(HttpStatusCode.Created, first.StatusCode); + Assert.Equal(HttpStatusCode.Created, second.StatusCode); + Assert.Equal((await first.Content.ReadFromJsonAsync())!.Id, + (await second.Content.ReadFromJsonAsync())!.Id); + + await using var scope = _fixture.Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var transfer = await db.WarehouseTransfers.SingleAsync(item => item.WorkspaceId == session.User.Workspace.Id); + Assert.Equal(2, await db.InventoryMovements.CountAsync(item => item.Id == transfer.SourceInventoryMovementId || + item.Id == transfer.DestinationInventoryMovementId)); + Assert.Equal(3m, await db.InventoryBalances.Where(item => item.WorkspaceId == session.User.Workspace.Id && + item.WarehouseId == sources.Source.Id && item.ProductId == sources.Product.Id).Select(item => item.Quantity).SingleAsync()); + Assert.Equal(2m, await db.InventoryBalances.Where(item => item.WorkspaceId == session.User.Workspace.Id && + item.WarehouseId == sources.Destination.Id && item.ProductId == sources.Product.Id).Select(item => item.Quantity).SingleAsync()); + } + + [Fact] + public async Task Transfers_WithCrossTenantIdsReturnNotFoundWithoutSideEffects() + { + var firstSession = await RegisterAsync(); + var secondSession = await RegisterAsync(); + var firstSources = await CreateSourcesAsync(firstSession); + var secondSources = await CreateSourcesAsync(secondSession); + + using var foreignSource = await SendAsync(HttpMethod.Post, "/api/transfers", secondSession.AccessToken, + new RecordWarehouseTransferRequest(firstSources.Source.Id, secondSources.Destination.Id, secondSources.Product.Id, 1m, + $"foreign-source-{Guid.NewGuid():N}")); + using var foreignDestination = await SendAsync(HttpMethod.Post, "/api/transfers", secondSession.AccessToken, + new RecordWarehouseTransferRequest(secondSources.Source.Id, firstSources.Destination.Id, secondSources.Product.Id, 1m, + $"foreign-destination-{Guid.NewGuid():N}")); + using var foreignProduct = await SendAsync(HttpMethod.Post, "/api/transfers", secondSession.AccessToken, + new RecordWarehouseTransferRequest(secondSources.Source.Id, secondSources.Destination.Id, firstSources.Product.Id, 1m, + $"foreign-product-{Guid.NewGuid():N}")); + + Assert.Equal(HttpStatusCode.NotFound, foreignSource.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, foreignDestination.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, foreignProduct.StatusCode); + await AssertNoTransferSideEffectsAsync(secondSession.User.Workspace.Id); + } + + [Fact] + public async Task Transfers_WithArchivedProductSourceOrDestinationReturnNotFoundWithoutSideEffects() + { + var session = await RegisterAsync(); + var archivedProductSources = await CreateSourcesAsync(session); + var archivedSourceSources = await CreateSourcesAsync(session); + var archivedDestinationSources = await CreateSourcesAsync(session); + + using var archiveProduct = await SendAsync(HttpMethod.Delete, $"/api/products/{archivedProductSources.Product.Id}", session.AccessToken); + using var archiveSource = await SendAsync(HttpMethod.Delete, $"/api/warehouses/{archivedSourceSources.Source.Id}", session.AccessToken); + using var archiveDestination = await SendAsync(HttpMethod.Delete, $"/api/warehouses/{archivedDestinationSources.Destination.Id}", session.AccessToken); + using var archivedProduct = await SendAsync(HttpMethod.Post, "/api/transfers", session.AccessToken, + new RecordWarehouseTransferRequest(archivedProductSources.Source.Id, archivedProductSources.Destination.Id, + archivedProductSources.Product.Id, 1m, $"archived-product-{Guid.NewGuid():N}")); + using var archivedSource = await SendAsync(HttpMethod.Post, "/api/transfers", session.AccessToken, + new RecordWarehouseTransferRequest(archivedSourceSources.Source.Id, archivedSourceSources.Destination.Id, + archivedSourceSources.Product.Id, 1m, $"archived-source-{Guid.NewGuid():N}")); + using var archivedDestination = await SendAsync(HttpMethod.Post, "/api/transfers", session.AccessToken, + new RecordWarehouseTransferRequest(archivedDestinationSources.Source.Id, archivedDestinationSources.Destination.Id, + archivedDestinationSources.Product.Id, 1m, $"archived-destination-{Guid.NewGuid():N}")); + + Assert.Equal(HttpStatusCode.NoContent, archiveProduct.StatusCode); + Assert.Equal(HttpStatusCode.NoContent, archiveSource.StatusCode); + Assert.Equal(HttpStatusCode.NoContent, archiveDestination.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, archivedProduct.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, archivedSource.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, archivedDestination.StatusCode); + await AssertNoTransferSideEffectsAsync(session.User.Workspace.Id); + } + + private async Task AssertNoTransferSideEffectsAsync(Guid workspaceId) + { + await using var scope = _fixture.Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + Assert.Empty(await db.WarehouseTransfers.Where(item => item.WorkspaceId == workspaceId).ToListAsync()); + Assert.Empty(await db.InventoryMovements.Where(item => item.WorkspaceId == workspaceId).ToListAsync()); + Assert.Empty(await db.InventoryBalances.Where(item => item.WorkspaceId == workspaceId).ToListAsync()); + } + + private async Task CreateSourcesAsync(AuthenticationResponse session, decimal? quantity = null) + { + var suffix = Guid.NewGuid().ToString("N"); + var supplier = await PostAsync("/api/suppliers", session.AccessToken, new CreateSupplierRequest($"Supplier {suffix}")); + var source = await PostAsync("/api/warehouses", session.AccessToken, new CreateWarehouseRequest($"Source {suffix}")); + var destination = await PostAsync("/api/warehouses", session.AccessToken, new CreateWarehouseRequest($"Destination {suffix}")); + var product = await PostAsync("/api/products", session.AccessToken, new CreateProductRequest($"Product {suffix}", $"SKU-{suffix}")); + if (quantity.HasValue) + await PostAsync("/api/purchases/receipts", session.AccessToken, + new RecordPurchaseReceiptRequest(supplier.Id, source.Id, product.Id, quantity.Value, $"receipt-{suffix}")); + return new Sources(source, destination, product); + } + + private async Task PostAsync(string path, string token, object content) + { + using var response = await SendAsync(HttpMethod.Post, path, token, content); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + return (await response.Content.ReadFromJsonAsync())!; + } + + private async Task RegisterAsync() + { + var suffix = Guid.NewGuid().ToString("N"); + using var response = await _client.PostAsJsonAsync("/api/auth/register", new RegisterUserCommand($"User {suffix}", + $"user-{suffix}@example.test", "Password!12345")); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return (await response.Content.ReadFromJsonAsync())!; + } + + private async Task SendAsync(HttpMethod method, string path, string token, object? content = null) + { + using var request = new HttpRequestMessage(method, path); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + if (content is not null) request.Content = JsonContent.Create(content); + return await _client.SendAsync(request); + } + + private sealed record Sources(WarehouseResponse Source, WarehouseResponse Destination, ProductResponse Product); +} diff --git a/backend/tests/InventoryFlow.IntegrationTests/Api/WorkspaceMigrationAndTenancyTests.cs b/backend/tests/InventoryFlow.IntegrationTests/Api/WorkspaceMigrationAndTenancyTests.cs new file mode 100644 index 0000000..dd66860 --- /dev/null +++ b/backend/tests/InventoryFlow.IntegrationTests/Api/WorkspaceMigrationAndTenancyTests.cs @@ -0,0 +1,266 @@ +using System.Net; +using System.Net.Http.Json; +using System.Security.Claims; +using System.Text.Json; +using InventoryFlow.Application.Features.Authentication; +using InventoryFlow.Domain.Entities; +using InventoryFlow.Infrastructure.Identity; +using InventoryFlow.Infrastructure.Persistence; +using InventoryFlow.Infrastructure.Authentication; +using InventoryFlow.Infrastructure.Tenancy; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Testcontainers.MsSql; + +namespace InventoryFlow.IntegrationTests.Api; + +/// +/// Verifies workspace migration backfill and current-workspace tenancy isolation against SQL Server. +/// +public sealed class WorkspaceMigrationAndTenancyTests : IClassFixture +{ + private const string RefreshCookieName = "inventory_flow_refresh"; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly WorkspaceMigrationFixture _fixture; + + /// + /// Initializes a new instance of the class. + /// + /// The SQL Server-backed workspace test fixture. + public WorkspaceMigrationAndTenancyTests(WorkspaceMigrationFixture fixture) + { + _fixture = fixture; + } + + /// + /// Backfills one owner workspace for a legacy user and returns it from login and refresh sessions. + /// + [Fact] + public async Task AddWorkspaces_BackfillsLegacyUser_AndReturnsWorkspaceFromLoginAndRefresh() + { + // Arrange + using var client = _fixture.Factory.CreateClient(new WebApplicationFactoryClientOptions + { + HandleCookies = false, + }); + + // Act + using var login = await client.PostAsJsonAsync( + "/api/auth/login", + new LoginUserCommand(_fixture.LegacyUser.Email!, WorkspaceMigrationFixture.Password)); + var loginSession = await ReadSessionAsync(login); + var refreshToken = GetRefreshCookieValue(login); + using var refreshRequest = new HttpRequestMessage(HttpMethod.Post, "/api/auth/refresh"); + refreshRequest.Headers.Add("Cookie", $"{RefreshCookieName}={refreshToken}"); + using var refresh = await client.SendAsync(refreshRequest); + var refreshSession = await ReadSessionAsync(refresh); + + await using var scope = _fixture.Factory.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var memberships = await dbContext.WorkspaceMembers + .Where(member => member.UserId == _fixture.LegacyUser.Id) + .ToListAsync(); + + // Assert + var membership = Assert.Single(memberships); + Assert.Equal(WorkspaceMemberRole.Owner, membership.Role); + var workspace = await dbContext.Workspaces.SingleAsync(item => item.Id == membership.WorkspaceId); + Assert.Equal(workspace.Id, loginSession.User.Workspace.Id); + Assert.Equal(workspace.Name, loginSession.User.Workspace.Name); + Assert.Equal(loginSession.User.Workspace, refreshSession.User.Workspace); + } + + /// + /// Resolves only the authenticated user's active workspace claim after SQL membership revalidation. + /// + [Fact] + public async Task CurrentWorkspaceResolver_EnforcesAuthenticatedUserBoundary_AndFailsClosedForStaleWorkspaceClaim() + { + // Arrange + await using var scope = _fixture.Factory.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var firstUser = CreateUser("first-owner@example.test", "First owner"); + var secondUser = CreateUser("second-owner@example.test", "Second owner"); + var firstWorkspace = new Workspace(Guid.NewGuid(), "First workspace", DateTimeOffset.UtcNow); + var secondWorkspace = new Workspace(Guid.NewGuid(), "Second workspace", DateTimeOffset.UtcNow); + dbContext.AddRange(firstUser, secondUser, firstWorkspace, secondWorkspace); + dbContext.AddRange( + new WorkspaceMember(Guid.NewGuid(), firstWorkspace.Id, firstUser.Id, WorkspaceMemberRole.Owner, DateTimeOffset.UtcNow), + new WorkspaceMember(Guid.NewGuid(), secondWorkspace.Id, secondUser.Id, WorkspaceMemberRole.Owner, DateTimeOffset.UtcNow)); + await dbContext.SaveChangesAsync(); + + var resolver = CreateResolver(dbContext, firstUser.Id, firstWorkspace.Id); + + // Act + var currentWorkspace = await resolver.GetAsync(); + + // Assert + Assert.NotNull(currentWorkspace); + Assert.Equal(firstWorkspace.Id, currentWorkspace.Id); + Assert.NotEqual(secondWorkspace.Id, currentWorkspace.Id); + Assert.Equal(WorkspaceMemberRole.Owner, currentWorkspace.Role); + + // Act + var staleWorkspace = await CreateResolver(dbContext, firstUser.Id, secondWorkspace.Id).GetAsync(); + + // Assert + Assert.Null(staleWorkspace); + } + + private static CurrentWorkspaceResolver CreateResolver(ApplicationDbContext dbContext, Guid userId, Guid workspaceId) + { + var identity = new ClaimsIdentity( + [new Claim(ClaimTypes.NameIdentifier, userId.ToString()), new Claim(JwtAccessTokenIssuer.WorkspaceIdClaimType, workspaceId.ToString())], + "Test"); + var accessor = new HttpContextAccessor + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(identity), + }, + }; + + return new CurrentWorkspaceResolver(accessor, dbContext); + } + + private static ApplicationUser CreateUser(string email, string displayName) => new() + { + Id = Guid.NewGuid(), + Email = email, + UserName = email, + NormalizedEmail = email.ToUpperInvariant(), + NormalizedUserName = email.ToUpperInvariant(), + DisplayName = displayName, + SecurityStamp = Guid.NewGuid().ToString(), + ConcurrencyStamp = Guid.NewGuid().ToString(), + }; + + private static async Task ReadSessionAsync(HttpResponseMessage response) + { + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var session = await response.Content.ReadFromJsonAsync(JsonOptions); + return Assert.IsType(session); + } + + private static string GetRefreshCookieValue(HttpResponseMessage response) + { + var header = Assert.Single(response.Headers.GetValues("Set-Cookie")); + var cookie = header.Split(';', 2)[0].Split('=', 2); + Assert.Equal(RefreshCookieName, cookie[0]); + return Uri.UnescapeDataString(cookie[1]); + } +} + +/// +/// Hosts a SQL Server database first migrated to the pre-workspace schema, then to the current schema. +/// +public sealed class WorkspaceMigrationFixture : IAsyncLifetime +{ + /// + /// Password assigned to the seeded pre-workspace Identity user. + /// + public const string Password = "Password!12345"; + + private const string ConnectionStringEnvironmentVariable = "ConnectionStrings__InventoryFlowDatabase"; + private const string SigningKeyEnvironmentVariable = "Jwt__SigningKey"; + private const string IssuerEnvironmentVariable = "Jwt__Issuer"; + private const string AudienceEnvironmentVariable = "Jwt__Audience"; + private const string PreviousMigration = "20260718100649_AddRefreshTokenFamilies"; + + private readonly MsSqlContainer _sqlServer = new MsSqlBuilder( + "mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04").Build(); + private readonly string? _originalConnectionString = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable); + private readonly string? _originalSigningKey = Environment.GetEnvironmentVariable(SigningKeyEnvironmentVariable); + private readonly string? _originalIssuer = Environment.GetEnvironmentVariable(IssuerEnvironmentVariable); + private readonly string? _originalAudience = Environment.GetEnvironmentVariable(AudienceEnvironmentVariable); + + /// + /// Gets the API host connected to the migrated database. + /// + public WebApplicationFactory Factory { get; private set; } = null!; + + /// + /// Gets the legacy user seeded before the workspace migration runs. + /// + public ApplicationUser LegacyUser { get; private set; } = null!; + + /// + public async Task InitializeAsync() + { + await _sqlServer.StartAsync(); + var databaseName = $"InventoryFlowWorkspaceTests_{Guid.NewGuid():N}"; + await using (var connection = new SqlConnection(_sqlServer.GetConnectionString())) + { + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = $"CREATE DATABASE [{databaseName}]"; + await command.ExecuteNonQueryAsync(); + } + + var connectionString = new SqlConnectionStringBuilder(_sqlServer.GetConnectionString()) + { + InitialCatalog = databaseName, + }.ConnectionString; + Environment.SetEnvironmentVariable(ConnectionStringEnvironmentVariable, connectionString); + Environment.SetEnvironmentVariable(SigningKeyEnvironmentVariable, "test-signing-key-that-is-at-least-thirty-two-bytes-long"); + Environment.SetEnvironmentVariable(IssuerEnvironmentVariable, "InventoryFlow.Test"); + Environment.SetEnvironmentVariable(AudienceEnvironmentVariable, "InventoryFlow.Test.Web"); + + var options = new DbContextOptionsBuilder() + .UseSqlServer(connectionString) + .Options; + await using (var dbContext = new ApplicationDbContext(options)) + { + await dbContext.Database.MigrateAsync(PreviousMigration); + LegacyUser = CreateLegacyUser(); + dbContext.Users.Add(LegacyUser); + await dbContext.SaveChangesAsync(); + await dbContext.Database.MigrateAsync(); + } + + Factory = new WorkspaceApiFactory(); + } + + /// + public async Task DisposeAsync() + { + Factory?.Dispose(); + Environment.SetEnvironmentVariable(ConnectionStringEnvironmentVariable, _originalConnectionString); + Environment.SetEnvironmentVariable(SigningKeyEnvironmentVariable, _originalSigningKey); + Environment.SetEnvironmentVariable(IssuerEnvironmentVariable, _originalIssuer); + Environment.SetEnvironmentVariable(AudienceEnvironmentVariable, _originalAudience); + await _sqlServer.DisposeAsync(); + } + + private static ApplicationUser CreateLegacyUser() + { + const string email = "legacy-owner@example.test"; + var user = new ApplicationUser + { + Id = Guid.NewGuid(), + Email = email, + UserName = email, + NormalizedEmail = email.ToUpperInvariant(), + NormalizedUserName = email.ToUpperInvariant(), + DisplayName = "Legacy owner", + SecurityStamp = Guid.NewGuid().ToString(), + ConcurrencyStamp = Guid.NewGuid().ToString(), + }; + user.PasswordHash = new PasswordHasher().HashPassword(user, Password); + return user; + } + + private sealed class WorkspaceApiFactory : WebApplicationFactory + { + /// + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Development"); + } + } +} diff --git a/backend/tests/InventoryFlow.IntegrationTests/InventoryFlow.IntegrationTests.csproj b/backend/tests/InventoryFlow.IntegrationTests/InventoryFlow.IntegrationTests.csproj new file mode 100644 index 0000000..cb204a3 --- /dev/null +++ b/backend/tests/InventoryFlow.IntegrationTests/InventoryFlow.IntegrationTests.csproj @@ -0,0 +1,30 @@ + + + false + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/backend/tests/InventoryFlow.IntegrationTests/TestCollectionBehavior.cs b/backend/tests/InventoryFlow.IntegrationTests/TestCollectionBehavior.cs new file mode 100644 index 0000000..2171200 --- /dev/null +++ b/backend/tests/InventoryFlow.IntegrationTests/TestCollectionBehavior.cs @@ -0,0 +1,3 @@ +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/backend/tests/InventoryFlow.UnitTests/Domain/DomainExceptionTests.cs b/backend/tests/InventoryFlow.UnitTests/Domain/DomainExceptionTests.cs new file mode 100644 index 0000000..1db42e7 --- /dev/null +++ b/backend/tests/InventoryFlow.UnitTests/Domain/DomainExceptionTests.cs @@ -0,0 +1,25 @@ +using InventoryFlow.Domain.Exceptions; + +namespace InventoryFlow.UnitTests.Domain; + +/// +/// Verifies behavior shared by domain exceptions. +/// +public sealed class DomainExceptionTests +{ + /// + /// Preserves the violated-invariant message. + /// + [Fact] + public void Constructor_WithMessage_PreservesMessage() + { + // Arrange + const string message = "Product SKU must be unique."; + + // Act + var exception = new DomainException(message); + + // Assert + Assert.Equal(message, exception.Message); + } +} diff --git a/backend/tests/InventoryFlow.UnitTests/Domain/InventoryBalanceTests.cs b/backend/tests/InventoryFlow.UnitTests/Domain/InventoryBalanceTests.cs new file mode 100644 index 0000000..428ccae --- /dev/null +++ b/backend/tests/InventoryFlow.UnitTests/Domain/InventoryBalanceTests.cs @@ -0,0 +1,37 @@ +using InventoryFlow.Domain.Entities; +using InventoryFlow.Domain.Exceptions; + +namespace InventoryFlow.UnitTests.Domain; + +/// Verifies inventory ledger invariants. +public sealed class InventoryBalanceTests +{ + /// Applies four-decimal receipts and issues while retaining the expected balance. + [Fact] + public void Apply_ReceiptAndIssue_MaintainsPreciseNonnegativeBalance() + { + var balance = new InventoryBalance(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), 0m); + balance.Apply(1.2345m); + balance.Apply(-0.2345m); + Assert.Equal(1.0000m, balance.Quantity); + } + + /// Rejects an issue that would make stock negative. + [Fact] + public void Apply_IssueExceedingBalance_ThrowsInsufficientInventoryException() + { + var balance = new InventoryBalance(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), 1m); + Assert.IsType(Record.Exception(() => balance.Apply(-1.0001m))); + } + + /// Rejects unsupported precision and invalid idempotency input. + [Fact] + public void Movement_WithInvalidQuantityOrKey_ThrowsDomainException() + { + var ids = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() }; + Assert.IsType(Record.Exception(() => new InventoryMovement(ids[0], ids[1], ids[2], ids[3], + InventoryMovementType.Receipt, 1.00001m, "key", 1m, DateTimeOffset.UtcNow))); + Assert.IsType(Record.Exception(() => new InventoryMovement(ids[0], ids[1], ids[2], ids[3], + InventoryMovementType.Receipt, 1m, " ", 1m, DateTimeOffset.UtcNow))); + } +} diff --git a/backend/tests/InventoryFlow.UnitTests/Domain/ProductTests.cs b/backend/tests/InventoryFlow.UnitTests/Domain/ProductTests.cs new file mode 100644 index 0000000..9320fd4 --- /dev/null +++ b/backend/tests/InventoryFlow.UnitTests/Domain/ProductTests.cs @@ -0,0 +1,37 @@ +using InventoryFlow.Domain.Entities; +using InventoryFlow.Domain.Exceptions; + +namespace InventoryFlow.UnitTests.Domain; + +/// Verifies product invariants. +public sealed class ProductTests +{ + /// Normalizes product name and SKU. + [Fact] + public void Constructor_NormalizesNameAndSku() + { + var product = new Product(Guid.NewGuid(), Guid.NewGuid(), " Widget ", " ab- 1 ", DateTimeOffset.UtcNow); + Assert.Equal("Widget", product.Name); + Assert.Equal("AB- 1", product.Sku); + } + + /// Rejects invalid identifiers, SKU, and timestamps. + [Fact] + public void Constructor_WithInvalidInput_ThrowsDomainException() + { + Assert.IsType(Record.Exception(() => new Product(Guid.Empty, Guid.NewGuid(), "Item", "SKU", DateTimeOffset.UtcNow))); + Assert.IsType(Record.Exception(() => new Product(Guid.NewGuid(), Guid.NewGuid(), "Item", " ", DateTimeOffset.UtcNow))); + Assert.IsType(Record.Exception(() => new Product(Guid.NewGuid(), Guid.NewGuid(), "Item", "SKU", DateTimeOffset.Now))); + } + + /// Archives once without changing the original archive instant. + [Fact] + public void Archive_IsIdempotent() + { + var product = new Product(Guid.NewGuid(), Guid.NewGuid(), "Item", "SKU", DateTimeOffset.UtcNow); + var archivedAt = DateTimeOffset.UtcNow; + product.Archive(archivedAt); + product.Archive(archivedAt.AddMinutes(1)); + Assert.Equal(archivedAt, product.ArchivedAtUtc); + } +} diff --git a/backend/tests/InventoryFlow.UnitTests/Domain/PurchaseReceiptTests.cs b/backend/tests/InventoryFlow.UnitTests/Domain/PurchaseReceiptTests.cs new file mode 100644 index 0000000..cd84f26 --- /dev/null +++ b/backend/tests/InventoryFlow.UnitTests/Domain/PurchaseReceiptTests.cs @@ -0,0 +1,28 @@ +using InventoryFlow.Domain.Entities; +using InventoryFlow.Domain.Exceptions; + +namespace InventoryFlow.UnitTests.Domain; + +public sealed class PurchaseReceiptTests +{ + [Fact] + public void Constructor_NormalizesReplayKeyAndExposesImmutableReceipt() + { + var receipt = new PurchaseReceipt(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), 2.5000m, + " receive-1 ", Guid.NewGuid(), DateTimeOffset.UtcNow); + + Assert.Equal("receive-1", receipt.IdempotencyKey); + Assert.Equal(2.5000m, receipt.Quantity); + } + + [Fact] + public void Constructor_RejectsMissingIdentifiersInvalidQuantityAndNonUtcTime() + { + Assert.IsType(Record.Exception(() => new PurchaseReceipt(Guid.Empty, Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), + Guid.NewGuid(), 1m, "key", Guid.NewGuid(), DateTimeOffset.UtcNow))); + Assert.IsType(Record.Exception(() => new PurchaseReceipt(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), + Guid.NewGuid(), 1.00001m, "key", Guid.NewGuid(), DateTimeOffset.UtcNow))); + Assert.IsType(Record.Exception(() => new PurchaseReceipt(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), + Guid.NewGuid(), 1m, "key", Guid.NewGuid(), DateTimeOffset.Now))); + } +} diff --git a/backend/tests/InventoryFlow.UnitTests/Domain/RefreshTokenTests.cs b/backend/tests/InventoryFlow.UnitTests/Domain/RefreshTokenTests.cs new file mode 100644 index 0000000..c65344f --- /dev/null +++ b/backend/tests/InventoryFlow.UnitTests/Domain/RefreshTokenTests.cs @@ -0,0 +1,112 @@ +using InventoryFlow.Domain.Entities; +using InventoryFlow.Domain.Exceptions; + +namespace InventoryFlow.UnitTests.Domain; + +/// +/// Verifies refresh-token domain invariants and lifecycle behavior. +/// +public sealed class RefreshTokenTests +{ + /// + /// Treats an unrevoked token as active before it expires. + /// + [Fact] + public void IsActive_WithUnrevokedUnexpiredToken_ReturnsTrue() + { + // Arrange + var now = DateTimeOffset.UtcNow; + var token = CreateToken(now.AddDays(7)); + + // Act + var isActive = token.IsActive(now); + + // Assert + Assert.True(isActive); + } + + /// + /// Treats a revoked token as inactive. + /// + [Fact] + public void Revoke_WithUtcInstant_MarksTokenInactive() + { + // Arrange + var now = DateTimeOffset.UtcNow; + var token = CreateToken(now.AddDays(7)); + + // Act + token.Revoke(now); + + // Assert + Assert.False(token.IsActive(now)); + Assert.Equal(now, token.RevokedAtUtc); + } + + /// + /// Rejects empty user identifiers. + /// + [Fact] + public void Constructor_WithEmptyUserIdentifier_ThrowsDomainException() + { + // Act + var exception = Record.Exception(() => new RefreshToken( + Guid.NewGuid(), + Guid.Empty, + Guid.NewGuid(), + Guid.NewGuid(), + "token-hash", + DateTimeOffset.UtcNow.AddDays(7))); + + // Assert + Assert.IsType(exception); + } + + /// + /// Rejects empty refresh-token family identifiers. + /// + [Fact] + public void Constructor_WithEmptyFamilyIdentifier_ThrowsDomainException() + { + var exception = Record.Exception(() => new RefreshToken( + Guid.NewGuid(), Guid.NewGuid(), Guid.Empty, Guid.NewGuid(), "token-hash", DateTimeOffset.UtcNow.AddDays(7))); + + Assert.IsType(exception); + } + + /// + /// Rejects empty workspace identifiers. + /// + [Fact] + public void Constructor_WithEmptyWorkspaceIdentifier_ThrowsDomainException() + { + var exception = Record.Exception(() => new RefreshToken( + Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.Empty, "token-hash", DateTimeOffset.UtcNow.AddDays(7))); + + Assert.IsType(exception); + } + + /// + /// Rejects refresh-token expiration times that are not UTC. + /// + [Fact] + public void Constructor_WithNonUtcExpiration_ThrowsDomainException() + { + // Arrange + var nonUtcExpiration = new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.FromHours(5)); + + // Act + var exception = Record.Exception(() => new RefreshToken( + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + "token-hash", + nonUtcExpiration)); + + // Assert + Assert.IsType(exception); + } + + private static RefreshToken CreateToken(DateTimeOffset expiresAtUtc) => new(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), "token-hash", expiresAtUtc); +} diff --git a/backend/tests/InventoryFlow.UnitTests/Domain/SupplierTests.cs b/backend/tests/InventoryFlow.UnitTests/Domain/SupplierTests.cs new file mode 100644 index 0000000..a7d2dd8 --- /dev/null +++ b/backend/tests/InventoryFlow.UnitTests/Domain/SupplierTests.cs @@ -0,0 +1,36 @@ +using InventoryFlow.Domain.Entities; +using InventoryFlow.Domain.Exceptions; + +namespace InventoryFlow.UnitTests.Domain; + +/// Verifies supplier invariants. +public sealed class SupplierTests +{ + /// Normalizes the supplier name. + [Fact] + public void Constructor_NormalizesName() + { + var supplier = new Supplier(Guid.NewGuid(), Guid.NewGuid(), " Acme Supply ", DateTimeOffset.UtcNow); + Assert.Equal("Acme Supply", supplier.Name); + } + + /// Rejects missing names and invalid identifiers or timestamps. + [Fact] + public void Constructor_WithInvalidInput_ThrowsDomainException() + { + Assert.IsType(Record.Exception(() => new Supplier(Guid.Empty, Guid.NewGuid(), "Supplier", DateTimeOffset.UtcNow))); + Assert.IsType(Record.Exception(() => new Supplier(Guid.NewGuid(), Guid.NewGuid(), " ", DateTimeOffset.UtcNow))); + Assert.IsType(Record.Exception(() => new Supplier(Guid.NewGuid(), Guid.NewGuid(), "Supplier", DateTimeOffset.Now))); + } + + /// Archives once without changing the original archive instant. + [Fact] + public void Archive_IsIdempotent() + { + var supplier = new Supplier(Guid.NewGuid(), Guid.NewGuid(), "Acme Supply", DateTimeOffset.UtcNow); + var archivedAt = DateTimeOffset.UtcNow; + supplier.Archive(archivedAt); + supplier.Archive(archivedAt.AddMinutes(1)); + Assert.Equal(archivedAt, supplier.ArchivedAtUtc); + } +} diff --git a/backend/tests/InventoryFlow.UnitTests/Domain/WarehouseTests.cs b/backend/tests/InventoryFlow.UnitTests/Domain/WarehouseTests.cs new file mode 100644 index 0000000..e2e56c4 --- /dev/null +++ b/backend/tests/InventoryFlow.UnitTests/Domain/WarehouseTests.cs @@ -0,0 +1,31 @@ +using InventoryFlow.Domain.Entities; +using InventoryFlow.Domain.Exceptions; + +namespace InventoryFlow.UnitTests.Domain; + +/// Verifies warehouse invariants. +public sealed class WarehouseTests +{ + [Fact] + public void Constructor_NormalizesName() + { + var warehouse = new Warehouse(Guid.NewGuid(), Guid.NewGuid(), " Central Depot ", DateTimeOffset.UtcNow); + Assert.Equal("Central Depot", warehouse.Name); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Constructor_RejectsInvalidName(string name) => + Assert.Throws(() => new Warehouse(Guid.NewGuid(), Guid.NewGuid(), name, DateTimeOffset.UtcNow)); + + [Fact] + public void Archive_IsIdempotent() + { + var warehouse = new Warehouse(Guid.NewGuid(), Guid.NewGuid(), "Depot", DateTimeOffset.UtcNow); + var first = DateTimeOffset.UtcNow; + warehouse.Archive(first); + warehouse.Archive(first.AddMinutes(1)); + Assert.Equal(first, warehouse.ArchivedAtUtc); + } +} diff --git a/backend/tests/InventoryFlow.UnitTests/Domain/WorkspaceInvitationTests.cs b/backend/tests/InventoryFlow.UnitTests/Domain/WorkspaceInvitationTests.cs new file mode 100644 index 0000000..fbd3ae5 --- /dev/null +++ b/backend/tests/InventoryFlow.UnitTests/Domain/WorkspaceInvitationTests.cs @@ -0,0 +1,170 @@ +using InventoryFlow.Domain.Entities; +using InventoryFlow.Domain.Exceptions; + +namespace InventoryFlow.UnitTests.Domain; + +/// Verifies workspace invitation invariants and lifecycle. +public sealed class WorkspaceInvitationTests +{ + /// Creates a pending Member invitation. + [Fact] + public void Constructor_WithMember_CreatesPendingInvitation() + { + var now = DateTimeOffset.UtcNow; + var invitation = CreateInvitation(now); + + Assert.Equal(WorkspaceMemberRole.Member, invitation.Role); + Assert.True(invitation.IsPending(now)); + } + + /// Rejects Owner invitations in the P0 collaboration slice. + [Fact] + public void Constructor_WithOwnerRole_ThrowsDomainException() + { + var now = DateTimeOffset.UtcNow; + var exception = Record.Exception(() => new WorkspaceInvitation(Guid.NewGuid(), Guid.NewGuid(), "USER@EXAMPLE.TEST", WorkspaceMemberRole.Owner, "hash", now.AddDays(1), Guid.NewGuid(), now)); + + Assert.IsType(exception); + } + + /// Marks a pending invitation as accepted. + [Fact] + public void Accept_WithPendingInvitation_MarksAccepted() + { + var now = DateTimeOffset.UtcNow; + var invitation = CreateInvitation(now); + var userId = Guid.NewGuid(); + + invitation.Accept(userId, now.AddMinutes(1)); + + Assert.Equal(userId, invitation.AcceptedByUserId); + Assert.NotNull(invitation.AcceptedAtUtc); + Assert.False(invitation.IsPending(now.AddMinutes(1))); + } + + /// Marks a pending invitation as revoked. + [Fact] + public void Revoke_WithPendingInvitation_MarksRevoked() + { + var now = DateTimeOffset.UtcNow; + var invitation = CreateInvitation(now); + + invitation.Revoke(now.AddMinutes(1)); + + Assert.NotNull(invitation.RevokedAtUtc); + Assert.False(invitation.IsPending(now.AddMinutes(1))); + } + + private static WorkspaceInvitation CreateInvitation(DateTimeOffset now) => new(Guid.NewGuid(), Guid.NewGuid(), "USER@EXAMPLE.TEST", WorkspaceMemberRole.Member, "hash", now.AddDays(1), Guid.NewGuid(), now); + + /// Rejects empty or whitespace normalized email. + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Constructor_WithEmptyOrWhitespaceEmail_ThrowsDomainException(string email) + { + var now = DateTimeOffset.UtcNow; + var exception = Record.Exception(() => new WorkspaceInvitation(Guid.NewGuid(), Guid.NewGuid(), email, WorkspaceMemberRole.Member, "hash", now.AddDays(1), Guid.NewGuid(), now)); + + Assert.IsType(exception); + } + + /// Rejects emails exceeding the normalized max length. + [Fact] + public void Constructor_WithEmailExceedingMaxLength_ThrowsDomainException() + { + var now = DateTimeOffset.UtcNow; + var tooLong = new string('a', WorkspaceInvitation.NormalizedEmailMaxLength + 1); + var exception = Record.Exception(() => new WorkspaceInvitation(Guid.NewGuid(), Guid.NewGuid(), tooLong, WorkspaceMemberRole.Member, "hash", now.AddDays(1), Guid.NewGuid(), now)); + + Assert.IsType(exception); + } + + /// Rejects empty or whitespace token hash. + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Constructor_WithEmptyOrWhitespaceTokenHash_ThrowsDomainException(string tokenHash) + { + var now = DateTimeOffset.UtcNow; + var exception = Record.Exception(() => new WorkspaceInvitation(Guid.NewGuid(), Guid.NewGuid(), "USER@EXAMPLE.TEST", WorkspaceMemberRole.Member, tokenHash, now.AddDays(1), Guid.NewGuid(), now)); + + Assert.IsType(exception); + } + + /// Rejects token hashes exceeding the max length. + [Fact] + public void Constructor_WithTokenHashExceedingMaxLength_ThrowsDomainException() + { + var now = DateTimeOffset.UtcNow; + var tooLong = new string('a', WorkspaceInvitation.TokenHashMaxLength + 1); + var exception = Record.Exception(() => new WorkspaceInvitation(Guid.NewGuid(), Guid.NewGuid(), "USER@EXAMPLE.TEST", WorkspaceMemberRole.Member, tooLong, now.AddDays(1), Guid.NewGuid(), now)); + + Assert.IsType(exception); + } + + /// Rejects non-UTC creation or expiration timestamps. + [Theory] + [InlineData(-5)] + [InlineData(330)] + public void Constructor_WithNonUtcTimestamps_ThrowsDomainException(int offsetMinutes) + { + var offset = TimeSpan.FromMinutes(offsetMinutes); + var created = new DateTimeOffset(2026, 1, 1, 0, 0, 0, offset); + var expires = new DateTimeOffset(2026, 1, 2, 0, 0, 0, offset); + var exception = Record.Exception(() => new WorkspaceInvitation(Guid.NewGuid(), Guid.NewGuid(), "USER@EXAMPLE.TEST", WorkspaceMemberRole.Member, "hash", expires, Guid.NewGuid(), created)); + + Assert.IsType(exception); + } + + /// Rejects expiration that is not strictly after creation. + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Constructor_WithExpirationNotAfterCreation_ThrowsDomainException(int deltaMinutes) + { + var now = DateTimeOffset.UtcNow; + var exception = Record.Exception(() => new WorkspaceInvitation(Guid.NewGuid(), Guid.NewGuid(), "USER@EXAMPLE.TEST", WorkspaceMemberRole.Member, "hash", now.AddMinutes(deltaMinutes), Guid.NewGuid(), now)); + + Assert.IsType(exception); + } + + /// Accept throws when the invitation is already accepted. + [Fact] + public void Accept_WhenAlreadyAccepted_ThrowsDomainException() + { + var now = DateTimeOffset.UtcNow; + var invitation = CreateInvitation(now); + invitation.Accept(Guid.NewGuid(), now.AddMinutes(1)); + + var exception = Record.Exception(() => invitation.Accept(Guid.NewGuid(), now.AddMinutes(2))); + + Assert.IsType(exception); + } + + /// Revoke throws when the invitation is already accepted. + [Fact] + public void Revoke_WhenAlreadyAccepted_ThrowsDomainException() + { + var now = DateTimeOffset.UtcNow; + var invitation = CreateInvitation(now); + invitation.Accept(Guid.NewGuid(), now.AddMinutes(1)); + + var exception = Record.Exception(() => invitation.Revoke(now.AddMinutes(2))); + + Assert.IsType(exception); + } + + /// Revoke throws when the invitation is already revoked. + [Fact] + public void Revoke_WhenAlreadyRevoked_ThrowsDomainException() + { + var now = DateTimeOffset.UtcNow; + var invitation = CreateInvitation(now); + invitation.Revoke(now.AddMinutes(1)); + + var exception = Record.Exception(() => invitation.Revoke(now.AddMinutes(2))); + + Assert.IsType(exception); + } +} diff --git a/backend/tests/InventoryFlow.UnitTests/Domain/WorkspaceMemberTests.cs b/backend/tests/InventoryFlow.UnitTests/Domain/WorkspaceMemberTests.cs new file mode 100644 index 0000000..f7bc0a0 --- /dev/null +++ b/backend/tests/InventoryFlow.UnitTests/Domain/WorkspaceMemberTests.cs @@ -0,0 +1,29 @@ +using InventoryFlow.Domain.Entities; +using InventoryFlow.Domain.Exceptions; + +namespace InventoryFlow.UnitTests.Domain; + +/// Verifies workspace membership invariants. +public sealed class WorkspaceMemberTests +{ + /// Creates the initial Owner membership. + [Fact] + public void Constructor_WithOwner_CreatesMembership() + { + var member = new WorkspaceMember(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), WorkspaceMemberRole.Owner, DateTimeOffset.UtcNow); + Assert.Equal(WorkspaceMemberRole.Owner, member.Role); + } + + /// Creates a Member membership. + [Fact] + public void Constructor_WithMember_CreatesMembership() + { + var member = new WorkspaceMember(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), WorkspaceMemberRole.Member, DateTimeOffset.UtcNow); + Assert.Equal(WorkspaceMemberRole.Member, member.Role); + } + + /// Rejects an undefined role. + [Fact] + public void Constructor_WithUnsupportedRole_ThrowsDomainException() => + Assert.IsType(Record.Exception(() => new WorkspaceMember(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), (WorkspaceMemberRole)99, DateTimeOffset.UtcNow))); +} diff --git a/backend/tests/InventoryFlow.UnitTests/Domain/WorkspaceTests.cs b/backend/tests/InventoryFlow.UnitTests/Domain/WorkspaceTests.cs new file mode 100644 index 0000000..f5fcd83 --- /dev/null +++ b/backend/tests/InventoryFlow.UnitTests/Domain/WorkspaceTests.cs @@ -0,0 +1,28 @@ +using InventoryFlow.Domain.Entities; +using InventoryFlow.Domain.Exceptions; + +namespace InventoryFlow.UnitTests.Domain; + +/// Verifies workspace invariants. +public sealed class WorkspaceTests +{ + /// Trims a valid workspace name. + [Fact] + public void Constructor_WithValidInput_CreatesWorkspace() + { + var workspace = new Workspace(Guid.NewGuid(), " Operations ", DateTimeOffset.UtcNow); + Assert.Equal("Operations", workspace.Name); + } + + /// Rejects invalid workspace names. + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Constructor_WithBlankName_ThrowsDomainException(string name) => + Assert.IsType(Record.Exception(() => new Workspace(Guid.NewGuid(), name, DateTimeOffset.UtcNow))); + + /// Rejects oversized names. + [Fact] + public void Constructor_WithOversizedName_ThrowsDomainException() => + Assert.IsType(Record.Exception(() => new Workspace(Guid.NewGuid(), new string('a', Workspace.NameMaxLength + 1), DateTimeOffset.UtcNow))); +} diff --git a/backend/tests/InventoryFlow.UnitTests/InventoryFlow.UnitTests.csproj b/backend/tests/InventoryFlow.UnitTests/InventoryFlow.UnitTests.csproj new file mode 100644 index 0000000..c4f2bf7 --- /dev/null +++ b/backend/tests/InventoryFlow.UnitTests/InventoryFlow.UnitTests.csproj @@ -0,0 +1,27 @@ + + + false + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c11402e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,57 @@ +services: + sql: + image: mcr.microsoft.com/mssql/server:2022-latest + environment: + ACCEPT_EULA: "Y" + MSSQL_PID: Developer + MSSQL_SA_PASSWORD: ${MSSQL_SA_PASSWORD:?Set MSSQL_SA_PASSWORD in .env} + ports: + - "1433:1433" + volumes: + - sql-data:/var/opt/mssql + healthcheck: + test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P \"$$MSSQL_SA_PASSWORD\" -C -Q \"SELECT 1\""] + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + + migrate: + build: + context: ./backend + target: migrator + environment: &api-environment + ASPNETCORE_ENVIRONMENT: Development + ConnectionStrings__InventoryFlowDatabase: Server=sql,1433;Database=InventoryFlow;User Id=sa;Password=${MSSQL_SA_PASSWORD:?Set MSSQL_SA_PASSWORD in .env};TrustServerCertificate=True;Encrypt=False + Jwt__SigningKey: ${Jwt__SigningKey:?Set Jwt__SigningKey in .env} + depends_on: + sql: + condition: service_healthy + restart: "no" + + api: + build: + context: ./backend + target: final + environment: *api-environment + depends_on: + migrate: + condition: service_completed_successfully + ports: + - "5255:8080" + + web: + build: + context: ./frontend + args: + VITE_API_BASE_URL: / + depends_on: + migrate: + condition: service_completed_successfully + api: + condition: service_started + ports: + - "8080:8080" + +volumes: + sql-data: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..57610b4 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +.vite/ +.git/ +.github/ +.env +.env.* +!.env.example +*.tsbuildinfo diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..7b1038b --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,2 @@ +# Use the Vite development proxy by default. Set an absolute API origin only when needed. +VITE_API_BASE_URL=/ diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..35bc32e --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,6 @@ +node_modules/ +coverage/ +.pnpm-store/ +pnpm-lock.yaml +package-lock.json +yarn.lock diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..9000bfa --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,11 @@ +{ + "endOfLine": "lf", + "semi": false, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 80, + "plugins": ["prettier-plugin-tailwindcss"], + "tailwindStylesheet": "src/index.css", + "tailwindFunctions": ["cn", "cva"] +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..0c69931 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,14 @@ +FROM oven/bun:1.3.14 AS build +WORKDIR /app + +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile +COPY . . +ARG VITE_API_BASE_URL=/ +ENV VITE_API_BASE_URL=$VITE_API_BASE_URL +RUN bun run build + +FROM nginxinc/nginx-unprivileged:1.27-alpine AS final +COPY --chown=nginx:nginx nginx.conf /etc/nginx/conf.d/default.conf +COPY --chown=nginx:nginx --from=build /app/dist /usr/share/nginx/html +EXPOSE 8080 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..9c6ceb2 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,30 @@ +# Inventory Flow Web + +The React single-page application for Inventory Flow. + +## Stack + +React 19, TypeScript, Vite, shadcn/ui, Tailwind CSS v4, React Router v7, TanStack Query/Table, React Hook Form, Zod, Axios, Zustand, Hugeicons, Recharts, Sonner, and Motion. + +## Commands + +```bash +bun install +bun run dev +bun run typecheck +bun run lint +bun run build +``` + +## Configuration + +Copy `.env.example` to `.env`. The default `/` uses the Vite development proxy to `http://localhost:5255`; set an absolute API origin only when needed. + +## Structure + +- `src/app`: application configuration, providers, and routing +- `src/components`: generated shadcn/ui primitives and shared layout components +- `src/features`: feature-owned UI, API clients, schemas, types, and hooks +- `src/layouts`: route layouts +- `src/lib`: shared client infrastructure +- `src/store`: Zustand client-only state diff --git a/frontend/bun.lock b/frontend/bun.lock new file mode 100644 index 0000000..07b3fd9 --- /dev/null +++ b/frontend/bun.lock @@ -0,0 +1,1150 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "vite-app", + "dependencies": { + "@base-ui/react": "^1.6.0", + "@fontsource-variable/geist": "^5.2.9", + "@hookform/resolvers": "^5.4.0", + "@hugeicons/core-free-icons": "^4.2.2", + "@hugeicons/react": "^1.1.9", + "@tailwindcss/vite": "^4", + "@tanstack/react-query": "^5.101.2", + "@tanstack/react-table": "^8.21.3", + "axios": "^1.18.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "motion": "^12.42.2", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-hook-form": "^7.81.0", + "react-router": "^7", + "recharts": "^3.9.2", + "shadcn": "^4.13.1", + "sonner": "^2.0.7", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4", + "tw-animate-css": "^1.4.0", + "zod": "^4.4.3", + "zustand": "^5.0.14", + }, + "devDependencies": { + "@eslint/js": "^10", + "@types/node": "^24", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^6", + "eslint": "^10", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17", + "prettier": "^3.8.3", + "prettier-plugin-tailwindcss": "^0.8.0", + "typescript": "~6", + "typescript-eslint": "^8", + "vite": "^8", + }, + }, + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.29.7", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ=="], + + "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], + + "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA=="], + + "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.29.7", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ=="], + + "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/plugin-syntax-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw=="], + + "@babel/preset-typescript": ["@babel/preset-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "@babel/plugin-syntax-jsx": "^7.29.7", "@babel/plugin-transform-modules-commonjs": "^7.29.7", "@babel/plugin-transform-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ=="], + + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@base-ui/react": ["@base-ui/react@1.6.0", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@base-ui/utils": "0.3.1", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@date-fns/tz": "^1.2.0", "@types/react": "^17 || ^18 || ^19", "date-fns": "^4.0.0", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@date-fns/tz", "@types/react", "date-fns"] }, "sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw=="], + + "@base-ui/utils": ["@base-ui/utils@0.3.1", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg=="], + + "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.75.1", "", { "dependencies": { "@dotenvx/primitives": "^0.8.0", "commander": "^11.1.0", "conf": "^10.2.0", "dotenv": "^17.2.1", "enquirer": "^2.4.1", "env-paths": "^2.2.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "open": "^8.4.2", "picomatch": "^4.0.4", "systeminformation": "^5.22.11", "undici": "^7.11.0", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ=="], + + "@dotenvx/primitives": ["@dotenvx/primitives@0.8.0", "", {}, "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg=="], + + "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="], + + "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], + + "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], + + "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="], + + "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.9", "", { "dependencies": { "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], + + "@fontsource-variable/geist": ["@fontsource-variable/geist@5.2.9", "", {}, "sha512-TP+QSBG3wxKGPE33CbMy/L0Nu3qvJ6Fy81Yc4LnQ95xH+i+cfEp8fyU8/kfV14YwszxIFPhnoMTbjL71waVpyQ=="], + + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + + "@hookform/resolvers": ["@hookform/resolvers@5.4.0", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw=="], + + "@hugeicons/core-free-icons": ["@hugeicons/core-free-icons@4.2.2", "", {}, "sha512-fjQW3p6cwoJmwK3oCnV8Z3PC5sHihDvr2jkAHax8cxqp62GaV/D7B/Rnao+JwicW8fmLZUQ6QrzWfoyMFOEQlQ=="], + + "@hugeicons/react": ["@hugeicons/react@1.1.9", "", { "peerDependencies": { "react": ">=16.0.0" } }, "sha512-O+lWSWjbijoAvMCxn4K2bQWCGN5+mP1y5j+X99j23mXMj+s0X25fs71T6t9YJLaBodwmZdaewD27dzS/PiboQw=="], + + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], + + "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], + + "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="], + + "@reduxjs/toolkit": ["@reduxjs/toolkit@2.12.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.5", "", { "os": "linux", "cpu": "arm" }, "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.5", "", { "os": "none", "cpu": "arm64" }, "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], + + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="], + + "@tanstack/query-core": ["@tanstack/query-core@5.101.2", "", {}, "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.101.2", "", { "dependencies": { "@tanstack/query-core": "5.101.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg=="], + + "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="], + + "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], + + "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + + "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.64.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.64.0", "@typescript-eslint/type-utils": "8.64.0", "@typescript-eslint/utils": "8.64.0", "@typescript-eslint/visitor-keys": "8.64.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.64.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.64.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.64.0", "@typescript-eslint/types": "8.64.0", "@typescript-eslint/typescript-estree": "8.64.0", "@typescript-eslint/visitor-keys": "8.64.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.64.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.64.0", "@typescript-eslint/types": "^8.64.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.64.0", "", { "dependencies": { "@typescript-eslint/types": "8.64.0", "@typescript-eslint/visitor-keys": "8.64.0" } }, "sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.64.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.64.0", "", { "dependencies": { "@typescript-eslint/types": "8.64.0", "@typescript-eslint/typescript-estree": "8.64.0", "@typescript-eslint/utils": "8.64.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.64.0", "", {}, "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.64.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.64.0", "@typescript-eslint/tsconfig-utils": "8.64.0", "@typescript-eslint/types": "8.64.0", "@typescript-eslint/visitor-keys": "8.64.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.64.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.64.0", "@typescript-eslint/types": "8.64.0", "@typescript-eslint/typescript-estree": "8.64.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.64.0", "", { "dependencies": { "@typescript-eslint/types": "8.64.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.3", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], + + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "atomically": ["atomically@1.7.0", "", {}, "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w=="], + + "axios": ["axios@1.18.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.43", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ=="], + + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + + "brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.28.6", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", "electron-to-chromium": "^1.5.389", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw=="], + + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="], + + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], + + "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "conf": ["conf@10.2.0", "", { "dependencies": { "ajv": "^8.6.3", "ajv-formats": "^2.1.1", "atomically": "^1.7.0", "debounce-fn": "^4.0.0", "dot-prop": "^6.0.1", "env-paths": "^2.2.1", "json-schema-typed": "^7.0.3", "onetime": "^5.1.2", "pkg-up": "^3.1.0", "semver": "^7.3.5" } }, "sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg=="], + + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cosmiconfig": ["cosmiconfig@9.0.2", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "debounce-fn": ["debounce-fn@4.0.0", "", { "dependencies": { "mimic-fn": "^3.0.0" } }, "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], + + "dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], + + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + + "dot-prop": ["dot-prop@6.0.1", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA=="], + + "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.393", "", {}, "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "enhanced-resolve": ["enhanced-resolve@5.24.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw=="], + + "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], + + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "es-toolkit": ["es-toolkit@1.49.0", "", {}, "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@10.7.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ=="], + + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="], + + "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.5.3", "", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA=="], + + "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + + "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.6.0", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fast-uri": ["fast-uri@3.1.3", "", {}, "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + + "follow-redirects": ["follow-redirects@1.16.0", "", { "peerDependencies": { "debug": "*" }, "optionalPeers": ["debug"] }, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], + + "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "fs-extra": ["fs-extra@11.3.6", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "fuzzysort": ["fuzzysort@3.1.0", "", {}, "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-own-enumerable-keys": ["get-own-enumerable-keys@1.0.0", "", {}, "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@17.7.0", "", {}, "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], + + "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + + "hono": ["hono@4.12.30", "", {}, "sha512-emn+JoJjrN9YTpRDS5it/UI2SO9BAE37T6I3d963RxcZ81G9A4pr2SZTEiiaiKbzx+NKRg5BZ89fCL7gCJCUog=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + + "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], + + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "immer": ["immer@11.1.15", "", {}, "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + + "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], + + "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], + + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + + "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-obj": ["is-obj@3.0.0", "", {}, "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "is-regexp": ["is-regexp@3.1.0", "", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="], + + "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], + + "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + + "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "mimic-fn": ["mimic-fn@3.1.0", "", {}, "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ=="], + + "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "motion": ["motion@12.42.2", "", { "dependencies": { "framer-motion": "^12.42.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q=="], + + "motion-dom": ["motion-dom@12.42.2", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA=="], + + "motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="], + + "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + + "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + + "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="], + + "postcss": ["postcss@8.5.19", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="], + + "postcss-selector-parser": ["postcss-selector-parser@7.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg=="], + + "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "prettier": ["prettier@3.9.5", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="], + + "prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.8.1", "", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-hermes": "*", "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-hermes", "@prettier/plugin-oxc", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-svelte"] }, "sha512-iaFMYqDsE4ffdDkn5qup0j5f2aCEBFZrdrZnvu9QKTlWx/iGPeQ4HHu7b7fCPMxeo9nwQBiOAh2nSypdFYWJkw=="], + + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + + "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], + + "react-hook-form": ["react-hook-form@7.81.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw=="], + + "react-is": ["react-is@19.2.7", "", {}, "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A=="], + + "react-redux": ["react-redux@9.3.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g=="], + + "react-router": ["react-router@7.18.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg=="], + + "recast": ["recast@0.23.12", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-dEWRjcINDu/F4l2dYx57ugBtD7HV9KXESyxhzw/MqWLeglJrsjJKqACPyUPg+6AF8mIgm+Zi0dZ3ACoIg+QtpA=="], + + "recharts": ["recharts@3.9.2", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^11.1.8", "react-redux": "8.x.x || 9.x.x", "reselect": "5.2.0", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw=="], + + "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], + + "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "reselect": ["reselect@5.2.0", "", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.1.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shadcn": ["shadcn@4.13.1", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "kleur": "^4.1.5", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "undici": "^7.27.2", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-pSNPND8mVWGBytdd8l4Cksg7MyRZOsv28HpvNCtFtLwZoxLSGM6v3NMUpmpbOaIoreopS/UOpQvnCyttOHVLAQ=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], + + "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "stringify-object": ["stringify-object@5.0.0", "", { "dependencies": { "get-own-enumerable-keys": "^1.0.0", "is-obj": "^3.0.0", "is-regexp": "^3.1.0" } }, "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + + "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], + + "systeminformation": ["systeminformation@5.31.17", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-TvFA9iwDWlMjqZVlKIJ0Cy+Zgm9ttlMx0SMRwJDMNKyhlEKWBMb3+WRwDi/3dvHdWbexpos4Osp4U49p5WjB5g=="], + + "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], + + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + + "ts-morph": ["ts-morph@26.0.0", "", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="], + + "tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "typescript-eslint": ["typescript-eslint@8.64.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.64.0", "@typescript-eslint/parser": "8.64.0", "@typescript-eslint/typescript-estree": "8.64.0", "@typescript-eslint/utils": "8.64.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ=="], + + "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], + + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], + + "vite": ["vite@8.1.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.17", "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "yocto-spinner": ["yocto-spinner@1.2.2", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng=="], + + "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + + "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + + "zustand": ["zustand@5.0.14", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g=="], + + "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], + + "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + + "@dotenvx/dotenvx/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], + + "@dotenvx/dotenvx/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], + + "@typescript-eslint/typescript-estree/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "accepts/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + + "conf/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "conf/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], + + "conf/json-schema-typed": ["json-schema-typed@7.0.3", "", {}, "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A=="], + + "conf/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="], + + "enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "express/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + + "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], + + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + + "onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + + "pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], + + "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + + "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + + "send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + + "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + + "@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + + "@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], + + "@dotenvx/dotenvx/execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "@dotenvx/dotenvx/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], + + "@dotenvx/dotenvx/execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "@dotenvx/dotenvx/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], + + "@dotenvx/dotenvx/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], + + "@dotenvx/dotenvx/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + + "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "accepts/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "conf/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "pkg-up/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], + + "send/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], + + "pkg-up/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], + + "pkg-up/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + } +} diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..d7b4d34 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-rhea", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "olive", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "hugeicons", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..f11c722 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,28 @@ +import js from "@eslint/js" +import globals from "globals" +import reactHooks from "eslint-plugin-react-hooks" +import reactRefresh from "eslint-plugin-react-refresh" +import tseslint from "typescript-eslint" +import { defineConfig, globalIgnores } from "eslint/config" + +export default defineConfig([ + globalIgnores(["dist"]), + { + files: ["**/*.{ts,tsx}"], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, + { + files: ["src/components/ui/**/*.{ts,tsx}"], + rules: { + "react-refresh/only-export-components": "off", + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b01bf8b --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + Inventory Flow + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..65965b5 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,28 @@ +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location = /api { + proxy_pass http://api:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api/ { + proxy_pass http://api:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..3443d1a --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,56 @@ +{ + "name": "inventory-flow-web", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "format": "prettier --write \"**/*.{ts,tsx}\"", + "typecheck": "tsc --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@base-ui/react": "^1.6.0", + "@fontsource-variable/geist": "^5.2.9", + "@hookform/resolvers": "^5.4.0", + "@hugeicons/core-free-icons": "^4.2.2", + "@hugeicons/react": "^1.1.9", + "@tailwindcss/vite": "^4", + "@tanstack/react-query": "^5.101.2", + "@tanstack/react-table": "^8.21.3", + "axios": "^1.18.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "motion": "^12.42.2", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-hook-form": "^7.81.0", + "react-router": "^7", + "recharts": "^3.9.2", + "shadcn": "^4.13.1", + "sonner": "^2.0.7", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4", + "tw-animate-css": "^1.4.0", + "zod": "^4.4.3", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@eslint/js": "^10", + "@types/node": "^24", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^6", + "eslint": "^10", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17", + "prettier": "^3.8.3", + "prettier-plugin-tailwindcss": "^0.8.0", + "typescript": "~6", + "typescript-eslint": "^8", + "vite": "^8" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..bebb314 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,13 @@ +import { RouterProvider } from "react-router" + +import { router } from "@/app/router" +import { SessionBootstrap } from "@/features/auth/session-bootstrap" + +export default function App() { + return ( + <> + + + + ) +} diff --git a/frontend/src/app/config/environment.ts b/frontend/src/app/config/environment.ts new file mode 100644 index 0000000..0bcf9fd --- /dev/null +++ b/frontend/src/app/config/environment.ts @@ -0,0 +1,5 @@ +const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "/" + +export const environment = { + apiBaseUrl, +} as const diff --git a/frontend/src/app/providers/AppProviders.tsx b/frontend/src/app/providers/AppProviders.tsx new file mode 100644 index 0000000..c4be74b --- /dev/null +++ b/frontend/src/app/providers/AppProviders.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from "react" +import { QueryClientProvider } from "@tanstack/react-query" +import { Toaster } from "sonner" + +import { ThemeProvider } from "@/components/theme-provider" +import { TooltipProvider } from "@/components/ui/tooltip" +import { queryClient } from "@/lib/query-client" + +type AppProvidersProps = { + children: ReactNode +} + +export function AppProviders({ children }: AppProvidersProps) { + return ( + + + {children} + + + + ) +} diff --git a/frontend/src/app/router/index.tsx b/frontend/src/app/router/index.tsx new file mode 100644 index 0000000..3460649 --- /dev/null +++ b/frontend/src/app/router/index.tsx @@ -0,0 +1,88 @@ +import { Navigate, createBrowserRouter } from "react-router" + +import { DashboardLayout } from "@/layouts/DashboardLayout" +import { PublicOnly } from "@/features/auth/components/PublicOnly" +import { RequireAuth } from "@/features/auth/components/RequireAuth" + +const placeholderPage = (title: string) => ( +
+

{title}

+

+ This feature is planned for a dedicated vertical slice. +

+
+) + +const notFoundPage = ( +
+
+

404

+

Page not found

+
+
+) + +export const router = createBrowserRouter([ + { + element: , + children: [ + { path: "login", lazy: () => import("@/features/auth/pages/LoginPage") }, + { + path: "register", + lazy: () => import("@/features/auth/pages/RegisterPage"), + }, + ], + }, + { + element: , + children: [ + { + path: "/", + element: , + children: [ + { index: true, element: }, + { + path: "dashboard", + lazy: () => import("@/features/dashboard/pages/DashboardPage"), + }, + { + path: "products", + lazy: () => import("@/features/products/pages/ProductsPage"), + }, + { path: "categories", element: placeholderPage("Categories") }, + { + path: "inventory", + lazy: () => import("@/features/inventory/pages/InventoryPage"), + }, + { + path: "suppliers", + lazy: () => import("@/features/suppliers/pages/SuppliersPage"), + }, + { + path: "warehouses", + lazy: () => import("@/features/warehouses/pages/WarehousesPage"), + }, + { + path: "purchases", + lazy: () => import("@/features/purchases/pages/PurchasesPage"), + }, + { + path: "sales", + lazy: () => import("@/features/sales/pages/SalesPage"), + }, + { + path: "transfers", + lazy: () => import("@/features/transfers/pages/TransfersPage"), + }, + { + path: "reports", + lazy: () => import("@/features/reports/pages/ReportsPage"), + }, + { path: "users", element: placeholderPage("Users") }, + { path: "settings", element: placeholderPage("Settings") }, + ], + }, + ], + }, + { path: "*", element: notFoundPage }, +]) diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..22f7b15 --- /dev/null +++ b/frontend/src/components/layout/Sidebar.tsx @@ -0,0 +1,109 @@ +import { + DashboardSquare01Icon, + DeliveryTruckIcon, + Layers01Icon, + PackageIcon, + Settings01Icon, + ShoppingCart01Icon, + UserGroupIcon, + WarehouseIcon, +} from "@hugeicons/core-free-icons" +import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react" +import { NavLink } from "react-router" + +import { cn } from "@/lib/utils" +import { useUiStore } from "@/store/ui-store" + +type NavigationItem = { + label: string + to: string + icon: IconSvgElement +} + +const navigationItems: NavigationItem[] = [ + { label: "Dashboard", to: "/dashboard", icon: DashboardSquare01Icon }, + { label: "Inventory", to: "/inventory", icon: Layers01Icon }, + { label: "Products", to: "/products", icon: PackageIcon }, + { label: "Categories", to: "/categories", icon: Layers01Icon }, + { label: "Suppliers", to: "/suppliers", icon: DeliveryTruckIcon }, + { label: "Warehouses", to: "/warehouses", icon: WarehouseIcon }, + { label: "Purchases", to: "/purchases", icon: ShoppingCart01Icon }, + { label: "Sales", to: "/sales", icon: ShoppingCart01Icon }, + { label: "Transfers", to: "/transfers", icon: DeliveryTruckIcon }, + { label: "Reports", to: "/reports", icon: DashboardSquare01Icon }, + { label: "Users", to: "/users", icon: UserGroupIcon }, + { label: "Settings", to: "/settings", icon: Settings01Icon }, +] + +type NavigationLinksProps = { + collapsed?: boolean + onNavigate?: () => void +} + +export function NavigationLinks({ + collapsed = false, + onNavigate, +}: NavigationLinksProps) { + return ( + + ) +} + +export function Sidebar() { + const isSidebarCollapsed = useUiStore((state) => state.isSidebarCollapsed) + + return ( + + ) +} diff --git a/frontend/src/components/layout/Topbar.tsx b/frontend/src/components/layout/Topbar.tsx new file mode 100644 index 0000000..51f9a26 --- /dev/null +++ b/frontend/src/components/layout/Topbar.tsx @@ -0,0 +1,167 @@ +import { useState } from "react" +import { + Menu01Icon, + Moon02Icon, + Notification01Icon, + Search01Icon, + Sun03Icon, +} from "@hugeicons/core-free-icons" +import { HugeiconsIcon } from "@hugeicons/react" + +import { NavigationLinks } from "@/components/layout/Sidebar" +import { useTheme } from "@/components/theme-provider" +import { Avatar, AvatarFallback } from "@/components/ui/avatar" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + Sheet, + SheetContent, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet" +import { useUiStore } from "@/store/ui-store" +import { logout, switchWorkspace } from "@/features/auth/auth-api" +import { useAuthStore } from "@/features/auth/auth-store" +import { invalidateSession } from "@/lib/api-client" +import { useNavigate } from "react-router" + +export function Topbar() { + const [isMobileNavOpen, setIsMobileNavOpen] = useState(false) + const [switchingWorkspaceId, setSwitchingWorkspaceId] = useState< + string | null + >(null) + const { setTheme, theme } = useTheme() + const toggleSidebar = useUiStore((state) => state.toggleSidebar) + const user = useAuthStore((state) => state.user) + const setSession = useAuthStore((state) => state.setSession) + const navigate = useNavigate() + const signOut = async () => { + invalidateSession() + + try { + await logout() + } finally { + navigate("/login", { replace: true }) + } + } + + const toggleTheme = () => { + setTheme(theme === "dark" ? "light" : "dark") + } + + const handleWorkspaceSwitch = async (workspaceId: string) => { + if (workspaceId === user?.workspace.id || switchingWorkspaceId) return + setSwitchingWorkspaceId(workspaceId) + + try { + const session = await switchWorkspace(workspaceId) + setSession(session) + } finally { + setSwitchingWorkspaceId(null) + } + } + + return ( +
+ + } + > + + Open navigation + + + Main navigation +
+
+ Inventory Flow +
+ setIsMobileNavOpen(false)} /> + + + + + +
+ + + + + } + > + + + {user?.displayName.slice(0, 2).toUpperCase() ?? "?"} + + + Open user menu + + + + {user?.displayName ?? "Account"} + + {user?.workspace.name ?? "Workspace"} + + + {user && user.workspaces.length > 0 ? ( + <> + + Switch workspace + {user.workspaces.map((workspace) => ( + void handleWorkspaceSwitch(workspace.id)} + > + + {workspace.name} + + {workspace.role} + {workspace.id === user.workspace.id ? " · Active" : ""} + + + + ))} + + ) : null} + + Profile + Settings + + + Sign out + + + +
+
+ ) +} diff --git a/frontend/src/components/shared/PageHeader.tsx b/frontend/src/components/shared/PageHeader.tsx new file mode 100644 index 0000000..6682477 --- /dev/null +++ b/frontend/src/components/shared/PageHeader.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from "react" + +type PageHeaderProps = { + title: string + description: string + actions?: ReactNode +} + +export function PageHeader({ title, description, actions }: PageHeaderProps) { + return ( +
+
+

{title}

+

{description}

+
+ {actions ? ( +
{actions}
+ ) : null} +
+ ) +} diff --git a/frontend/src/components/theme-provider.tsx b/frontend/src/components/theme-provider.tsx new file mode 100644 index 0000000..1349a0c --- /dev/null +++ b/frontend/src/components/theme-provider.tsx @@ -0,0 +1,230 @@ +/* eslint-disable react-refresh/only-export-components */ +import * as React from "react" + +type Theme = "dark" | "light" | "system" +type ResolvedTheme = "dark" | "light" + +type ThemeProviderProps = { + children: React.ReactNode + defaultTheme?: Theme + storageKey?: string + disableTransitionOnChange?: boolean +} + +type ThemeProviderState = { + theme: Theme + setTheme: (theme: Theme) => void +} + +const COLOR_SCHEME_QUERY = "(prefers-color-scheme: dark)" +const THEME_VALUES: Theme[] = ["dark", "light", "system"] + +const ThemeProviderContext = React.createContext< + ThemeProviderState | undefined +>(undefined) + +function isTheme(value: string | null): value is Theme { + if (value === null) { + return false + } + + return THEME_VALUES.includes(value as Theme) +} + +function getSystemTheme(): ResolvedTheme { + if (window.matchMedia(COLOR_SCHEME_QUERY).matches) { + return "dark" + } + + return "light" +} + +function disableTransitionsTemporarily() { + const style = document.createElement("style") + style.appendChild( + document.createTextNode( + "*,*::before,*::after{-webkit-transition:none!important;transition:none!important}" + ) + ) + document.head.appendChild(style) + + return () => { + window.getComputedStyle(document.body) + requestAnimationFrame(() => { + requestAnimationFrame(() => { + style.remove() + }) + }) + } +} + +function isEditableTarget(target: EventTarget | null) { + if (!(target instanceof HTMLElement)) { + return false + } + + if (target.isContentEditable) { + return true + } + + const editableParent = target.closest( + "input, textarea, select, [contenteditable='true']" + ) + if (editableParent) { + return true + } + + return false +} + +export function ThemeProvider({ + children, + defaultTheme = "system", + storageKey = "theme", + disableTransitionOnChange = true, + ...props +}: ThemeProviderProps) { + const [theme, setThemeState] = React.useState(() => { + const storedTheme = localStorage.getItem(storageKey) + if (isTheme(storedTheme)) { + return storedTheme + } + + return defaultTheme + }) + + const setTheme = React.useCallback( + (nextTheme: Theme) => { + localStorage.setItem(storageKey, nextTheme) + setThemeState(nextTheme) + }, + [storageKey] + ) + + const applyTheme = React.useCallback( + (nextTheme: Theme) => { + const root = document.documentElement + const resolvedTheme = + nextTheme === "system" ? getSystemTheme() : nextTheme + const restoreTransitions = disableTransitionOnChange + ? disableTransitionsTemporarily() + : null + + root.classList.remove("light", "dark") + root.classList.add(resolvedTheme) + + if (restoreTransitions) { + restoreTransitions() + } + }, + [disableTransitionOnChange] + ) + + React.useEffect(() => { + applyTheme(theme) + + if (theme !== "system") { + return undefined + } + + const mediaQuery = window.matchMedia(COLOR_SCHEME_QUERY) + const handleChange = () => { + applyTheme("system") + } + + mediaQuery.addEventListener("change", handleChange) + + return () => { + mediaQuery.removeEventListener("change", handleChange) + } + }, [theme, applyTheme]) + + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.repeat) { + return + } + + if (event.metaKey || event.ctrlKey || event.altKey) { + return + } + + if (isEditableTarget(event.target)) { + return + } + + if (event.key.toLowerCase() !== "d") { + return + } + + setThemeState((currentTheme) => { + const nextTheme = + currentTheme === "dark" + ? "light" + : currentTheme === "light" + ? "dark" + : getSystemTheme() === "dark" + ? "light" + : "dark" + + localStorage.setItem(storageKey, nextTheme) + return nextTheme + }) + } + + window.addEventListener("keydown", handleKeyDown) + + return () => { + window.removeEventListener("keydown", handleKeyDown) + } + }, [storageKey]) + + React.useEffect(() => { + const handleStorageChange = (event: StorageEvent) => { + if (event.storageArea !== localStorage) { + return + } + + if (event.key !== storageKey) { + return + } + + if (isTheme(event.newValue)) { + setThemeState(event.newValue) + return + } + + setThemeState(defaultTheme) + } + + window.addEventListener("storage", handleStorageChange) + + return () => { + window.removeEventListener("storage", handleStorageChange) + } + }, [defaultTheme, storageKey]) + + const value = React.useMemo( + () => ({ + theme, + setTheme, + }), + [theme, setTheme] + ) + + return ( + + {children} + + ) +} + +export const useTheme = () => { + const context = React.useContext(ThemeProviderContext) + + if (context === undefined) { + throw new Error("useTheme must be used within a ThemeProvider") + } + + return context +} diff --git a/frontend/src/components/ui/avatar.tsx b/frontend/src/components/ui/avatar.tsx new file mode 100644 index 0000000..e4fed86 --- /dev/null +++ b/frontend/src/components/ui/avatar.tsx @@ -0,0 +1,109 @@ +"use client" + +import * as React from "react" +import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + size = "default", + ...props +}: AvatarPrimitive.Root.Props & { + size?: "default" | "sm" | "lg" +}) { + return ( + + ) +} + +function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: AvatarPrimitive.Fallback.Props) { + return ( + + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className + )} + {...props} + /> + ) +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, + AvatarBadge, +} diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000..6d49b91 --- /dev/null +++ b/frontend/src/components/ui/badge.tsx @@ -0,0 +1,52 @@ +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-2xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: + "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: + "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: + "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + render, + ...props +}: useRender.ComponentProps<"span"> & VariantProps) { + return useRender({ + defaultTagName: "span", + props: mergeProps<"span">( + { + className: cn(badgeVariants({ variant }), className), + }, + props + ), + render, + state: { + slot: "badge", + variant, + }, + }) +} + +export { Badge, badgeVariants } diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..e87e24d --- /dev/null +++ b/frontend/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +import { Button as ButtonPrimitive } from "@base-ui/react/button" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-2xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/80", + outline: + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-transparent dark:hover:bg-input/30", + secondary: + "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-8 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5", + xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + lg: "h-9 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", + icon: "size-8", + "icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3", + "icon-sm": "size-7", + "icon-lg": "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + ...props +}: ButtonPrimitive.Props & VariantProps) { + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..1137b40 --- /dev/null +++ b/frontend/src/components/ui/card.tsx @@ -0,0 +1,100 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] dark:ring-foreground/10 *:[img:first-child]:rounded-t-[min(var(--radius-4xl),24px)] *:[img:last-child]:rounded-b-[min(var(--radius-4xl),24px)]", + className + )} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..91e4799 --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,277 @@ +"use client" + +import * as React from "react" +import { Menu as MenuPrimitive } from "@base-ui/react/menu" + +import { cn } from "@/lib/utils" +import { HugeiconsIcon } from "@hugeicons/react" +import { ArrowRight01Icon, Tick02Icon } from "@hugeicons/core-free-icons" + +function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) { + return +} + +function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) { + return +} + +function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) { + return +} + +function DropdownMenuContent({ + align = "start", + alignOffset = 0, + side = "bottom", + sideOffset = 4, + className, + ...props +}: MenuPrimitive.Popup.Props & + Pick< + MenuPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + + + ) +} + +function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) { + return +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: MenuPrimitive.GroupLabel.Props & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: MenuPrimitive.Item.Props & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: MenuPrimitive.SubmenuTrigger.Props & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + align = "start", + alignOffset = -3, + side = "right", + sideOffset = 0, + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: MenuPrimitive.CheckboxItem.Props & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: MenuPrimitive.RadioItem.Props & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: MenuPrimitive.Separator.Props) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/frontend/src/components/ui/separator.tsx b/frontend/src/components/ui/separator.tsx new file mode 100644 index 0000000..4f65961 --- /dev/null +++ b/frontend/src/components/ui/separator.tsx @@ -0,0 +1,23 @@ +import { Separator as SeparatorPrimitive } from "@base-ui/react/separator" + +import { cn } from "@/lib/utils" + +function Separator({ + className, + orientation = "horizontal", + ...props +}: SeparatorPrimitive.Props) { + return ( + + ) +} + +export { Separator } diff --git a/frontend/src/components/ui/sheet.tsx b/frontend/src/components/ui/sheet.tsx new file mode 100644 index 0000000..9b08eb0 --- /dev/null +++ b/frontend/src/components/ui/sheet.tsx @@ -0,0 +1,136 @@ +import * as React from "react" +import { Dialog as SheetPrimitive } from "@base-ui/react/dialog" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { HugeiconsIcon } from "@hugeicons/react" +import { Cancel01Icon } from "@hugeicons/core-free-icons" + +function Sheet({ ...props }: SheetPrimitive.Root.Props) { + return +} + +function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) { + return +} + +function SheetClose({ ...props }: SheetPrimitive.Close.Props) { + return +} + +function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) { + return +} + +function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) { + return ( + + ) +} + +function SheetContent({ + className, + children, + side = "right", + showCloseButton = true, + ...props +}: SheetPrimitive.Popup.Props & { + side?: "top" | "right" | "bottom" | "left" + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + } + > + + Close + + )} + + + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) { + return ( + + ) +} + +function SheetDescription({ + className, + ...props +}: SheetPrimitive.Description.Props) { + return ( + + ) +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/frontend/src/components/ui/skeleton.tsx b/frontend/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..b1deda9 --- /dev/null +++ b/frontend/src/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import type { ComponentProps } from "react" + +import { cn } from "@/lib/utils" + +function Skeleton({ className, ...props }: ComponentProps<"div">) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/frontend/src/components/ui/tooltip.tsx b/frontend/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..aad8238 --- /dev/null +++ b/frontend/src/components/ui/tooltip.tsx @@ -0,0 +1,64 @@ +import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip" + +import { cn } from "@/lib/utils" + +function TooltipProvider({ + delay = 0, + ...props +}: TooltipPrimitive.Provider.Props) { + return ( + + ) +} + +function Tooltip({ ...props }: TooltipPrimitive.Root.Props) { + return +} + +function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) { + return +} + +function TooltipContent({ + className, + side = "top", + sideOffset = 4, + align = "center", + alignOffset = 0, + children, + ...props +}: TooltipPrimitive.Popup.Props & + Pick< + TooltipPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + {children} + + + + + ) +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/frontend/src/features/auth/auth-api.ts b/frontend/src/features/auth/auth-api.ts new file mode 100644 index 0000000..fc38666 --- /dev/null +++ b/frontend/src/features/auth/auth-api.ts @@ -0,0 +1,31 @@ +import { apiClient, authClient } from "@/lib/api-client" +import type { + AuthenticationResponse, + AuthenticatedUser, +} from "@/features/auth/types" + +export const register = (payload: { + displayName: string + email: string + password: string +}) => + authClient + .post("/api/auth/register", payload) + .then((response) => response.data) +export const login = (payload: { email: string; password: string }) => + authClient + .post("/api/auth/login", payload) + .then((response) => response.data) +export const refresh = () => + authClient + .post("/api/auth/refresh") + .then((response) => response.data) +export const switchWorkspace = (workspaceId: string) => + apiClient + .post("/api/auth/workspace/switch", { workspaceId }) + .then((response) => response.data) +export const logout = () => authClient.post("/api/auth/logout") +export const getCurrentUser = () => + apiClient + .get("/api/auth/me") + .then((response) => response.data) diff --git a/frontend/src/features/auth/auth-redirect.ts b/frontend/src/features/auth/auth-redirect.ts new file mode 100644 index 0000000..a0c7a21 --- /dev/null +++ b/frontend/src/features/auth/auth-redirect.ts @@ -0,0 +1,17 @@ +export const getSafeReturnPath = (value: unknown) => { + if ( + typeof value !== "string" || + !value.startsWith("/") || + value.startsWith("//") + ) + return "/dashboard" + + try { + const url = new URL(value, window.location.origin) + return url.origin === window.location.origin + ? `${url.pathname}${url.search}${url.hash}` + : "/dashboard" + } catch { + return "/dashboard" + } +} diff --git a/frontend/src/features/auth/auth-schema.ts b/frontend/src/features/auth/auth-schema.ts new file mode 100644 index 0000000..a4049c6 --- /dev/null +++ b/frontend/src/features/auth/auth-schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod" +export const loginSchema = z.object({ + email: z.email("Enter a valid email address."), + password: z.string().min(1, "Password is required."), +}) +export const registerSchema = loginSchema.extend({ + displayName: z.string().trim().min(1, "Display name is required.").max(200), + password: z.string().min(1, "Password is required."), +}) diff --git a/frontend/src/features/auth/auth-store.ts b/frontend/src/features/auth/auth-store.ts new file mode 100644 index 0000000..89ba304 --- /dev/null +++ b/frontend/src/features/auth/auth-store.ts @@ -0,0 +1,37 @@ +import { create } from "zustand" + +import type { + AuthenticationResponse, + AuthenticatedUser, +} from "@/features/auth/types" +import { queryClient } from "@/lib/query-client" + +type AuthState = { + accessToken: string | null + user: AuthenticatedUser | null + isRestoring: boolean + setSession: (session: AuthenticationResponse) => void + clearSession: () => void + finishRestoring: () => void +} +export const useAuthStore = create((set) => ({ + accessToken: null, + user: null, + isRestoring: true, + setSession: (session) => + set((state) => { + if ( + state.user && + (state.user.id !== session.user.id || + state.user.workspace.id !== session.user.workspace.id) + ) + queryClient.clear() + + return { accessToken: session.accessToken, user: session.user } + }), + clearSession: () => { + queryClient.clear() + set({ accessToken: null, user: null }) + }, + finishRestoring: () => set({ isRestoring: false }), +})) diff --git a/frontend/src/features/auth/components/PublicOnly.tsx b/frontend/src/features/auth/components/PublicOnly.tsx new file mode 100644 index 0000000..49f7514 --- /dev/null +++ b/frontend/src/features/auth/components/PublicOnly.tsx @@ -0,0 +1,12 @@ +import { Navigate, Outlet } from "react-router" +import { useAuthStore } from "@/features/auth/auth-store" +export function PublicOnly() { + const { accessToken, isRestoring } = useAuthStore() + if (isRestoring) + return ( +
+ Restoring session… +
+ ) + return accessToken ? : +} diff --git a/frontend/src/features/auth/components/RequireAuth.tsx b/frontend/src/features/auth/components/RequireAuth.tsx new file mode 100644 index 0000000..ab56532 --- /dev/null +++ b/frontend/src/features/auth/components/RequireAuth.tsx @@ -0,0 +1,21 @@ +import { Navigate, Outlet, useLocation } from "react-router" +import { useAuthStore } from "@/features/auth/auth-store" +export function RequireAuth() { + const { accessToken, isRestoring } = useAuthStore() + const location = useLocation() + if (isRestoring) + return ( +
+ Restoring session… +
+ ) + return accessToken ? ( + + ) : ( + + ) +} diff --git a/frontend/src/features/auth/pages/LoginPage.tsx b/frontend/src/features/auth/pages/LoginPage.tsx new file mode 100644 index 0000000..5f1ba90 --- /dev/null +++ b/frontend/src/features/auth/pages/LoginPage.tsx @@ -0,0 +1,100 @@ +import { useState } from "react" +import { Link, useLocation, useNavigate } from "react-router" +import { loginSchema } from "@/features/auth/auth-schema" +import { login } from "@/features/auth/auth-api" +import { useAuthStore } from "@/features/auth/auth-store" +import { getSafeReturnPath } from "@/features/auth/auth-redirect" +export function Component() { + const navigate = useNavigate() + const location = useLocation() + const setSession = useAuthStore((state) => state.setSession) + const [error, setError] = useState("") + const [invalidField, setInvalidField] = useState(null) + const [pending, setPending] = useState(false) + const submit = async (form: FormData) => { + const parsed = loginSchema.safeParse(Object.fromEntries(form)) + if (!parsed.success) { + setInvalidField(parsed.error.issues[0]?.path[0]?.toString() ?? null) + return setError(parsed.error.issues[0]?.message ?? "Check your details.") + } + + setPending(true) + setError("") + setInvalidField(null) + try { + setSession(await login(parsed.data)) + navigate(getSafeReturnPath(location.state?.from), { replace: true }) + } catch { + setError("Email or password is incorrect.") + } finally { + setPending(false) + } + } + return ( +
+
+
+

Sign in

+

+ Access your inventory workspace. +

+
+ + + {error && ( + + )} + +

+ New here?{" "} + + Create an account + +

+
+
+ ) +} diff --git a/frontend/src/features/auth/pages/RegisterPage.tsx b/frontend/src/features/auth/pages/RegisterPage.tsx new file mode 100644 index 0000000..d0469c9 --- /dev/null +++ b/frontend/src/features/auth/pages/RegisterPage.tsx @@ -0,0 +1,114 @@ +import { useState } from "react" +import { Link, useLocation, useNavigate } from "react-router" +import { registerSchema } from "@/features/auth/auth-schema" +import { register } from "@/features/auth/auth-api" +import { useAuthStore } from "@/features/auth/auth-store" +import { getSafeReturnPath } from "@/features/auth/auth-redirect" +export function Component() { + const navigate = useNavigate() + const location = useLocation() + const setSession = useAuthStore((state) => state.setSession) + const [error, setError] = useState("") + const [invalidField, setInvalidField] = useState(null) + const [pending, setPending] = useState(false) + const submit = async (form: FormData) => { + const parsed = registerSchema.safeParse(Object.fromEntries(form)) + if (!parsed.success) { + setInvalidField(parsed.error.issues[0]?.path[0]?.toString() ?? null) + return setError(parsed.error.issues[0]?.message ?? "Check your details.") + } + + setPending(true) + setError("") + setInvalidField(null) + try { + setSession(await register(parsed.data)) + navigate(getSafeReturnPath(location.state?.from), { replace: true }) + } catch { + setError( + "We could not create your account. Check the password requirements and try again." + ) + } finally { + setPending(false) + } + } + return ( +
+
+
+

Create account

+

+ Start managing inventory. +

+
+ + + + {error && ( + + )} + +

+ Already registered?{" "} + + Sign in + +

+
+
+ ) +} diff --git a/frontend/src/features/auth/session-bootstrap.tsx b/frontend/src/features/auth/session-bootstrap.tsx new file mode 100644 index 0000000..fab51b4 --- /dev/null +++ b/frontend/src/features/auth/session-bootstrap.tsx @@ -0,0 +1,9 @@ +import { useEffect } from "react" +import { restoreSession } from "@/lib/api-client" +let bootstrap: Promise | undefined +export function SessionBootstrap() { + useEffect(() => { + bootstrap ??= restoreSession() + }, []) + return null +} diff --git a/frontend/src/features/auth/types.ts b/frontend/src/features/auth/types.ts new file mode 100644 index 0000000..6bba000 --- /dev/null +++ b/frontend/src/features/auth/types.ts @@ -0,0 +1,18 @@ +export type WorkspaceRole = "Owner" | "Member" +export type AuthenticatedWorkspace = { + id: string + name: string + role: WorkspaceRole +} +export type AuthenticatedUser = { + id: string + email: string + displayName: string + workspace: AuthenticatedWorkspace + workspaces: AuthenticatedWorkspace[] +} +export type AuthenticationResponse = { + accessToken: string + accessTokenExpiresAtUtc: string + user: AuthenticatedUser +} diff --git a/frontend/src/features/dashboard/components/MetricCard.tsx b/frontend/src/features/dashboard/components/MetricCard.tsx new file mode 100644 index 0000000..72ce18e --- /dev/null +++ b/frontend/src/features/dashboard/components/MetricCard.tsx @@ -0,0 +1,66 @@ +import { + ArrowDownRightIcon, + ArrowUpRightIcon, +} from "@hugeicons/core-free-icons" +import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react" +import { motion } from "motion/react" + +import { Card, CardContent } from "@/components/ui/card" +import { cn } from "@/lib/utils" + +type MetricCardProps = { + label: string + value: string + change: string + trend: "up" | "down" + icon: IconSvgElement + index: number +} + +export function MetricCard({ + label, + value, + change, + trend, + icon, + index, +}: MetricCardProps) { + const isPositive = trend === "up" + + return ( + + + +
+

{label}

+

+ {value} +

+

+ + {change} from last month +

+
+
+ +
+
+
+
+ ) +} diff --git a/frontend/src/features/dashboard/pages/DashboardPage.tsx b/frontend/src/features/dashboard/pages/DashboardPage.tsx new file mode 100644 index 0000000..1401677 --- /dev/null +++ b/frontend/src/features/dashboard/pages/DashboardPage.tsx @@ -0,0 +1,268 @@ +import { + Alert02Icon, + MoneyReceiveSquareIcon, + PackageIcon, + ShoppingCart01Icon, + WarehouseIcon, +} from "@hugeicons/core-free-icons" +import type { IconSvgElement } from "@hugeicons/react" +import { HugeiconsIcon } from "@hugeicons/react" +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts" + +import { MetricCard } from "@/features/dashboard/components/MetricCard" +import { PageHeader } from "@/components/shared/PageHeader" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" + +type Metric = { + label: string + value: string + change: string + trend: "up" | "down" + icon: IconSvgElement +} + +const metrics: Metric[] = [ + { + label: "Total products", + value: "1,248", + change: "12.5%", + trend: "up", + icon: PackageIcon, + }, + { + label: "Inventory value", + value: "$84,250", + change: "8.2%", + trend: "up", + icon: MoneyReceiveSquareIcon, + }, + { + label: "Today's sales", + value: "$3,480", + change: "4.1%", + trend: "up", + icon: ShoppingCart01Icon, + }, + { + label: "Low stock items", + value: "24", + change: "2.4%", + trend: "down", + icon: Alert02Icon, + }, +] + +const salesData = [ + { month: "Jan", revenue: 18_400 }, + { month: "Feb", revenue: 21_200 }, + { month: "Mar", revenue: 19_600 }, + { month: "Apr", revenue: 24_800 }, + { month: "May", revenue: 28_100 }, + { month: "Jun", revenue: 31_500 }, +] + +const recentActivity = [ + { + title: "Purchase order PO-1048 received", + detail: "Northwind Traders · 24 items", + time: "12 min ago", + }, + { + title: "Stock adjusted for Wireless Barcode Scanner", + detail: "Main Warehouse · -2 units", + time: "38 min ago", + }, + { + title: "Sales order SO-3012 dispatched", + detail: "Acme Retail · 8 items", + time: "1 hr ago", + }, +] + +export function Component() { + return ( +
+ Export report} + description="A real-time view of inventory health and operational activity." + title="Dashboard" + /> + +
+ {metrics.map((metric, index) => ( + + ))} +
+ +
+ + + Revenue overview + + Monthly sales performance for the current year. + + + + + + + + + + + + + + `$${value / 1_000}k`} + tickLine={false} + tickMargin={8} + /> + [ + `$${Number(value).toLocaleString()}`, + "Revenue", + ]} + /> + + + + + + + + + Warehouse coverage + + Stock distribution across locations. + + + + {[ + ["Main Warehouse", "62%", "782 items"], + ["East Distribution", "24%", "299 items"], + ["West Distribution", "14%", "167 items"], + ].map(([name, percentage, count]) => ( +
+
+ {name} + {count} +
+
+
+
+
+ ))} + + +
+ +
+ + + Recent activity + + Latest movements across your operations. + + + + {recentActivity.map((activity) => ( +
+
+ +
+
+

{activity.title}

+

+ {activity.detail} +

+
+ + {activity.time} + +
+ ))} +
+
+ + + + Inventory alerts + Items that need attention. + + +
+
+ + 24 low stock items +
+ Review +
+
+
+ + 3 pending transfers +
+ Open +
+
+
+
+
+ ) +} diff --git a/frontend/src/features/inventory/inventory-api.ts b/frontend/src/features/inventory/inventory-api.ts new file mode 100644 index 0000000..34ff546 --- /dev/null +++ b/frontend/src/features/inventory/inventory-api.ts @@ -0,0 +1,26 @@ +import { apiClient } from "@/lib/api-client" +import type { + InventoryBalance, + InventoryMovement, + RecordInventoryMovementPayload, +} from "@/features/inventory/types" + +type BalanceFilters = { + warehouseId?: string + productId?: string +} + +export const listInventoryBalances = (filters: BalanceFilters) => + apiClient + .get("/api/inventory/balances", { params: filters }) + .then((response) => response.data) + +export const recordInventoryMovement = ( + kind: "receipt" | "issue", + payload: RecordInventoryMovementPayload +) => + apiClient + .post(`/api/inventory/${kind}s`, payload, { + headers: { "Idempotency-Key": payload.idempotencyKey }, + }) + .then((response) => response.data) diff --git a/frontend/src/features/inventory/inventory-queries.ts b/frontend/src/features/inventory/inventory-queries.ts new file mode 100644 index 0000000..601113d --- /dev/null +++ b/frontend/src/features/inventory/inventory-queries.ts @@ -0,0 +1,19 @@ +export const inventoryBalancesKey = ( + userId: string, + workspaceId: string, + warehouseId = "", + productId = "" +) => + [ + "inventory", + "balances", + userId, + workspaceId, + warehouseId, + productId, + ] as const + +export const inventoryBalancesKeyPrefix = ( + userId: string, + workspaceId: string +) => ["inventory", "balances", userId, workspaceId] as const diff --git a/frontend/src/features/inventory/inventory-schema.ts b/frontend/src/features/inventory/inventory-schema.ts new file mode 100644 index 0000000..cfb9eba --- /dev/null +++ b/frontend/src/features/inventory/inventory-schema.ts @@ -0,0 +1,15 @@ +import { z } from "zod" + +const decimalQuantity = z + .string() + .regex( + /^(?:0|[1-9]\d{0,13})(?:\.\d{1,4})?$/, + "Quantity must be a positive decimal with up to four decimal places." + ) + .refine((value) => Number(value) > 0, "Quantity must be greater than zero.") + +export const inventoryMovementSchema = z.object({ + warehouseId: z.string().uuid("Choose a warehouse."), + productId: z.string().uuid("Choose a product."), + quantity: decimalQuantity, +}) diff --git a/frontend/src/features/inventory/pages/InventoryPage.tsx b/frontend/src/features/inventory/pages/InventoryPage.tsx new file mode 100644 index 0000000..fe2b30c --- /dev/null +++ b/frontend/src/features/inventory/pages/InventoryPage.tsx @@ -0,0 +1,273 @@ +import { useState } from "react" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" + +import { useAuthStore } from "@/features/auth/auth-store" +import { + listInventoryBalances, + recordInventoryMovement, +} from "@/features/inventory/inventory-api" +import { inventoryMovementSchema } from "@/features/inventory/inventory-schema" +import { + inventoryBalancesKey, + inventoryBalancesKeyPrefix, +} from "@/features/inventory/inventory-queries" +import type { RecordInventoryMovementPayload } from "@/features/inventory/types" +import { listProducts } from "@/features/products/products-api" +import { listWarehouses } from "@/features/warehouses/warehouses-api" +import { PageHeader } from "@/components/shared/PageHeader" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" + +const productsKey = (userId: string, workspaceId: string) => + ["products", userId, workspaceId] as const +const warehousesKey = (userId: string, workspaceId: string) => + ["warehouses", userId, workspaceId] as const +const inventoryMovementKey = (userId: string, workspaceId: string) => + ["inventory", "movement", userId, workspaceId] as const + +type MovementInput = RecordInventoryMovementPayload & { + kind: "receipt" | "issue" +} + +const readError = (error: unknown) => { + if (typeof error === "object" && error && "response" in error) { + const data = ( + error as { response?: { data?: { detail?: string; title?: string } } } + ).response?.data + return data?.detail ?? data?.title ?? "Unable to record the movement." + } + return "Unable to record the movement." +} + +const displayQuantity = (quantity: number) => + new Intl.NumberFormat(undefined, { maximumFractionDigits: 4 }).format( + quantity + ) + +export function Component() { + const queryClient = useQueryClient() + const user = useAuthStore((state) => state.user) + const userId = user?.id ?? "anonymous" + const workspaceId = user?.workspace.id ?? "none" + const [warehouseId, setWarehouseId] = useState("") + const [productId, setProductId] = useState("") + const [quantity, setQuantity] = useState("") + const [formError, setFormError] = useState("") + const [retryMovement, setRetryMovement] = useState(null) + + const products = useQuery({ + queryKey: productsKey(userId, workspaceId), + queryFn: listProducts, + enabled: user !== null, + }) + const warehouses = useQuery({ + queryKey: warehousesKey(userId, workspaceId), + queryFn: listWarehouses, + enabled: user !== null, + }) + const balances = useQuery({ + queryKey: inventoryBalancesKey(userId, workspaceId, warehouseId, productId), + queryFn: () => + listInventoryBalances({ + warehouseId: warehouseId || undefined, + productId: productId || undefined, + }), + enabled: user !== null, + }) + const movement = useMutation({ + mutationKey: inventoryMovementKey(userId, workspaceId), + mutationFn: ({ kind, ...payload }: MovementInput) => + recordInventoryMovement(kind, payload), + onSuccess: () => { + setFormError("") + setRetryMovement(null) + setQuantity("") + queryClient.invalidateQueries({ + queryKey: inventoryBalancesKeyPrefix(userId, workspaceId), + }) + }, + onError: (reason, variables) => { + setRetryMovement(variables) + setFormError(readError(reason)) + }, + }) + + const submit = (kind: "receipt" | "issue") => { + const parsed = inventoryMovementSchema.safeParse({ + warehouseId, + productId, + quantity, + }) + if (!parsed.success) { + setFormError( + parsed.error.issues[0]?.message ?? "Check the movement details." + ) + return + } + + setFormError("") + movement.mutate({ + ...parsed.data, + kind, + idempotencyKey: crypto.randomUUID(), + }) + } + + const productNames = new Map( + products.data?.map((product) => [product.id, product.name]) + ) + const warehouseNames = new Map( + warehouses.data?.map((warehouse) => [warehouse.id, warehouse.name]) + ) + const catalogLoading = products.isLoading || warehouses.isLoading + const canRecord = !catalogLoading && !movement.isPending + + return ( +
+ +
+ + + Record movement + + + {catalogLoading ? ( +

+ Loading products and warehouses… +

+ ) : null} + {products.isError || warehouses.isError ? ( +

+ {readError(products.error ?? warehouses.error)} +

+ ) : null} +
+ + + + {formError ? ( +

+ {formError} +

+ ) : null} + {retryMovement ? ( + + ) : null} +
+ + +
+
+
+
+ + + On hand + + + {balances.isLoading ? ( +

Loading balances…

+ ) : null} + {balances.isError ? ( +

+ {readError(balances.error)} +

+ ) : null} + {balances.data?.length === 0 ? ( +

+ No inventory balances match these selections. +

+ ) : null} + {balances.data?.length ? ( +
    + {balances.data.map((balance) => ( +
  • +
    +

    + {productNames.get(balance.productId) ?? + balance.productId} +

    +

    + {warehouseNames.get(balance.warehouseId) ?? + balance.warehouseId} +

    +
    + + {displayQuantity(balance.quantity)} + +
  • + ))} +
+ ) : null} +
+
+
+
+ ) +} diff --git a/frontend/src/features/inventory/types.ts b/frontend/src/features/inventory/types.ts new file mode 100644 index 0000000..71c1705 --- /dev/null +++ b/frontend/src/features/inventory/types.ts @@ -0,0 +1,22 @@ +export type InventoryBalance = { + warehouseId: string + productId: string + quantity: number +} + +export type InventoryMovement = { + id: string + warehouseId: string + productId: string + type: number + quantity: number + balanceAfterQuantity: number + occurredAtUtc: string +} + +export type RecordInventoryMovementPayload = { + warehouseId: string + productId: string + quantity: string + idempotencyKey: string +} diff --git a/frontend/src/features/products/pages/ProductsPage.tsx b/frontend/src/features/products/pages/ProductsPage.tsx new file mode 100644 index 0000000..2689872 --- /dev/null +++ b/frontend/src/features/products/pages/ProductsPage.tsx @@ -0,0 +1,151 @@ +import { useState } from "react" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" + +import { PageHeader } from "@/components/shared/PageHeader" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { + archiveProduct, + createProduct, + listProducts, +} from "@/features/products/products-api" +import { productSchema } from "@/features/products/products-schema" +import { useAuthStore } from "@/features/auth/auth-store" + +const productsKey = (userId: string, workspaceId: string) => + ["products", userId, workspaceId] as const + +const readError = (error: unknown) => { + if (typeof error === "object" && error && "response" in error) { + const data = ( + error as { response?: { data?: { detail?: string; title?: string } } } + ).response?.data + return data?.detail ?? data?.title ?? "Unable to save the product." + } + return "Unable to save the product." +} + +export function Component() { + const queryClient = useQueryClient() + const user = useAuthStore((state) => state.user) + const [error, setError] = useState("") + const queryKey = productsKey( + user?.id ?? "anonymous", + user?.workspace.id ?? "none" + ) + const products = useQuery({ + queryKey, + queryFn: listProducts, + enabled: user !== null, + }) + const create = useMutation({ + mutationFn: createProduct, + onSuccess: () => { + setError("") + queryClient.invalidateQueries({ queryKey }) + }, + onError: (reason) => setError(readError(reason)), + }) + const archive = useMutation({ + mutationFn: archiveProduct, + onSuccess: () => queryClient.invalidateQueries({ queryKey }), + onError: (reason) => setError(readError(reason)), + }) + + const submit = (form: FormData) => { + const parsed = productSchema.safeParse(Object.fromEntries(form)) + if (!parsed.success) + return setError( + parsed.error.issues[0]?.message ?? "Check the product details." + ) + setError("") + create.mutate(parsed.data) + } + + return ( +
+ +
+ + + Catalog + + + {products.isLoading ? ( +

Loading products…

+ ) : null} + {products.isError ? ( +

+ {readError(products.error)} +

+ ) : null} + {products.data?.length === 0 ? ( +

No active products yet.

+ ) : null} + {products.data?.length ? ( +
    + {products.data.map((product) => ( +
  • +
    +

    {product.name}

    +

    + {product.sku} +

    +
    + +
  • + ))} +
+ ) : null} +
+
+ + + Add product + + +
+ + + {error ? ( +

+ {error} +

+ ) : null} + +
+
+
+
+
+ ) +} diff --git a/frontend/src/features/products/products-api.ts b/frontend/src/features/products/products-api.ts new file mode 100644 index 0000000..ad14a5f --- /dev/null +++ b/frontend/src/features/products/products-api.ts @@ -0,0 +1,11 @@ +import { apiClient } from "@/lib/api-client" +import type { CreateProductPayload, Product } from "@/features/products/types" + +export const listProducts = () => + apiClient.get("/api/products").then((response) => response.data) +export const createProduct = (payload: CreateProductPayload) => + apiClient + .post("/api/products", payload) + .then((response) => response.data) +export const archiveProduct = (id: string) => + apiClient.delete(`/api/products/${id}`) diff --git a/frontend/src/features/products/products-schema.ts b/frontend/src/features/products/products-schema.ts new file mode 100644 index 0000000..b8121ed --- /dev/null +++ b/frontend/src/features/products/products-schema.ts @@ -0,0 +1,6 @@ +import { z } from "zod" + +export const productSchema = z.object({ + name: z.string().trim().min(1, "Name is required.").max(200), + sku: z.string().trim().min(1, "SKU is required.").max(100), +}) diff --git a/frontend/src/features/products/types.ts b/frontend/src/features/products/types.ts new file mode 100644 index 0000000..66a3af7 --- /dev/null +++ b/frontend/src/features/products/types.ts @@ -0,0 +1,11 @@ +export type Product = { + id: string + name: string + sku: string + createdAtUtc: string +} + +export type CreateProductPayload = { + name: string + sku: string +} diff --git a/frontend/src/features/purchases/pages/PurchasesPage.tsx b/frontend/src/features/purchases/pages/PurchasesPage.tsx new file mode 100644 index 0000000..e077b04 --- /dev/null +++ b/frontend/src/features/purchases/pages/PurchasesPage.tsx @@ -0,0 +1,247 @@ +import { useState } from "react" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" + +import { PageHeader } from "@/components/shared/PageHeader" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { useAuthStore } from "@/features/auth/auth-store" +import { listProducts } from "@/features/products/products-api" +import { + listPurchaseReceipts, + recordPurchaseReceipt, +} from "@/features/purchases/purchases-api" +import { inventoryBalancesKeyPrefix } from "@/features/inventory/inventory-queries" +import { purchaseReceiptSchema } from "@/features/purchases/purchases-schema" +import type { RecordPurchaseReceiptPayload } from "@/features/purchases/types" +import { listSuppliers } from "@/features/suppliers/suppliers-api" +import { listWarehouses } from "@/features/warehouses/warehouses-api" + +const key = (name: string, userId: string, workspaceId: string) => + [name, userId, workspaceId] as const +const readError = (error: unknown) => { + if (typeof error === "object" && error && "response" in error) { + const data = ( + error as { response?: { data?: { detail?: string; title?: string } } } + ).response?.data + return data?.detail ?? data?.title ?? "Unable to receive the purchase." + } + return "Unable to receive the purchase." +} + +export function Component() { + const queryClient = useQueryClient() + const user = useAuthStore((state) => state.user) + const userId = user?.id ?? "anonymous" + const workspaceId = user?.workspace.id ?? "none" + const [supplierId, setSupplierId] = useState("") + const [warehouseId, setWarehouseId] = useState("") + const [productId, setProductId] = useState("") + const [quantity, setQuantity] = useState("") + const [formError, setFormError] = useState("") + const [retryReceipt, setRetryReceipt] = + useState(null) + const suppliers = useQuery({ + queryKey: key("suppliers", userId, workspaceId), + queryFn: listSuppliers, + enabled: user !== null, + }) + const warehouses = useQuery({ + queryKey: key("warehouses", userId, workspaceId), + queryFn: listWarehouses, + enabled: user !== null, + }) + const products = useQuery({ + queryKey: key("products", userId, workspaceId), + queryFn: listProducts, + enabled: user !== null, + }) + const receipts = useQuery({ + queryKey: key("purchases-receipts", userId, workspaceId), + queryFn: listPurchaseReceipts, + enabled: user !== null, + }) + const mutation = useMutation({ + mutationKey: key("purchases-receipts", userId, workspaceId), + mutationFn: recordPurchaseReceipt, + onSuccess: () => { + setFormError("") + setRetryReceipt(null) + setQuantity("") + queryClient.invalidateQueries({ + queryKey: key("purchases-receipts", userId, workspaceId), + }) + queryClient.invalidateQueries({ + queryKey: inventoryBalancesKeyPrefix(userId, workspaceId), + }) + }, + onError: (error, variables) => { + setRetryReceipt(variables) + setFormError(readError(error)) + }, + }) + const submit = () => { + const parsed = purchaseReceiptSchema.safeParse({ + supplierId, + warehouseId, + productId, + quantity, + }) + if (!parsed.success) { + setFormError( + parsed.error.issues[0]?.message ?? "Check the receipt details." + ) + return + } + setFormError("") + mutation.mutate({ ...parsed.data, idempotencyKey: crypto.randomUUID() }) + } + const loading = + suppliers.isLoading || warehouses.isLoading || products.isLoading + const names = { + suppliers: new Map(suppliers.data?.map((x) => [x.id, x.name])), + warehouses: new Map(warehouses.data?.map((x) => [x.id, x.name])), + products: new Map(products.data?.map((x) => [x.id, x.name])), + } + const disabled = loading || mutation.isPending + return ( +
+ +
+ + + Receive purchase + + +
+ {loading ? ( +

Loading catalog…

+ ) : null} + {suppliers.isError || warehouses.isError || products.isError ? ( +

+ {readError( + suppliers.error ?? warehouses.error ?? products.error + )} +

+ ) : null} + + + + + {formError ? ( +

+ {formError} +

+ ) : null} + {retryReceipt ? ( + + ) : null} + +
+
+
+ + + Receipt history + + + {receipts.isLoading ? ( +

Loading receipts…

+ ) : null} + {receipts.isError ? ( +

+ {readError(receipts.error)} +

+ ) : null} + {receipts.data?.length === 0 ? ( +

No purchase receipts yet.

+ ) : null} +
    + {receipts.data?.map((receipt) => ( +
  • +

    + {names.products.get(receipt.productId) ?? receipt.productId}{" "} + · {receipt.quantity} +

    +

    + {names.suppliers.get(receipt.supplierId) ?? + receipt.supplierId}{" "} + →{" "} + {names.warehouses.get(receipt.warehouseId) ?? + receipt.warehouseId} +

    +
  • + ))} +
+
+
+
+
+ ) +} diff --git a/frontend/src/features/purchases/purchases-api.ts b/frontend/src/features/purchases/purchases-api.ts new file mode 100644 index 0000000..652d985 --- /dev/null +++ b/frontend/src/features/purchases/purchases-api.ts @@ -0,0 +1,17 @@ +import { apiClient } from "@/lib/api-client" +import type { + PurchaseReceipt, + RecordPurchaseReceiptPayload, +} from "@/features/purchases/types" + +export const listPurchaseReceipts = () => + apiClient + .get("/api/purchases/receipts") + .then((response) => response.data) + +export const recordPurchaseReceipt = (payload: RecordPurchaseReceiptPayload) => + apiClient + .post("/api/purchases/receipts", payload, { + headers: { "Idempotency-Key": payload.idempotencyKey }, + }) + .then((response) => response.data) diff --git a/frontend/src/features/purchases/purchases-schema.ts b/frontend/src/features/purchases/purchases-schema.ts new file mode 100644 index 0000000..5784538 --- /dev/null +++ b/frontend/src/features/purchases/purchases-schema.ts @@ -0,0 +1,16 @@ +import { z } from "zod" + +const decimalQuantity = z + .string() + .regex( + /^(?:0|[1-9]\d{0,13})(?:\.\d{1,4})?$/, + "Quantity must be a positive decimal with up to four decimal places." + ) + .refine((value) => Number(value) > 0, "Quantity must be greater than zero.") + +export const purchaseReceiptSchema = z.object({ + supplierId: z.string().uuid("Choose a supplier."), + warehouseId: z.string().uuid("Choose a warehouse."), + productId: z.string().uuid("Choose a product."), + quantity: decimalQuantity, +}) diff --git a/frontend/src/features/purchases/types.ts b/frontend/src/features/purchases/types.ts new file mode 100644 index 0000000..87fd568 --- /dev/null +++ b/frontend/src/features/purchases/types.ts @@ -0,0 +1,17 @@ +export type PurchaseReceipt = { + id: string + supplierId: string + warehouseId: string + productId: string + quantity: number + inventoryMovementId: string + receivedAtUtc: string +} + +export type RecordPurchaseReceiptPayload = { + supplierId: string + warehouseId: string + productId: string + quantity: string + idempotencyKey: string +} diff --git a/frontend/src/features/reports/pages/ReportsPage.tsx b/frontend/src/features/reports/pages/ReportsPage.tsx new file mode 100644 index 0000000..d0ff4f9 --- /dev/null +++ b/frontend/src/features/reports/pages/ReportsPage.tsx @@ -0,0 +1,191 @@ +import { useState } from "react" +import { useQuery } from "@tanstack/react-query" + +import { PageHeader } from "@/components/shared/PageHeader" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { useAuthStore } from "@/features/auth/auth-store" +import { listInventoryBalances } from "@/features/inventory/inventory-api" +import { inventoryBalancesKey } from "@/features/inventory/inventory-queries" +import { listProducts } from "@/features/products/products-api" +import { listWarehouses } from "@/features/warehouses/warehouses-api" + +const catalogKey = (name: string, userId: string, workspaceId: string) => + [name, userId, workspaceId] as const + +const readError = (error: unknown) => { + if (typeof error === "object" && error && "response" in error) { + const data = ( + error as { response?: { data?: { detail?: string; title?: string } } } + ).response?.data + return ( + data?.detail ?? data?.title ?? "Unable to load inventory availability." + ) + } + return "Unable to load inventory availability." +} + +const displayQuantity = (quantity: number) => + new Intl.NumberFormat(undefined, { maximumFractionDigits: 4 }).format( + quantity + ) + +export function Component() { + const user = useAuthStore((state) => state.user) + const userId = user?.id ?? "anonymous" + const workspaceId = user?.workspace.id ?? "none" + const [warehouseId, setWarehouseId] = useState("") + const [productId, setProductId] = useState("") + + const products = useQuery({ + queryKey: catalogKey("products", userId, workspaceId), + queryFn: listProducts, + enabled: user !== null, + }) + const warehouses = useQuery({ + queryKey: catalogKey("warehouses", userId, workspaceId), + queryFn: listWarehouses, + enabled: user !== null, + }) + const balances = useQuery({ + queryKey: inventoryBalancesKey(userId, workspaceId, warehouseId, productId), + queryFn: () => + listInventoryBalances({ + warehouseId: warehouseId || undefined, + productId: productId || undefined, + }), + enabled: user !== null, + }) + + const productsById = new Map( + products.data?.map((product) => [product.id, product]) + ) + const warehousesById = new Map( + warehouses.data?.map((warehouse) => [warehouse.id, warehouse]) + ) + const catalogLoading = products.isLoading || warehouses.isLoading + const catalogError = products.error ?? warehouses.error + + return ( +
+ + + + Inventory availability + + +
+ + +
+ + {catalogLoading ? ( +

+ Loading catalog… +

+ ) : null} + {catalogError ? ( +

+ {readError(catalogError)} +

+ ) : null} + {balances.isLoading ? ( +

+ Loading availability… +

+ ) : null} + {balances.isError ? ( +

+ {readError(balances.error)} +

+ ) : null} + {balances.data?.length === 0 ? ( +

+ No inventory balances match these filters. +

+ ) : null} + {balances.data?.length ? ( +
+ + + + + + + + + + + {balances.data.map((balance) => { + const product = productsById.get(balance.productId) + const warehouse = warehousesById.get(balance.warehouseId) + + return ( + + + + + + + ) + })} + +
+ Product + + SKU + + Warehouse + + On hand +
+ {product?.name ?? balance.productId} + + {product?.sku ?? balance.productId} + + {warehouse?.name ?? balance.warehouseId} + + {displayQuantity(balance.quantity)} +
+
+ ) : null} +
+
+
+ ) +} diff --git a/frontend/src/features/sales/pages/SalesPage.tsx b/frontend/src/features/sales/pages/SalesPage.tsx new file mode 100644 index 0000000..13702b2 --- /dev/null +++ b/frontend/src/features/sales/pages/SalesPage.tsx @@ -0,0 +1,219 @@ +import { useState } from "react" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" + +import { PageHeader } from "@/components/shared/PageHeader" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { useAuthStore } from "@/features/auth/auth-store" +import { listProducts } from "@/features/products/products-api" +import { + listSalesFulfillments, + recordSalesFulfillment, +} from "@/features/sales/sales-api" +import { inventoryBalancesKeyPrefix } from "@/features/inventory/inventory-queries" +import { salesFulfillmentSchema } from "@/features/sales/sales-schema" +import type { RecordSalesFulfillmentPayload } from "@/features/sales/types" +import { listWarehouses } from "@/features/warehouses/warehouses-api" + +const key = (name: string, userId: string, workspaceId: string) => + [name, userId, workspaceId] as const +const readError = (error: unknown) => { + if (typeof error === "object" && error && "response" in error) { + const data = ( + error as { response?: { data?: { detail?: string; title?: string } } } + ).response?.data + return data?.detail ?? data?.title ?? "Unable to fulfill the sale." + } + return "Unable to fulfill the sale." +} + +export function Component() { + const queryClient = useQueryClient() + const user = useAuthStore((state) => state.user) + const userId = user?.id ?? "anonymous" + const workspaceId = user?.workspace.id ?? "none" + const [warehouseId, setWarehouseId] = useState("") + const [productId, setProductId] = useState("") + const [quantity, setQuantity] = useState("") + const [formError, setFormError] = useState("") + const [retryFulfillment, setRetryFulfillment] = + useState(null) + const warehouses = useQuery({ + queryKey: key("warehouses", userId, workspaceId), + queryFn: listWarehouses, + enabled: user !== null, + }) + const products = useQuery({ + queryKey: key("products", userId, workspaceId), + queryFn: listProducts, + enabled: user !== null, + }) + const fulfillments = useQuery({ + queryKey: key("sales-fulfillments", userId, workspaceId), + queryFn: listSalesFulfillments, + enabled: user !== null, + }) + const mutation = useMutation({ + mutationKey: key("sales-fulfillments", userId, workspaceId), + mutationFn: recordSalesFulfillment, + onSuccess: () => { + setFormError("") + setRetryFulfillment(null) + setQuantity("") + queryClient.invalidateQueries({ + queryKey: key("sales-fulfillments", userId, workspaceId), + }) + queryClient.invalidateQueries({ + queryKey: inventoryBalancesKeyPrefix(userId, workspaceId), + }) + }, + onError: (error, variables) => { + setRetryFulfillment(variables) + setFormError(readError(error)) + }, + }) + const submit = () => { + const parsed = salesFulfillmentSchema.safeParse({ + warehouseId, + productId, + quantity, + }) + if (!parsed.success) { + setFormError( + parsed.error.issues[0]?.message ?? "Check the fulfillment details." + ) + return + } + setFormError("") + mutation.mutate({ ...parsed.data, idempotencyKey: crypto.randomUUID() }) + } + const loading = warehouses.isLoading || products.isLoading + const names = { + warehouses: new Map(warehouses.data?.map((x) => [x.id, x.name])), + products: new Map(products.data?.map((x) => [x.id, x.name])), + } + const disabled = loading || mutation.isPending + return ( +
+ +
+ + + Fulfill sale + + +
+ {loading ? ( +

Loading catalog…

+ ) : null} + {warehouses.isError || products.isError ? ( +

+ {readError(warehouses.error ?? products.error)} +

+ ) : null} + + + + {formError ? ( +

+ {formError} +

+ ) : null} + {retryFulfillment ? ( + + ) : null} + +
+
+
+ + + Fulfillment history + + + {fulfillments.isLoading ? ( +

Loading fulfillments…

+ ) : null} + {fulfillments.isError ? ( +

+ {readError(fulfillments.error)} +

+ ) : null} + {fulfillments.data?.length === 0 ? ( +

+ No sales fulfillments yet. +

+ ) : null} +
    + {fulfillments.data?.map((fulfillment) => ( +
  • +

    + {names.products.get(fulfillment.productId) ?? + fulfillment.productId}{" "} + · {fulfillment.quantity} +

    +

    + {names.warehouses.get(fulfillment.warehouseId) ?? + fulfillment.warehouseId} +

    +
  • + ))} +
+
+
+
+
+ ) +} diff --git a/frontend/src/features/sales/sales-api.ts b/frontend/src/features/sales/sales-api.ts new file mode 100644 index 0000000..91ab038 --- /dev/null +++ b/frontend/src/features/sales/sales-api.ts @@ -0,0 +1,19 @@ +import { apiClient } from "@/lib/api-client" +import type { + RecordSalesFulfillmentPayload, + SalesFulfillment, +} from "@/features/sales/types" + +export const listSalesFulfillments = () => + apiClient + .get("/api/sales/fulfillments") + .then((response) => response.data) + +export const recordSalesFulfillment = ( + payload: RecordSalesFulfillmentPayload +) => + apiClient + .post("/api/sales/fulfillments", payload, { + headers: { "Idempotency-Key": payload.idempotencyKey }, + }) + .then((response) => response.data) diff --git a/frontend/src/features/sales/sales-schema.ts b/frontend/src/features/sales/sales-schema.ts new file mode 100644 index 0000000..80c22d6 --- /dev/null +++ b/frontend/src/features/sales/sales-schema.ts @@ -0,0 +1,15 @@ +import { z } from "zod" + +const decimalQuantity = z + .string() + .regex( + /^(?:0|[1-9]\d{0,13})(?:\.\d{1,4})?$/, + "Quantity must be a positive decimal with up to four decimal places." + ) + .refine((value) => Number(value) > 0, "Quantity must be greater than zero.") + +export const salesFulfillmentSchema = z.object({ + warehouseId: z.string().uuid("Choose a warehouse."), + productId: z.string().uuid("Choose a product."), + quantity: decimalQuantity, +}) diff --git a/frontend/src/features/sales/types.ts b/frontend/src/features/sales/types.ts new file mode 100644 index 0000000..41d405b --- /dev/null +++ b/frontend/src/features/sales/types.ts @@ -0,0 +1,15 @@ +export type SalesFulfillment = { + id: string + warehouseId: string + productId: string + quantity: number + inventoryMovementId: string + fulfilledAtUtc: string +} + +export type RecordSalesFulfillmentPayload = { + warehouseId: string + productId: string + quantity: string + idempotencyKey: string +} diff --git a/frontend/src/features/suppliers/pages/SuppliersPage.tsx b/frontend/src/features/suppliers/pages/SuppliersPage.tsx new file mode 100644 index 0000000..61cb6ba --- /dev/null +++ b/frontend/src/features/suppliers/pages/SuppliersPage.tsx @@ -0,0 +1,138 @@ +import { useState } from "react" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" + +import { PageHeader } from "@/components/shared/PageHeader" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { + archiveSupplier, + createSupplier, + listSuppliers, +} from "@/features/suppliers/suppliers-api" +import { supplierSchema } from "@/features/suppliers/suppliers-schema" +import { useAuthStore } from "@/features/auth/auth-store" + +const suppliersKey = (userId: string, workspaceId: string) => + ["suppliers", userId, workspaceId] as const + +const readError = (error: unknown) => { + if (typeof error === "object" && error && "response" in error) { + const data = ( + error as { response?: { data?: { detail?: string; title?: string } } } + ).response?.data + return data?.detail ?? data?.title ?? "Unable to save the supplier." + } + return "Unable to save the supplier." +} + +export function Component() { + const queryClient = useQueryClient() + const user = useAuthStore((state) => state.user) + const [error, setError] = useState("") + const queryKey = suppliersKey( + user?.id ?? "anonymous", + user?.workspace.id ?? "none" + ) + const suppliers = useQuery({ + queryKey, + queryFn: listSuppliers, + enabled: user !== null, + }) + const create = useMutation({ + mutationFn: createSupplier, + onSuccess: () => { + setError("") + queryClient.invalidateQueries({ queryKey }) + }, + onError: (reason) => setError(readError(reason)), + }) + const archive = useMutation({ + mutationFn: archiveSupplier, + onSuccess: () => queryClient.invalidateQueries({ queryKey }), + onError: (reason) => setError(readError(reason)), + }) + + const submit = (form: FormData) => { + const parsed = supplierSchema.safeParse(Object.fromEntries(form)) + if (!parsed.success) + return setError( + parsed.error.issues[0]?.message ?? "Check the supplier details." + ) + setError("") + create.mutate(parsed.data) + } + + return ( +
+ +
+ + + Catalog + + + {suppliers.isLoading ? ( +

Loading suppliers…

+ ) : null} + {suppliers.isError ? ( +

+ {readError(suppliers.error)} +

+ ) : null} + {suppliers.data?.length === 0 ? ( +

No active suppliers yet.

+ ) : null} + {suppliers.data?.length ? ( +
    + {suppliers.data.map((supplier) => ( +
  • +

    {supplier.name}

    + +
  • + ))} +
+ ) : null} +
+
+ + + Add supplier + + +
+ + {error ? ( +

+ {error} +

+ ) : null} + +
+
+
+
+
+ ) +} diff --git a/frontend/src/features/suppliers/suppliers-api.ts b/frontend/src/features/suppliers/suppliers-api.ts new file mode 100644 index 0000000..bfb40e5 --- /dev/null +++ b/frontend/src/features/suppliers/suppliers-api.ts @@ -0,0 +1,14 @@ +import { apiClient } from "@/lib/api-client" +import type { + CreateSupplierPayload, + Supplier, +} from "@/features/suppliers/types" + +export const listSuppliers = () => + apiClient.get("/api/suppliers").then((response) => response.data) +export const createSupplier = (payload: CreateSupplierPayload) => + apiClient + .post("/api/suppliers", payload) + .then((response) => response.data) +export const archiveSupplier = (id: string) => + apiClient.delete(`/api/suppliers/${id}`) diff --git a/frontend/src/features/suppliers/suppliers-schema.ts b/frontend/src/features/suppliers/suppliers-schema.ts new file mode 100644 index 0000000..64208fb --- /dev/null +++ b/frontend/src/features/suppliers/suppliers-schema.ts @@ -0,0 +1,5 @@ +import { z } from "zod" + +export const supplierSchema = z.object({ + name: z.string().trim().min(1, "Name is required.").max(200), +}) diff --git a/frontend/src/features/suppliers/types.ts b/frontend/src/features/suppliers/types.ts new file mode 100644 index 0000000..cdd704a --- /dev/null +++ b/frontend/src/features/suppliers/types.ts @@ -0,0 +1,9 @@ +export type Supplier = { + id: string + name: string + createdAtUtc: string +} + +export type CreateSupplierPayload = { + name: string +} diff --git a/frontend/src/features/transfers/pages/TransfersPage.tsx b/frontend/src/features/transfers/pages/TransfersPage.tsx new file mode 100644 index 0000000..7b6ade4 --- /dev/null +++ b/frontend/src/features/transfers/pages/TransfersPage.tsx @@ -0,0 +1,239 @@ +import { useState } from "react" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" + +import { PageHeader } from "@/components/shared/PageHeader" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { useAuthStore } from "@/features/auth/auth-store" +import { listProducts } from "@/features/products/products-api" +import { + listWarehouseTransfers, + recordWarehouseTransfer, +} from "@/features/transfers/transfers-api" +import { inventoryBalancesKeyPrefix } from "@/features/inventory/inventory-queries" +import { warehouseTransferSchema } from "@/features/transfers/transfers-schema" +import type { RecordWarehouseTransferPayload } from "@/features/transfers/types" +import { listWarehouses } from "@/features/warehouses/warehouses-api" + +const key = (name: string, userId: string, workspaceId: string) => + [name, userId, workspaceId] as const +const readError = (error: unknown) => { + if (typeof error === "object" && error && "response" in error) { + const data = ( + error as { response?: { data?: { detail?: string; title?: string } } } + ).response?.data + return data?.detail ?? data?.title ?? "Unable to transfer inventory." + } + return "Unable to transfer inventory." +} + +export function Component() { + const queryClient = useQueryClient() + const user = useAuthStore((state) => state.user) + const userId = user?.id ?? "anonymous" + const workspaceId = user?.workspace.id ?? "none" + const [sourceWarehouseId, setSourceWarehouseId] = useState("") + const [destinationWarehouseId, setDestinationWarehouseId] = useState("") + const [productId, setProductId] = useState("") + const [quantity, setQuantity] = useState("") + const [formError, setFormError] = useState("") + const [retryTransfer, setRetryTransfer] = + useState(null) + const warehouses = useQuery({ + queryKey: key("warehouses", userId, workspaceId), + queryFn: listWarehouses, + enabled: user !== null, + }) + const products = useQuery({ + queryKey: key("products", userId, workspaceId), + queryFn: listProducts, + enabled: user !== null, + }) + const transfers = useQuery({ + queryKey: key("transfers", userId, workspaceId), + queryFn: listWarehouseTransfers, + enabled: user !== null, + }) + const mutation = useMutation({ + mutationKey: key("transfers", userId, workspaceId), + mutationFn: recordWarehouseTransfer, + onSuccess: () => { + setFormError("") + setRetryTransfer(null) + setQuantity("") + queryClient.invalidateQueries({ + queryKey: key("transfers", userId, workspaceId), + }) + queryClient.invalidateQueries({ + queryKey: inventoryBalancesKeyPrefix(userId, workspaceId), + }) + }, + onError: (error, variables) => { + setRetryTransfer(variables) + setFormError(readError(error)) + }, + }) + const submit = () => { + const parsed = warehouseTransferSchema.safeParse({ + sourceWarehouseId, + destinationWarehouseId, + productId, + quantity, + }) + if (!parsed.success) { + setFormError( + parsed.error.issues[0]?.message ?? "Check the transfer details." + ) + return + } + setFormError("") + mutation.mutate({ ...parsed.data, idempotencyKey: crypto.randomUUID() }) + } + const loading = warehouses.isLoading || products.isLoading + const names = { + warehouses: new Map(warehouses.data?.map((x) => [x.id, x.name])), + products: new Map(products.data?.map((x) => [x.id, x.name])), + } + const disabled = loading || mutation.isPending + + return ( +
+ +
+ + + Transfer inventory + + +
+ {loading ? ( +

Loading catalog…

+ ) : null} + {warehouses.isError || products.isError ? ( +

+ {readError(warehouses.error ?? products.error)} +

+ ) : null} + + + + + {formError ? ( +

+ {formError} +

+ ) : null} + {retryTransfer ? ( + + ) : null} + +
+
+
+ + + Transfer history + + + {transfers.isLoading ? ( +

Loading transfers…

+ ) : null} + {transfers.isError ? ( +

+ {readError(transfers.error)} +

+ ) : null} + {transfers.data?.length === 0 ? ( +

No transfers yet.

+ ) : null} +
    + {transfers.data?.map((transfer) => ( +
  • +

    + {names.products.get(transfer.productId) ?? + transfer.productId}{" "} + · {transfer.quantity} +

    +

    + {names.warehouses.get(transfer.sourceWarehouseId) ?? + transfer.sourceWarehouseId}{" "} + →{" "} + {names.warehouses.get(transfer.destinationWarehouseId) ?? + transfer.destinationWarehouseId} +

    +
  • + ))} +
+
+
+
+
+ ) +} diff --git a/frontend/src/features/transfers/transfers-api.ts b/frontend/src/features/transfers/transfers-api.ts new file mode 100644 index 0000000..1210a52 --- /dev/null +++ b/frontend/src/features/transfers/transfers-api.ts @@ -0,0 +1,19 @@ +import { apiClient } from "@/lib/api-client" +import type { + RecordWarehouseTransferPayload, + WarehouseTransfer, +} from "@/features/transfers/types" + +export const listWarehouseTransfers = () => + apiClient + .get("/api/transfers") + .then((response) => response.data) + +export const recordWarehouseTransfer = ( + payload: RecordWarehouseTransferPayload +) => + apiClient + .post("/api/transfers", payload, { + headers: { "Idempotency-Key": payload.idempotencyKey }, + }) + .then((response) => response.data) diff --git a/frontend/src/features/transfers/transfers-schema.ts b/frontend/src/features/transfers/transfers-schema.ts new file mode 100644 index 0000000..fc31e02 --- /dev/null +++ b/frontend/src/features/transfers/transfers-schema.ts @@ -0,0 +1,21 @@ +import { z } from "zod" + +const decimalQuantity = z + .string() + .regex( + /^(?:0|[1-9]\d{0,13})(?:\.\d{1,4})?$/, + "Quantity must be a positive decimal with up to four decimal places." + ) + .refine((value) => Number(value) > 0, "Quantity must be greater than zero.") + +export const warehouseTransferSchema = z + .object({ + sourceWarehouseId: z.string().uuid("Choose a source warehouse."), + destinationWarehouseId: z.string().uuid("Choose a destination warehouse."), + productId: z.string().uuid("Choose a product."), + quantity: decimalQuantity, + }) + .refine((value) => value.sourceWarehouseId !== value.destinationWarehouseId, { + message: "Source and destination warehouses must be different.", + path: ["destinationWarehouseId"], + }) diff --git a/frontend/src/features/transfers/types.ts b/frontend/src/features/transfers/types.ts new file mode 100644 index 0000000..6c771cf --- /dev/null +++ b/frontend/src/features/transfers/types.ts @@ -0,0 +1,18 @@ +export type WarehouseTransfer = { + id: string + sourceWarehouseId: string + destinationWarehouseId: string + productId: string + quantity: number + sourceInventoryMovementId: string + destinationInventoryMovementId: string + transferredAtUtc: string +} + +export type RecordWarehouseTransferPayload = { + sourceWarehouseId: string + destinationWarehouseId: string + productId: string + quantity: string + idempotencyKey: string +} diff --git a/frontend/src/features/warehouses/pages/WarehousesPage.tsx b/frontend/src/features/warehouses/pages/WarehousesPage.tsx new file mode 100644 index 0000000..99f56e4 --- /dev/null +++ b/frontend/src/features/warehouses/pages/WarehousesPage.tsx @@ -0,0 +1,151 @@ +import { useState } from "react" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" + +import { PageHeader } from "@/components/shared/PageHeader" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { + archiveWarehouse, + createWarehouse, + listWarehouses, +} from "@/features/warehouses/warehouses-api" +import { warehouseSchema } from "@/features/warehouses/warehouses-schema" +import { useAuthStore } from "@/features/auth/auth-store" + +const warehousesKey = (userId: string, workspaceId: string) => + ["warehouses", userId, workspaceId] as const + +const readError = (error: unknown) => { + if (typeof error === "object" && error && "response" in error) { + const data = ( + error as { response?: { data?: { detail?: string; title?: string } } } + ).response?.data + return data?.detail ?? data?.title ?? "Unable to save the warehouse." + } + return "Unable to save the warehouse." +} + +export function Component() { + const queryClient = useQueryClient() + const user = useAuthStore((state) => state.user) + const [error, setError] = useState("") + const queryKey = warehousesKey( + user?.id ?? "anonymous", + user?.workspace.id ?? "none" + ) + const warehouses = useQuery({ + queryKey, + queryFn: listWarehouses, + enabled: user !== null, + }) + const create = useMutation({ + mutationFn: createWarehouse, + onSuccess: () => { + setError("") + queryClient.invalidateQueries({ queryKey }) + }, + onError: (reason) => setError(readError(reason)), + }) + const archive = useMutation({ + mutationFn: archiveWarehouse, + onSuccess: () => queryClient.invalidateQueries({ queryKey }), + onError: (reason) => setError(readError(reason)), + }) + + const submit = (form: FormData) => { + const parsed = warehouseSchema.safeParse(Object.fromEntries(form)) + if (!parsed.success) + return setError( + parsed.error.issues[0]?.message ?? "Check the warehouse details." + ) + setError("") + create.mutate(parsed.data) + } + + return ( +
+ +
+ + + Catalog + + + {warehouses.isLoading ? ( +

Loading warehouses…

+ ) : null} + {warehouses.isError ? ( +

+ {readError(warehouses.error)} +

+ ) : null} + {warehouses.data?.length === 0 ? ( +

No active warehouses yet.

+ ) : null} + {warehouses.data?.length ? ( +
    + {warehouses.data.map((warehouse) => ( +
  • +
    +

    {warehouse.name}

    +

    + {warehouse.name} +

    +
    + +
  • + ))} +
+ ) : null} +
+
+ + + Add warehouse + + +
+ + + {error ? ( +

+ {error} +

+ ) : null} + +
+
+
+
+
+ ) +} diff --git a/frontend/src/features/warehouses/types.ts b/frontend/src/features/warehouses/types.ts new file mode 100644 index 0000000..d80d804 --- /dev/null +++ b/frontend/src/features/warehouses/types.ts @@ -0,0 +1,2 @@ +export type Warehouse = { id: string; name: string; createdAtUtc: string } +export type CreateWarehouseInput = { name: string } diff --git a/frontend/src/features/warehouses/warehouses-api.ts b/frontend/src/features/warehouses/warehouses-api.ts new file mode 100644 index 0000000..6f3713a --- /dev/null +++ b/frontend/src/features/warehouses/warehouses-api.ts @@ -0,0 +1,8 @@ +import { apiClient } from "@/lib/api-client" +import type { CreateWarehouseInput, Warehouse } from "./types" +export const listWarehouses = () => + apiClient.get("/api/warehouses").then((x) => x.data) +export const createWarehouse = (x: CreateWarehouseInput) => + apiClient.post("/api/warehouses", x).then((x) => x.data) +export const archiveWarehouse = (id: string) => + apiClient.delete(`/api/warehouses/${id}`) diff --git a/frontend/src/features/warehouses/warehouses-schema.ts b/frontend/src/features/warehouses/warehouses-schema.ts new file mode 100644 index 0000000..64fa2dd --- /dev/null +++ b/frontend/src/features/warehouses/warehouses-schema.ts @@ -0,0 +1,4 @@ +import { z } from "zod" +export const warehouseSchema = z.object({ + name: z.string().trim().min(1).max(200), +}) diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..202601b --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,130 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; +@import "@fontsource-variable/geist"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --font-heading: var(--font-sans); + --font-sans: "Geist Variable", sans-serif; + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.153 0.006 107.1); + --card: oklch(1 0 0); + --card-foreground: oklch(0.153 0.006 107.1); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.153 0.006 107.1); + --primary: oklch(0.852 0.199 91.936); + --primary-foreground: oklch(0.421 0.095 57.708); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.966 0.005 106.5); + --muted-foreground: oklch(0.58 0.031 107.3); + --accent: oklch(0.966 0.005 106.5); + --accent-foreground: oklch(0.228 0.013 107.4); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.93 0.007 106.5); + --input: oklch(0.93 0.007 106.5); + --ring: oklch(0.737 0.021 106.9); + --chart-1: oklch(0.905 0.182 98.111); + --chart-2: oklch(0.795 0.184 86.047); + --chart-3: oklch(0.681 0.162 75.834); + --chart-4: oklch(0.554 0.135 66.442); + --chart-5: oklch(0.476 0.114 61.907); + --radius: 0.45rem; + --sidebar: oklch(0.988 0.003 106.5); + --sidebar-foreground: oklch(0.153 0.006 107.1); + --sidebar-primary: oklch(0.681 0.162 75.834); + --sidebar-primary-foreground: oklch(0.987 0.026 102.212); + --sidebar-accent: oklch(0.966 0.005 106.5); + --sidebar-accent-foreground: oklch(0.228 0.013 107.4); + --sidebar-border: oklch(0.93 0.007 106.5); + --sidebar-ring: oklch(0.737 0.021 106.9); +} + +.dark { + --background: oklch(0.153 0.006 107.1); + --foreground: oklch(0.988 0.003 106.5); + --card: oklch(0.228 0.013 107.4); + --card-foreground: oklch(0.988 0.003 106.5); + --popover: oklch(0.228 0.013 107.4); + --popover-foreground: oklch(0.988 0.003 106.5); + --primary: oklch(0.795 0.184 86.047); + --primary-foreground: oklch(0.421 0.095 57.708); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.286 0.016 107.4); + --muted-foreground: oklch(0.737 0.021 106.9); + --accent: oklch(0.286 0.016 107.4); + --accent-foreground: oklch(0.988 0.003 106.5); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.58 0.031 107.3); + --chart-1: oklch(0.905 0.182 98.111); + --chart-2: oklch(0.795 0.184 86.047); + --chart-3: oklch(0.681 0.162 75.834); + --chart-4: oklch(0.554 0.135 66.442); + --chart-5: oklch(0.476 0.114 61.907); + --sidebar: oklch(0.228 0.013 107.4); + --sidebar-foreground: oklch(0.988 0.003 106.5); + --sidebar-primary: oklch(0.795 0.184 86.047); + --sidebar-primary-foreground: oklch(0.987 0.026 102.212); + --sidebar-accent: oklch(0.286 0.016 107.4); + --sidebar-accent-foreground: oklch(0.988 0.003 106.5); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.58 0.031 107.3); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} diff --git a/frontend/src/layouts/DashboardLayout.tsx b/frontend/src/layouts/DashboardLayout.tsx new file mode 100644 index 0000000..beb57f7 --- /dev/null +++ b/frontend/src/layouts/DashboardLayout.tsx @@ -0,0 +1,22 @@ +import { Outlet } from "react-router" + +import { Sidebar } from "@/components/layout/Sidebar" +import { Topbar } from "@/components/layout/Topbar" +import { cn } from "@/lib/utils" +import { useUiStore } from "@/store/ui-store" + +export function DashboardLayout() { + const isSidebarCollapsed = useUiStore((state) => state.isSidebarCollapsed) + + return ( +
+ +
+ +
+ +
+
+
+ ) +} diff --git a/frontend/src/lib/api-client.ts b/frontend/src/lib/api-client.ts new file mode 100644 index 0000000..52874e8 --- /dev/null +++ b/frontend/src/lib/api-client.ts @@ -0,0 +1,78 @@ +import axios, { type AxiosError, type InternalAxiosRequestConfig } from "axios" +import { environment } from "@/app/config/environment" +import { useAuthStore } from "@/features/auth/auth-store" + +export const authClient = axios.create({ + baseURL: environment.apiBaseUrl, + withCredentials: true, + headers: { "Content-Type": "application/json" }, +}) +export const apiClient = axios.create({ + baseURL: environment.apiBaseUrl, + withCredentials: true, + headers: { "Content-Type": "application/json" }, +}) + +let refreshPromise: Promise | null = null +let sessionEpoch = 0 + +export const invalidateSession = () => { + sessionEpoch += 1 + useAuthStore.getState().clearSession() +} + +const refreshAccessToken = () => { + if (!refreshPromise) { + const refreshEpoch = sessionEpoch + refreshPromise = authClient + .post("/api/auth/refresh") + .then((response) => { + if (refreshEpoch !== sessionEpoch) return null + + useAuthStore.getState().setSession(response.data) + return response.data.accessToken as string + }) + .catch(() => { + if (refreshEpoch === sessionEpoch) invalidateSession() + return null + }) + .finally(() => { + refreshPromise = null + }) + } + return refreshPromise +} + +apiClient.interceptors.request.use((config) => { + const token = useAuthStore.getState().accessToken + if (token) config.headers.Authorization = `Bearer ${token}` + return config +}) +apiClient.interceptors.response.use(undefined, async (error: AxiosError) => { + const request = error.config as + (InternalAxiosRequestConfig & { _authRetried?: boolean }) | undefined + if (error.response?.status !== 401 || !request || request._authRetried) + return Promise.reject(error) + request._authRetried = true + const token = await refreshAccessToken() + if (!token) return Promise.reject(error) + request.headers.Authorization = `Bearer ${token}` + return apiClient(request) +}) + +export const restoreSession = async () => { + const restoreEpoch = sessionEpoch + + try { + const session = await authClient + .post("/api/auth/refresh") + .then((response) => response.data) + + if (restoreEpoch === sessionEpoch) + useAuthStore.getState().setSession(session) + } catch { + if (restoreEpoch === sessionEpoch) invalidateSession() + } finally { + useAuthStore.getState().finishRestoring() + } +} diff --git a/frontend/src/lib/query-client.ts b/frontend/src/lib/query-client.ts new file mode 100644 index 0000000..558c0e7 --- /dev/null +++ b/frontend/src/lib/query-client.ts @@ -0,0 +1,11 @@ +import { QueryClient } from "@tanstack/react-query" + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + staleTime: 30_000, + }, + }, +}) diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..bd0c391 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..424c0a4 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,14 @@ +import { StrictMode } from "react" +import { createRoot } from "react-dom/client" + +import App from "@/App" +import { AppProviders } from "@/app/providers/AppProviders" +import "@/index.css" + +createRoot(document.getElementById("root")!).render( + + + + + +) diff --git a/frontend/src/store/ui-store.ts b/frontend/src/store/ui-store.ts new file mode 100644 index 0000000..e67ee60 --- /dev/null +++ b/frontend/src/store/ui-store.ts @@ -0,0 +1,13 @@ +import { create } from "zustand" + +type UiState = { + isSidebarCollapsed: boolean + toggleSidebar: () => void +} + +export const useUiStore = create((set) => ({ + isSidebarCollapsed: false, + toggleSidebar: () => { + set((state) => ({ isSidebarCollapsed: !state.isSidebarCollapsed })) + }, +})) diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..148bcc6 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..c36d52a --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,12 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ], + "compilerOptions": { + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..d3c52ea --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..8fa4fa7 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,45 @@ +import path from "path" +import tailwindcss from "@tailwindcss/vite" +import react from "@vitejs/plugin-react" +import { defineConfig } from "vite" + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + server: { + proxy: { + "/api": "http://localhost:5255", + }, + }, + build: { + rolldownOptions: { + output: { + manualChunks: (id) => { + const normalizedId = id.replaceAll("\\", "/") + + if ( + normalizedId.includes("/node_modules/react/") || + normalizedId.includes("/node_modules/react-dom/") || + normalizedId.includes("/node_modules/react-router/") + ) { + return "react" + } + + if ( + normalizedId.includes("/node_modules/@base-ui/") || + normalizedId.includes("/node_modules/@hugeicons/") + ) { + return "ui" + } + + return undefined + }, + }, + }, + }, +}) diff --git a/global.json b/global.json new file mode 100644 index 0000000..dd89c65 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "rollForward": "latestPatch", + "version": "9.0.316" + } +} diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..a120a10 --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +dotnet = "9.0.316" From 4e1c2814981fe2ebe7246b2f05537f5e777e17b7 Mon Sep 17 00:00:00 2001 From: Adnan Ahmad Date: Fri, 24 Jul 2026 00:04:47 +0500 Subject: [PATCH 2/5] fix: address CodeRabbit review comments - null checks, async safety, idempotency validation, session epoch, and cleanup --- backend/Dockerfile | 4 +- .../GlobalExceptionHandler.cs | 1 + .../Common/Tenancy/CurrentWorkspace.cs | 13 ++++- .../AuthenticationValidators.cs | 20 ++++++- .../Warehouses/WarehouseValidators.cs | 2 +- .../Entities/PurchaseReceipt.cs | 1 + .../Inventory/EfInventoryLedger.cs | 21 ++++--- .../Inventory/InventoryLedgerWriter.cs | 8 ++- .../InventoryBalanceConfiguration.cs | 1 + .../InventoryMovementConfiguration.cs | 1 + .../Configurations/ProductConfiguration.cs | 1 + .../PurchaseReceiptConfiguration.cs | 1 + .../RefreshTokenConfiguration.cs | 1 + .../SalesFulfillmentConfiguration.cs | 1 + .../Configurations/SupplierConfiguration.cs | 1 + .../Configurations/WarehouseConfiguration.cs | 2 +- .../WarehouseTransferConfiguration.cs | 1 + .../Configurations/WorkspaceConfiguration.cs | 1 + .../WorkspaceInvitationConfiguration.cs | 3 +- .../Products/EfProductCatalog.cs | 27 +++++---- .../Purchases/EfPurchaseReceiptService.cs | 50 ++++++++++++---- .../Sales/EfSalesFulfillmentService.cs | 57 ++++++++++--------- .../Transfers/EfWarehouseTransferService.cs | 3 + .../Api/AuthenticatedApiFixture.cs | 5 +- .../Api/InventoryEndpointsTests.cs | 6 +- .../Api/InventoryFlowApiFactory.cs | 6 ++ docker-compose.yml | 6 +- frontend/src/components/layout/Topbar.tsx | 4 ++ .../dashboard/components/MetricCard.tsx | 4 +- .../dashboard/pages/DashboardPage.tsx | 3 +- .../features/products/pages/ProductsPage.tsx | 4 +- .../purchases/pages/PurchasesPage.tsx | 10 ++-- .../src/features/sales/pages/SalesPage.tsx | 8 ++- .../suppliers/pages/SuppliersPage.tsx | 4 +- .../warehouses/pages/WarehousesPage.tsx | 15 +---- frontend/src/lib/api-client.ts | 21 +++++-- global.json | 2 +- 37 files changed, 215 insertions(+), 104 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index cae344e..ff914bc 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/dotnet/sdk:9.0 AS base +FROM mcr.microsoft.com/dotnet/sdk:9.0.316 AS base ARG APP_UID=10001 ARG APP_GID=10001 RUN groupadd --system --gid ${APP_GID} inventoryflow \ @@ -26,7 +26,7 @@ RUN dotnet build src/InventoryFlow.Api/InventoryFlow.Api.csproj --configuration FROM build AS publish RUN dotnet publish src/InventoryFlow.Api/InventoryFlow.Api.csproj --configuration Release --no-build --output /app/publish -FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final +FROM mcr.microsoft.com/dotnet/aspnet:9.0.316 AS final ARG APP_UID=10001 ARG APP_GID=10001 RUN groupadd --system --gid ${APP_GID} inventoryflow \ diff --git a/backend/src/InventoryFlow.Api/ExceptionHandling/GlobalExceptionHandler.cs b/backend/src/InventoryFlow.Api/ExceptionHandling/GlobalExceptionHandler.cs index 3d115a7..af8920c 100644 --- a/backend/src/InventoryFlow.Api/ExceptionHandling/GlobalExceptionHandler.cs +++ b/backend/src/InventoryFlow.Api/ExceptionHandling/GlobalExceptionHandler.cs @@ -73,6 +73,7 @@ public async ValueTask TryHandleAsync( Type = statusCode switch { StatusCodes.Status400BadRequest => "https://tools.ietf.org/html/rfc9110#section-15.5.1", + StatusCodes.Status401Unauthorized => "https://tools.ietf.org/html/rfc9110#section-15.5.2", StatusCodes.Status403Forbidden => "https://tools.ietf.org/html/rfc9110#section-15.5.4", StatusCodes.Status409Conflict => "https://tools.ietf.org/html/rfc9110#section-15.5.10", _ => "https://tools.ietf.org/html/rfc9110#section-15.6.1", diff --git a/backend/src/InventoryFlow.Application/Common/Tenancy/CurrentWorkspace.cs b/backend/src/InventoryFlow.Application/Common/Tenancy/CurrentWorkspace.cs index 554695f..0383256 100644 --- a/backend/src/InventoryFlow.Application/Common/Tenancy/CurrentWorkspace.cs +++ b/backend/src/InventoryFlow.Application/Common/Tenancy/CurrentWorkspace.cs @@ -3,4 +3,15 @@ namespace InventoryFlow.Application.Common.Tenancy; /// Represents the server-resolved workspace for the current request. -public sealed record CurrentWorkspace(Guid Id, string Name, WorkspaceMemberRole Role); +public sealed record CurrentWorkspace +{ + public CurrentWorkspace(Guid id, string name, WorkspaceMemberRole role) + { + ArgumentNullException.ThrowIfNull(name); + (Id, Name, Role) = (id, name, role); + } + + public Guid Id { get; } + public string Name { get; } + public WorkspaceMemberRole Role { get; } +} diff --git a/backend/src/InventoryFlow.Application/Features/Authentication/AuthenticationValidators.cs b/backend/src/InventoryFlow.Application/Features/Authentication/AuthenticationValidators.cs index 1a2953d..d9d7f5b 100644 --- a/backend/src/InventoryFlow.Application/Features/Authentication/AuthenticationValidators.cs +++ b/backend/src/InventoryFlow.Application/Features/Authentication/AuthenticationValidators.cs @@ -8,7 +8,7 @@ public sealed class RegisterUserCommandValidator : AbstractValidatorInitializes validation rules. public RegisterUserCommandValidator() { - RuleFor(command => command.DisplayName).NotEmpty().MaximumLength(200).Must(name => name == name.Trim()).WithMessage("Display name cannot have leading or trailing whitespace."); + RuleFor(command => command.DisplayName).NotEmpty().MaximumLength(200).Must(name => name is not null && name == name.Trim()).WithMessage("Display name cannot have leading or trailing whitespace."); RuleFor(command => command.Email).NotEmpty().EmailAddress().MaximumLength(256); RuleFor(command => command.Password).NotEmpty(); } @@ -21,4 +21,20 @@ public sealed class RefreshSessionCommandValidator : AbstractValidator RuleFor(command => command.RefreshToken).NotEmpty(); } /// Validates current-user lookup input. public sealed class GetCurrentUserQueryValidator : AbstractValidator -{ public GetCurrentUserQueryValidator() => RuleFor(query => query.UserId).NotEmpty(); } +{ + public GetCurrentUserQueryValidator() + { + RuleFor(query => query.UserId).NotEmpty(); + RuleFor(query => query.WorkspaceId).NotEmpty(); + } +} +/// Validates workspace switch input. +public sealed class SwitchWorkspaceCommandValidator : AbstractValidator +{ + public SwitchWorkspaceCommandValidator() + { + RuleFor(command => command.UserId).NotEmpty(); + RuleFor(command => command.WorkspaceId).NotEmpty(); + RuleFor(command => command.RefreshToken).NotEmpty(); + } +} diff --git a/backend/src/InventoryFlow.Application/Features/Warehouses/WarehouseValidators.cs b/backend/src/InventoryFlow.Application/Features/Warehouses/WarehouseValidators.cs index 67f3990..c3e64dc 100644 --- a/backend/src/InventoryFlow.Application/Features/Warehouses/WarehouseValidators.cs +++ b/backend/src/InventoryFlow.Application/Features/Warehouses/WarehouseValidators.cs @@ -1,3 +1,3 @@ using FluentValidation; using InventoryFlow.Domain.Entities; -namespace InventoryFlow.Application.Features.Warehouses; public sealed class CreateWarehouseCommandValidator : AbstractValidator { public CreateWarehouseCommandValidator() { RuleFor(x => x.WorkspaceId).NotEmpty(); RuleFor(x => x.Name).NotEmpty().MaximumLength(Warehouse.NameMaxLength); } } +namespace InventoryFlow.Application.Features.Warehouses; public sealed class CreateWarehouseCommandValidator : AbstractValidator { public CreateWarehouseCommandValidator() { RuleFor(x => x.WorkspaceId).NotEmpty(); RuleFor(x => x.Name).NotEmpty().MaximumLength(Warehouse.NameMaxLength).Must(name => name.Trim().Length <= Warehouse.NameMaxLength).WithMessage("Warehouse name is too long after trimming."); } } diff --git a/backend/src/InventoryFlow.Domain/Entities/PurchaseReceipt.cs b/backend/src/InventoryFlow.Domain/Entities/PurchaseReceipt.cs index 4716667..f9509d2 100644 --- a/backend/src/InventoryFlow.Domain/Entities/PurchaseReceipt.cs +++ b/backend/src/InventoryFlow.Domain/Entities/PurchaseReceipt.cs @@ -13,6 +13,7 @@ public PurchaseReceipt(Guid id, Guid workspaceId, Guid supplierId, Guid warehous productId == Guid.Empty || inventoryMovementId == Guid.Empty) throw new DomainException("Purchase receipt identifiers are required."); if (receivedAtUtc.Offset != TimeSpan.Zero) throw new DomainException("Purchase receipt time must be in UTC."); + if (idempotencyKey is null) throw new DomainException("Purchase receipt idempotency key is required."); WorkspaceId = workspaceId; SupplierId = supplierId; WarehouseId = warehouseId; diff --git a/backend/src/InventoryFlow.Infrastructure/Inventory/EfInventoryLedger.cs b/backend/src/InventoryFlow.Infrastructure/Inventory/EfInventoryLedger.cs index 7ed7aab..8830bc8 100644 --- a/backend/src/InventoryFlow.Infrastructure/Inventory/EfInventoryLedger.cs +++ b/backend/src/InventoryFlow.Infrastructure/Inventory/EfInventoryLedger.cs @@ -3,11 +3,12 @@ using InventoryFlow.Domain.Entities; using InventoryFlow.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; namespace InventoryFlow.Infrastructure.Inventory; /// Persists inventory movements and materialized balances atomically. -public sealed class EfInventoryLedger(ApplicationDbContext dbContext) : IInventoryLedger +public sealed class EfInventoryLedger(ApplicationDbContext dbContext, IServiceScopeFactory scopeFactory) : IInventoryLedger { public async Task RecordAsync(Guid workspaceId, Guid warehouseId, Guid productId, InventoryMovementType type, decimal quantity, string idempotencyKey, DateTimeOffset occurredAtUtc, CancellationToken cancellationToken) @@ -15,16 +16,18 @@ public sealed class EfInventoryLedger(ApplicationDbContext dbContext) : IInvento quantity = InventoryMovement.ValidateQuantity(quantity); idempotencyKey = InventoryMovement.NormalizeIdempotencyKey(idempotencyKey); var strategy = dbContext.Database.CreateExecutionStrategy(); - return await strategy.ExecuteAsync(async () => + return await strategy.ExecuteAsync(async (ct) => { - await using var transaction = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken); - var movement = await new InventoryLedgerWriter(dbContext).RecordAsync(workspaceId, warehouseId, productId, type, quantity, - idempotencyKey, occurredAtUtc, Guid.NewGuid(), cancellationToken); - if (movement is not null && dbContext.Entry(movement).State == EntityState.Added) - await dbContext.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); + await using var scope = scopeFactory.CreateAsyncScope(); + var attemptCtx = scope.ServiceProvider.GetRequiredService(); + await using var transaction = await attemptCtx.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct); + var movement = await new InventoryLedgerWriter(attemptCtx).RecordAsync(workspaceId, warehouseId, productId, type, quantity, + idempotencyKey, occurredAtUtc, Guid.NewGuid(), ct); + if (movement is not null && attemptCtx.Entry(movement).State == EntityState.Added) + await attemptCtx.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); return movement; - }); + }, cancellationToken); } public async Task> ListBalancesAsync(Guid workspaceId, Guid? warehouseId, Guid? productId, diff --git a/backend/src/InventoryFlow.Infrastructure/Inventory/InventoryLedgerWriter.cs b/backend/src/InventoryFlow.Infrastructure/Inventory/InventoryLedgerWriter.cs index 1cec13d..116edc2 100644 --- a/backend/src/InventoryFlow.Infrastructure/Inventory/InventoryLedgerWriter.cs +++ b/backend/src/InventoryFlow.Infrastructure/Inventory/InventoryLedgerWriter.cs @@ -16,7 +16,13 @@ internal sealed class InventoryLedgerWriter(ApplicationDbContext dbContext) var existing = await dbContext.InventoryMovements.SingleOrDefaultAsync(movement => movement.WorkspaceId == workspaceId && movement.IdempotencyKey == idempotencyKey, cancellationToken); - if (existing is not null) return existing; + if (existing is not null) + { + if (existing.WarehouseId != warehouseId || existing.ProductId != productId || + existing.Type != type || existing.Quantity != quantity) + throw new InvalidOperationException("Idempotency key reused with different parameters."); + return existing; + } var warehouseExists = await dbContext.Warehouses.AnyAsync(warehouse => warehouse.Id == warehouseId && warehouse.WorkspaceId == workspaceId && warehouse.ArchivedAtUtc == null, cancellationToken); diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/InventoryBalanceConfiguration.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/InventoryBalanceConfiguration.cs index 73eea2f..c4a7eb6 100644 --- a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/InventoryBalanceConfiguration.cs +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/InventoryBalanceConfiguration.cs @@ -10,6 +10,7 @@ public sealed class InventoryBalanceConfiguration : IEntityTypeConfiguration public void Configure(EntityTypeBuilder builder) { + ArgumentNullException.ThrowIfNull(builder); builder.ToTable("InventoryBalances"); builder.HasKey(balance => new { balance.WorkspaceId, balance.WarehouseId, balance.ProductId }); builder.Property(balance => balance.Quantity).HasPrecision(18, 4).IsRequired(); diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/InventoryMovementConfiguration.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/InventoryMovementConfiguration.cs index dedee91..bf933dd 100644 --- a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/InventoryMovementConfiguration.cs +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/InventoryMovementConfiguration.cs @@ -10,6 +10,7 @@ public sealed class InventoryMovementConfiguration : IEntityTypeConfiguration public void Configure(EntityTypeBuilder builder) { + ArgumentNullException.ThrowIfNull(builder); builder.ToTable("InventoryMovements"); builder.HasKey(movement => movement.Id); builder.Property(movement => movement.Type).HasConversion().IsRequired(); diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/ProductConfiguration.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/ProductConfiguration.cs index f1d382f..74cf936 100644 --- a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/ProductConfiguration.cs +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/ProductConfiguration.cs @@ -10,6 +10,7 @@ public sealed class ProductConfiguration : IEntityTypeConfiguration /// public void Configure(EntityTypeBuilder builder) { + ArgumentNullException.ThrowIfNull(builder); builder.ToTable("Products"); builder.HasKey(product => product.Id); builder.Property(product => product.Name).HasMaxLength(Product.NameMaxLength).IsRequired(); diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/PurchaseReceiptConfiguration.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/PurchaseReceiptConfiguration.cs index 6cf7b83..322c865 100644 --- a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/PurchaseReceiptConfiguration.cs +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/PurchaseReceiptConfiguration.cs @@ -8,6 +8,7 @@ public sealed class PurchaseReceiptConfiguration : IEntityTypeConfiguration builder) { + ArgumentNullException.ThrowIfNull(builder); builder.ToTable("PurchaseReceipts"); builder.HasKey(receipt => receipt.Id); builder.Property(receipt => receipt.Quantity).HasPrecision(18, 4).IsRequired(); diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs index 0a52c05..a3f7501 100644 --- a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs @@ -13,6 +13,7 @@ public sealed class RefreshTokenConfiguration : IEntityTypeConfiguration public void Configure(EntityTypeBuilder builder) { + ArgumentNullException.ThrowIfNull(builder); builder.ToTable("RefreshTokens"); builder.HasKey(token => token.Id); diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/SalesFulfillmentConfiguration.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/SalesFulfillmentConfiguration.cs index a90f254..ea685a3 100644 --- a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/SalesFulfillmentConfiguration.cs +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/SalesFulfillmentConfiguration.cs @@ -8,6 +8,7 @@ public sealed class SalesFulfillmentConfiguration : IEntityTypeConfiguration builder) { + ArgumentNullException.ThrowIfNull(builder); builder.ToTable("SalesFulfillments"); builder.HasKey(fulfillment => fulfillment.Id); builder.Property(fulfillment => fulfillment.Quantity).HasPrecision(18, 4).IsRequired(); diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/SupplierConfiguration.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/SupplierConfiguration.cs index 7c72392..98289f6 100644 --- a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/SupplierConfiguration.cs +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/SupplierConfiguration.cs @@ -10,6 +10,7 @@ public sealed class SupplierConfiguration : IEntityTypeConfiguration /// public void Configure(EntityTypeBuilder builder) { + ArgumentNullException.ThrowIfNull(builder); builder.ToTable("Suppliers"); builder.HasKey(supplier => supplier.Id); builder.Property(supplier => supplier.Name).HasMaxLength(Supplier.NameMaxLength).IsRequired(); diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WarehouseConfiguration.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WarehouseConfiguration.cs index e178cf0..4fb357d 100644 --- a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WarehouseConfiguration.cs +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WarehouseConfiguration.cs @@ -1,4 +1,4 @@ using InventoryFlow.Domain.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -namespace InventoryFlow.Infrastructure.Persistence.Configurations; public sealed class WarehouseConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder b) { b.ToTable("Warehouses"); b.HasKey(x => x.Id); b.Property(x => x.Name).HasMaxLength(Warehouse.NameMaxLength).IsRequired(); b.HasIndex(x => new { x.WorkspaceId, x.Name }).IsUnique(); b.HasIndex(x => new { x.WorkspaceId, x.ArchivedAtUtc, x.Name }); b.HasOne().WithMany().HasForeignKey(x => x.WorkspaceId).OnDelete(DeleteBehavior.Cascade); } } +namespace InventoryFlow.Infrastructure.Persistence.Configurations; public sealed class WarehouseConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder b) { ArgumentNullException.ThrowIfNull(b); b.ToTable("Warehouses"); b.HasKey(x => x.Id); b.Property(x => x.Name).HasMaxLength(Warehouse.NameMaxLength).IsRequired(); b.HasIndex(x => new { x.WorkspaceId, x.Name }).IsUnique(); b.HasIndex(x => new { x.WorkspaceId, x.ArchivedAtUtc, x.Name }); b.HasOne().WithMany().HasForeignKey(x => x.WorkspaceId).OnDelete(DeleteBehavior.Cascade); } } diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WarehouseTransferConfiguration.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WarehouseTransferConfiguration.cs index e7d54f4..00143e6 100644 --- a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WarehouseTransferConfiguration.cs +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WarehouseTransferConfiguration.cs @@ -8,6 +8,7 @@ public sealed class WarehouseTransferConfiguration : IEntityTypeConfiguration builder) { + ArgumentNullException.ThrowIfNull(builder); builder.ToTable("WarehouseTransfers"); builder.HasKey(transfer => transfer.Id); builder.Property(transfer => transfer.Quantity).HasPrecision(18, 4).IsRequired(); diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WorkspaceConfiguration.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WorkspaceConfiguration.cs index 30132b8..88702d1 100644 --- a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WorkspaceConfiguration.cs +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WorkspaceConfiguration.cs @@ -10,6 +10,7 @@ public sealed class WorkspaceConfiguration : IEntityTypeConfiguration /// public void Configure(EntityTypeBuilder builder) { + ArgumentNullException.ThrowIfNull(builder); builder.ToTable("Workspaces"); builder.HasKey(workspace => workspace.Id); builder.Property(workspace => workspace.Name).HasMaxLength(Workspace.NameMaxLength).IsRequired(); diff --git a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WorkspaceInvitationConfiguration.cs b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WorkspaceInvitationConfiguration.cs index 9a395f2..c526aa4 100644 --- a/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WorkspaceInvitationConfiguration.cs +++ b/backend/src/InventoryFlow.Infrastructure/Persistence/Configurations/WorkspaceInvitationConfiguration.cs @@ -11,6 +11,7 @@ public sealed class WorkspaceInvitationConfiguration : IEntityTypeConfiguration< /// public void Configure(EntityTypeBuilder builder) { + ArgumentNullException.ThrowIfNull(builder); builder.ToTable("WorkspaceInvitations"); builder.HasKey(invitation => invitation.Id); builder.Property(invitation => invitation.NormalizedEmail).HasMaxLength(WorkspaceInvitation.NormalizedEmailMaxLength).IsRequired(); @@ -24,7 +25,7 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(invitation => invitation.TokenHash).IsUnique(); builder.HasIndex(invitation => new { invitation.WorkspaceId, invitation.NormalizedEmail }) .IsUnique() - .HasFilter("[AcceptedAtUtc] IS NULL AND [RevokedAtUtc] IS NULL"); + .HasFilter("[AcceptedAtUtc] IS NULL AND [RevokedAtUtc] IS NULL AND [ExpiresAtUtc] > GETUTCDATE()"); builder.HasIndex(invitation => new { invitation.WorkspaceId, invitation.CreatedAtUtc }); builder.HasOne().WithMany().HasForeignKey(invitation => invitation.WorkspaceId).OnDelete(DeleteBehavior.Cascade); builder.HasOne().WithMany().HasForeignKey(invitation => invitation.CreatedByUserId).OnDelete(DeleteBehavior.NoAction); diff --git a/backend/src/InventoryFlow.Infrastructure/Products/EfProductCatalog.cs b/backend/src/InventoryFlow.Infrastructure/Products/EfProductCatalog.cs index b06c116..e07e2f3 100644 --- a/backend/src/InventoryFlow.Infrastructure/Products/EfProductCatalog.cs +++ b/backend/src/InventoryFlow.Infrastructure/Products/EfProductCatalog.cs @@ -5,11 +5,12 @@ using InventoryFlow.Infrastructure.Persistence; using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; namespace InventoryFlow.Infrastructure.Products; /// Persists workspace-scoped products with SQL Server uniqueness and lifecycle handling. -public sealed class EfProductCatalog(ApplicationDbContext dbContext) : IProductCatalog +public sealed class EfProductCatalog(ApplicationDbContext dbContext, IServiceScopeFactory scopeFactory) : IProductCatalog { /// public async Task CreateAsync(Product product, CancellationToken cancellationToken) @@ -40,30 +41,32 @@ public async Task ArchiveAsync(Guid workspaceId, Guid productId, DateTimeO CancellationToken cancellationToken) { var strategy = dbContext.Database.CreateExecutionStrategy(); - return await strategy.ExecuteAsync(async () => + return await strategy.ExecuteAsync(async (ct) => { - await using var transaction = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken); - var product = await dbContext.Products.SingleOrDefaultAsync(item => item.WorkspaceId == workspaceId && - item.Id == productId, cancellationToken); + await using var scope = scopeFactory.CreateAsyncScope(); + var attemptCtx = scope.ServiceProvider.GetRequiredService(); + await using var transaction = await attemptCtx.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct); + var product = await attemptCtx.Products.SingleOrDefaultAsync(item => item.WorkspaceId == workspaceId && + item.Id == productId, ct); if (product is null) { - await transaction.RollbackAsync(cancellationToken); + await transaction.RollbackAsync(ct); return false; } - var hasOnHandBalance = await dbContext.InventoryBalances.AnyAsync(balance => balance.WorkspaceId == workspaceId && - balance.ProductId == productId && balance.Quantity != 0m, cancellationToken); + var hasOnHandBalance = await attemptCtx.InventoryBalances.AnyAsync(balance => balance.WorkspaceId == workspaceId && + balance.ProductId == productId && balance.Quantity != 0m, ct); if (hasOnHandBalance) { - await transaction.RollbackAsync(cancellationToken); + await transaction.RollbackAsync(ct); throw new InventoryArchiveConflictException(); } product.Archive(archivedAtUtc); - await dbContext.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); + await attemptCtx.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); return true; - }); + }, cancellationToken); } /// diff --git a/backend/src/InventoryFlow.Infrastructure/Purchases/EfPurchaseReceiptService.cs b/backend/src/InventoryFlow.Infrastructure/Purchases/EfPurchaseReceiptService.cs index b64f27b..1623cc5 100644 --- a/backend/src/InventoryFlow.Infrastructure/Purchases/EfPurchaseReceiptService.cs +++ b/backend/src/InventoryFlow.Infrastructure/Purchases/EfPurchaseReceiptService.cs @@ -4,28 +4,58 @@ using InventoryFlow.Infrastructure.Inventory; using InventoryFlow.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; namespace InventoryFlow.Infrastructure.Purchases; /// Posts immutable purchase receipts and their ledger entries in one transaction. -public sealed class EfPurchaseReceiptService(ApplicationDbContext dbContext) : IPurchaseReceiptService +public sealed class EfPurchaseReceiptService(ApplicationDbContext dbContext, IServiceScopeFactory scopeFactory) : IPurchaseReceiptService { public async Task RecordAsync(Guid workspaceId, Guid supplierId, Guid warehouseId, Guid productId, decimal quantity, string idempotencyKey, DateTimeOffset receivedAtUtc, CancellationToken cancellationToken) { quantity = InventoryMovement.ValidateQuantity(quantity); idempotencyKey = InventoryMovement.NormalizeIdempotencyKey(idempotencyKey); - var strategy = dbContext.Database.CreateExecutionStrategy(); - return await strategy.ExecuteAsync(async () => - { - await using var transaction = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken); - var existing = await dbContext.PurchaseReceipts.SingleOrDefaultAsync(receipt => receipt.WorkspaceId == workspaceId && - receipt.IdempotencyKey == idempotencyKey, cancellationToken); - if (existing is not null) + var strategy = dbContext.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => { + var attemptCtx = scopeFactory.CreateScope().ServiceProvider.GetRequiredService(); + await using var transaction = await attemptCtx.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken); + var existing = await attemptCtx.PurchaseReceipts.SingleOrDefaultAsync(receipt => receipt.WorkspaceId == workspaceId && + receipt.IdempotencyKey == idempotencyKey, cancellationToken); + if (existing is not null) + { + if (existing.SupplierId != supplierId || existing.WarehouseId != warehouseId || + existing.ProductId != productId || existing.Quantity != quantity) + throw new InvalidOperationException("Idempotency key reused with different parameters."); + await transaction.CommitAsync(cancellationToken); + return existing; + } + + var supplierExists = await attemptCtx.Suppliers.AnyAsync(supplier => supplier.Id == supplierId && + supplier.WorkspaceId == workspaceId && supplier.ArchivedAtUtc == null, cancellationToken); + if (!supplierExists) + { + await transaction.RollbackAsync(cancellationToken); + return null; + } + + var receiptId = Guid.NewGuid(); + var movement = await new InventoryLedgerWriter(attemptCtx).RecordAsync(workspaceId, warehouseId, productId, + InventoryMovementType.Receipt, quantity, receiptId.ToString("N"), receivedAtUtc, Guid.NewGuid(), cancellationToken); + if (movement is null) + { + await transaction.RollbackAsync(cancellationToken); + return null; + } + + var receipt = new PurchaseReceipt(receiptId, workspaceId, supplierId, warehouseId, productId, quantity, idempotencyKey, + movement.Id, receivedAtUtc); + attemptCtx.PurchaseReceipts.Add(receipt); + await attemptCtx.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); - return existing; - } + return receipt; + }); var supplierExists = await dbContext.Suppliers.AnyAsync(supplier => supplier.Id == supplierId && supplier.WorkspaceId == workspaceId && supplier.ArchivedAtUtc == null, cancellationToken); diff --git a/backend/src/InventoryFlow.Infrastructure/Sales/EfSalesFulfillmentService.cs b/backend/src/InventoryFlow.Infrastructure/Sales/EfSalesFulfillmentService.cs index fe46e03..ae6b171 100644 --- a/backend/src/InventoryFlow.Infrastructure/Sales/EfSalesFulfillmentService.cs +++ b/backend/src/InventoryFlow.Infrastructure/Sales/EfSalesFulfillmentService.cs @@ -4,45 +4,50 @@ using InventoryFlow.Infrastructure.Inventory; using InventoryFlow.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; namespace InventoryFlow.Infrastructure.Sales; /// Posts immutable sales fulfillments and their issue ledger entries in one transaction. -public sealed class EfSalesFulfillmentService(ApplicationDbContext dbContext) : ISalesFulfillmentService +public sealed class EfSalesFulfillmentService(ApplicationDbContext dbContext, IServiceScopeFactory scopeFactory) : ISalesFulfillmentService { public async Task RecordAsync(Guid workspaceId, Guid warehouseId, Guid productId, decimal quantity, string idempotencyKey, DateTimeOffset fulfilledAtUtc, CancellationToken cancellationToken) { quantity = InventoryMovement.ValidateQuantity(quantity); idempotencyKey = InventoryMovement.NormalizeIdempotencyKey(idempotencyKey); - var strategy = dbContext.Database.CreateExecutionStrategy(); - return await strategy.ExecuteAsync(async () => - { - await using var transaction = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken); - var existing = await dbContext.SalesFulfillments.SingleOrDefaultAsync(fulfillment => fulfillment.WorkspaceId == workspaceId && - fulfillment.IdempotencyKey == idempotencyKey, cancellationToken); - if (existing is not null) + var strategy = dbContext.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => { - await transaction.CommitAsync(cancellationToken); - return existing; - } + var attemptCtx = scopeFactory.CreateScope().ServiceProvider.GetRequiredService(); + await using var transaction = await attemptCtx.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken); + var existing = await attemptCtx.SalesFulfillments.SingleOrDefaultAsync(fulfillment => fulfillment.WorkspaceId == workspaceId && + fulfillment.IdempotencyKey == idempotencyKey, cancellationToken); + if (existing is not null) + { + if (existing.WarehouseId != warehouseId || existing.ProductId != productId || + existing.Quantity != quantity) + throw new InvalidOperationException("Idempotency key reused with different parameters."); + await transaction.CommitAsync(cancellationToken); + return existing; + } - var fulfillmentId = Guid.NewGuid(); - var movement = await new InventoryLedgerWriter(dbContext).RecordAsync(workspaceId, warehouseId, productId, - InventoryMovementType.Issue, quantity, fulfillmentId.ToString("N"), fulfilledAtUtc, Guid.NewGuid(), cancellationToken); - if (movement is null) - { - await transaction.RollbackAsync(cancellationToken); - return null; - } + var fulfillmentId = Guid.NewGuid(); + var movement = await new InventoryLedgerWriter(attemptCtx).RecordAsync(workspaceId, warehouseId, productId, + InventoryMovementType.Issue, quantity, fulfillmentId.ToString("N"), fulfilledAtUtc, Guid.NewGuid(), cancellationToken); + if (movement is null) + { + await transaction.RollbackAsync(cancellationToken); + return null; + } - var fulfillment = new SalesFulfillment(fulfillmentId, workspaceId, warehouseId, productId, quantity, idempotencyKey, - movement.Id, fulfilledAtUtc); - dbContext.SalesFulfillments.Add(fulfillment); - await dbContext.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); - return fulfillment; - }); + var fulfillment = new SalesFulfillment(fulfillmentId, workspaceId, warehouseId, productId, quantity, idempotencyKey, + movement.Id, fulfilledAtUtc); + attemptCtx.SalesFulfillments.Add(fulfillment); + await attemptCtx.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return fulfillment; + }); } public async Task> ListAsync(Guid workspaceId, CancellationToken cancellationToken) => diff --git a/backend/src/InventoryFlow.Infrastructure/Transfers/EfWarehouseTransferService.cs b/backend/src/InventoryFlow.Infrastructure/Transfers/EfWarehouseTransferService.cs index 6bb5430..d385a28 100644 --- a/backend/src/InventoryFlow.Infrastructure/Transfers/EfWarehouseTransferService.cs +++ b/backend/src/InventoryFlow.Infrastructure/Transfers/EfWarehouseTransferService.cs @@ -43,6 +43,9 @@ await dbContext.WarehouseTransfers.AsNoTracking().Where(transfer => transfer.Wor transfer.IdempotencyKey == idempotencyKey, cancellationToken); if (existing is not null) { + if (existing.SourceWarehouseId != sourceWarehouseId || existing.DestinationWarehouseId != destinationWarehouseId || + existing.ProductId != productId || existing.Quantity != quantity) + throw new InvalidOperationException("Idempotency key reused with different parameters."); await transaction.CommitAsync(cancellationToken); return existing; } diff --git a/backend/tests/InventoryFlow.IntegrationTests/Api/AuthenticatedApiFixture.cs b/backend/tests/InventoryFlow.IntegrationTests/Api/AuthenticatedApiFixture.cs index b8a3c5d..9549cf8 100644 --- a/backend/tests/InventoryFlow.IntegrationTests/Api/AuthenticatedApiFixture.cs +++ b/backend/tests/InventoryFlow.IntegrationTests/Api/AuthenticatedApiFixture.cs @@ -38,7 +38,8 @@ public sealed class AuthenticatedApiFixture : IAsyncLifetime /// public async Task InitializeAsync() { - await _sqlServer.StartAsync(); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(3)); + await _sqlServer.StartAsync(timeoutCts.Token); var databaseName = $"InventoryFlowTests_{Guid.NewGuid():N}"; await using (var connection = new SqlConnection(_sqlServer.GetConnectionString())) @@ -65,7 +66,7 @@ public async Task InitializeAsync() await using var scope = Factory.Services.CreateAsyncScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - await dbContext.Database.MigrateAsync(); + await dbContext.Database.MigrateAsync(timeoutCts.Token); } /// diff --git a/backend/tests/InventoryFlow.IntegrationTests/Api/InventoryEndpointsTests.cs b/backend/tests/InventoryFlow.IntegrationTests/Api/InventoryEndpointsTests.cs index 5214b14..9a74c78 100644 --- a/backend/tests/InventoryFlow.IntegrationTests/Api/InventoryEndpointsTests.cs +++ b/backend/tests/InventoryFlow.IntegrationTests/Api/InventoryEndpointsTests.cs @@ -201,7 +201,8 @@ private async Task PostAsync(string path, string token, object content) { using var response = await SendAsync(HttpMethod.Post, path, token, content); Assert.Equal(HttpStatusCode.Created, response.StatusCode); - return (await response.Content.ReadFromJsonAsync())!; + var payload = await response.Content.ReadFromJsonAsync(); + return Assert.IsType(payload); } private async Task RegisterSessionAsync() @@ -209,7 +210,8 @@ private async Task RegisterSessionAsync() var suffix = Guid.NewGuid().ToString("N"); using var response = await _client.PostAsJsonAsync("/api/auth/register", new RegisterUserCommand($"User {suffix}", $"user-{suffix}@example.test", "Password!12345")); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - return (await response.Content.ReadFromJsonAsync())!; + var payload = await response.Content.ReadFromJsonAsync(); + return Assert.IsType(payload); } private async Task SendAsync(HttpMethod method, string path, string token, object? content = null) diff --git a/backend/tests/InventoryFlow.IntegrationTests/Api/InventoryFlowApiFactory.cs b/backend/tests/InventoryFlow.IntegrationTests/Api/InventoryFlowApiFactory.cs index f21cc72..367dba9 100644 --- a/backend/tests/InventoryFlow.IntegrationTests/Api/InventoryFlowApiFactory.cs +++ b/backend/tests/InventoryFlow.IntegrationTests/Api/InventoryFlowApiFactory.cs @@ -18,6 +18,9 @@ public sealed class InventoryFlowApiFactory : WebApplicationFactory private readonly string? _originalConnectionString = Environment.GetEnvironmentVariable( ConnectionStringEnvironmentVariable); + private readonly string? _originalSigningKey = Environment.GetEnvironmentVariable("Jwt__SigningKey"); + private readonly string? _originalIssuer = Environment.GetEnvironmentVariable("Jwt__Issuer"); + private readonly string? _originalAudience = Environment.GetEnvironmentVariable("Jwt__Audience"); /// /// Initializes a new instance of the class. @@ -71,6 +74,9 @@ protected override void Dispose(bool disposing) Environment.SetEnvironmentVariable( ConnectionStringEnvironmentVariable, _originalConnectionString); + Environment.SetEnvironmentVariable("Jwt__SigningKey", _originalSigningKey); + Environment.SetEnvironmentVariable("Jwt__Issuer", _originalIssuer); + Environment.SetEnvironmentVariable("Jwt__Audience", _originalAudience); } base.Dispose(disposing); diff --git a/docker-compose.yml b/docker-compose.yml index c11402e..cd867dc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,7 +18,8 @@ services: migrate: build: - context: ./backend + context: . + dockerfile: backend/Dockerfile target: migrator environment: &api-environment ASPNETCORE_ENVIRONMENT: Development @@ -31,7 +32,8 @@ services: api: build: - context: ./backend + context: . + dockerfile: backend/Dockerfile target: final environment: *api-environment depends_on: diff --git a/frontend/src/components/layout/Topbar.tsx b/frontend/src/components/layout/Topbar.tsx index 51f9a26..80cddec 100644 --- a/frontend/src/components/layout/Topbar.tsx +++ b/frontend/src/components/layout/Topbar.tsx @@ -47,6 +47,8 @@ export function Topbar() { try { await logout() + } catch { + console.error("Failed to log out on the server") } finally { navigate("/login", { replace: true }) } @@ -63,6 +65,8 @@ export function Topbar() { try { const session = await switchWorkspace(workspaceId) setSession(session) + } catch { + console.error("Failed to switch workspace") } finally { setSwitchingWorkspaceId(null) } diff --git a/frontend/src/features/dashboard/components/MetricCard.tsx b/frontend/src/features/dashboard/components/MetricCard.tsx index 72ce18e..dd5cd0c 100644 --- a/frontend/src/features/dashboard/components/MetricCard.tsx +++ b/frontend/src/features/dashboard/components/MetricCard.tsx @@ -13,6 +13,7 @@ type MetricCardProps = { value: string change: string trend: "up" | "down" + favorable?: boolean icon: IconSvgElement index: number } @@ -22,10 +23,11 @@ export function MetricCard({ value, change, trend, + favorable = trend === "up", icon, index, }: MetricCardProps) { - const isPositive = trend === "up" + const isPositive = favorable return ( Export report} + actions={} description="A real-time view of inventory health and operational activity." title="Dashboard" /> diff --git a/frontend/src/features/products/pages/ProductsPage.tsx b/frontend/src/features/products/pages/ProductsPage.tsx index 2689872..45cee7f 100644 --- a/frontend/src/features/products/pages/ProductsPage.tsx +++ b/frontend/src/features/products/pages/ProductsPage.tsx @@ -52,14 +52,14 @@ export function Component() { onError: (reason) => setError(readError(reason)), }) - const submit = (form: FormData) => { + const submit = async (form: FormData) => { const parsed = productSchema.safeParse(Object.fromEntries(form)) if (!parsed.success) return setError( parsed.error.issues[0]?.message ?? "Check the product details." ) setError("") - create.mutate(parsed.data) + await create.mutateAsync(parsed.data).catch(() => {}) } return ( diff --git a/frontend/src/features/purchases/pages/PurchasesPage.tsx b/frontend/src/features/purchases/pages/PurchasesPage.tsx index e077b04..6c558fe 100644 --- a/frontend/src/features/purchases/pages/PurchasesPage.tsx +++ b/frontend/src/features/purchases/pages/PurchasesPage.tsx @@ -40,6 +40,8 @@ export function Component() { const [formError, setFormError] = useState("") const [retryReceipt, setRetryReceipt] = useState(null) + + const clearRetry = () => { if (retryReceipt) setRetryReceipt(null) } const suppliers = useQuery({ queryKey: key("suppliers", userId, workspaceId), queryFn: listSuppliers, @@ -131,7 +133,7 @@ export function Component() { setWarehouseId(e.target.value)} + onChange={(e) => { setWarehouseId(e.target.value); clearRetry() }} value={warehouseId} > @@ -163,7 +165,7 @@ export function Component() { setWarehouseId(e.target.value)} + onChange={(e) => { setWarehouseId(e.target.value); clearRetry() }} value={warehouseId} > @@ -135,7 +137,7 @@ export function Component() { - {error ? (

{error} diff --git a/frontend/src/lib/api-client.ts b/frontend/src/lib/api-client.ts index 52874e8..e29f9e9 100644 --- a/frontend/src/lib/api-client.ts +++ b/frontend/src/lib/api-client.ts @@ -2,6 +2,11 @@ import axios, { type AxiosError, type InternalAxiosRequestConfig } from "axios" import { environment } from "@/app/config/environment" import { useAuthStore } from "@/features/auth/auth-store" +type AuthRequestConfig = InternalAxiosRequestConfig & { + _authRetried?: boolean + _sessionEpoch?: number +} + export const authClient = axios.create({ baseURL: environment.apiBaseUrl, withCredentials: true, @@ -44,14 +49,20 @@ const refreshAccessToken = () => { } apiClient.interceptors.request.use((config) => { + const request = config as AuthRequestConfig + request._sessionEpoch = sessionEpoch const token = useAuthStore.getState().accessToken - if (token) config.headers.Authorization = `Bearer ${token}` - return config + if (token) request.headers.Authorization = `Bearer ${token}` + return request }) apiClient.interceptors.response.use(undefined, async (error: AxiosError) => { - const request = error.config as - (InternalAxiosRequestConfig & { _authRetried?: boolean }) | undefined - if (error.response?.status !== 401 || !request || request._authRetried) + const request = error.config as AuthRequestConfig | undefined + if ( + error.response?.status !== 401 || + !request || + request._authRetried || + request._sessionEpoch !== sessionEpoch + ) return Promise.reject(error) request._authRetried = true const token = await refreshAccessToken() diff --git a/global.json b/global.json index dd89c65..26b29b2 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "rollForward": "latestPatch", + "rollForward": "disable", "version": "9.0.316" } } From c9eab4e8670a1e45cb2f7e4eedd88ed3493cbd97 Mon Sep 17 00:00:00 2001 From: Adnan Ahmad Date: Sat, 25 Jul 2026 19:52:24 +0500 Subject: [PATCH 3/5] fix: address remaining CodeRabbit comments - remove orphaned code, readError fallbacks --- .../Purchases/EfPurchaseReceiptService.cs | 74 +++++++------------ .../warehouses/pages/WarehousesPage.tsx | 12 +-- 2 files changed, 31 insertions(+), 55 deletions(-) diff --git a/backend/src/InventoryFlow.Infrastructure/Purchases/EfPurchaseReceiptService.cs b/backend/src/InventoryFlow.Infrastructure/Purchases/EfPurchaseReceiptService.cs index 1623cc5..28996e0 100644 --- a/backend/src/InventoryFlow.Infrastructure/Purchases/EfPurchaseReceiptService.cs +++ b/backend/src/InventoryFlow.Infrastructure/Purchases/EfPurchaseReceiptService.cs @@ -16,71 +16,47 @@ public sealed class EfPurchaseReceiptService(ApplicationDbContext dbContext, ISe { quantity = InventoryMovement.ValidateQuantity(quantity); idempotencyKey = InventoryMovement.NormalizeIdempotencyKey(idempotencyKey); - var strategy = dbContext.Database.CreateExecutionStrategy(); - return await strategy.ExecuteAsync(async () => + var strategy = dbContext.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async (ct) => + { + await using var scope = scopeFactory.CreateScope(); + var attemptCtx = scope.ServiceProvider.GetRequiredService(); + await using var transaction = await attemptCtx.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct); + var existing = await attemptCtx.PurchaseReceipts.SingleOrDefaultAsync(receipt => receipt.WorkspaceId == workspaceId && + receipt.IdempotencyKey == idempotencyKey, ct); + if (existing is not null) { - var attemptCtx = scopeFactory.CreateScope().ServiceProvider.GetRequiredService(); - await using var transaction = await attemptCtx.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken); - var existing = await attemptCtx.PurchaseReceipts.SingleOrDefaultAsync(receipt => receipt.WorkspaceId == workspaceId && - receipt.IdempotencyKey == idempotencyKey, cancellationToken); - if (existing is not null) - { - if (existing.SupplierId != supplierId || existing.WarehouseId != warehouseId || - existing.ProductId != productId || existing.Quantity != quantity) - throw new InvalidOperationException("Idempotency key reused with different parameters."); - await transaction.CommitAsync(cancellationToken); - return existing; - } - - var supplierExists = await attemptCtx.Suppliers.AnyAsync(supplier => supplier.Id == supplierId && - supplier.WorkspaceId == workspaceId && supplier.ArchivedAtUtc == null, cancellationToken); - if (!supplierExists) - { - await transaction.RollbackAsync(cancellationToken); - return null; - } - - var receiptId = Guid.NewGuid(); - var movement = await new InventoryLedgerWriter(attemptCtx).RecordAsync(workspaceId, warehouseId, productId, - InventoryMovementType.Receipt, quantity, receiptId.ToString("N"), receivedAtUtc, Guid.NewGuid(), cancellationToken); - if (movement is null) - { - await transaction.RollbackAsync(cancellationToken); - return null; - } - - var receipt = new PurchaseReceipt(receiptId, workspaceId, supplierId, warehouseId, productId, quantity, idempotencyKey, - movement.Id, receivedAtUtc); - attemptCtx.PurchaseReceipts.Add(receipt); - await attemptCtx.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); - return receipt; - }); + if (existing.SupplierId != supplierId || existing.WarehouseId != warehouseId || + existing.ProductId != productId || existing.Quantity != quantity) + throw new InvalidOperationException("Idempotency key reused with different parameters."); + await transaction.CommitAsync(ct); + return existing; + } - var supplierExists = await dbContext.Suppliers.AnyAsync(supplier => supplier.Id == supplierId && - supplier.WorkspaceId == workspaceId && supplier.ArchivedAtUtc == null, cancellationToken); + var supplierExists = await attemptCtx.Suppliers.AnyAsync(supplier => supplier.Id == supplierId && + supplier.WorkspaceId == workspaceId && supplier.ArchivedAtUtc == null, ct); if (!supplierExists) { - await transaction.RollbackAsync(cancellationToken); + await transaction.RollbackAsync(ct); return null; } var receiptId = Guid.NewGuid(); - var movement = await new InventoryLedgerWriter(dbContext).RecordAsync(workspaceId, warehouseId, productId, - InventoryMovementType.Receipt, quantity, receiptId.ToString("N"), receivedAtUtc, Guid.NewGuid(), cancellationToken); + var movement = await new InventoryLedgerWriter(attemptCtx).RecordAsync(workspaceId, warehouseId, productId, + InventoryMovementType.Receipt, quantity, receiptId.ToString("N"), receivedAtUtc, Guid.NewGuid(), ct); if (movement is null) { - await transaction.RollbackAsync(cancellationToken); + await transaction.RollbackAsync(ct); return null; } var receipt = new PurchaseReceipt(receiptId, workspaceId, supplierId, warehouseId, productId, quantity, idempotencyKey, movement.Id, receivedAtUtc); - dbContext.PurchaseReceipts.Add(receipt); - await dbContext.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); + attemptCtx.PurchaseReceipts.Add(receipt); + await attemptCtx.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); return receipt; - }); + }, cancellationToken); } public async Task> ListAsync(Guid workspaceId, CancellationToken cancellationToken) => diff --git a/frontend/src/features/warehouses/pages/WarehousesPage.tsx b/frontend/src/features/warehouses/pages/WarehousesPage.tsx index 6479c1e..80ef5aa 100644 --- a/frontend/src/features/warehouses/pages/WarehousesPage.tsx +++ b/frontend/src/features/warehouses/pages/WarehousesPage.tsx @@ -15,14 +15,14 @@ import { useAuthStore } from "@/features/auth/auth-store" const warehousesKey = (userId: string, workspaceId: string) => ["warehouses", userId, workspaceId] as const -const readError = (error: unknown) => { +const readError = (error: unknown, fallback = "Unable to save the warehouse.") => { if (typeof error === "object" && error && "response" in error) { const data = ( error as { response?: { data?: { detail?: string; title?: string } } } ).response?.data - return data?.detail ?? data?.title ?? "Unable to save the warehouse." + return data?.detail ?? data?.title ?? fallback } - return "Unable to save the warehouse." + return fallback } export function Component() { @@ -49,7 +49,7 @@ export function Component() { const archive = useMutation({ mutationFn: archiveWarehouse, onSuccess: () => queryClient.invalidateQueries({ queryKey }), - onError: (reason) => setError(readError(reason)), + onError: (reason) => setError(readError(reason, "Unable to archive the warehouse.")), }) const submit = async (form: FormData) => { @@ -59,7 +59,7 @@ export function Component() { parsed.error.issues[0]?.message ?? "Check the warehouse details." ) setError("") - await create.mutateAsync(parsed.data).catch(() => {}) + await create.mutateAsync(parsed.data) } return ( @@ -79,7 +79,7 @@ export function Component() { ) : null} {warehouses.isError ? (

- {readError(warehouses.error)} + {readError(warehouses.error, "Unable to load warehouses.")}

) : null} {warehouses.data?.length === 0 ? ( From 7926a1d7ccd87cab062c6717db25a2f55b0d77e0 Mon Sep 17 00:00:00 2001 From: Adnan Ahmad Date: Sat, 25 Jul 2026 20:01:43 +0500 Subject: [PATCH 4/5] fix: add catch for create.mutateAsync, separate archiveError state --- .../warehouses/pages/WarehousesPage.tsx | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/frontend/src/features/warehouses/pages/WarehousesPage.tsx b/frontend/src/features/warehouses/pages/WarehousesPage.tsx index 80ef5aa..8d10c23 100644 --- a/frontend/src/features/warehouses/pages/WarehousesPage.tsx +++ b/frontend/src/features/warehouses/pages/WarehousesPage.tsx @@ -38,6 +38,7 @@ export function Component() { queryFn: listWarehouses, enabled: user !== null, }) + const [archiveError, setArchiveError] = useState("") const create = useMutation({ mutationFn: createWarehouse, onSuccess: () => { @@ -48,8 +49,11 @@ export function Component() { }) const archive = useMutation({ mutationFn: archiveWarehouse, - onSuccess: () => queryClient.invalidateQueries({ queryKey }), - onError: (reason) => setError(readError(reason, "Unable to archive the warehouse.")), + onSuccess: () => { + setArchiveError("") + queryClient.invalidateQueries({ queryKey }) + }, + onError: (reason) => setArchiveError(readError(reason, "Unable to archive the warehouse.")), }) const submit = async (form: FormData) => { @@ -59,7 +63,7 @@ export function Component() { parsed.error.issues[0]?.message ?? "Check the warehouse details." ) setError("") - await create.mutateAsync(parsed.data) + await create.mutateAsync(parsed.data).catch(() => {}) } return ( @@ -123,11 +127,16 @@ export function Component() { required /> - {error ? ( -

- {error} -

- ) : null} + {error ? ( +

+ {error} +

+ ) : null} + {archiveError ? ( +

+ {archiveError} +

+ ) : null} From b50f6c5749aeb31769a2431d1e5086c69ef6264c Mon Sep 17 00:00:00 2001 From: Adnan Ahmad Date: Sat, 25 Jul 2026 20:07:22 +0500 Subject: [PATCH 5/5] fix: replace await using with using for IDisposable scope in EfPurchaseReceiptService --- .../Purchases/EfPurchaseReceiptService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/InventoryFlow.Infrastructure/Purchases/EfPurchaseReceiptService.cs b/backend/src/InventoryFlow.Infrastructure/Purchases/EfPurchaseReceiptService.cs index 28996e0..e9f1ae5 100644 --- a/backend/src/InventoryFlow.Infrastructure/Purchases/EfPurchaseReceiptService.cs +++ b/backend/src/InventoryFlow.Infrastructure/Purchases/EfPurchaseReceiptService.cs @@ -19,7 +19,7 @@ public sealed class EfPurchaseReceiptService(ApplicationDbContext dbContext, ISe var strategy = dbContext.Database.CreateExecutionStrategy(); return await strategy.ExecuteAsync(async (ct) => { - await using var scope = scopeFactory.CreateScope(); + using var scope = scopeFactory.CreateScope(); var attemptCtx = scope.ServiceProvider.GetRequiredService(); await using var transaction = await attemptCtx.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct); var existing = await attemptCtx.PurchaseReceipts.SingleOrDefaultAsync(receipt => receipt.WorkspaceId == workspaceId &&