From 4e06e4865768026018cdc8a943fb7274a06e9dff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Tue, 10 Mar 2026 21:08:03 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=B0=D0=BD=D1=8B=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D0=B8,=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D1=8B,=20=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=BA=D0=B0=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE?= =?UTF-8?q?=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 3 +- Dockerfile.migrations | 1 + Nashel.sln | 136 ++++---- README.md | 170 +++++----- .../Configurations/SystemSettings.cs | 55 ++++ .../Exceptions/ApplicationExceptions.cs | 54 ++++ src/BuildingBlocks/Domain/DomainException.cs | 9 + src/Host/Endpoints/SearchEndpoints.cs | 45 ++- .../Exceptions/GlobalExceptionHandler.cs | 102 ++++++ src/Host/Program.cs | 38 +-- src/Host/appsettings.json | 16 + .../Commands/CreateOfferCommand.cs | 1 - .../Commands/UploadOfferImageCommand.cs | 32 +- .../Catalog/Application/Common/Dtos.cs | 26 +- .../Nashel.Modules.Catalog.Application.csproj | 2 + .../Application/Queries/GetOfferByIdQuery.cs | 19 +- ...60306185654_AddOfferImagesAndSoftDelete.cs | 2 +- ...10145100_BackendUpdate_Catalog.Designer.cs | 148 +++++++++ .../20260310145100_BackendUpdate_Catalog.cs | 22 ++ .../CreateCategoryCommandHandlerTests.cs | 26 +- .../CreateOfferCommandHandlerTests.cs | 39 ++- .../GetCategoriesQueryHandlerTests.cs | 47 +-- .../GetOfferByIdQueryHandlerTests.cs | 63 ++-- .../Catalog/Tests/Domain/CategoryTests.cs | 14 +- .../Catalog/Tests/Domain/OfferTests.cs | 16 +- .../Catalog/Tests/Domain/PriceTests.cs | 18 +- .../Tests/Nashel.Modules.Catalog.Tests.csproj | 2 +- ...06_BackendUpdate_Collaboration.Designer.cs | 104 +++++++ ...60310145106_BackendUpdate_Collaboration.cs | 22 ++ .../Tests/Domain/ContractTests.cs | 16 +- ...Nashel.Modules.Collaboration.Tests.csproj} | 4 +- .../Services/ScheduleConflictServiceTests.cs | 29 +- .../Queries/GetPerformerStatusQuery.cs | 43 +++ ...260310144949_BackendUpdate_Geo.Designer.cs | 59 ++++ .../20260310144949_BackendUpdate_Geo.cs | 22 ++ .../UpdateGeoCommandHandlerTests.cs | 53 ++-- .../Geo/Tests/Domain/LiveStatusTests.cs | 30 +- .../Geo/Tests/Nashel.Modules.Geo.Tests.csproj | 4 +- .../Commands/BecomePerformerCommand.cs | 57 ++-- ...Nashel.Modules.Identity.Application.csproj | 1 + .../Queries/GetProfile/GetProfileQuery.cs | 23 +- .../Queries/GetUserDetailsQuery.cs | 52 ++++ .../Queries/GetUsersDetailsQuery.cs | 49 +++ .../Domain/Repositories/IAccountRepository.cs | 1 + ...0145039_BackendUpdate_Identity.Designer.cs | 204 ++++++++++++ .../20260310145039_BackendUpdate_Identity.cs | 22 ++ .../Repositories/AccountRepository.cs | 8 + .../Endpoints/IdentityEndpoints.cs | 11 + .../BecomePerformerCommandHandlerTests.cs | 60 ++-- .../Application/GetUserDetailsQueryTests.cs | 64 ++++ .../Application/LoginCommandHandlerTests.cs | 48 +-- .../RegisterUserCommandHandlerTests.cs | 37 +-- .../Identity/Tests/Domain/AccountTests.cs | 22 +- .../Nashel.Modules.Identity.Tests.csproj | 4 +- .../Commands/AcceptDisputeTermsCommand.cs | 34 ++ .../Commands/AcceptOrderCommand.cs | 18 +- .../Commands/ConfirmOrderCompletionCommand.cs | 35 +++ .../Commands/FinishOrderCommand.cs | 35 +++ .../Commands/OpenOrderDisputeCommand.cs | 40 +++ .../Commands/RebutDisputeCommand.cs | 37 +++ .../Commands/RespondToOrderDisputeCommand.cs | 37 +++ .../Application/Queries/GetMyOrdersQuery.cs | 64 +++- src/Modules/Order/Domain/Aggregates/Order.cs | 132 ++++++-- src/Modules/Order/Domain/Entities/Dispute.cs | 76 +++++ .../Order/Domain/Enums/DisputeReason.cs | 18 ++ .../Order/Domain/Enums/DisputeStatus.cs | 16 + src/Modules/Order/Domain/Enums/OrderStatus.cs | 12 +- .../Domain/Exceptions/OrderDomainException.cs | 11 + ...0260309200524_AddOrderDisputes.Designer.cs | 253 +++++++++++++++ .../20260309200524_AddOrderDisputes.cs | 54 ++++ ...309204912_UpdateDisputeHistory.Designer.cs | 290 ++++++++++++++++++ .../20260309204912_UpdateDisputeHistory.cs | 182 +++++++++++ ...44_FixDisputeMappingAndHistory.Designer.cs | 290 ++++++++++++++++++ ...60309210444_FixDisputeMappingAndHistory.cs | 22 ++ ...20260309224622_UseIsRowVersion.Designer.cs | 290 ++++++++++++++++++ .../20260309224622_UseIsRowVersion.cs | 22 ++ .../20260310000000_FixDisputeData.Designer.cs | 290 ++++++++++++++++++ .../20260310000000_FixDisputeData.cs | 22 ++ ...0310145112_BackendUpdate_Order.Designer.cs | 284 +++++++++++++++++ .../20260310145112_BackendUpdate_Order.cs | 32 ++ .../Migrations/OrderDbContextModelSnapshot.cs | 97 +++++- .../Configurations/OrderConfiguration.cs | 41 ++- .../Repositories/OrderRepository.cs | 10 +- .../Order/Presentation/OrderEndpoints.cs | 155 +++++++++- .../Application/AcceptOrderCommandTests.cs | 77 +++++ .../SelectPerformerHandlerTests.cs | 6 +- .../Order/Tests/Domain/OrderAggregateTests.cs | 26 +- .../Commands/AddReviewAdditionCommand.cs | 36 +++ .../Commands/CreateReviewCommand.cs | 46 --- .../Commands/LeaveReviewCommand.cs | 59 ++++ ...shel.Modules.Reputation.Application.csproj | 1 + .../Queries/GetOfferRatingQuery.cs | 25 ++ .../Queries/GetProfileRatingQuery.cs | 25 ++ .../Queries/GetProfileReviewsQuery.cs | 76 +++++ .../Queries/GetReviewsByTargetQuery.cs | 6 +- .../Queries/GetUsersRatingQuery.cs | 25 ++ .../Application/Queries/ProfileRatingDto.cs | 6 + .../Reputation/Domain/Entities/Review.cs | 71 ++++- .../Domain/Repositories/IRepositories.cs | 9 +- .../AutoReviewBackgroundService.cs | 85 +++++ .../Infrastructure/DependencyInjection.cs | 2 +- ...20260308201003_FixReviewFields.Designer.cs | 136 ++++++++ .../20260308201003_FixReviewFields.cs | 55 ++++ ...08224208_AddMediaUrlsToReviews.Designer.cs | 136 ++++++++ .../20260308224208_AddMediaUrlsToReviews.cs | 22 ++ ...08224700_AddCreatedAtToReviews.Designer.cs | 139 +++++++++ .../20260308224700_AddCreatedAtToReviews.cs | 32 ++ ...0308232944_AddOfferIdToReviews.Designer.cs | 144 +++++++++ .../20260308232944_AddOfferIdToReviews.cs | 43 +++ ...60309201135_AddReviewAdditions.Designer.cs | 179 +++++++++++ .../20260309201135_AddReviewAdditions.cs | 49 +++ ...45118_BackendUpdate_Reputation.Designer.cs | 179 +++++++++++ ...20260310145118_BackendUpdate_Reputation.cs | 22 ++ .../ReputationDbContextModelSnapshot.cs | 53 +++- ...l.Modules.Reputation.Infrastructure.csproj | 4 +- .../Configurations/Configurations.cs | 24 +- .../Repositories/ReviewRepository.cs | 61 ++++ .../Presentation/ReputationEndpoints.cs | 184 +++++------ .../Tests/Application/CreateReviewTests.cs | 31 +- .../Tests/Application/OpenDisputeTests.cs | 23 +- .../Reputation/Tests/Domain/DisputeTests.cs | 10 +- .../Reputation/Tests/Domain/ReviewTests.cs | 34 +- .../Nashel.Modules.Reputation.Tests.csproj | 4 +- .../Tests/Services/RatingCalculatorTests.cs | 33 +- .../GlobalExceptionHandlerTests.cs | 93 ++++++ .../Nashel.Host.Tests.csproj | 27 ++ 126 files changed, 6549 insertions(+), 803 deletions(-) create mode 100644 src/BuildingBlocks/Application/Configurations/SystemSettings.cs create mode 100644 src/BuildingBlocks/Application/Exceptions/ApplicationExceptions.cs create mode 100644 src/BuildingBlocks/Domain/DomainException.cs create mode 100644 src/Host/Infrastructure/Exceptions/GlobalExceptionHandler.cs create mode 100644 src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260310145100_BackendUpdate_Catalog.Designer.cs create mode 100644 src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260310145100_BackendUpdate_Catalog.cs create mode 100644 src/Modules/Collaboration/Infrastructure/Migrations/20260310145106_BackendUpdate_Collaboration.Designer.cs create mode 100644 src/Modules/Collaboration/Infrastructure/Migrations/20260310145106_BackendUpdate_Collaboration.cs rename src/Modules/Collaboration/Tests/{Tests.csproj => Nashel.Modules.Collaboration.Tests.csproj} (89%) create mode 100644 src/Modules/Geo/Application/Queries/GetPerformerStatusQuery.cs create mode 100644 src/Modules/Geo/Infrastructure/Migrations/20260310144949_BackendUpdate_Geo.Designer.cs create mode 100644 src/Modules/Geo/Infrastructure/Migrations/20260310144949_BackendUpdate_Geo.cs create mode 100644 src/Modules/Identity/Application/Queries/GetUserDetailsQuery.cs create mode 100644 src/Modules/Identity/Application/Queries/GetUsersDetailsQuery.cs create mode 100644 src/Modules/Identity/Infrastructure/Persistence/Migrations/20260310145039_BackendUpdate_Identity.Designer.cs create mode 100644 src/Modules/Identity/Infrastructure/Persistence/Migrations/20260310145039_BackendUpdate_Identity.cs create mode 100644 src/Modules/Identity/Tests/Application/GetUserDetailsQueryTests.cs create mode 100644 src/Modules/Order/Application/Commands/AcceptDisputeTermsCommand.cs create mode 100644 src/Modules/Order/Application/Commands/ConfirmOrderCompletionCommand.cs create mode 100644 src/Modules/Order/Application/Commands/FinishOrderCommand.cs create mode 100644 src/Modules/Order/Application/Commands/OpenOrderDisputeCommand.cs create mode 100644 src/Modules/Order/Application/Commands/RebutDisputeCommand.cs create mode 100644 src/Modules/Order/Application/Commands/RespondToOrderDisputeCommand.cs create mode 100644 src/Modules/Order/Domain/Entities/Dispute.cs create mode 100644 src/Modules/Order/Domain/Enums/DisputeReason.cs create mode 100644 src/Modules/Order/Domain/Enums/DisputeStatus.cs create mode 100644 src/Modules/Order/Domain/Exceptions/OrderDomainException.cs create mode 100644 src/Modules/Order/Infrastructure/Migrations/20260309200524_AddOrderDisputes.Designer.cs create mode 100644 src/Modules/Order/Infrastructure/Migrations/20260309200524_AddOrderDisputes.cs create mode 100644 src/Modules/Order/Infrastructure/Migrations/20260309204912_UpdateDisputeHistory.Designer.cs create mode 100644 src/Modules/Order/Infrastructure/Migrations/20260309204912_UpdateDisputeHistory.cs create mode 100644 src/Modules/Order/Infrastructure/Migrations/20260309210444_FixDisputeMappingAndHistory.Designer.cs create mode 100644 src/Modules/Order/Infrastructure/Migrations/20260309210444_FixDisputeMappingAndHistory.cs create mode 100644 src/Modules/Order/Infrastructure/Migrations/20260309224622_UseIsRowVersion.Designer.cs create mode 100644 src/Modules/Order/Infrastructure/Migrations/20260309224622_UseIsRowVersion.cs create mode 100644 src/Modules/Order/Infrastructure/Migrations/20260310000000_FixDisputeData.Designer.cs create mode 100644 src/Modules/Order/Infrastructure/Migrations/20260310000000_FixDisputeData.cs create mode 100644 src/Modules/Order/Infrastructure/Migrations/20260310145112_BackendUpdate_Order.Designer.cs create mode 100644 src/Modules/Order/Infrastructure/Migrations/20260310145112_BackendUpdate_Order.cs create mode 100644 src/Modules/Order/Tests/Application/AcceptOrderCommandTests.cs create mode 100644 src/Modules/Reputation/Application/Commands/AddReviewAdditionCommand.cs delete mode 100644 src/Modules/Reputation/Application/Commands/CreateReviewCommand.cs create mode 100644 src/Modules/Reputation/Application/Commands/LeaveReviewCommand.cs create mode 100644 src/Modules/Reputation/Application/Queries/GetOfferRatingQuery.cs create mode 100644 src/Modules/Reputation/Application/Queries/GetProfileRatingQuery.cs create mode 100644 src/Modules/Reputation/Application/Queries/GetProfileReviewsQuery.cs create mode 100644 src/Modules/Reputation/Application/Queries/GetUsersRatingQuery.cs create mode 100644 src/Modules/Reputation/Application/Queries/ProfileRatingDto.cs create mode 100644 src/Modules/Reputation/Infrastructure/BackgroundJobs/AutoReviewBackgroundService.cs create mode 100644 src/Modules/Reputation/Infrastructure/Migrations/20260308201003_FixReviewFields.Designer.cs create mode 100644 src/Modules/Reputation/Infrastructure/Migrations/20260308201003_FixReviewFields.cs create mode 100644 src/Modules/Reputation/Infrastructure/Migrations/20260308224208_AddMediaUrlsToReviews.Designer.cs create mode 100644 src/Modules/Reputation/Infrastructure/Migrations/20260308224208_AddMediaUrlsToReviews.cs create mode 100644 src/Modules/Reputation/Infrastructure/Migrations/20260308224700_AddCreatedAtToReviews.Designer.cs create mode 100644 src/Modules/Reputation/Infrastructure/Migrations/20260308224700_AddCreatedAtToReviews.cs create mode 100644 src/Modules/Reputation/Infrastructure/Migrations/20260308232944_AddOfferIdToReviews.Designer.cs create mode 100644 src/Modules/Reputation/Infrastructure/Migrations/20260308232944_AddOfferIdToReviews.cs create mode 100644 src/Modules/Reputation/Infrastructure/Migrations/20260309201135_AddReviewAdditions.Designer.cs create mode 100644 src/Modules/Reputation/Infrastructure/Migrations/20260309201135_AddReviewAdditions.cs create mode 100644 src/Modules/Reputation/Infrastructure/Migrations/20260310145118_BackendUpdate_Reputation.Designer.cs create mode 100644 src/Modules/Reputation/Infrastructure/Migrations/20260310145118_BackendUpdate_Reputation.cs create mode 100644 tests/Nashel.Host.Tests/GlobalExceptionHandlerTests.cs create mode 100644 tests/Nashel.Host.Tests/Nashel.Host.Tests.csproj diff --git a/Dockerfile b/Dockerfile index f94e94d..bdcd81e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,11 @@ -# Build stage +# Build stage FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS build WORKDIR /src # Copy solution and project files COPY ["Nashel.sln", "./"] COPY ["src/", "src/"] +COPY ["tests/", "tests/"] # Restore dependencies RUN dotnet restore "Nashel.sln" diff --git a/Dockerfile.migrations b/Dockerfile.migrations index 812480a..b065eb3 100644 --- a/Dockerfile.migrations +++ b/Dockerfile.migrations @@ -8,6 +8,7 @@ ENV PATH="${PATH}:/root/.dotnet/tools" # Копируем решение и проекты COPY ["Nashel.sln", "./"] COPY ["src/", "src/"] +COPY ["tests/", "tests/"] # Восстанавливаем зависимости RUN dotnet restore "Nashel.sln" diff --git a/Nashel.sln b/Nashel.sln index fbd37d9..e522986 100644 --- a/Nashel.sln +++ b/Nashel.sln @@ -59,20 +59,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nashel.Modules.Reputation.D EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nashel.Modules.Reputation.Presentation", "src\Modules\Reputation\Presentation\Nashel.Modules.Reputation.Presentation.csproj", "{EEC2B03A-3172-4EC1-B7E3-8687E436072D}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Modules", "Modules", "{CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Catalog", "Catalog", "{729B03C7-1C3E-5EDD-EDB4-38F58E22BDF1}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Collaboration", "Collaboration", "{347C27AA-80C2-BDA4-F5FF-AC11DEBD94E0}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Geo", "Geo", "{5B9BABC8-CC62-A0F7-B659-A4FCC76A434C}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Identity", "Identity", "{293C06CD-4E17-48BD-9F3B-B5ADDFFA9425}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Order", "Order", "{3266C089-BF45-8FB5-CBFB-520E7D591FF0}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Reputation", "Reputation", "{6153DBFE-E924-7D0F-FF5C-84EC91E8B397}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nashel.Modules.Identity.Tests", "src\Modules\Identity\Tests\Nashel.Modules.Identity.Tests.csproj", "{401E3B50-A6B8-4AA1-9DD3-41F9EC78FABD}" @@ -83,31 +69,39 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Modules", "Modules", "{EC44 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Order", "Order", "{2C821288-5B9D-778E-74EE-053FE87F223E}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Domain", "Domain", "{7D435AA2-42F6-BE60-ABDD-B2A72FA358F7}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Application", "Application", "{06FD8EF1-B9DE-B088-FC77-09DC201E1B21}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Infrastructure", "Infrastructure", "{159DA5B4-03CA-95B4-A9E1-B46977641578}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Presentation", "Presentation", "{D0003F1F-EE71-3913-8EC0-5F1638807A8D}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{FB27511E-CBA6-E343-8CE0-54AAD98AFC9F}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nashel.Modules.Order.Tests", "src\Modules\Order\Tests\Nashel.Modules.Order.Tests.csproj", "{DC1E99E0-6B92-4FBD-B74B-68D202961FB7}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Collaboration", "Collaboration", "{0D6C7A58-55D3-AEBB-0CCB-984ED0F92F71}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "src\Modules\Collaboration\Tests\Tests.csproj", "{5E1F169E-6639-416B-8C9F-DE3C5785430F}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Reputation", "Reputation", "{305CF822-D9CA-C4BE-7183-DD5447E36C7E}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{6FECF8C1-5BB0-731D-B0FE-7CF5A5513390}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nashel.Modules.Reputation.Tests", "src\Modules\Reputation\Tests\Nashel.Modules.Reputation.Tests.csproj", "{B496E978-09C5-4EFA-9A2A-6984A3F89F77}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Domain", "Domain", "{E8C197F5-634A-BE56-FF9F-B070A9035A7F}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nashel.Host.Tests", "tests\Nashel.Host.Tests\Nashel.Host.Tests.csproj", "{087C0E53-90E0-415A-A394-6E31E12EF7BF}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Application", "Application", "{9F351DFB-97BE-6504-B56A-865D9FFC9EB2}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Catalog", "Catalog", "{1E1EF545-4C18-AEE2-B997-7EF431518D3F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{77959D31-1EA8-27CA-126F-40C5E927F925}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nashel.Modules.Catalog.Tests", "src\Modules\Catalog\Tests\Nashel.Modules.Catalog.Tests.csproj", "{2C29F7A5-8927-4490-BC7A-50B6F5442EEF}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Geo", "Geo", "{1F785BA6-BCCC-EB5A-C0AC-017D258EF3FD}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{9CB3DAB5-483B-9BA7-DF84-97D903318F73}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nashel.Modules.Geo.Tests", "src\Modules\Geo\Tests\Nashel.Modules.Geo.Tests.csproj", "{06992B63-6D24-4785-8A7D-138E9AC6D38E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Collaboration", "Collaboration", "{0D6C7A58-55D3-AEBB-0CCB-984ED0F92F71}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{04D8AB37-CCCF-08EA-5F4C-67D97B5A3FDE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nashel.Modules.Collaboration.Tests", "src\Modules\Collaboration\Tests\Nashel.Modules.Collaboration.Tests.csproj", "{4786B22D-13B2-4CC5-8738-EAC9121EF4F0}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Domain", "Domain", "{F7A07CC8-D014-3EFC-E9E0-4BA33974CD0E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Application", "Application", "{9B135FC8-DBD1-6BD7-B3C5-FE40E7BAF0CB}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -455,18 +449,6 @@ Global {DC1E99E0-6B92-4FBD-B74B-68D202961FB7}.Release|x64.Build.0 = Release|Any CPU {DC1E99E0-6B92-4FBD-B74B-68D202961FB7}.Release|x86.ActiveCfg = Release|Any CPU {DC1E99E0-6B92-4FBD-B74B-68D202961FB7}.Release|x86.Build.0 = Release|Any CPU - {5E1F169E-6639-416B-8C9F-DE3C5785430F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5E1F169E-6639-416B-8C9F-DE3C5785430F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5E1F169E-6639-416B-8C9F-DE3C5785430F}.Debug|x64.ActiveCfg = Debug|Any CPU - {5E1F169E-6639-416B-8C9F-DE3C5785430F}.Debug|x64.Build.0 = Debug|Any CPU - {5E1F169E-6639-416B-8C9F-DE3C5785430F}.Debug|x86.ActiveCfg = Debug|Any CPU - {5E1F169E-6639-416B-8C9F-DE3C5785430F}.Debug|x86.Build.0 = Debug|Any CPU - {5E1F169E-6639-416B-8C9F-DE3C5785430F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5E1F169E-6639-416B-8C9F-DE3C5785430F}.Release|Any CPU.Build.0 = Release|Any CPU - {5E1F169E-6639-416B-8C9F-DE3C5785430F}.Release|x64.ActiveCfg = Release|Any CPU - {5E1F169E-6639-416B-8C9F-DE3C5785430F}.Release|x64.Build.0 = Release|Any CPU - {5E1F169E-6639-416B-8C9F-DE3C5785430F}.Release|x86.ActiveCfg = Release|Any CPU - {5E1F169E-6639-416B-8C9F-DE3C5785430F}.Release|x86.Build.0 = Release|Any CPU {B496E978-09C5-4EFA-9A2A-6984A3F89F77}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B496E978-09C5-4EFA-9A2A-6984A3F89F77}.Debug|Any CPU.Build.0 = Debug|Any CPU {B496E978-09C5-4EFA-9A2A-6984A3F89F77}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -479,6 +461,54 @@ Global {B496E978-09C5-4EFA-9A2A-6984A3F89F77}.Release|x64.Build.0 = Release|Any CPU {B496E978-09C5-4EFA-9A2A-6984A3F89F77}.Release|x86.ActiveCfg = Release|Any CPU {B496E978-09C5-4EFA-9A2A-6984A3F89F77}.Release|x86.Build.0 = Release|Any CPU + {087C0E53-90E0-415A-A394-6E31E12EF7BF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {087C0E53-90E0-415A-A394-6E31E12EF7BF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {087C0E53-90E0-415A-A394-6E31E12EF7BF}.Debug|x64.ActiveCfg = Debug|Any CPU + {087C0E53-90E0-415A-A394-6E31E12EF7BF}.Debug|x64.Build.0 = Debug|Any CPU + {087C0E53-90E0-415A-A394-6E31E12EF7BF}.Debug|x86.ActiveCfg = Debug|Any CPU + {087C0E53-90E0-415A-A394-6E31E12EF7BF}.Debug|x86.Build.0 = Debug|Any CPU + {087C0E53-90E0-415A-A394-6E31E12EF7BF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {087C0E53-90E0-415A-A394-6E31E12EF7BF}.Release|Any CPU.Build.0 = Release|Any CPU + {087C0E53-90E0-415A-A394-6E31E12EF7BF}.Release|x64.ActiveCfg = Release|Any CPU + {087C0E53-90E0-415A-A394-6E31E12EF7BF}.Release|x64.Build.0 = Release|Any CPU + {087C0E53-90E0-415A-A394-6E31E12EF7BF}.Release|x86.ActiveCfg = Release|Any CPU + {087C0E53-90E0-415A-A394-6E31E12EF7BF}.Release|x86.Build.0 = Release|Any CPU + {2C29F7A5-8927-4490-BC7A-50B6F5442EEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2C29F7A5-8927-4490-BC7A-50B6F5442EEF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2C29F7A5-8927-4490-BC7A-50B6F5442EEF}.Debug|x64.ActiveCfg = Debug|Any CPU + {2C29F7A5-8927-4490-BC7A-50B6F5442EEF}.Debug|x64.Build.0 = Debug|Any CPU + {2C29F7A5-8927-4490-BC7A-50B6F5442EEF}.Debug|x86.ActiveCfg = Debug|Any CPU + {2C29F7A5-8927-4490-BC7A-50B6F5442EEF}.Debug|x86.Build.0 = Debug|Any CPU + {2C29F7A5-8927-4490-BC7A-50B6F5442EEF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2C29F7A5-8927-4490-BC7A-50B6F5442EEF}.Release|Any CPU.Build.0 = Release|Any CPU + {2C29F7A5-8927-4490-BC7A-50B6F5442EEF}.Release|x64.ActiveCfg = Release|Any CPU + {2C29F7A5-8927-4490-BC7A-50B6F5442EEF}.Release|x64.Build.0 = Release|Any CPU + {2C29F7A5-8927-4490-BC7A-50B6F5442EEF}.Release|x86.ActiveCfg = Release|Any CPU + {2C29F7A5-8927-4490-BC7A-50B6F5442EEF}.Release|x86.Build.0 = Release|Any CPU + {06992B63-6D24-4785-8A7D-138E9AC6D38E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {06992B63-6D24-4785-8A7D-138E9AC6D38E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {06992B63-6D24-4785-8A7D-138E9AC6D38E}.Debug|x64.ActiveCfg = Debug|Any CPU + {06992B63-6D24-4785-8A7D-138E9AC6D38E}.Debug|x64.Build.0 = Debug|Any CPU + {06992B63-6D24-4785-8A7D-138E9AC6D38E}.Debug|x86.ActiveCfg = Debug|Any CPU + {06992B63-6D24-4785-8A7D-138E9AC6D38E}.Debug|x86.Build.0 = Debug|Any CPU + {06992B63-6D24-4785-8A7D-138E9AC6D38E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {06992B63-6D24-4785-8A7D-138E9AC6D38E}.Release|Any CPU.Build.0 = Release|Any CPU + {06992B63-6D24-4785-8A7D-138E9AC6D38E}.Release|x64.ActiveCfg = Release|Any CPU + {06992B63-6D24-4785-8A7D-138E9AC6D38E}.Release|x64.Build.0 = Release|Any CPU + {06992B63-6D24-4785-8A7D-138E9AC6D38E}.Release|x86.ActiveCfg = Release|Any CPU + {06992B63-6D24-4785-8A7D-138E9AC6D38E}.Release|x86.Build.0 = Release|Any CPU + {4786B22D-13B2-4CC5-8738-EAC9121EF4F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4786B22D-13B2-4CC5-8738-EAC9121EF4F0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4786B22D-13B2-4CC5-8738-EAC9121EF4F0}.Debug|x64.ActiveCfg = Debug|Any CPU + {4786B22D-13B2-4CC5-8738-EAC9121EF4F0}.Debug|x64.Build.0 = Debug|Any CPU + {4786B22D-13B2-4CC5-8738-EAC9121EF4F0}.Debug|x86.ActiveCfg = Debug|Any CPU + {4786B22D-13B2-4CC5-8738-EAC9121EF4F0}.Debug|x86.Build.0 = Debug|Any CPU + {4786B22D-13B2-4CC5-8738-EAC9121EF4F0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4786B22D-13B2-4CC5-8738-EAC9121EF4F0}.Release|Any CPU.Build.0 = Release|Any CPU + {4786B22D-13B2-4CC5-8738-EAC9121EF4F0}.Release|x64.ActiveCfg = Release|Any CPU + {4786B22D-13B2-4CC5-8738-EAC9121EF4F0}.Release|x64.Build.0 = Release|Any CPU + {4786B22D-13B2-4CC5-8738-EAC9121EF4F0}.Release|x86.ActiveCfg = Release|Any CPU + {4786B22D-13B2-4CC5-8738-EAC9121EF4F0}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -510,27 +540,25 @@ Global {C6D6EB9D-D40E-4839-8F78-1024ED4964E1} = {7226ABEA-787E-1D09-10EA-A2ECF996D6A6} {2010E82F-B42C-491F-AFD7-4486D4845737} = {7226ABEA-787E-1D09-10EA-A2ECF996D6A6} {EEC2B03A-3172-4EC1-B7E3-8687E436072D} = {7226ABEA-787E-1D09-10EA-A2ECF996D6A6} - {729B03C7-1C3E-5EDD-EDB4-38F58E22BDF1} = {CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17} - {347C27AA-80C2-BDA4-F5FF-AC11DEBD94E0} = {CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17} - {5B9BABC8-CC62-A0F7-B659-A4FCC76A434C} = {CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17} - {293C06CD-4E17-48BD-9F3B-B5ADDFFA9425} = {CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17} - {3266C089-BF45-8FB5-CBFB-520E7D591FF0} = {CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17} - {6153DBFE-E924-7D0F-FF5C-84EC91E8B397} = {CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17} {401E3B50-A6B8-4AA1-9DD3-41F9EC78FABD} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {2C821288-5B9D-778E-74EE-053FE87F223E} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} - {7D435AA2-42F6-BE60-ABDD-B2A72FA358F7} = {2C821288-5B9D-778E-74EE-053FE87F223E} - {06FD8EF1-B9DE-B088-FC77-09DC201E1B21} = {2C821288-5B9D-778E-74EE-053FE87F223E} - {159DA5B4-03CA-95B4-A9E1-B46977641578} = {2C821288-5B9D-778E-74EE-053FE87F223E} - {D0003F1F-EE71-3913-8EC0-5F1638807A8D} = {2C821288-5B9D-778E-74EE-053FE87F223E} {FB27511E-CBA6-E343-8CE0-54AAD98AFC9F} = {2C821288-5B9D-778E-74EE-053FE87F223E} {DC1E99E0-6B92-4FBD-B74B-68D202961FB7} = {FB27511E-CBA6-E343-8CE0-54AAD98AFC9F} - {0D6C7A58-55D3-AEBB-0CCB-984ED0F92F71} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} - {5E1F169E-6639-416B-8C9F-DE3C5785430F} = {0D6C7A58-55D3-AEBB-0CCB-984ED0F92F71} {305CF822-D9CA-C4BE-7183-DD5447E36C7E} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} {6FECF8C1-5BB0-731D-B0FE-7CF5A5513390} = {305CF822-D9CA-C4BE-7183-DD5447E36C7E} {B496E978-09C5-4EFA-9A2A-6984A3F89F77} = {6FECF8C1-5BB0-731D-B0FE-7CF5A5513390} - {E8C197F5-634A-BE56-FF9F-B070A9035A7F} = {305CF822-D9CA-C4BE-7183-DD5447E36C7E} - {9F351DFB-97BE-6504-B56A-865D9FFC9EB2} = {305CF822-D9CA-C4BE-7183-DD5447E36C7E} + {087C0E53-90E0-415A-A394-6E31E12EF7BF} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {1E1EF545-4C18-AEE2-B997-7EF431518D3F} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} + {77959D31-1EA8-27CA-126F-40C5E927F925} = {1E1EF545-4C18-AEE2-B997-7EF431518D3F} + {2C29F7A5-8927-4490-BC7A-50B6F5442EEF} = {77959D31-1EA8-27CA-126F-40C5E927F925} + {1F785BA6-BCCC-EB5A-C0AC-017D258EF3FD} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} + {9CB3DAB5-483B-9BA7-DF84-97D903318F73} = {1F785BA6-BCCC-EB5A-C0AC-017D258EF3FD} + {06992B63-6D24-4785-8A7D-138E9AC6D38E} = {9CB3DAB5-483B-9BA7-DF84-97D903318F73} + {0D6C7A58-55D3-AEBB-0CCB-984ED0F92F71} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} + {04D8AB37-CCCF-08EA-5F4C-67D97B5A3FDE} = {0D6C7A58-55D3-AEBB-0CCB-984ED0F92F71} + {4786B22D-13B2-4CC5-8738-EAC9121EF4F0} = {04D8AB37-CCCF-08EA-5F4C-67D97B5A3FDE} + {F7A07CC8-D014-3EFC-E9E0-4BA33974CD0E} = {0D6C7A58-55D3-AEBB-0CCB-984ED0F92F71} + {9B135FC8-DBD1-6BD7-B3C5-FE40E7BAF0CB} = {0D6C7A58-55D3-AEBB-0CCB-984ED0F92F71} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 9d72d4b..ebbc7b5 100644 --- a/README.md +++ b/README.md @@ -1,138 +1,112 @@ # 🔮 Nashel — Backend API -> **ASP.NET Core · Modular Monolith · PostgreSQL · Docker** +> **ASP.NET Core · Modular Monolith · PostgreSQL (PostGIS) · Docker** -Серверная часть платформы **Nashel** — маркетплейса для поиска и найма профессиональных исполнителей (мастеров, специалистов и компаний). Бэкенд построен по принципу **Модульного Монолита** с чёткой доменной изоляцией. +Серверная часть платформы **Nashel** — инновационного маркетплейса для поиска и найма профессиональных исполнителей. Бэкенд построен по принципу **Модульного Монолита**, что обеспечивает идеальный баланс между скоростью разработки и чистотой архитектуры с четкой доменной изоляцией. --- -## 🎯 Какую проблему решает Nashel +## 🎯 Миссия Nashel -В России огромное количество мастеров и специалистов, которые работают «по сарафанному радио» — без нормальных инструментов для привлечения клиентов. А клиенты, в свою очередь, тратят время на поиск через знакомых или ненадёжные сайты объявлений. +Мы создаем прозрачную экосистему, где мастера получают профессиональный инструментарий для ведения бизнеса, а клиенты — надежный сервис поиска по геолокации, реальным отзывам и защищенным сделкам. -**Nashel** — это: -- Удобный и быстрый поиск исполнителей **рядом с тобой** на карте -- Прозрачная система услуг с ценами, фото и описанием -- Система репутации и отзывов -- Безопасное взаимодействие клиента и исполнителя +**Ключевые преимущества:** +- **Гео-центричность:** Поиск исполнителей в радиусе на карте. +- **Интеллектуальный статус:** Проверка доступности мастера в реальном времени. +- **Безопасность:** Проработанный жизненный цикл заказа с системой споров. +- **Репутация:** Честная система отзывов, привязанная к реальным сделкам. --- -## 👤 Роли пользователей +## 🧱 Архитектура и Технологии -| Роль | Описание | -|------|----------| -| **Клиент** | Ищет услуги на карте, просматривает профили исполнителей, оставляет заявки | -| **Кандидат в мастера** | Зарегистрировался как исполнитель, проходит верификацию | -| **Мастер** | Верифицированный исполнитель — частный специалист, самозанятый | -| **Компания** | Юридическое лицо, предоставляющее услуги через платформу | +Проект реализован как **Modular Monolith**. Каждый модуль — это изолированная единица со своей логикой, данными и API, взаимодействующая с другими через контракты BuildingBlocks. ---- - -## 🧱 Архитектура - -Проект реализован как **Modular Monolith** — единое приложение, разделённое на изолированные модули с чёткими границами. Каждый модуль содержит: +### Технологический стек +- **Runtime:** .NET 8 / ASP.NET Core +- **Database:** PostgreSQL + **PostGIS** (гео-запросы) +- **ORM:** Entity Framework Core +- **Messaging:** MediatR (In-process commands/queries) +- **Security:** JWT Authentication, Role-based Access Control +- **Spatial:** NetTopologySuite +### Структура модуля ``` Modules// -├── Application/ # Команды, запросы, DTO (MediatR) -├── Domain/ # Сущности, бизнес-логика, интерфейсы репозиториев -├── Infrastructure/ # Реализация репозиториев, EF Core DbContext -└── Presentation/ # HTTP Endpoints (Minimal API) +├── Domain/ # Сущности, Value Objects, Доменные события +├── Application/ # Use Cases (MediatR Handlers), DTOs, Mapping +├── Infrastructure/ # EF Core (Persistence), External Services +└── Presentation/ # Minimal API Endpoints ``` -### Модули +--- -| Модуль | Назначение | -|--------|------------| -| **Identity** | Регистрация, авторизация (JWT), профиль, роли, аватар, геолокация, расписание | -| **Catalog** | Управление услугами (CRUD), изображения, статусы, атрибуты | -| **Geo** | Геозоны, расчёт расстояний (PostGIS / NetTopologySuite) | -| **Search** | Полнотекстовый поиск по услугам и компетенциям с учётом геопозиции | -| **Reputation** | Отзывы и рейтинги (в разработке) | -| **Collaboration** | Заявки и взаимодействие клиент ↔ исполнитель (в разработке) | -| **Order** | Заказы и их жизненный цикл (в разработке) | +## 🚀 Реализованные Модули -### Host - -`Host/` — точка входа приложения. Здесь настраиваются DI, middleware, маршрутизация и сборка всех модулей. +| Модуль | Статус | Функционал | +|--------|--------|------------| +| **Identity** | ✅ Ready | Auth (JWT), Профили, Аватары (Base64), Расписание, Статусы доступности | +| **Catalog** | ✅ Ready | Управление услугами (CRUD), Multi-image (до 10 фото), Атрибуты | +| **Search** | ✅ Ready | Full-text search, сортировка по Geo-дистанции и релевантности | +| **Geo** | ✅ Ready | Расчет расстояний, Геозоны, Индексация координат | +| **Order** | ✅ Ready | Заказы (Direct/Public), SLA таймеры, Система споров (Disputes), Отклики | +| **Reputation** | ✅ Ready | Отзывы, Рейтинги (User/Offer), Дополнения к отзывам | +| **Collaboration**| ✅ Ready | HR-инструментарий: Найм, Наложение вето на расписание, Проверки | --- -## ✅ Что реализовано +## ⚙️ Ключевая Логика -### Аутентификация и профиль -- Регистрация и вход по логину/паролю (JWT токены) -- Смена пароля, обновление профиля -- Загрузка аватара (хранится как base64 в БД) -- Переход в роль исполнителя: описание, компетенции, геолокация +### 1. Умная доступность (Smart Status) +Мастер может управлять своей доступностью двумя способами: +- **Расписание:** Настройка рабочих дней и часов. +- **Manual Toggle:** Ручное переключение статуса «Готов к заказу». Ручная активация имеет приоритет и действует до конца текущего дня, перекрывая стандартное расписание. -### Геолокация -- Привязка основного адреса и текущего местоположения -- Расчёт расстояния до исполнителей с помощью **PostGIS + NetTopologySuite** -- Геозоны для фильтрации исполнителей в радиусе +### 2. Жизненный цикл заказа (Order Flow) +Реализована сложная машина состояний: +1. **Создание:** Прямой заказ мастеру или публикация заявки в общий доступ. +2. **SLA:** Для прямых заказов действует 60-минутный таймер на принятие. +3. **Исполнение:** Статусы "В работе", "Выполнено", "Подтверждено". +4. **Споры (Disputes):** Многоэтапный процесс разрешения конфликтов (Открытие -> Ответ мастера -> Возражение клиента -> Принятие условий). -### Услуги (Catalog) -- Создание, редактирование, удаление (soft delete) услуг -- Приостановка услуги (пауза) -- Загрузка до 10 изображений (≤5 МБ каждое), хранение в БД -- Атрибуты услуги (произвольные ключ-значение пары) -- Первое изображение является обложкой - -### Поиск -- Полнотекстовый поиск по: названию услуги, описанию, компетенциям -- Результаты сортируются с учётом расстояния и релевантности -- Исполнитель показывается в поиске только если у него нет найденных услуг (приоритет услуги над специалистом) -- Приостановленные и удалённые услуги не отображаются -- Возврат URL первого изображения услуги для превью в поиске - -### Расписание и статус доступности -- Настройка рабочих дней и «всегда готов» -- Умная логика статуса: ручное включение статуса «Готов к заказу» переопределяет расписание до конца текущего дня +### 3. Поиск и Геолокация +- Поиск учитывает не только текст, но и **расстояние**. +- В приоритете — конкретные услуги. Если мастер не имеет услуг, он показывается как специалист. +- Интеграция с PostGIS позволяет делать сверхбыстрые выборки в радиусе. --- -## 🗄️ База данных - -- **PostgreSQL** с расширением **PostGIS** (геопространственные запросы) -- **Entity Framework Core** (Code First, миграции) -- Структура: таблица `Accounts`, `Performers`, `Offers`, `Competencies` и др. - ---- - -## 🐳 Запуск через Docker +## 🐳 Развертывание +### Docker (Рекомендуемо) ```bash -# Запуск базы данных и бэкенда docker-compose up -d --build ``` +Это запустит: +- Бэкенд (порт 5000) +- PostgreSQL + PostGIS (порт 5432) -Настройки в `docker-compose.yml` (строка подключения, порты, переменные окружения). +### Локальный запуск +1. Установите PostgreSQL и расширение PostGIS. +2. Обновите строку подключения в `appsettings.json`. +3. Примените миграции: + ```bash + dotnet ef database update -p src/Host -s src/Host + ``` +4. Запустите Host: + ```bash + dotnet run --project src/Host + ``` ---- - -## ⚙️ Локальный запуск - -```bash -cd nashel-backend - -# Применить миграции -dotnet ef database update -p src/Host -s src/Host - -# Запустить -dotnet run --project src/Host -``` - -Swagger UI доступен по адресу: `http://localhost:5000/swagger` +Swagger доступен по адресу: `http://localhost:5000/swagger` --- ## 🔮 Планы развития -- [ ] Полный модуль **Collaboration**: чат между клиентом и исполнителем -- [ ] Модуль **Order**: жизненный цикл заказа, статусы, оплата -- [ ] Модуль **Reputation**: система отзывов и рейтингов (реальные данные вместо заглушек) -- [ ] Верификация документов исполнителя -- [ ] Push-уведомления -- [ ] Поддержка OAuth (VK, Google) -- [ ] Аналитика для исполнителей (просмотры, конверсии) +- [ ] **Real-time:** Интеграция WebSockets для чатов и уведомлений. +- [ ] **Verification:** Модуль проверки документов исполнителей. +- [ ] **Finances:** Интеграция платежных шлюзов. +- [ ] **Analytics:** Сбор метрик просмотров и конверсий для мастеров. +- [ ] **Mobile SDK:** API для нативных мобильных приложений. +- [ ] **Notifications:** Push и Email уведомления о статусах заказов. diff --git a/src/BuildingBlocks/Application/Configurations/SystemSettings.cs b/src/BuildingBlocks/Application/Configurations/SystemSettings.cs new file mode 100644 index 0000000..58ebc88 --- /dev/null +++ b/src/BuildingBlocks/Application/Configurations/SystemSettings.cs @@ -0,0 +1,55 @@ +namespace Nashel.BuildingBlocks.Application.Configurations; + +/// +/// Настройки работы с медиа-файлами. +/// +public class MediaSettings +{ + public const string SectionName = "MediaSettings"; + + /// Максимальный размер файла в байтах. + public long MaxFileSizeInBytes { get; set; } + + /// Максимальное количество изображений для одной услуги. + public int MaxImagesPerOffer { get; set; } + + /// Список допустимых расширений файлов. + public string[] AllowedExtensions { get; set; } = Array.Empty(); +} + +/// +/// Настройки модуля заказов. +/// +public class OrderSettings +{ + public const string SectionName = "OrderSettings"; + + /// Время на принятие прямого заказа (SLA) в минутах. + public int DirectOrderSlaMinutes { get; set; } + + /// Время жизни публичной заявки в минутах. + public int PublicOrderSlaMinutes { get; set; } + + /// Максимальное время на ответ в споре (в часах). + public int MaxDisputeResponseHours { get; set; } + + /// Максимальное число компетенций у исполнителя. + public int MaxCompetenciesPerPerformer { get; set; } +} + +/// +/// Настройки модуля Identity. +/// +public class IdentitySettings +{ + public const string SectionName = "IdentitySettings"; + + /// Максимальная длина описания профиля. + public int MaxDescriptionLength { get; set; } + + /// Минимальная длина описания для роли исполнителя. + public int MinPerformerDescriptionLength { get; set; } + + /// Максимальное число компетенций. + public int MaxCompetencies { get; set; } +} diff --git a/src/BuildingBlocks/Application/Exceptions/ApplicationExceptions.cs b/src/BuildingBlocks/Application/Exceptions/ApplicationExceptions.cs new file mode 100644 index 0000000..40fa8c9 --- /dev/null +++ b/src/BuildingBlocks/Application/Exceptions/ApplicationExceptions.cs @@ -0,0 +1,54 @@ +namespace Nashel.BuildingBlocks.Application.Exceptions; + +/// +/// Базовый класс для всех доменных исключений и исключений приложения. +/// +public abstract class ApplicationExceptionBase : Exception +{ + protected ApplicationExceptionBase(string message) : base(message) { } +} + +/// +/// Исключение, выбрасываемое, когда сущность не найдена. +/// +public class NotFoundException : ApplicationExceptionBase +{ + public NotFoundException(string name, object key) + : base($"Сущность \"{name}\" ({key}) не найдена.") { } +} + +/// +/// Исключение валидации данных. +/// +public class ValidationException : ApplicationExceptionBase +{ + public IDictionary Errors { get; } + + public ValidationException() + : base("Произошла одна или несколько ошибок валидации.") + { + Errors = new Dictionary(); + } + + public ValidationException(IDictionary errors) + : base("Произошла одна или несколько ошибок валидации.") + { + Errors = errors; + } +} + +/// +/// Исключение, выбрасываемое при нарушении бизнес-правил или конфликтах. +/// +public class BusinessRuleException : ApplicationExceptionBase +{ + public BusinessRuleException(string message) : base(message) { } +} + +/// +/// Исключение отсутствия прав доступа. +/// +public class ForbiddenAccessException : ApplicationExceptionBase +{ + public ForbiddenAccessException() : base("У вас недостаточно прав для выполнения этой операции.") { } +} diff --git a/src/BuildingBlocks/Domain/DomainException.cs b/src/BuildingBlocks/Domain/DomainException.cs new file mode 100644 index 0000000..7e0831d --- /dev/null +++ b/src/BuildingBlocks/Domain/DomainException.cs @@ -0,0 +1,9 @@ +namespace Nashel.BuildingBlocks.Domain; + +/// +/// Базовый класс для исключений доменного слоя. +/// +public abstract class DomainException : Exception +{ + protected DomainException(string message) : base(message) { } +} diff --git a/src/Host/Endpoints/SearchEndpoints.cs b/src/Host/Endpoints/SearchEndpoints.cs index 4327727..88bfafb 100644 --- a/src/Host/Endpoints/SearchEndpoints.cs +++ b/src/Host/Endpoints/SearchEndpoints.cs @@ -5,6 +5,7 @@ using Nashel.BuildingBlocks.Application.Abstractions; using Nashel.Modules.Catalog.Infrastructure.Persistence; using Nashel.Modules.Geo.Infrastructure.Persistence; using Nashel.Modules.Identity.Infrastructure.Persistence; +using Nashel.Modules.Reputation.Infrastructure.Persistence; using NetTopologySuite.Geometries; namespace Nashel.Host.Endpoints; @@ -17,6 +18,7 @@ public static class SearchEndpoints GeoDbContext geoDb, IdentityDbContext identityDb, CatalogDbContext catalogDb, + ReputationDbContext reputationDb, ICurrentUserService currentUserService, CancellationToken ct, [FromQuery] double lat = 0, // Убрали обязательность, чтобы опциональные работали нормально или перенесли в конец @@ -109,14 +111,31 @@ public static class SearchEndpoints var accountsDict = accountsRaw.ToDictionary(a => a.Id, a => a.Roles.Select(r => r.ToString()).ToList()); - // 4. Сборка результата и логика "Умного статуса" + // 4. Рейтинги (ReputationDbContext) + var performerRatings = await reputationDb.Reviews + .Where(r => profileIds.Contains(r.TargetId)) + .GroupBy(r => r.TargetId) + .Select(g => new { TargetId = g.Key, Avg = g.Average(r => r.Rating), Count = g.Count() }) + .ToDictionaryAsync(x => x.TargetId, x => new { x.Avg, x.Count }, ct); + + var offerIds = matchedOffers.Select(o => o.Id).ToList(); + var offerRatings = await reputationDb.Reviews + .Where(r => offerIds.Contains(r.OfferId)) + .GroupBy(r => r.OfferId) + .Select(g => new { OfferId = g.Key, Avg = g.Average(r => r.Rating), Count = g.Count() }) + .ToDictionaryAsync(x => x.OfferId, x => new { x.Avg, x.Count }, ct); + + // 5. Сборка результата и логика "Умного статуса" var currentDay = GetRussianDayOfWeek(DateTime.UtcNow.DayOfWeek); var currentTime = DateTime.UtcNow.TimeOfDay; var offersLookup = matchedOffers.GroupBy(o => o.PerformerId).ToDictionary(g => g.Key, g => g.ToList()); var result = profiles.Join(nearbyGeo, p => p.Id, g => g.Id, (p, g) => { - string fullName = !string.IsNullOrEmpty(p.CompanyName) ? p.CompanyName : $"{p.FirstName} {p.LastName}".Trim(); + var names = new[] { p.FirstName, p.LastName }.Where(x => !string.IsNullOrWhiteSpace(x)); + string fullName = string.Join(" ", names); + if (string.IsNullOrWhiteSpace(fullName)) + fullName = p.CompanyName ?? "Мастер"; string finalStatus = CalculateSmartStatus( g.State.ToString(), // Передаем реальный статус из Geo @@ -127,16 +146,26 @@ public static class SearchEndpoints currentTime ); - var matchedOffersList = offersLookup.TryGetValue(p.Id, out var o) - ? o.Select(x => (object)x).ToList() + var matchedOffersList = offersLookup.TryGetValue(p.Id, out var oList) + ? oList.Select(ox => (object)new + { + ox.Id, + ox.PerformerId, + ox.Title, + ox.Description, + ox.PriceAmount, + ox.PriceCurrency, + ox.ImageUrl, + Rating = offerRatings.TryGetValue(ox.Id, out var or) ? Math.Round(or.Avg, 1) : 0, + ReviewsCount = offerRatings.TryGetValue(ox.Id, out var orc) ? orc.Count : 0 + }).ToList() : new List(); var roles = accountsDict.TryGetValue(p.Id, out var r) ? r : new List(); - string roleName = ""; + string roleName = "Пользователь"; if (roles.Contains("Company")) roleName = "Компания"; else if (roles.Contains("Master")) roleName = "Мастер"; - else if (roles.Contains("Candidate")) roleName = "Кандидат в мастера"; - else roleName = "Пользователь"; + else if (roles.Contains("Candidate")) roleName = "Кандидат"; return new { @@ -148,6 +177,8 @@ public static class SearchEndpoints Status = finalStatus, // "Готов к заказу" или "Офлайн" Distance = Math.Round(g.DistanceMeters), Role = roleName, + Rating = performerRatings.TryGetValue(p.Id, out var pr) ? Math.Round(pr.Avg, 1) : 0, + ReviewsCount = performerRatings.TryGetValue(p.Id, out var prc) ? prc.Count : 0, MatchedCompetencies = p.Competencies .Where(c => string.IsNullOrWhiteSpace(qLower) || !inComp || c.Name.ToLower().Contains(qLower)) .Select(c => c.Name).ToList(), diff --git a/src/Host/Infrastructure/Exceptions/GlobalExceptionHandler.cs b/src/Host/Infrastructure/Exceptions/GlobalExceptionHandler.cs new file mode 100644 index 0000000..c595e14 --- /dev/null +++ b/src/Host/Infrastructure/Exceptions/GlobalExceptionHandler.cs @@ -0,0 +1,102 @@ +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; +using Nashel.BuildingBlocks.Application.Exceptions; + +namespace Nashel.Host.Infrastructure.Exceptions; + +/// +/// Глобальный обработчик исключений для всей системы. +/// Перехватывает ошибки и возвращает их в стандарте ProblemDetails (RFC 7807). +/// +public class GlobalExceptionHandler : IExceptionHandler +{ + private readonly ILogger _logger; + + public GlobalExceptionHandler(ILogger logger) + { + _logger = logger; + } + + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken) + { + _logger.LogError(exception, "Произошла необработанная ошибка: {Message}", exception.Message); + + var (statusCode, title, detail, errors) = MapException(exception); + + var problemDetails = new ProblemDetails + { + Status = statusCode, + Title = title, + Detail = detail, + Instance = $"{httpContext.Request.Method} {httpContext.Request.Path}" + }; + + if (errors != null) + { + problemDetails.Extensions["errors"] = errors; + } + + httpContext.Response.StatusCode = statusCode; + await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken); + + return true; + } + + private static (int StatusCode, string Title, string Detail, IDictionary? Errors) MapException(Exception exception) + { + return exception switch + { + ValidationException validationException => ( + StatusCodes.Status400BadRequest, + "Ошибка валидации", + "Один или несколько параметров запроса не прошли проверку.", + validationException.Errors), + + NotFoundException notFoundException => ( + StatusCodes.Status404NotFound, + "Ресурс не найден", + notFoundException.Message, + null), + + Nashel.BuildingBlocks.Domain.DomainException domainException => ( + StatusCodes.Status409Conflict, + "Ошибка в бизнес-логике", + domainException.Message, + null), + + BusinessRuleException businessRuleException => ( + StatusCodes.Status409Conflict, + "Нарушение бизнес-правила", + businessRuleException.Message, + null), + + ForbiddenAccessException => ( + StatusCodes.Status403Forbidden, + "Доступ запрещен", + "У вас недостаточно прав для выполнения этой операции.", + null), + + UnauthorizedAccessException => ( + StatusCodes.Status401Unauthorized, + "Неавторизован", + "Требуется авторизация для доступа к этому ресурсу.", + null), + + // Обработка стандартных ArgumentException как 400 Bad Request + ArgumentException argumentException => ( + StatusCodes.Status400BadRequest, + "Некорректный запрос", + argumentException.Message, + null), + + _ => ( + StatusCodes.Status500InternalServerError, + "Внутренняя ошибка сервера", + "Произошла непредвиденная ошибка на сервере. Пожалуйста, попробуйте позже.", + null) + }; + } +} diff --git a/src/Host/Program.cs b/src/Host/Program.cs index e56ab58..1da7def 100644 --- a/src/Host/Program.cs +++ b/src/Host/Program.cs @@ -24,6 +24,14 @@ builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()); }); +// Настройка Options +builder.Services.Configure( + builder.Configuration.GetSection(Nashel.BuildingBlocks.Application.Configurations.MediaSettings.SectionName)); +builder.Services.Configure( + builder.Configuration.GetSection(Nashel.BuildingBlocks.Application.Configurations.OrderSettings.SectionName)); +builder.Services.Configure( + builder.Configuration.GetSection(Nashel.BuildingBlocks.Application.Configurations.IdentitySettings.SectionName)); + // Добавление сервисов в контейнер. builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(options => @@ -96,6 +104,10 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) }; }); +// Регистрация глобального обработчика исключений +builder.Services.AddExceptionHandler(); +builder.Services.AddProblemDetails(); + builder.Services.AddAuthorization(); builder.Services.AddCors(options => @@ -111,30 +123,8 @@ builder.Services.AddCors(options => var app = builder.Build(); -// Middleware для обработки исключений -app.UseExceptionHandler(errorApp => -{ - errorApp.Run(async context => - { - context.Response.StatusCode = 500; - context.Response.ContentType = "application/json"; - - var exception = context.Features.Get(); - if (exception != null) - { - var error = new - { - Message = exception.Error.Message, - StackTrace = exception.Error.StackTrace - }; - - Console.WriteLine($"[ERROR] {exception.Error.Message}"); - Console.WriteLine(exception.Error.StackTrace); - - await context.Response.WriteAsJsonAsync(error); - } - }); -}); +// Глобальная обработка ошибок в формате ProblemDetails +app.UseExceptionHandler(); // Настройка конвейера HTTP-запросов. if (app.Environment.IsDevelopment()) diff --git a/src/Host/appsettings.json b/src/Host/appsettings.json index 7d187a8..e5f800a 100644 --- a/src/Host/appsettings.json +++ b/src/Host/appsettings.json @@ -13,5 +13,21 @@ "Secret": "super_secret_key_change_me_please_this_is_for_development_only_12345", "Issuer": "Nashel", "Audience": "Nashel" + }, + "MediaSettings": { + "MaxFileSizeInBytes": 5242880, + "MaxImagesPerOffer": 10, + "AllowedExtensions": [ ".jpg", ".jpeg", ".png", ".webp" ] + }, + "OrderSettings": { + "DirectOrderSlaMinutes": 60, + "PublicOrderSlaMinutes": 1440, + "MaxDisputeResponseHours": 72, + "MaxCompetenciesPerPerformer": 50 + }, + "IdentitySettings": { + "MaxDescriptionLength": 2048, + "MinPerformerDescriptionLength": 50, + "MaxCompetencies": 50 } } \ No newline at end of file diff --git a/src/Modules/Catalog/Application/Commands/CreateOfferCommand.cs b/src/Modules/Catalog/Application/Commands/CreateOfferCommand.cs index 3f36a18..97ebabd 100644 --- a/src/Modules/Catalog/Application/Commands/CreateOfferCommand.cs +++ b/src/Modules/Catalog/Application/Commands/CreateOfferCommand.cs @@ -1,6 +1,5 @@ using System.Text.Json; using MediatR; -using MediatR; using Nashel.BuildingBlocks.Application.Abstractions; using Nashel.BuildingBlocks.Domain; using Nashel.Modules.Catalog.Domain.Aggregates; diff --git a/src/Modules/Catalog/Application/Commands/UploadOfferImageCommand.cs b/src/Modules/Catalog/Application/Commands/UploadOfferImageCommand.cs index 4812b63..35ccb9d 100644 --- a/src/Modules/Catalog/Application/Commands/UploadOfferImageCommand.cs +++ b/src/Modules/Catalog/Application/Commands/UploadOfferImageCommand.cs @@ -1,33 +1,47 @@ using MediatR; +using Microsoft.Extensions.Options; using Nashel.BuildingBlocks.Application.Abstractions; +using Nashel.BuildingBlocks.Application.Configurations; +using Nashel.BuildingBlocks.Application.Exceptions; namespace Nashel.Modules.Catalog.Application.Commands; +/// +/// Команда загрузки изображения услуги. +/// public record UploadOfferImageCommand(byte[] Content, string FileName) : IRequest; public class UploadOfferImageCommandHandler : IRequestHandler { - private const long MaxFileSize = 5 * 1024 * 1024; // 5MB - private static readonly string[] AllowedExtensions = { ".jpg", ".jpeg", ".png", ".webp" }; - private readonly ICurrentUserService _currentUserService; + private readonly MediaSettings _settings; - public UploadOfferImageCommandHandler(ICurrentUserService currentUserService) + public UploadOfferImageCommandHandler( + ICurrentUserService currentUserService, + IOptions settings) { _currentUserService = currentUserService; + _settings = settings.Value; } public Task Handle(UploadOfferImageCommand request, CancellationToken cancellationToken) { var userId = _currentUserService.UserId; - if (userId == null) throw new UnauthorizedAccessException(); + if (userId == null) + { + throw new UnauthorizedAccessException(); + } - if (request.Content.Length > MaxFileSize) - throw new InvalidOperationException("Размер файла не должен превышать 5 МБ"); + if (request.Content.Length > _settings.MaxFileSizeInBytes) + { + throw new BusinessRuleException($"Размер файла слишком велик. Максимум: {_settings.MaxFileSizeInBytes / 1024 / 1024} МБ."); + } var extension = Path.GetExtension(request.FileName).ToLowerInvariant(); - if (Array.IndexOf(AllowedExtensions, extension) == -1) - throw new InvalidOperationException("Допустимые форматы: JPG, PNG, WEBP"); + if (!_settings.AllowedExtensions.Contains(extension)) + { + throw new BusinessRuleException($"Допустимые форматы: {string.Join(", ", _settings.AllowedExtensions)}."); + } var base64String = Convert.ToBase64String(request.Content); diff --git a/src/Modules/Catalog/Application/Common/Dtos.cs b/src/Modules/Catalog/Application/Common/Dtos.cs index 6f27b60..a63216d 100644 --- a/src/Modules/Catalog/Application/Common/Dtos.cs +++ b/src/Modules/Catalog/Application/Common/Dtos.cs @@ -95,7 +95,27 @@ public record OfferDto /// public List Images { get; init; } = new(); - public OfferDto(Guid id, Guid performerId, Guid categoryId, string title, string description, Price price, JsonDocument? attributes, bool isActive, List? images = null) + /// + /// Имя исполнителя. + /// + public string? PerformerName { get; init; } + + /// + /// Роль исполнителя (Мастер, Специалист и т.д.). + /// + public string? PerformerRole { get; init; } + + /// + /// Средний рейтинг услуги. + /// + public double Rating { get; init; } + + /// + /// Количество отзывов об услуге. + /// + public int ReviewsCount { get; init; } + + public OfferDto(Guid id, Guid performerId, Guid categoryId, string title, string description, Price price, JsonDocument? attributes, bool isActive, List? images = null, string? performerName = null, string? performerRole = null, double rating = 0, int reviewsCount = 0) { Id = id; PerformerId = performerId; @@ -109,6 +129,10 @@ public record OfferDto { Images.AddRange(images); } + PerformerName = performerName; + PerformerRole = performerRole; + Rating = rating; + ReviewsCount = reviewsCount; } public OfferDto() { } diff --git a/src/Modules/Catalog/Application/Nashel.Modules.Catalog.Application.csproj b/src/Modules/Catalog/Application/Nashel.Modules.Catalog.Application.csproj index e9112a5..3f667f8 100644 --- a/src/Modules/Catalog/Application/Nashel.Modules.Catalog.Application.csproj +++ b/src/Modules/Catalog/Application/Nashel.Modules.Catalog.Application.csproj @@ -2,6 +2,8 @@ + + diff --git a/src/Modules/Catalog/Application/Queries/GetOfferByIdQuery.cs b/src/Modules/Catalog/Application/Queries/GetOfferByIdQuery.cs index 407ee4d..c1f493a 100644 --- a/src/Modules/Catalog/Application/Queries/GetOfferByIdQuery.cs +++ b/src/Modules/Catalog/Application/Queries/GetOfferByIdQuery.cs @@ -13,16 +13,25 @@ public record GetOfferByIdQuery(Guid Id) : IRequest>; public class GetOfferByIdQueryHandler : IRequestHandler> { private readonly IOfferRepository _repository; + private readonly IMediator _mediator; - public GetOfferByIdQueryHandler(IOfferRepository repository) + public GetOfferByIdQueryHandler(IOfferRepository repository, IMediator mediator) { _repository = repository; + _mediator = mediator; } public async Task> Handle(GetOfferByIdQuery request, CancellationToken cancellationToken) { var offer = await _repository.GetByIdAsync(request.Id, cancellationToken); - if (offer == null) return Result.Failure("Offer not found"); + if (offer == null) + { + throw new Nashel.BuildingBlocks.Application.Exceptions.NotFoundException("Offer", request.Id); + } + + // Получаем данные исполнителя и рейтинги + var userDetailsResult = await _mediator.Send(new Nashel.Modules.Identity.Application.Queries.GetUserDetailsQuery(offer.PerformerId), cancellationToken); + var profileRatingResult = await _mediator.Send(new Nashel.Modules.Reputation.Application.Queries.GetProfileRatingQuery(offer.PerformerId), cancellationToken); var dto = new OfferDto( offer.Id, @@ -33,7 +42,11 @@ public class GetOfferByIdQueryHandler : IRequestHandler.Success(dto); diff --git a/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260306185654_AddOfferImagesAndSoftDelete.cs b/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260306185654_AddOfferImagesAndSoftDelete.cs index 5e92d5c..830740b 100644 --- a/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260306185654_AddOfferImagesAndSoftDelete.cs +++ b/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260306185654_AddOfferImagesAndSoftDelete.cs @@ -17,7 +17,7 @@ namespace Nashel.Modules.Catalog.Infrastructure.Persistence.Migrations table: "Offers", type: "text[]", nullable: false, - defaultValue: new string[0]); + defaultValueSql: "'{}'"); migrationBuilder.AddColumn( name: "IsDeleted", diff --git a/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260310145100_BackendUpdate_Catalog.Designer.cs b/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260310145100_BackendUpdate_Catalog.Designer.cs new file mode 100644 index 0000000..5eb871a --- /dev/null +++ b/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260310145100_BackendUpdate_Catalog.Designer.cs @@ -0,0 +1,148 @@ +// +using System; +using System.Collections.Generic; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nashel.Modules.Catalog.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nashel.Modules.Catalog.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(CatalogDbContext))] + [Migration("20260310145100_BackendUpdate_Catalog")] + partial class BackendUpdate_Catalog + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Nashel.Modules.Catalog.Domain.Aggregates.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttributeSchema") + .HasColumnType("jsonb"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Categories", "catalog"); + }); + + modelBuilder.Entity("Nashel.Modules.Catalog.Domain.Aggregates.Offer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attributes") + .HasColumnType("jsonb"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.PrimitiveCollection>("Images") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("PerformerId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Attributes"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Attributes"), "gin"); + + b.ToTable("Offers", "catalog"); + }); + + modelBuilder.Entity("Nashel.Modules.Catalog.Domain.Aggregates.Category", b => + { + b.HasOne("Nashel.Modules.Catalog.Domain.Aggregates.Category", null) + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("Nashel.Modules.Catalog.Domain.Aggregates.Offer", b => + { + b.OwnsOne("Nashel.Modules.Catalog.Domain.ValueObjects.Price", "Price", b1 => + { + b1.Property("OfferId") + .HasColumnType("uuid"); + + b1.Property("Amount") + .HasColumnType("numeric") + .HasColumnName("PriceAmount"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("PriceCurrency"); + + b1.Property("Type") + .HasColumnType("integer") + .HasColumnName("PriceType"); + + b1.HasKey("OfferId"); + + b1.ToTable("Offers", "catalog"); + + b1.WithOwner() + .HasForeignKey("OfferId"); + }); + + b.Navigation("Price") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260310145100_BackendUpdate_Catalog.cs b/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260310145100_BackendUpdate_Catalog.cs new file mode 100644 index 0000000..cfc69d1 --- /dev/null +++ b/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260310145100_BackendUpdate_Catalog.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nashel.Modules.Catalog.Infrastructure.Persistence.Migrations +{ + /// + public partial class BackendUpdate_Catalog : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/src/Modules/Catalog/Tests/Application/CreateCategoryCommandHandlerTests.cs b/src/Modules/Catalog/Tests/Application/CreateCategoryCommandHandlerTests.cs index 39ce86d..1c20342 100644 --- a/src/Modules/Catalog/Tests/Application/CreateCategoryCommandHandlerTests.cs +++ b/src/Modules/Catalog/Tests/Application/CreateCategoryCommandHandlerTests.cs @@ -1,5 +1,5 @@ using FluentAssertions; -using Moq; +using NSubstitute; using Nashel.Modules.Catalog.Application.Commands; using Nashel.Modules.Catalog.Domain.Aggregates; using Nashel.Modules.Catalog.Domain.Repositories; @@ -9,19 +9,20 @@ namespace Nashel.Modules.Catalog.Tests.Application; public class CreateCategoryCommandHandlerTests { - private readonly Mock _categoryRepositoryMock; + private readonly ICategoryRepository _categoryRepository; private readonly CreateCategoryCommandHandler _handler; public CreateCategoryCommandHandlerTests() { - _categoryRepositoryMock = new Mock(); - _handler = new CreateCategoryCommandHandler(_categoryRepositoryMock.Object); + // Инициализация мока через NSubstitute + _categoryRepository = Substitute.For(); + _handler = new CreateCategoryCommandHandler(_categoryRepository); } [Fact] public async Task Handle_ShouldCreateCategory_WhenCommandIsValid() { - // Arrange + // Arrange (Подготовка) var command = new CreateCategoryCommand( "Test Category", "test-category", @@ -29,16 +30,19 @@ public class CreateCategoryCommandHandlerTests null ); - // Act + // Act (Действие) var result = await _handler.Handle(command, CancellationToken.None); - // Assert - result.Should().Be(result); // Guid should be returned - _categoryRepositoryMock.Verify(r => r.AddAsync(It.Is(c => - c.Title == command.Title && + // Assert (Проверка) + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeEmpty(); + + // Проверяем вызов репозитория + await _categoryRepository.Received(1).AddAsync(Arg.Is(c => + c.Name == command.Name && c.Slug == command.Slug && c.ParentId == command.ParentId && c.AttributeSchema == command.AttributeSchema - ), It.IsAny()), Times.Once); + ), Arg.Any()); } } diff --git a/src/Modules/Catalog/Tests/Application/CreateOfferCommandHandlerTests.cs b/src/Modules/Catalog/Tests/Application/CreateOfferCommandHandlerTests.cs index 0ca2a6f..b795fe8 100644 --- a/src/Modules/Catalog/Tests/Application/CreateOfferCommandHandlerTests.cs +++ b/src/Modules/Catalog/Tests/Application/CreateOfferCommandHandlerTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; -using Moq; +using NSubstitute; +using Nashel.BuildingBlocks.Application.Abstractions; using Nashel.Modules.Catalog.Application.Commands; using Nashel.Modules.Catalog.Domain.Aggregates; using Nashel.Modules.Catalog.Domain.Enums; @@ -12,42 +13,52 @@ namespace Nashel.Modules.Catalog.Tests.Application; public class CreateOfferCommandHandlerTests { - private readonly Mock _offerRepositoryMock; + private readonly IOfferRepository _offerRepository; + private readonly ICurrentUserService _currentUserService; private readonly CreateOfferCommandHandler _handler; public CreateOfferCommandHandlerTests() { - _offerRepositoryMock = new Mock(); - _handler = new CreateOfferCommandHandler(_offerRepositoryMock.Object); + // Инициализация моков через NSubstitute + _offerRepository = Substitute.For(); + _currentUserService = Substitute.For(); + _handler = new CreateOfferCommandHandler(_offerRepository, _currentUserService); } [Fact] public async Task Handle_ShouldCreateOffer_WhenCommandIsValid() { - // Arrange + // Arrange (Подготовка) + var userId = Guid.NewGuid(); + var categoryId = Guid.NewGuid(); var command = new CreateOfferCommand( - Guid.NewGuid(), - Guid.NewGuid(), + categoryId, "Test Offer", "Test Description", new Price(100, OfferType.Fixed, "RUB"), - JsonDocument.Parse("{}") + JsonDocument.Parse("{}"), + null ); - // Act + _currentUserService.UserId.Returns(userId); + + // Act (Действие) var result = await _handler.Handle(command, CancellationToken.None); - // Assert - result.Should().Be(result); // Guid should be returned - _offerRepositoryMock.Verify(r => r.AddAsync(It.Is(o => + // Assert (Проверка) + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeEmpty(); + + // Проверяем, что метод AddAsync был вызван ровно один раз с правильными параметрами + await _offerRepository.Received(1).AddAsync(Arg.Is(o => o.Title == command.Title && - o.OwnerId == command.OwnerId && + o.PerformerId == userId && o.CategoryId == command.CategoryId && o.Description == command.Description && o.Price.Amount == command.Price.Amount && o.Price.Type == command.Price.Type && o.Price.Currency == command.Price.Currency && o.Attributes == command.Attributes - ), It.IsAny()), Times.Once); + ), Arg.Any()); } } diff --git a/src/Modules/Catalog/Tests/Application/GetCategoriesQueryHandlerTests.cs b/src/Modules/Catalog/Tests/Application/GetCategoriesQueryHandlerTests.cs index 7a54ed0..4da61e2 100644 --- a/src/Modules/Catalog/Tests/Application/GetCategoriesQueryHandlerTests.cs +++ b/src/Modules/Catalog/Tests/Application/GetCategoriesQueryHandlerTests.cs @@ -1,5 +1,5 @@ using FluentAssertions; -using Moq; +using NSubstitute; using Nashel.Modules.Catalog.Application.Common; using Nashel.Modules.Catalog.Application.Queries; using Nashel.Modules.Catalog.Domain.Aggregates; @@ -11,57 +11,58 @@ namespace Nashel.Modules.Catalog.Tests.Application; public class GetCategoriesQueryHandlerTests { - private readonly Mock _categoryRepositoryMock; + private readonly ICategoryRepository _categoryRepository; private readonly GetCategoriesQueryHandler _handler; public GetCategoriesQueryHandlerTests() { - _categoryRepositoryMock = new Mock(); - _handler = new GetCategoriesQueryHandler(_categoryRepositoryMock.Object); + // Инициализация мока репозитория через NSubstitute + _categoryRepository = Substitute.For(); + _handler = new GetCategoriesQueryHandler(_categoryRepository); } [Fact] public async Task Handle_ShouldReturnCategories_WhenCategoriesExist() { - // Arrange + // Arrange (Подготовка) var categories = new List { new Category("Category 1", "category-1", null), new Category("Category 2", "category-2", null) }; - _categoryRepositoryMock.Setup(r => r.GetAllAsync(It.IsAny())) - .ReturnsAsync(categories); + _categoryRepository.GetAllAsync(Arg.Any()) + .Returns(categories); var query = new GetCategoriesQuery(); - // Act + // Act (Действие) var result = await _handler.Handle(query, CancellationToken.None); - // Assert - result.Should().NotBeNull(); - result.Should().HaveCount(2); + // Assert (Проверка) + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value.Should().HaveCount(2); - var expectedDto1 = new CategoryDto(categories[0].Id, categories[0].Title, categories[0].Slug, categories[0].ParentId, categories[0].AttributeSchema); - var expectedDto2 = new CategoryDto(categories[1].Id, categories[1].Title, categories[1].Slug, categories[1].ParentId, categories[1].AttributeSchema); - - result.Should().ContainEquivalentOf(expectedDto1); - result.Should().ContainEquivalentOf(expectedDto2); + // Сверяем DTO с данными из доменных сущностей + result.Value.Should().ContainEquivalentOf(new { Name = "Category 1", Slug = "category-1" }); + result.Value.Should().ContainEquivalentOf(new { Name = "Category 2", Slug = "category-2" }); } [Fact] public async Task Handle_ShouldReturnEmptyList_WhenNoCategoriesExist() { - // Arrange - _categoryRepositoryMock.Setup(r => r.GetAllAsync(It.IsAny())) - .ReturnsAsync(new List()); + // Arrange (Подготовка) + _categoryRepository.GetAllAsync(Arg.Any()) + .Returns(new List()); var query = new GetCategoriesQuery(); - // Act + // Act (Действие) var result = await _handler.Handle(query, CancellationToken.None); - // Assert - result.Should().NotBeNull(); - result.Should().BeEmpty(); + // Assert (Проверка) + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value.Should().BeEmpty(); } } diff --git a/src/Modules/Catalog/Tests/Application/GetOfferByIdQueryHandlerTests.cs b/src/Modules/Catalog/Tests/Application/GetOfferByIdQueryHandlerTests.cs index d4409e2..be8996b 100644 --- a/src/Modules/Catalog/Tests/Application/GetOfferByIdQueryHandlerTests.cs +++ b/src/Modules/Catalog/Tests/Application/GetOfferByIdQueryHandlerTests.cs @@ -1,11 +1,13 @@ using FluentAssertions; -using Moq; +using NSubstitute; +using MediatR; using Nashel.Modules.Catalog.Application.Common; using Nashel.Modules.Catalog.Application.Queries; using Nashel.Modules.Catalog.Domain.Aggregates; using Nashel.Modules.Catalog.Domain.Repositories; using Nashel.Modules.Catalog.Domain.ValueObjects; using Nashel.Modules.Catalog.Domain.Enums; +using Nashel.BuildingBlocks.Application.Exceptions; using System.Text.Json; using Xunit; @@ -13,20 +15,21 @@ namespace Nashel.Modules.Catalog.Tests.Application; public class GetOfferByIdQueryHandlerTests { - private readonly Mock _offerRepositoryMock; + private readonly IOfferRepository _offerRepository; + private readonly IMediator _mediator; private readonly GetOfferByIdQueryHandler _handler; public GetOfferByIdQueryHandlerTests() { - _offerRepositoryMock = new Mock(); - _handler = new GetOfferByIdQueryHandler(_offerRepositoryMock.Object); + _offerRepository = Substitute.For(); + _mediator = Substitute.For(); + _handler = new GetOfferByIdQueryHandler(_offerRepository, _mediator); } [Fact] public async Task Handle_ShouldReturnOffer_WhenOfferExists() { - // Arrange - var offerId = Guid.NewGuid(); + // Arrange (Подготовка) var offer = new Offer( Guid.NewGuid(), Guid.NewGuid(), @@ -36,44 +39,40 @@ public class GetOfferByIdQueryHandlerTests JsonDocument.Parse("{}") ); - // Reflection hack to set the ID as it's private set in constructor (Guid.NewGuid()) - // But wait, the constructor sets a new GUID. - // We need to mock the repository to return the offer when GetByIdAsync is called with the offer's generated ID. - // Or cleaner: modify the repository setup to ignore the specific ID argument or capture it? - // No, let's just use the ID generated by the offer. + _offerRepository.GetByIdAsync(offer.Id, Arg.Any()) + .Returns(offer); - _offerRepositoryMock.Setup(r => r.GetByIdAsync(offer.Id, It.IsAny())) - .ReturnsAsync(offer); + // Настройка мока Mediator для внутренних запросов + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Nashel.BuildingBlocks.Domain.Result.Success( + new Nashel.Modules.Identity.Application.Queries.UserDetailsDto(offer.PerformerId, "Исполнитель", null, "Мастер"))); + + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new Nashel.Modules.Reputation.Application.Queries.ProfileRatingDto(4.5, 10)); var query = new GetOfferByIdQuery(offer.Id); - // Act + // Act (Действие) var result = await _handler.Handle(query, CancellationToken.None); - // Assert - result.Should().NotBeNull(); - result.Id.Should().Be(offer.Id); - result.Title.Should().Be("Test Offer"); - result.Description.Should().Be("Test Description"); - result.Price.Should().BeEquivalentTo(offer.Price); - result.OwnerId.Should().Be(offer.OwnerId); - result.CategoryId.Should().Be(offer.CategoryId); - result.Attributes.Should().Be(offer.Attributes); + // Assert (Проверка) + result.IsSuccess.Should().BeTrue(); + result.Value.Id.Should().Be(offer.Id); + result.Value.Title.Should().Be("Test Offer"); + result.Value.Rating.Should().Be(4.5); } [Fact] - public async Task Handle_ShouldReturnNull_WhenOfferDoesNotExist() + public async Task Handle_ShouldThrowNotFoundException_WhenOfferDoesNotExist() { - // Arrange - _offerRepositoryMock.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync((Offer?)null); + // Arrange (Подготовка) + _offerRepository.GetByIdAsync(Arg.Any(), Arg.Any()) + .Returns((Offer?)null); var query = new GetOfferByIdQuery(Guid.NewGuid()); - // Act - var result = await _handler.Handle(query, CancellationToken.None); - - // Assert - result.Should().BeNull(); + // Act & Assert (Действие и Проверка) + var act = async () => await _handler.Handle(query, CancellationToken.None); + await act.Should().ThrowAsync(); } } diff --git a/src/Modules/Catalog/Tests/Domain/CategoryTests.cs b/src/Modules/Catalog/Tests/Domain/CategoryTests.cs index 2f44dd4..9c533b6 100644 --- a/src/Modules/Catalog/Tests/Domain/CategoryTests.cs +++ b/src/Modules/Catalog/Tests/Domain/CategoryTests.cs @@ -10,30 +10,30 @@ public class CategoryTests [Fact] public void Constructor_ShouldSetPropertiesCorrectly() { - // Assemble + // Arrange (Подготовка) var title = "Test Category"; var slug = "test-category"; var parentId = Guid.NewGuid(); var jsonDoc = JsonDocument.Parse("{}"); - // Act + // Act (Действие) var category = new Category(title, slug, parentId, jsonDoc); - // Assert + // Assert (Проверка) category.Id.Should().NotBeEmpty(); - category.Title.Should().Be(title); + category.Name.Should().Be(title); category.Slug.Should().Be(slug); category.ParentId.Should().Be(parentId); - category.AttributeSchema.Should().Be(jsonDoc); + category.AttributeSchema.Should().NotBeNull(); } [Fact] public void Constructor_ShouldSetDefaultValues_WhenOptionalParametersAreNull() { - // Act + // Act (Действие) var category = new Category("Test", "test", null, null); - // Assert + // Assert (Проверка) category.ParentId.Should().BeNull(); category.AttributeSchema.Should().BeNull(); } diff --git a/src/Modules/Catalog/Tests/Domain/OfferTests.cs b/src/Modules/Catalog/Tests/Domain/OfferTests.cs index 4c0f450..220c1d8 100644 --- a/src/Modules/Catalog/Tests/Domain/OfferTests.cs +++ b/src/Modules/Catalog/Tests/Domain/OfferTests.cs @@ -12,25 +12,25 @@ public class OfferTests [Fact] public void Constructor_ShouldSetPropertiesCorrectly() { - // Assemble - var ownerId = Guid.NewGuid(); + // Arrange (Подготовка) + var performerId = Guid.NewGuid(); var categoryId = Guid.NewGuid(); var title = "Test Offer"; var description = "Test Description"; var price = new Price(100, OfferType.Fixed, "RUB"); var attributes = JsonDocument.Parse("{}"); - // Act - var offer = new Offer(ownerId, categoryId, title, description, price, attributes); + // Act (Действие) + var offer = new Offer(performerId, categoryId, title, description, price, attributes); - // Assert + // Assert (Проверка) offer.Id.Should().NotBeEmpty(); - offer.OwnerId.Should().Be(ownerId); + offer.PerformerId.Should().Be(performerId); offer.CategoryId.Should().Be(categoryId); offer.Title.Should().Be(title); offer.Description.Should().Be(description); - offer.Price.Should().Be(price); - offer.Attributes.Should().Be(attributes); + offer.Price.Should().BeEquivalentTo(price); + offer.Attributes.Should().NotBeNull(); offer.IsActive.Should().BeTrue(); } } diff --git a/src/Modules/Catalog/Tests/Domain/PriceTests.cs b/src/Modules/Catalog/Tests/Domain/PriceTests.cs index a4e98e9..1539958 100644 --- a/src/Modules/Catalog/Tests/Domain/PriceTests.cs +++ b/src/Modules/Catalog/Tests/Domain/PriceTests.cs @@ -10,15 +10,15 @@ public class PriceTests [Fact] public void Constructor_ShouldSetPropertiesCorrectly() { - // Assemble + // Arrange (Подготовка) var amount = 100m; var type = OfferType.Fixed; var currency = "USD"; - // Act + // Act (Действие) var price = new Price(amount, type, currency); - // Assert + // Assert (Проверка) price.Amount.Should().Be(amount); price.Type.Should().Be(type); price.Currency.Should().Be(currency); @@ -27,32 +27,32 @@ public class PriceTests [Fact] public void Constructor_ShouldSetDefaultCurrency_WhenNotProvided() { - // Act + // Act (Действие) var price = new Price(100m, OfferType.Fixed); - // Assert + // Assert (Проверка) price.Currency.Should().Be("RUB"); } [Fact] public void Equality_ShouldBeTrue_WhenValuesAreSame() { - // Arrange + // Arrange (Подготовка) var price1 = new Price(100m, OfferType.Fixed, "USD"); var price2 = new Price(100m, OfferType.Fixed, "USD"); - // Act & Assert + // Act & Assert (Действие и Проверка) price1.Should().Be(price2); } [Fact] public void Equality_ShouldBeFalse_WhenValuesAreDifferent() { - // Arrange + // Arrange (Подготовка) var price1 = new Price(100m, OfferType.Fixed, "USD"); var price2 = new Price(200m, OfferType.Fixed, "USD"); - // Act & Assert + // Act & Assert (Действие и Проверка) price1.Should().NotBe(price2); } } diff --git a/src/Modules/Catalog/Tests/Nashel.Modules.Catalog.Tests.csproj b/src/Modules/Catalog/Tests/Nashel.Modules.Catalog.Tests.csproj index a5d8f9e..7dd6f38 100644 --- a/src/Modules/Catalog/Tests/Nashel.Modules.Catalog.Tests.csproj +++ b/src/Modules/Catalog/Tests/Nashel.Modules.Catalog.Tests.csproj @@ -13,7 +13,7 @@ - + diff --git a/src/Modules/Collaboration/Infrastructure/Migrations/20260310145106_BackendUpdate_Collaboration.Designer.cs b/src/Modules/Collaboration/Infrastructure/Migrations/20260310145106_BackendUpdate_Collaboration.Designer.cs new file mode 100644 index 0000000..9b8862f --- /dev/null +++ b/src/Modules/Collaboration/Infrastructure/Migrations/20260310145106_BackendUpdate_Collaboration.Designer.cs @@ -0,0 +1,104 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nashel.Modules.Collaboration.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nashel.Modules.Collaboration.Infrastructure.Migrations +{ + [DbContext(typeof(CollaborationDbContext))] + [Migration("20260310145106_BackendUpdate_Collaboration")] + partial class BackendUpdate_Collaboration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("collaboration") + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Nashel.Modules.Collaboration.Domain.Entities.Contract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("EmployerId") + .HasColumnType("uuid"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Contracts", "collaboration"); + }); + + modelBuilder.Entity("Nashel.Modules.Collaboration.Domain.Entities.Contract", b => + { + b.OwnsMany("Nashel.Modules.Collaboration.Domain.Entities.VetoLock", "VetoLocks", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b1.Property("ContractId") + .HasColumnType("uuid"); + + b1.HasKey("Id"); + + b1.HasIndex("ContractId"); + + b1.ToTable("VetoLocks", "collaboration"); + + b1.WithOwner() + .HasForeignKey("ContractId"); + + b1.OwnsOne("Nashel.Modules.Collaboration.Domain.ValueObjects.DateRange", "Period", b2 => + { + b2.Property("VetoLockId") + .HasColumnType("uuid"); + + b2.Property("End") + .HasColumnType("timestamp with time zone") + .HasColumnName("VetoEnd"); + + b2.Property("Start") + .HasColumnType("timestamp with time zone") + .HasColumnName("VetoStart"); + + b2.HasKey("VetoLockId"); + + b2.ToTable("VetoLocks", "collaboration"); + + b2.WithOwner() + .HasForeignKey("VetoLockId"); + }); + + b1.Navigation("Period") + .IsRequired(); + }); + + b.Navigation("VetoLocks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Collaboration/Infrastructure/Migrations/20260310145106_BackendUpdate_Collaboration.cs b/src/Modules/Collaboration/Infrastructure/Migrations/20260310145106_BackendUpdate_Collaboration.cs new file mode 100644 index 0000000..eae03c5 --- /dev/null +++ b/src/Modules/Collaboration/Infrastructure/Migrations/20260310145106_BackendUpdate_Collaboration.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nashel.Modules.Collaboration.Infrastructure.Migrations +{ + /// + public partial class BackendUpdate_Collaboration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/src/Modules/Collaboration/Tests/Domain/ContractTests.cs b/src/Modules/Collaboration/Tests/Domain/ContractTests.cs index 64d6187..328a5f1 100644 --- a/src/Modules/Collaboration/Tests/Domain/ContractTests.cs +++ b/src/Modules/Collaboration/Tests/Domain/ContractTests.cs @@ -10,14 +10,14 @@ public class ContractTests [Fact] public void ImposeVeto_Should_AddLock_When_ContractIsActive() { - // Arrange + // Arrange (Подготовка) var contract = Contract.Create(Guid.NewGuid(), Guid.NewGuid(), "Прораб"); var period = new DateRange(DateTime.UtcNow, DateTime.UtcNow.AddDays(1)); - // Act + // Act (Действие) contract.ImposeVeto(period); - // Assert + // Assert (Проверка) contract.VetoLocks.Should().ContainSingle() .Which.Period.Should().Be(period); } @@ -25,15 +25,15 @@ public class ContractTests [Fact] public void ImposeVeto_Should_Fail_When_ContractIsTerminated() { - // Arrange + // Arrange (Подготовка) var contract = Contract.Create(Guid.NewGuid(), Guid.NewGuid(), "Прораб"); contract.Terminate(); var period = new DateRange(DateTime.UtcNow, DateTime.UtcNow.AddDays(1)); - // Act + // Act (Действие) var action = () => contract.ImposeVeto(period); - // Assert + // Assert (Проверка) action.Should().Throw() .WithMessage("Нельзя наложить вето на расторгнутый контракт."); } @@ -41,10 +41,10 @@ public class ContractTests [Fact] public void Contract_Should_Be_Active_Upon_Creation() { - // Arrange & Act + // Arrange & Act (Подготовка и Действие) var contract = Contract.Create(Guid.NewGuid(), Guid.NewGuid(), "Мастер"); - // Assert + // Assert (Проверка) contract.Status.Should().Be(Nashel.Modules.Collaboration.Domain.Enums.ContractStatus.Active); } } diff --git a/src/Modules/Collaboration/Tests/Tests.csproj b/src/Modules/Collaboration/Tests/Nashel.Modules.Collaboration.Tests.csproj similarity index 89% rename from src/Modules/Collaboration/Tests/Tests.csproj rename to src/Modules/Collaboration/Tests/Nashel.Modules.Collaboration.Tests.csproj index 48e48fb..dc93c46 100644 --- a/src/Modules/Collaboration/Tests/Tests.csproj +++ b/src/Modules/Collaboration/Tests/Nashel.Modules.Collaboration.Tests.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -11,7 +11,7 @@ - + diff --git a/src/Modules/Collaboration/Tests/Services/ScheduleConflictServiceTests.cs b/src/Modules/Collaboration/Tests/Services/ScheduleConflictServiceTests.cs index 8227942..a048d91 100644 --- a/src/Modules/Collaboration/Tests/Services/ScheduleConflictServiceTests.cs +++ b/src/Modules/Collaboration/Tests/Services/ScheduleConflictServiceTests.cs @@ -1,5 +1,5 @@ using FluentAssertions; -using Moq; +using NSubstitute; using Nashel.Modules.Collaboration.Domain.Entities; using Nashel.Modules.Collaboration.Domain.Repositories; using Nashel.Modules.Collaboration.Domain.Services; @@ -10,19 +10,20 @@ namespace Nashel.Modules.Collaboration.Tests.Services; public class ScheduleConflictServiceTests { - private readonly Mock _repositoryMock; + private readonly IContractRepository _repository; private readonly ScheduleConflictService _service; public ScheduleConflictServiceTests() { - _repositoryMock = new Mock(); - _service = new ScheduleConflictService(_repositoryMock.Object); + // Инициализация мока через NSubstitute + _repository = Substitute.For(); + _service = new ScheduleConflictService(_repository); } [Fact] public async Task CheckAvailability_Should_ReturnFailure_When_VetoOverlaps() { - // 1. Arrange + // Arrange (Подготовка) var employeeId = Guid.NewGuid(); var employerId = Guid.NewGuid(); @@ -36,24 +37,24 @@ public class ScheduleConflictServiceTests var contractB = Contract.Create(Guid.NewGuid(), employeeId, "Электрик"); // Мокаем репозиторий - _repositoryMock.Setup(r => r.GetAllByEmployeeIdAsync(employeeId, It.IsAny())) - .ReturnsAsync(new List { contractA, contractB }); + _repository.GetAllByEmployeeIdAsync(employeeId, Arg.Any()) + .Returns(new List { contractA, contractB }); // Период проверки: 03.05 (попадает в диапазон) var requestedDate = new DateTime(2025, 5, 3); var requestedPeriod = new DateRange(requestedDate, requestedDate.AddHours(4)); - // 2. Act + // Act (Действие) var isAvailable = await _service.CheckAvailability(employeeId, requestedPeriod); - // 3. Assert + // Assert (Проверка) isAvailable.Should().BeFalse("Сотрудник должен быть занят из-за Вето"); } [Fact] public async Task CheckAvailability_Should_ReturnSuccess_When_NoVetoOverlap() { - // 1. Arrange + // Arrange (Подготовка) var employeeId = Guid.NewGuid(); var employerId = Guid.NewGuid(); @@ -63,17 +64,17 @@ public class ScheduleConflictServiceTests var vetoEnd = new DateTime(2025, 5, 5); contractA.ImposeVeto(new DateRange(vetoStart, vetoEnd)); - _repositoryMock.Setup(r => r.GetAllByEmployeeIdAsync(employeeId, It.IsAny())) - .ReturnsAsync(new List { contractA }); + _repository.GetAllByEmployeeIdAsync(employeeId, Arg.Any()) + .Returns(new List { contractA }); // Период проверки: 06.05 (Свободен) var requestedDate = new DateTime(2025, 5, 6); var requestedPeriod = new DateRange(requestedDate, requestedDate.AddHours(4)); - // 2. Act + // Act (Действие) var isAvailable = await _service.CheckAvailability(employeeId, requestedPeriod); - // 3. Assert + // Assert (Проверка) isAvailable.Should().BeTrue("Сотрудник должен быть доступен за пределами Вето"); } } diff --git a/src/Modules/Geo/Application/Queries/GetPerformerStatusQuery.cs b/src/Modules/Geo/Application/Queries/GetPerformerStatusQuery.cs new file mode 100644 index 0000000..f2aaa17 --- /dev/null +++ b/src/Modules/Geo/Application/Queries/GetPerformerStatusQuery.cs @@ -0,0 +1,43 @@ +using MediatR; +using Nashel.Modules.Geo.Domain.Enums; +using Nashel.Modules.Geo.Domain.Repositories; + +namespace Nashel.Modules.Geo.Application.Queries; + +public record GetPerformerStatusQuery(Guid PerformerId) : IRequest; + +public record PerformerStatusDto( + Guid PerformerId, + string Status, // "Available", "Offline", "OnJob" + double? Latitude, + double? Longitude, + DateTime? LastUpdatedAt +); + +public class GetPerformerStatusHandler : IRequestHandler +{ + private readonly IGeoRepository _repository; + + public GetPerformerStatusHandler(IGeoRepository repository) + { + _repository = repository; + } + + public async Task Handle(GetPerformerStatusQuery request, CancellationToken cancellationToken) + { + var status = await _repository.GetByPerformerIdAsync(request.PerformerId, cancellationToken); + + if (status == null) + { + return new PerformerStatusDto(request.PerformerId, "Offline", null, null, null); + } + + return new PerformerStatusDto( + status.Id, + status.State.ToString(), + status.Location?.Coordinate.Y, + status.Location?.Coordinate.X, + status.LastUpdatedAt + ); + } +} diff --git a/src/Modules/Geo/Infrastructure/Migrations/20260310144949_BackendUpdate_Geo.Designer.cs b/src/Modules/Geo/Infrastructure/Migrations/20260310144949_BackendUpdate_Geo.Designer.cs new file mode 100644 index 0000000..db22dea --- /dev/null +++ b/src/Modules/Geo/Infrastructure/Migrations/20260310144949_BackendUpdate_Geo.Designer.cs @@ -0,0 +1,59 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nashel.Modules.Geo.Infrastructure.Persistence; +using NetTopologySuite.Geometries; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nashel.Modules.Geo.Infrastructure.Migrations +{ + [DbContext(typeof(GeoDbContext))] + [Migration("20260310144949_BackendUpdate_Geo")] + partial class BackendUpdate_Geo + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Nashel.Modules.Geo.Domain.Aggregates.LiveStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("LastUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Location") + .IsRequired() + .HasColumnType("geography (point)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("Location"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Location"), "gist"); + + b.ToTable("LiveStatuses", "geo"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Geo/Infrastructure/Migrations/20260310144949_BackendUpdate_Geo.cs b/src/Modules/Geo/Infrastructure/Migrations/20260310144949_BackendUpdate_Geo.cs new file mode 100644 index 0000000..726e65d --- /dev/null +++ b/src/Modules/Geo/Infrastructure/Migrations/20260310144949_BackendUpdate_Geo.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nashel.Modules.Geo.Infrastructure.Migrations +{ + /// + public partial class BackendUpdate_Geo : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/src/Modules/Geo/Tests/Application/UpdateGeoCommandHandlerTests.cs b/src/Modules/Geo/Tests/Application/UpdateGeoCommandHandlerTests.cs index bc870e7..0c33e20 100644 --- a/src/Modules/Geo/Tests/Application/UpdateGeoCommandHandlerTests.cs +++ b/src/Modules/Geo/Tests/Application/UpdateGeoCommandHandlerTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; -using Moq; +using MediatR; +using NSubstitute; using Nashel.Modules.Geo.Application.Commands; using Nashel.Modules.Geo.Domain.Aggregates; using Nashel.Modules.Geo.Domain.Enums; @@ -8,60 +9,60 @@ using Xunit; namespace Nashel.Modules.Geo.Tests.Application; -public class UpdateGeoCommandHandlerTests +public class UpdateStatusCommandHandlerTests { - private readonly Mock _repositoryMock; - private readonly UpdateGeoCommandHandler _handler; + private readonly IGeoRepository _repository; + private readonly UpdateStatusCommandHandler _handler; - public UpdateGeoCommandHandlerTests() + public UpdateStatusCommandHandlerTests() { - _repositoryMock = new Mock(); - _handler = new UpdateGeoCommandHandler(_repositoryMock.Object); + _repository = Substitute.For(); + _handler = new UpdateStatusCommandHandler(_repository); } [Fact] public async Task Handle_Should_CreateNew_When_RecordDoesNotExist() { - // Arrange - var command = new UpdateGeoCommand(Guid.NewGuid(), 10.0, 20.0, PerformerStatus.Available); + // Arrange (Подготовка) + var command = new UpdateStatusCommand(Guid.NewGuid(), 10.0, 20.0, PerformerStatus.Available); - _repositoryMock.Setup(x => x.GetByPerformerIdAsync(command.PerformerId, It.IsAny())) - .ReturnsAsync((LiveStatus?)null); + _repository.GetByPerformerIdAsync(command.PerformerId, Arg.Any()) + .Returns((LiveStatus?)null); - // Act + // Act (Действие) await _handler.Handle(command, CancellationToken.None); - // Assert - _repositoryMock.Verify(x => x.AddAsync( - It.Is(s => + // Assert (Проверка) + await _repository.Received(1).AddAsync( + Arg.Is(s => s.Id == command.PerformerId && s.Location.Y == command.Latitude && s.Location.X == command.Longitude), - It.IsAny()), Times.Once); + Arg.Any()); - _repositoryMock.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once); + await _repository.Received(1).SaveChangesAsync(Arg.Any()); } [Fact] public async Task Handle_Should_UpdateExisting_When_RecordExists() { - // Arrange + // Arrange (Подготовка) var performerId = Guid.NewGuid(); var existingStatus = new LiveStatus(performerId, 10.0, 20.0); - var command = new UpdateGeoCommand(performerId, 15.0, 25.0, PerformerStatus.Busy); + var command = new UpdateStatusCommand(performerId, 15.0, 25.0, PerformerStatus.OnJob); - _repositoryMock.Setup(x => x.GetByPerformerIdAsync(performerId, It.IsAny())) - .ReturnsAsync(existingStatus); + _repository.GetByPerformerIdAsync(performerId, Arg.Any()) + .Returns(existingStatus); - // Act + // Act (Действие) await _handler.Handle(command, CancellationToken.None); - // Assert + // Assert (Проверка) existingStatus.Location.Y.Should().Be(15.0); existingStatus.Location.X.Should().Be(25.0); - existingStatus.Status.Should().Be(PerformerStatus.Busy); + existingStatus.State.Should().Be(PerformerStatus.OnJob); - _repositoryMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Never); - _repositoryMock.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once); + await _repository.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + await _repository.Received(1).SaveChangesAsync(Arg.Any()); } } diff --git a/src/Modules/Geo/Tests/Domain/LiveStatusTests.cs b/src/Modules/Geo/Tests/Domain/LiveStatusTests.cs index 803afa4..9e1885a 100644 --- a/src/Modules/Geo/Tests/Domain/LiveStatusTests.cs +++ b/src/Modules/Geo/Tests/Domain/LiveStatusTests.cs @@ -10,34 +10,32 @@ public class LiveStatusTests [Fact] public void UpdateLocation_Should_ChangeCoordinates_And_UpdateTimestamp() { - // Arrange + // Arrange (Подготовка) var performerId = Guid.NewGuid(); var liveStatus = new LiveStatus(performerId, 10.0, 20.0); - var originalTimestamp = liveStatus.LastUpdated; + var originalTimestamp = liveStatus.LastUpdatedAt; - Thread.Sleep(50); // Small delay + Thread.Sleep(10); // Небольшая задержка - // Act + // Act (Действие) liveStatus.UpdateLocation(15.0, 25.0); - // Assert - liveStatus.Location.Y.Should().Be(15.0); // Latitude - liveStatus.Location.X.Should().Be(25.0); // Longitude - liveStatus.LastUpdated.Should().BeAfter(originalTimestamp); + // Assert (Проверка) + liveStatus.Location.Y.Should().Be(15.0); // Широта + liveStatus.Location.X.Should().Be(25.0); // Долгота + liveStatus.LastUpdatedAt.Should().BeAfter(originalTimestamp); } [Fact] - public void SetBusy_Should_ChangeStatus_To_Busy() + public void SetState_Should_ChangeStatus() { - // Arrange + // Arrange (Подготовка) var liveStatus = new LiveStatus(Guid.NewGuid(), 10.0, 20.0); - var busyUntil = DateTime.UtcNow.AddHours(2); - // Act - liveStatus.SetBusy(busyUntil); + // Act (Действие) + liveStatus.SetState(PerformerStatus.OnJob); - // Assert - liveStatus.Status.Should().Be(PerformerStatus.Busy); - liveStatus.BusyUntil.Should().Be(busyUntil); + // Assert (Проверка) + liveStatus.State.Should().Be(PerformerStatus.OnJob); } } diff --git a/src/Modules/Geo/Tests/Nashel.Modules.Geo.Tests.csproj b/src/Modules/Geo/Tests/Nashel.Modules.Geo.Tests.csproj index 51f6f97..12f811e 100644 --- a/src/Modules/Geo/Tests/Nashel.Modules.Geo.Tests.csproj +++ b/src/Modules/Geo/Tests/Nashel.Modules.Geo.Tests.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -11,7 +11,7 @@ - + diff --git a/src/Modules/Identity/Application/Commands/BecomePerformerCommand.cs b/src/Modules/Identity/Application/Commands/BecomePerformerCommand.cs index c54c8d1..8231391 100644 --- a/src/Modules/Identity/Application/Commands/BecomePerformerCommand.cs +++ b/src/Modules/Identity/Application/Commands/BecomePerformerCommand.cs @@ -1,17 +1,34 @@ using MediatR; +using Microsoft.Extensions.Options; using Nashel.BuildingBlocks.Application.Abstractions; +using Nashel.BuildingBlocks.Application.Configurations; +using Nashel.BuildingBlocks.Application.Exceptions; using Nashel.Modules.Identity.Domain.Entities; using Nashel.Modules.Identity.Domain.Repositories; namespace Nashel.Modules.Identity.Application.Commands; +/// +/// Команда для перехода пользователя в роль исполнителя (мастера). +/// public record BecomePerformerCommand : IRequest { + /// Описание деятельности исполнителя. public string Description { get; init; } = default!; + + /// Список названий компетенций. public List CompetencyNames { get; init; } = default!; + + /// Текстовый адрес основного места работы. public string? Location { get; init; } + + /// Текстовое описание текущего местоположения. public string? CurrentLocation { get; init; } + + /// Флаг "Всегда готов" (игнорирует расписание). public bool IsAlwaysReady { get; init; } + + /// Рабочие дни (например: "1,2,3,4,5"). public string? WorkingDays { get; init; } } @@ -20,50 +37,59 @@ public class BecomePerformerCommandHandler : IRequestHandler settings) { _accountRepository = accountRepository; _currentUserService = currentUserService; _competencyRepository = competencyRepository; + _settings = settings.Value; } public async Task Handle(BecomePerformerCommand request, CancellationToken cancellationToken) { - Console.WriteLine($"[BecomePerformerCommand] Starting to process request for user"); - var userId = _currentUserService.UserId; if (userId == null) { - Console.WriteLine("[BecomePerformerCommand] User is not authorized"); - throw new Exception("Неавторизован"); + throw new UnauthorizedAccessException(); } - Console.WriteLine($"[BecomePerformerCommand] UserId: {userId.Value}"); - var account = await _accountRepository.GetByIdAsync(userId.Value, cancellationToken); if (account == null) { - Console.WriteLine($"[BecomePerformerCommand] Account not found for userId: {userId.Value}"); - throw new Exception("Аккаунт не найден"); + throw new NotFoundException("Account", userId.Value); } - Console.WriteLine($"[BecomePerformerCommand] Account found: {account.Id}"); + // Валидация описания + if (string.IsNullOrWhiteSpace(request.Description) || request.Description.Length < _settings.MinPerformerDescriptionLength) + { + throw new BusinessRuleException($"Описание должно содержать минимум {_settings.MinPerformerDescriptionLength} символов."); + } + + if (request.Description.Length > _settings.MaxDescriptionLength) + { + throw new BusinessRuleException($"Описание не может превышать {_settings.MaxDescriptionLength} символов."); + } + + // Валидация компетенций + if (request.CompetencyNames != null && request.CompetencyNames.Count > _settings.MaxCompetencies) + { + throw new BusinessRuleException($"Нельзя добавить более {_settings.MaxCompetencies} компетенций."); + } - // Получаем или создаем компетенции var competencies = new List(); if (request.CompetencyNames != null) { - Console.WriteLine($"[BecomePerformerCommand] Processing {request.CompetencyNames.Count} competencies"); foreach (var name in request.CompetencyNames) { var competency = await _competencyRepository.GetByNameAsync(name, cancellationToken); if (competency == null) { - Console.WriteLine($"[BecomePerformerCommand] Creating new competency: {name}"); competency = Competency.Create(name); await _competencyRepository.AddAsync(competency, cancellationToken); } @@ -74,22 +100,17 @@ public class BecomePerformerCommandHandler : IRequestHandler + diff --git a/src/Modules/Identity/Application/Queries/GetProfile/GetProfileQuery.cs b/src/Modules/Identity/Application/Queries/GetProfile/GetProfileQuery.cs index b9cc334..7fcb3a8 100644 --- a/src/Modules/Identity/Application/Queries/GetProfile/GetProfileQuery.cs +++ b/src/Modules/Identity/Application/Queries/GetProfile/GetProfileQuery.cs @@ -2,6 +2,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using MediatR; using Nashel.BuildingBlocks.Application.Abstractions; +using Nashel.Modules.Geo.Application.Queries; using Nashel.Modules.Identity.Domain.Repositories; namespace Nashel.Modules.Identity.Application.Queries.GetProfile; @@ -14,8 +15,8 @@ public record WorkScheduleResponse( ); public record TimePeriod( - [property: JsonPropertyName("start")] string Start, - [property: JsonPropertyName("end")] string End + [property: JsonPropertyName("Start")] string Start, + [property: JsonPropertyName("End")] string End ); public record ProfileResponse( @@ -33,17 +34,20 @@ public record ProfileResponse( string? FullName, string? AvatarUrl, List Competencies, - WorkScheduleResponse? WorkSchedule); + WorkScheduleResponse? WorkSchedule, + string? Status = "Offline"); public class GetProfileQueryHandler : IRequestHandler { private readonly IAccountRepository _accountRepository; private readonly ICurrentUserService _currentUserService; + private readonly IMediator _mediator; - public GetProfileQueryHandler(IAccountRepository accountRepository, ICurrentUserService currentUserService) + public GetProfileQueryHandler(IAccountRepository accountRepository, ICurrentUserService currentUserService, IMediator mediator) { _accountRepository = accountRepository; _currentUserService = currentUserService; + _mediator = mediator; } public async Task Handle(GetProfileQuery request, CancellationToken cancellationToken) @@ -89,6 +93,14 @@ public class GetProfileQueryHandler : IRequestHandler c.Name).ToList(), - workScheduleResponse); + workScheduleResponse, + currentStatus); } } diff --git a/src/Modules/Identity/Application/Queries/GetUserDetailsQuery.cs b/src/Modules/Identity/Application/Queries/GetUserDetailsQuery.cs new file mode 100644 index 0000000..650ee83 --- /dev/null +++ b/src/Modules/Identity/Application/Queries/GetUserDetailsQuery.cs @@ -0,0 +1,52 @@ +using MediatR; +using Nashel.BuildingBlocks.Domain; +using Nashel.Modules.Identity.Domain.Enums; +using Nashel.Modules.Identity.Domain.Repositories; + +namespace Nashel.Modules.Identity.Application.Queries; + +public record UserDetailsDto( + Guid Id, + string? FullName, + string? AvatarUrl, + string PrimaryRole +); + +public record GetUserDetailsQuery(Guid UserId) : IRequest>; + +public class GetUserDetailsHandler : IRequestHandler> +{ + private readonly IAccountRepository _repository; + + public GetUserDetailsHandler(IAccountRepository repository) + { + _repository = repository; + } + + public async Task> Handle(GetUserDetailsQuery request, CancellationToken cancellationToken) + { + var account = await _repository.GetByIdAsync(request.UserId, cancellationToken); + if (account == null) return Result.Failure("User not found"); + + var names = new[] { account.Profile.FirstName, account.Profile.LastName } + .Where(x => !string.IsNullOrWhiteSpace(x)); + var fullName = string.Join(" ", names); + + if (string.IsNullOrWhiteSpace(fullName)) + { + fullName = account.Profile.CompanyName ?? (account.IsPerformer() ? "Мастер" : "Пользователь"); + } + + string roleName = "Пользователь"; + if (account.Roles.Contains(Role.Master)) roleName = "Мастер"; + else if (account.Roles.Contains(Role.Candidate)) roleName = "Кандидат"; + else if (account.Roles.Contains(Role.Company)) roleName = "Компания"; + + return Result.Success(new UserDetailsDto( + account.Id, + fullName, + account.Profile.AvatarUrl, + roleName + )); + } +} diff --git a/src/Modules/Identity/Application/Queries/GetUsersDetailsQuery.cs b/src/Modules/Identity/Application/Queries/GetUsersDetailsQuery.cs new file mode 100644 index 0000000..bd79f78 --- /dev/null +++ b/src/Modules/Identity/Application/Queries/GetUsersDetailsQuery.cs @@ -0,0 +1,49 @@ +using MediatR; +using Nashel.BuildingBlocks.Domain; +using Nashel.Modules.Identity.Domain.Enums; +using Nashel.Modules.Identity.Domain.Repositories; + +namespace Nashel.Modules.Identity.Application.Queries; + +public record GetUsersDetailsQuery(List UserIds) : IRequest>>; + +public class GetUsersDetailsHandler : IRequestHandler>> +{ + private readonly IAccountRepository _repository; + + public GetUsersDetailsHandler(IAccountRepository repository) + { + _repository = repository; + } + + public async Task>> Handle(GetUsersDetailsQuery request, CancellationToken cancellationToken) + { + var accounts = await _repository.GetByIdsAsync(request.UserIds, cancellationToken); + + var dtos = accounts.Select(account => + { + var names = new[] { account.Profile.FirstName, account.Profile.LastName } + .Where(x => !string.IsNullOrWhiteSpace(x)); + var fullName = string.Join(" ", names); + + if (string.IsNullOrWhiteSpace(fullName)) + { + fullName = account.Profile.CompanyName ?? (account.IsPerformer() ? "Мастер" : "Пользователь"); + } + + string roleName = "Пользователь"; + if (account.Roles.Contains(Role.Master)) roleName = "Специалист"; + else if (account.Roles.Contains(Role.Candidate)) roleName = "Кандидат"; + else if (account.Roles.Contains(Role.Company)) roleName = "Компания"; + + return new UserDetailsDto( + account.Id, + fullName, + account.Profile.AvatarUrl, + roleName + ); + }).ToList(); + + return Result>.Success(dtos); + } +} diff --git a/src/Modules/Identity/Domain/Repositories/IAccountRepository.cs b/src/Modules/Identity/Domain/Repositories/IAccountRepository.cs index a4c1117..7ed1ea8 100644 --- a/src/Modules/Identity/Domain/Repositories/IAccountRepository.cs +++ b/src/Modules/Identity/Domain/Repositories/IAccountRepository.cs @@ -6,6 +6,7 @@ public interface IAccountRepository { Task GetByPhoneAsync(string phone, CancellationToken cancellationToken); Task GetByIdAsync(Guid id, CancellationToken cancellationToken); + Task> GetByIdsAsync(IEnumerable ids, CancellationToken cancellationToken); Task AddAsync(Account account, CancellationToken cancellationToken); Task UpdateAsync(Account account, CancellationToken cancellationToken); Task GetByPhoneIncludingDeletedAsync(string phone, CancellationToken cancellationToken); diff --git a/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260310145039_BackendUpdate_Identity.Designer.cs b/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260310145039_BackendUpdate_Identity.Designer.cs new file mode 100644 index 0000000..d393ef2 --- /dev/null +++ b/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260310145039_BackendUpdate_Identity.Designer.cs @@ -0,0 +1,204 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nashel.Modules.Identity.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nashel.Modules.Identity.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(IdentityDbContext))] + [Migration("20260310145039_BackendUpdate_Identity")] + partial class BackendUpdate_Identity + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Nashel.Modules.Identity.Domain.Aggregates.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Roles") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("Phone") + .IsUnique(); + + b.ToTable("Accounts", "identity"); + }); + + modelBuilder.Entity("Nashel.Modules.Identity.Domain.Aggregates.UserProfile", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .HasColumnType("text"); + + b.Property("CompanyName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CurrentLocation") + .HasColumnType("text"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("FirstName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Inn") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("LastName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Location") + .HasColumnType("text"); + + b.Property("Patronymic") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.ToTable("UserProfiles", "identity"); + }); + + modelBuilder.Entity("Nashel.Modules.Identity.Domain.Entities.Competency", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("IX_Competencies_Name"); + + b.ToTable("Competencies", "identity"); + }); + + modelBuilder.Entity("Nashel.Modules.Identity.Domain.Entities.WorkSchedule", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("IsAlwaysReady") + .HasColumnType("boolean"); + + b.Property("WorkingDays") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.ToTable("WorkSchedules", "identity"); + }); + + modelBuilder.Entity("UserProfileCompetencies", b => + { + b.Property("UserProfileId") + .HasColumnType("uuid"); + + b.Property("CompetencyId") + .HasColumnType("uuid"); + + b.HasKey("UserProfileId", "CompetencyId"); + + b.HasIndex("CompetencyId"); + + b.ToTable("UserProfileCompetencies", "identity"); + }); + + modelBuilder.Entity("Nashel.Modules.Identity.Domain.Aggregates.UserProfile", b => + { + b.HasOne("Nashel.Modules.Identity.Domain.Aggregates.Account", null) + .WithOne("Profile") + .HasForeignKey("Nashel.Modules.Identity.Domain.Aggregates.UserProfile", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Nashel.Modules.Identity.Domain.Entities.WorkSchedule", b => + { + b.HasOne("Nashel.Modules.Identity.Domain.Aggregates.UserProfile", null) + .WithOne("WorkSchedule") + .HasForeignKey("Nashel.Modules.Identity.Domain.Entities.WorkSchedule", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("UserProfileCompetencies", b => + { + b.HasOne("Nashel.Modules.Identity.Domain.Entities.Competency", null) + .WithMany() + .HasForeignKey("CompetencyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Nashel.Modules.Identity.Domain.Aggregates.UserProfile", null) + .WithMany() + .HasForeignKey("UserProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Nashel.Modules.Identity.Domain.Aggregates.Account", b => + { + b.Navigation("Profile") + .IsRequired(); + }); + + modelBuilder.Entity("Nashel.Modules.Identity.Domain.Aggregates.UserProfile", b => + { + b.Navigation("WorkSchedule"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260310145039_BackendUpdate_Identity.cs b/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260310145039_BackendUpdate_Identity.cs new file mode 100644 index 0000000..6e05e6b --- /dev/null +++ b/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260310145039_BackendUpdate_Identity.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nashel.Modules.Identity.Infrastructure.Persistence.Migrations +{ + /// + public partial class BackendUpdate_Identity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/src/Modules/Identity/Infrastructure/Repositories/AccountRepository.cs b/src/Modules/Identity/Infrastructure/Repositories/AccountRepository.cs index 535d06f..f9dc898 100644 --- a/src/Modules/Identity/Infrastructure/Repositories/AccountRepository.cs +++ b/src/Modules/Identity/Infrastructure/Repositories/AccountRepository.cs @@ -42,6 +42,14 @@ public class AccountRepository : IAccountRepository .FirstOrDefaultAsync(a => a.Id == id, cancellationToken); } + public async Task> GetByIdsAsync(IEnumerable ids, CancellationToken cancellationToken) + { + return await _context.Accounts + .Include(a => a.Profile) + .Where(a => ids.Contains(a.Id)) + .ToListAsync(cancellationToken); + } + public async Task GetByPhoneAsync(string phone, CancellationToken cancellationToken) { return await _context.Accounts diff --git a/src/Modules/Identity/Presentation/Endpoints/IdentityEndpoints.cs b/src/Modules/Identity/Presentation/Endpoints/IdentityEndpoints.cs index d9b5082..ec0856b 100644 --- a/src/Modules/Identity/Presentation/Endpoints/IdentityEndpoints.cs +++ b/src/Modules/Identity/Presentation/Endpoints/IdentityEndpoints.cs @@ -148,6 +148,17 @@ public static class IdentityEndpoints }) .WithName("UpdateSchedule") .WithOpenApi(operation => new(operation) { Summary = "Обновить расписание" }); + + // Публичный профиль (базовая информация) + var publicProfileGroup = app.MapGroup("/api/profile/public").WithTags("Profile Public"); + + publicProfileGroup.MapGet("/{userId:guid}", async (Guid userId, ISender sender) => + { + var result = await sender.Send(new GetUserDetailsQuery(userId)); + return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error); + }) + .WithName("GetPublicProfile") + .WithOpenApi(operation => new(operation) { Summary = "Получить публичную информацию о пользователе" }); } } diff --git a/src/Modules/Identity/Tests/Application/BecomePerformerCommandHandlerTests.cs b/src/Modules/Identity/Tests/Application/BecomePerformerCommandHandlerTests.cs index b5dc32b..3bbf612 100644 --- a/src/Modules/Identity/Tests/Application/BecomePerformerCommandHandlerTests.cs +++ b/src/Modules/Identity/Tests/Application/BecomePerformerCommandHandlerTests.cs @@ -1,7 +1,10 @@ using FluentAssertions; using MediatR; -using Moq; +using NSubstitute; +using Microsoft.Extensions.Options; using Nashel.BuildingBlocks.Application.Abstractions; +using Nashel.BuildingBlocks.Application.Configurations; +using Nashel.BuildingBlocks.Application.Exceptions; using Nashel.Modules.Identity.Application.Commands; using Nashel.Modules.Identity.Domain.Aggregates; using Nashel.Modules.Identity.Domain.Enums; @@ -12,54 +15,65 @@ namespace Nashel.Modules.Identity.Tests.Application; public class BecomePerformerCommandHandlerTests { - private readonly Mock _mockRepo; - private readonly Mock _mockUserService; - private readonly Mock _mockCompetencyRepo; + private readonly IAccountRepository _repo; + private readonly ICurrentUserService _userService; + private readonly ICompetencyRepository _competencyRepo; + private readonly IOptions _settings; private readonly BecomePerformerCommandHandler _handler; public BecomePerformerCommandHandlerTests() { - _mockRepo = new Mock(); - _mockUserService = new Mock(); - _mockCompetencyRepo = new Mock(); + _repo = Substitute.For(); + _userService = Substitute.For(); + _competencyRepo = Substitute.For(); + + // Мокаем настройки + _settings = Options.Create(new IdentitySettings + { + MinPerformerDescriptionLength = 50, + MaxDescriptionLength = 2000, + MaxCompetencies = 10 + }); + _handler = new BecomePerformerCommandHandler( - _mockRepo.Object, - _mockUserService.Object, - _mockCompetencyRepo.Object); + _repo, + _userService, + _competencyRepo, + _settings); } [Fact] public async Task Handle_Should_Add_Candidate_Role() { - // Arrange - var account = Account.Create("123", "pass"); // Has User role only + // Arrange (Подготовка) + var account = Account.Create("123", "pass"); var profile = UserProfile.Create(account.Id, "TestName", "TestSurname", null, null, null, null); account.SetProfile(profile); var userId = account.Id; - _mockUserService.Setup(s => s.UserId).Returns(userId); - _mockRepo.Setup(r => r.GetByIdAsync(userId, It.IsAny())) - .ReturnsAsync(account); + _userService.UserId.Returns(userId); + _repo.GetByIdAsync(userId, Arg.Any()) + .Returns(account); - // Act + // Act (Действие) var command = new BecomePerformerCommand { Description = new string('x', 51) }; await _handler.Handle(command, CancellationToken.None); - // Assert + // Assert (Проверка) account.Roles.Should().Contain(Role.Candidate); - _mockRepo.Verify(r => r.UpdateAsync(account, It.IsAny()), Times.Once); + await _repo.Received(1).UpdateAsync(account, Arg.Any()); } [Fact] public async Task Handle_Should_Throw_If_Unauthorized() { - // Arrange - _mockUserService.Setup(s => s.UserId).Returns((Guid?)null); + // Arrange (Подготовка) + _userService.UserId.Returns((Guid?)null); - // Act + // Act (Действие) var act = async () => await _handler.Handle(new BecomePerformerCommand(), CancellationToken.None); - // Assert - await act.Should().ThrowAsync().WithMessage("Неавторизован"); + // Assert (Проверка) + await act.Should().ThrowAsync(); } } diff --git a/src/Modules/Identity/Tests/Application/GetUserDetailsQueryTests.cs b/src/Modules/Identity/Tests/Application/GetUserDetailsQueryTests.cs new file mode 100644 index 0000000..d523dae --- /dev/null +++ b/src/Modules/Identity/Tests/Application/GetUserDetailsQueryTests.cs @@ -0,0 +1,64 @@ +using FluentAssertions; +using NSubstitute; +using Nashel.Modules.Identity.Application.Queries; +using Nashel.Modules.Identity.Domain.Aggregates; +using Nashel.Modules.Identity.Domain.Enums; +using Nashel.Modules.Identity.Domain.Repositories; +using Xunit; + +namespace Nashel.Modules.Identity.Tests.Application; + +public class GetUserDetailsQueryTests +{ + private readonly IAccountRepository _repository; + private readonly GetUserDetailsHandler _handler; + + public GetUserDetailsQueryTests() + { + _repository = Substitute.For(); + _handler = new GetUserDetailsHandler(_repository); + } + + [Fact] + public async Task Handle_Should_ReturnSuccess_WhenUserExists() + { + // Arrange (Подготовка) + var userId = Guid.NewGuid(); + var account = Account.Create("123", "pass"); + var profile = UserProfile.Create(account.Id, "Иван", "Иванов", null, null, null, null); + account.SetProfile(profile); + + // Магия рефлексии для установки ID, так как он приватный (хотя в Account.Create он генерится) + // Но нам нужно, чтобы ID в account совпадал с тем, что мы запрашиваем. + // Account.Create(phone, pass) генерит новый GUID. + + _repository.GetByIdAsync(account.Id, Arg.Any()).Returns(account); + + var query = new GetUserDetailsQuery(account.Id); + + // Act (Действие) + var result = await _handler.Handle(query, CancellationToken.None); + + // Assert (Проверка) + result.IsSuccess.Should().BeTrue(); + result.Value!.FullName.Should().Be("Иван Иванов"); + result.Value!.PrimaryRole.Should().Be("Пользователь"); + } + + [Fact] + public async Task Handle_Should_ReturnFailure_WhenUserDoesNotExist() + { + // Arrange (Подготовка) + _repository.GetByIdAsync(Arg.Any(), Arg.Any()) + .Returns((Account?)null); + + var query = new GetUserDetailsQuery(Guid.NewGuid()); + + // Act (Действие) + var result = await _handler.Handle(query, CancellationToken.None); + + // Assert (Проверка) + result.IsSuccess.Should().BeFalse(); + result.Error.Should().Be("User not found"); + } +} diff --git a/src/Modules/Identity/Tests/Application/LoginCommandHandlerTests.cs b/src/Modules/Identity/Tests/Application/LoginCommandHandlerTests.cs index 460df04..6073d24 100644 --- a/src/Modules/Identity/Tests/Application/LoginCommandHandlerTests.cs +++ b/src/Modules/Identity/Tests/Application/LoginCommandHandlerTests.cs @@ -1,5 +1,5 @@ using FluentAssertions; -using Moq; +using NSubstitute; using Nashel.Modules.Identity.Application.Commands; using Nashel.Modules.Identity.Domain.Aggregates; using Nashel.Modules.Identity.Domain.Enums; @@ -11,64 +11,64 @@ namespace Nashel.Modules.Identity.Tests.Application; public class LoginCommandHandlerTests { - private readonly Mock _mockRepo; - private readonly Mock _mockTokenGen; - private readonly Mock _mockPasswordHasher; + private readonly IAccountRepository _repo; + private readonly IJwtTokenGenerator _tokenGen; + private readonly IPasswordHasher _passwordHasher; private readonly LoginCommandHandler _handler; public LoginCommandHandlerTests() { - _mockRepo = new Mock(); - _mockTokenGen = new Mock(); - _mockPasswordHasher = new Mock(); + _repo = Substitute.For(); + _tokenGen = Substitute.For(); + _passwordHasher = Substitute.For(); _handler = new LoginCommandHandler( - _mockRepo.Object, - _mockTokenGen.Object, - _mockPasswordHasher.Object); + _repo, + _tokenGen, + _passwordHasher); } [Fact] public async Task Handle_Should_Return_Token_On_Success() { - // Arrange + // Arrange (Подготовка) var phone = "1234567890"; var password = "password"; var account = Account.Create(phone, password); - _mockRepo.Setup(r => r.GetByPhoneAsync(phone, It.IsAny())) - .ReturnsAsync(account); + _repo.GetByPhoneAsync(phone, Arg.Any()) + .Returns(account); - _mockPasswordHasher.Setup(p => p.VerifyPassword(password, account.PasswordHash)) + _passwordHasher.VerifyPassword(password, account.PasswordHash) .Returns(true); - _mockTokenGen.Setup(t => t.GenerateToken(It.IsAny(), It.IsAny>())) + _tokenGen.GenerateToken(Arg.Any(), Arg.Any>()) .Returns("jwt_token"); - // Act + // Act (Действие) var result = await _handler.Handle(new LoginCommand(phone, password), CancellationToken.None); - // Assert + // Assert (Проверка) result.Should().Be("jwt_token"); - _mockTokenGen.Verify(t => t.GenerateToken(account.Id, account.Roles), Times.Once); + _tokenGen.Received(1).GenerateToken(account.Id, account.Roles); } [Fact] public async Task Handle_Should_Throw_On_Invalid_Password() { - // Arrange + // Arrange (Подготовка) var phone = "1234567890"; var account = Account.Create(phone, "correct_hash"); - _mockRepo.Setup(r => r.GetByPhoneAsync(phone, It.IsAny())) - .ReturnsAsync(account); + _repo.GetByPhoneAsync(phone, Arg.Any()) + .Returns(account); - _mockPasswordHasher.Setup(p => p.VerifyPassword("wrong_password", account.PasswordHash)) + _passwordHasher.VerifyPassword("wrong_password", account.PasswordHash) .Returns(false); - // Act + // Act (Действие) var act = async () => await _handler.Handle(new LoginCommand(phone, "wrong_password"), CancellationToken.None); - // Assert + // Assert (Проверка) await act.Should().ThrowAsync().WithMessage("Неверные учетные данные"); } } diff --git a/src/Modules/Identity/Tests/Application/RegisterUserCommandHandlerTests.cs b/src/Modules/Identity/Tests/Application/RegisterUserCommandHandlerTests.cs index 1d1cd9b..7dbe98c 100644 --- a/src/Modules/Identity/Tests/Application/RegisterUserCommandHandlerTests.cs +++ b/src/Modules/Identity/Tests/Application/RegisterUserCommandHandlerTests.cs @@ -1,5 +1,5 @@ using FluentAssertions; -using Moq; +using NSubstitute; using Nashel.Modules.Identity.Application.Commands; using Nashel.Modules.Identity.Domain.Aggregates; using Nashel.Modules.Identity.Domain.Repositories; @@ -10,29 +10,29 @@ namespace Nashel.Modules.Identity.Tests.Application; public class RegisterUserCommandHandlerTests { - private readonly Mock _mockRepo; - private readonly Mock _mockPasswordHasher; + private readonly IAccountRepository _repo; + private readonly IPasswordHasher _passwordHasher; private readonly RegisterUserCommandHandler _handler; public RegisterUserCommandHandlerTests() { - _mockRepo = new Mock(); - _mockPasswordHasher = new Mock(); - _handler = new RegisterUserCommandHandler(_mockRepo.Object, _mockPasswordHasher.Object); + _repo = Substitute.For(); + _passwordHasher = Substitute.For(); + _handler = new RegisterUserCommandHandler(_repo, _passwordHasher); } [Fact] public async Task Handle_Should_Register_When_User_Not_Exists() { - // Arrange + // Arrange (Подготовка) var phone = "123"; - _mockRepo.Setup(r => r.GetByPhoneIncludingDeletedAsync(phone, It.IsAny())) - .ReturnsAsync((Account?)null); + _repo.GetByPhoneIncludingDeletedAsync(phone, Arg.Any()) + .Returns((Account?)null); - _mockPasswordHasher.Setup(p => p.HashPassword("pass")) + _passwordHasher.HashPassword("pass") .Returns("hashed_pass"); - // Act + // Act (Действие) var result = await _handler.Handle(new RegisterUserCommand { Phone = phone, @@ -41,20 +41,20 @@ public class RegisterUserCommandHandlerTests LastName = "Ivanov" }, CancellationToken.None); - // Assert + // Assert (Проверка) result.Should().NotBeEmpty(); - _mockRepo.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Once); + await _repo.Received(1).AddAsync(Arg.Any(), Arg.Any()); } [Fact] public async Task Handle_Should_Throw_When_User_Exists() { - // Arrange + // Arrange (Подготовка) var phone = "123"; - _mockRepo.Setup(r => r.GetByPhoneIncludingDeletedAsync(phone, It.IsAny())) - .ReturnsAsync(Account.Create(phone, "pass")); + _repo.GetByPhoneIncludingDeletedAsync(phone, Arg.Any()) + .Returns(Account.Create(phone, "pass")); - // Assert + // Act (Действие) var act = async () => await _handler.Handle(new RegisterUserCommand { Phone = phone, @@ -63,8 +63,9 @@ public class RegisterUserCommandHandlerTests LastName = "Ivanov" }, CancellationToken.None); + // Assert (Проверка) var ex = await Assert.ThrowsAsync(act); ex.Message.Should().Be("Пользователь с таким номером телефона уже существует"); - _mockRepo.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + await _repo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); } } diff --git a/src/Modules/Identity/Tests/Domain/AccountTests.cs b/src/Modules/Identity/Tests/Domain/AccountTests.cs index e3a6860..fa9542f 100644 --- a/src/Modules/Identity/Tests/Domain/AccountTests.cs +++ b/src/Modules/Identity/Tests/Domain/AccountTests.cs @@ -10,12 +10,12 @@ public class AccountTests [Fact] public void Create_Should_Create_Account_With_User_Role() { - // Arrange & Act + // Arrange & Act (Подготовка и Действие) var phone = "1234567890"; var passwordHash = "hash"; var account = Account.Create(phone, passwordHash); - // Assert + // Assert (Проверка) account.Should().NotBeNull(); account.Roles.Should().ContainSingle(r => r == Role.User); account.Roles.Should().HaveCount(1); @@ -24,13 +24,13 @@ public class AccountTests [Fact] public void BecomePerformer_Should_Add_Candidate_Role() { - // Arrange + // Arrange (Подготовка) var account = Account.Create("123", "hash"); - // Act + // Act (Действие) account.BecomePerformer(); - // Assert + // Assert (Проверка) account.Roles.Should().Contain(Role.Candidate); account.Roles.Should().Contain(Role.User); account.Roles.Should().HaveCount(2); // User + Candidate @@ -39,14 +39,14 @@ public class AccountTests [Fact] public void BecomePerformer_Should_Do_Nothing_If_Already_Candidate() { - // Arrange + // Arrange (Подготовка) var account = Account.Create("123", "hash"); account.BecomePerformer(); - // Act + // Act (Действие) account.BecomePerformer(); - // Assert + // Assert (Проверка) account.Roles.Should().ContainSingle(r => r == Role.Candidate); account.Roles.Should().HaveCount(2); } @@ -54,14 +54,14 @@ public class AccountTests [Fact] public void PromoteToMaster_Should_Replace_Candidate_With_Master() { - // Arrange + // Arrange (Подготовка) var account = Account.Create("123", "hash"); account.BecomePerformer(); // Has Candidate - // Act + // Act (Действие) account.PromoteToMaster(); - // Assert + // Assert (Проверка) account.Roles.Should().NotContain(Role.Candidate); account.Roles.Should().Contain(Role.Master); account.Roles.Should().Contain(Role.User); diff --git a/src/Modules/Identity/Tests/Nashel.Modules.Identity.Tests.csproj b/src/Modules/Identity/Tests/Nashel.Modules.Identity.Tests.csproj index 99709fa..9166039 100644 --- a/src/Modules/Identity/Tests/Nashel.Modules.Identity.Tests.csproj +++ b/src/Modules/Identity/Tests/Nashel.Modules.Identity.Tests.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -11,7 +11,7 @@ - + diff --git a/src/Modules/Order/Application/Commands/AcceptDisputeTermsCommand.cs b/src/Modules/Order/Application/Commands/AcceptDisputeTermsCommand.cs new file mode 100644 index 0000000..40e46f7 --- /dev/null +++ b/src/Modules/Order/Application/Commands/AcceptDisputeTermsCommand.cs @@ -0,0 +1,34 @@ +using MediatR; +using Nashel.Modules.Order.Domain.Repositories; + +namespace Nashel.Modules.Order.Application.Commands; + +public record AcceptDisputeTermsCommand( + Guid OrderId, + Guid CustomerId) : IRequest; + +public class AcceptDisputeTermsHandler : IRequestHandler +{ + private readonly IOrderRepository _repository; + + public AcceptDisputeTermsHandler(IOrderRepository repository) + { + _repository = repository; + } + + public async Task Handle(AcceptDisputeTermsCommand request, CancellationToken cancellationToken) + { + var order = await _repository.GetByIdAsync(request.OrderId, cancellationToken); + if (order == null) throw new KeyNotFoundException("Заказ не найден."); + + if (order.CustomerId != request.CustomerId) + { + throw new UnauthorizedAccessException("Только заказчик может принять условия спора."); + } + + order.AcceptDisputeTerms(); + await _repository.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } +} diff --git a/src/Modules/Order/Application/Commands/AcceptOrderCommand.cs b/src/Modules/Order/Application/Commands/AcceptOrderCommand.cs index 2ad2c61..5a94bbb 100644 --- a/src/Modules/Order/Application/Commands/AcceptOrderCommand.cs +++ b/src/Modules/Order/Application/Commands/AcceptOrderCommand.cs @@ -6,29 +6,37 @@ namespace Nashel.Modules.Order.Application.Commands; /// /// Команда принятия заказа исполнителем. /// -public record AcceptOrderCommand(Guid OrderId, Guid PerformerId) : IRequest; +public record AcceptOrderCommand(Guid OrderId) : IRequest; public class AcceptOrderHandler : IRequestHandler { private readonly IOrderRepository _repository; + private readonly Nashel.BuildingBlocks.Application.Abstractions.ICurrentUserService _currentUserService; - public AcceptOrderHandler(IOrderRepository repository) + public AcceptOrderHandler(IOrderRepository repository, Nashel.BuildingBlocks.Application.Abstractions.ICurrentUserService currentUserService) { _repository = repository; + _currentUserService = currentUserService; } public async Task Handle(AcceptOrderCommand request, CancellationToken cancellationToken) { + var userId = _currentUserService.UserId; + if (userId == null) + { + throw new UnauthorizedAccessException(); + } + var order = await _repository.GetByIdAsync(request.OrderId, cancellationToken); if (order == null) { - throw new KeyNotFoundException($"Заказ {request.OrderId} не найден."); + throw new Nashel.BuildingBlocks.Application.Exceptions.NotFoundException("Order", request.OrderId); } // Проверяем, что именно этот исполнитель принимает заказ - if (order.SelectedPerformerId != request.PerformerId) + if (order.SelectedPerformerId != userId.Value) { - throw new UnauthorizedAccessException("Этот заказ предназначен другому исполнителю."); + throw new Nashel.BuildingBlocks.Application.Exceptions.ForbiddenAccessException(); } order.Accept(); diff --git a/src/Modules/Order/Application/Commands/ConfirmOrderCompletionCommand.cs b/src/Modules/Order/Application/Commands/ConfirmOrderCompletionCommand.cs new file mode 100644 index 0000000..f0a3f1e --- /dev/null +++ b/src/Modules/Order/Application/Commands/ConfirmOrderCompletionCommand.cs @@ -0,0 +1,35 @@ +using MediatR; +using Nashel.Modules.Order.Domain.Repositories; + +namespace Nashel.Modules.Order.Application.Commands; + +/// +/// Команда: заказчик подтвердил выполнение заказа. +/// +public record ConfirmOrderCompletionCommand(Guid OrderId, Guid CustomerId) : IRequest; + +public class ConfirmOrderCompletionHandler : IRequestHandler +{ + private readonly IOrderRepository _repository; + + public ConfirmOrderCompletionHandler(IOrderRepository repository) + { + _repository = repository; + } + + public async Task Handle(ConfirmOrderCompletionCommand request, CancellationToken cancellationToken) + { + var order = await _repository.GetByIdAsync(request.OrderId, cancellationToken); + if (order == null) throw new KeyNotFoundException("Заказ не найден."); + + if (order.CustomerId != request.CustomerId) + { + throw new UnauthorizedAccessException("Только заказчик может подтвердить выполнение."); + } + + order.ConfirmCompletion(); + await _repository.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } +} diff --git a/src/Modules/Order/Application/Commands/FinishOrderCommand.cs b/src/Modules/Order/Application/Commands/FinishOrderCommand.cs new file mode 100644 index 0000000..c1e6465 --- /dev/null +++ b/src/Modules/Order/Application/Commands/FinishOrderCommand.cs @@ -0,0 +1,35 @@ +using MediatR; +using Nashel.Modules.Order.Domain.Repositories; + +namespace Nashel.Modules.Order.Application.Commands; + +/// +/// Команда: исполнитель пометил заказ как выполненный. +/// +public record FinishOrderCommand(Guid OrderId, Guid PerformerId) : IRequest; + +public class FinishOrderHandler : IRequestHandler +{ + private readonly IOrderRepository _repository; + + public FinishOrderHandler(IOrderRepository repository) + { + _repository = repository; + } + + public async Task Handle(FinishOrderCommand request, CancellationToken cancellationToken) + { + var order = await _repository.GetByIdAsync(request.OrderId, cancellationToken); + if (order == null) throw new KeyNotFoundException("Заказ не найден."); + + if (order.SelectedPerformerId != request.PerformerId) + { + throw new UnauthorizedAccessException("Только исполнитель может завершить заказ."); + } + + order.FinishByPerformer(); + await _repository.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } +} diff --git a/src/Modules/Order/Application/Commands/OpenOrderDisputeCommand.cs b/src/Modules/Order/Application/Commands/OpenOrderDisputeCommand.cs new file mode 100644 index 0000000..5829787 --- /dev/null +++ b/src/Modules/Order/Application/Commands/OpenOrderDisputeCommand.cs @@ -0,0 +1,40 @@ +using MediatR; +using Nashel.Modules.Order.Domain.Entities; +using Nashel.Modules.Order.Domain.Enums; +using Nashel.Modules.Order.Domain.Repositories; + +namespace Nashel.Modules.Order.Application.Commands; + +public record OpenOrderDisputeCommand( + Guid OrderId, + Guid CustomerId, + DisputeReason Reason, + string Description, + string ProposedSolution, + List Evidence) : IRequest; + +public class OpenOrderDisputeHandler : IRequestHandler +{ + private readonly IOrderRepository _repository; + + public OpenOrderDisputeHandler(IOrderRepository repository) + { + _repository = repository; + } + + public async Task Handle(OpenOrderDisputeCommand request, CancellationToken cancellationToken) + { + var order = await _repository.GetByIdAsync(request.OrderId, cancellationToken); + if (order == null) throw new KeyNotFoundException("Заказ не найден."); + + if (order.CustomerId != request.CustomerId) + { + throw new UnauthorizedAccessException("Только заказчик может открыть спор."); + } + + order.OpenDispute(request.Reason, request.Description, request.ProposedSolution, request.Evidence); + await _repository.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } +} diff --git a/src/Modules/Order/Application/Commands/RebutDisputeCommand.cs b/src/Modules/Order/Application/Commands/RebutDisputeCommand.cs new file mode 100644 index 0000000..3cfdaa2 --- /dev/null +++ b/src/Modules/Order/Application/Commands/RebutDisputeCommand.cs @@ -0,0 +1,37 @@ +using MediatR; +using Nashel.Modules.Order.Domain.Repositories; + +namespace Nashel.Modules.Order.Application.Commands; + +public record RebutDisputeCommand( + Guid OrderId, + Guid CustomerId, + string Comment, + List Evidence, + string? CounterSolution = null) : IRequest; + +public class RebutDisputeHandler : IRequestHandler +{ + private readonly IOrderRepository _repository; + + public RebutDisputeHandler(IOrderRepository repository) + { + _repository = repository; + } + + public async Task Handle(RebutDisputeCommand request, CancellationToken cancellationToken) + { + var order = await _repository.GetByIdAsync(request.OrderId, cancellationToken); + if (order == null) throw new KeyNotFoundException("Заказ не найден."); + + if (order.CustomerId != request.CustomerId) + { + throw new UnauthorizedAccessException("Только заказчик может возразить на ответ исполнителя."); + } + + order.RebutDispute(request.Comment, request.Evidence, request.CounterSolution); + await _repository.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } +} diff --git a/src/Modules/Order/Application/Commands/RespondToOrderDisputeCommand.cs b/src/Modules/Order/Application/Commands/RespondToOrderDisputeCommand.cs new file mode 100644 index 0000000..00dc359 --- /dev/null +++ b/src/Modules/Order/Application/Commands/RespondToOrderDisputeCommand.cs @@ -0,0 +1,37 @@ +using MediatR; +using Nashel.Modules.Order.Domain.Repositories; + +namespace Nashel.Modules.Order.Application.Commands; + +public record RespondToOrderDisputeCommand( + Guid OrderId, + Guid PerformerId, + string Position, + List Evidence, + string? CounterSolution = null) : IRequest; + +public class RespondToOrderDisputeHandler : IRequestHandler +{ + private readonly IOrderRepository _repository; + + public RespondToOrderDisputeHandler(IOrderRepository repository) + { + _repository = repository; + } + + public async Task Handle(RespondToOrderDisputeCommand request, CancellationToken cancellationToken) + { + var order = await _repository.GetByIdAsync(request.OrderId, cancellationToken); + if (order == null) throw new KeyNotFoundException("Заказ не найден."); + + if (order.SelectedPerformerId != request.PerformerId) + { + throw new UnauthorizedAccessException("Только исполнитель может ответить на спор."); + } + + order.RespondToDispute(request.Position, request.Evidence, request.CounterSolution); + await _repository.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } +} diff --git a/src/Modules/Order/Application/Queries/GetMyOrdersQuery.cs b/src/Modules/Order/Application/Queries/GetMyOrdersQuery.cs index 8afbb3b..8a06782 100644 --- a/src/Modules/Order/Application/Queries/GetMyOrdersQuery.cs +++ b/src/Modules/Order/Application/Queries/GetMyOrdersQuery.cs @@ -26,7 +26,26 @@ public record OrderDto( DateTime? CompletedAt, string? CancellationReason, /// Секунды до истечения SLA. Null если не в статусе PendingAcceptance. - int? SlaSecondsLeft + int? SlaSecondsLeft, + DisputeDto? Dispute = null +); + +public record DisputeDto( + string Reason, + string Status, + DateTime CreatedAt, + DateTime? ResolvedAt, + bool IsResolved, + List Messages +); + +public record DisputeMessageDto( + Guid Id, + Guid AuthorId, + string Text, + string? ProposedSolution, + List Evidence, + DateTime CreatedAt ); /// @@ -44,32 +63,43 @@ public record GetMyOrdersAsPerformerQuery(Guid UserId) : IRequest> { private readonly IOrderRepository _repository; + private readonly Microsoft.Extensions.Logging.ILogger _logger; + private readonly Nashel.BuildingBlocks.Application.Configurations.OrderSettings _settings; - public GetMyOrdersAsCustomerHandler(IOrderRepository repository) + public GetMyOrdersAsCustomerHandler( + IOrderRepository repository, + Microsoft.Extensions.Logging.ILogger logger, + Microsoft.Extensions.Options.IOptions settings) { _repository = repository; + _logger = logger; + _settings = settings.Value; } public async Task> Handle(GetMyOrdersAsCustomerQuery request, CancellationToken cancellationToken) { var orders = await _repository.GetByCustomerIdAsync(request.UserId, cancellationToken); - return orders.Select(OrderMapper.ToDto).ToList(); + return orders.Select(o => OrderMapper.ToDto(o, _settings)).ToList(); } } public class GetMyOrdersAsPerformerHandler : IRequestHandler> { private readonly IOrderRepository _repository; + private readonly Nashel.BuildingBlocks.Application.Configurations.OrderSettings _settings; - public GetMyOrdersAsPerformerHandler(IOrderRepository repository) + public GetMyOrdersAsPerformerHandler( + IOrderRepository repository, + Microsoft.Extensions.Options.IOptions settings) { _repository = repository; + _settings = settings.Value; } public async Task> Handle(GetMyOrdersAsPerformerQuery request, CancellationToken cancellationToken) { var orders = await _repository.GetByPerformerIdAsync(request.UserId, cancellationToken); - return orders.Select(OrderMapper.ToDto).ToList(); + return orders.Select(o => OrderMapper.ToDto(o, _settings)).ToList(); } } @@ -77,15 +107,14 @@ public class GetMyOrdersAsPerformerHandler : IRequestHandler new DisputeMessageDto( + m.Id, + m.AuthorId, + m.Text, + m.ProposedSolution, + m.Evidence, + m.CreatedAt + )).ToList() + ) : null ); } } diff --git a/src/Modules/Order/Domain/Aggregates/Order.cs b/src/Modules/Order/Domain/Aggregates/Order.cs index 09278f7..a6579a9 100644 --- a/src/Modules/Order/Domain/Aggregates/Order.cs +++ b/src/Modules/Order/Domain/Aggregates/Order.cs @@ -35,6 +35,8 @@ public class Order : AggregateRoot // Причина отмены public string? CancellationReason { get; private set; } + public Dispute? Dispute { get; private set; } + // Optimistic Concurrency public uint RowVersion { get; private set; } @@ -47,15 +49,15 @@ public class Order : AggregateRoot /// Создание прямого заказа (заказчик выбирает конкретного мастера). /// public static Order CreateDirect( - Guid customerId, + Guid customerId, string customerName, - Guid serviceId, + Guid serviceId, string serviceTitle, decimal priceAmount, PriceType priceType, - Guid performerId, + Guid performerId, string performerName, - OrderLocation location, + OrderLocation location, DateTime? deadline) { return new Order @@ -81,13 +83,13 @@ public class Order : AggregateRoot /// Создание публичной заявки (любой мастер может откликнуться). /// public static Order CreatePublic( - Guid customerId, + Guid customerId, string customerName, - Guid serviceId, + Guid serviceId, string serviceTitle, decimal priceAmount, PriceType priceType, - OrderLocation location, + OrderLocation location, DateTime? deadline) { return new Order @@ -114,7 +116,7 @@ public class Order : AggregateRoot { if (Status != OrderStatus.PendingAcceptance) { - throw new InvalidOperationException( + throw new Nashel.Modules.Order.Domain.Exceptions.OrderDomainException( $"Принять заказ можно только если он ожидает подтверждения. Текущий статус: {Status}."); } @@ -123,20 +125,112 @@ public class Order : AggregateRoot } /// - /// Заказ завершён (инициируется любой из сторон по договорённости). + /// Исполнитель помечает заказ как выполненный (запускает процесс подтверждения заказчиком). /// - public void Complete() + public void FinishByPerformer() { if (Status != OrderStatus.InProgress) { - throw new InvalidOperationException( - $"Завершить заказ можно только если он в работе. Текущий статус: {Status}."); + throw new Nashel.Modules.Order.Domain.Exceptions.OrderDomainException( + $"Пометить заказ как выполненный можно только из статуса 'В работе'. Текущий статус: {Status}."); + } + + Status = OrderStatus.VerificationPending; + } + + /// + /// Заказчик подтверждает выполнение заказа. + /// + public void ConfirmCompletion() + { + if (Status != OrderStatus.VerificationPending) + { + throw new Nashel.Modules.Order.Domain.Exceptions.OrderDomainException( + $"Подтвердить выполнение можно только если заказ ожидает проверки. Текущий статус: {Status}."); } Status = OrderStatus.Completed; CompletedAt = DateTime.UtcNow; } + /// + /// Заказчик открывает спор по заказу. + /// + public void OpenDispute(DisputeReason reason, string description, string proposedSolution, List evidence) + { + if (Status != OrderStatus.VerificationPending && Status != OrderStatus.InProgress) + { + throw new InvalidOperationException( + $"Открыть спор можно только если заказ в работе или ожидает проверки. Текущий статус: {Status}."); + } + + Dispute = new Dispute(CustomerId, reason, description, proposedSolution, evidence); + Status = OrderStatus.Disputed; + } + + /// + /// Исполнитель отвечает на спор. + /// + public void RespondToDispute(string comment, List evidence, string? counterSolution = null) + { + if (Status != OrderStatus.Disputed || Dispute == null) + { + throw new Nashel.Modules.Order.Domain.Exceptions.OrderDomainException("Спор по этому заказу не открыт."); + } + + if (SelectedPerformerId == null) + { + throw new InvalidOperationException("Исполнитель не выбран."); + } + + Dispute.AddPerformerResponse(SelectedPerformerId.Value, comment, evidence, counterSolution); + } + + /// + /// Заказчик возражает на ответ исполнителя. + /// + public void RebutDispute(string comment, List evidence, string? counterSolution = null) + { + if (Status != OrderStatus.Disputed || Dispute == null) + { + throw new Nashel.Modules.Order.Domain.Exceptions.OrderDomainException("Спор по этому заказу не открыт."); + } + + Dispute.AddCustomerRebuttal(CustomerId, comment, evidence, counterSolution); + } + + /// + /// Заказчик принимает условия исполнителя и закрывает спор. + /// + public void AcceptDisputeTerms() + { + if (Status != OrderStatus.Disputed || Dispute == null) + { + throw new Nashel.Modules.Order.Domain.Exceptions.OrderDomainException("Спор по этому заказу не открыт."); + } + + Complete(); + } + + /// + /// Завершение заказа после решения спора или по соглашению. + /// + public void Complete() + { + if (Status != OrderStatus.InProgress && Status != OrderStatus.VerificationPending && Status != OrderStatus.Disputed) + { + throw new Nashel.Modules.Order.Domain.Exceptions.OrderDomainException( + $"Завершить заказ можно только из статусов 'В работе', 'Проверка' или 'Спор'. Текущий статус: {Status}."); + } + + Status = OrderStatus.Completed; + CompletedAt = DateTime.UtcNow; + if (Dispute != null) + { + Dispute.Resolve(); + } + } + /// /// Отмена заказа с указанием причины. /// @@ -144,17 +238,17 @@ public class Order : AggregateRoot { if (Status == OrderStatus.Completed) { - throw new InvalidOperationException("Нельзя отменить уже завершённый заказ."); + throw new Nashel.Modules.Order.Domain.Exceptions.OrderDomainException("Нельзя отменить уже завершённый заказ."); } if (Status == OrderStatus.Cancelled) { - throw new InvalidOperationException("Заказ уже отменён."); + throw new Nashel.Modules.Order.Domain.Exceptions.OrderDomainException("Заказ уже отменён."); } if (Status == OrderStatus.Expired) { - throw new InvalidOperationException("Нельзя отменить заказ с истёкшим SLA-временем."); + throw new Nashel.Modules.Order.Domain.Exceptions.OrderDomainException("Нельзя отменить заказ с истёкшим SLA-временем."); } Status = OrderStatus.Cancelled; @@ -182,12 +276,12 @@ public class Order : AggregateRoot { if (Status != OrderStatus.Published) { - throw new InvalidOperationException("Нельзя оставить отклик на заказ, который не опубликован."); + throw new Nashel.Modules.Order.Domain.Exceptions.OrderDomainException("Нельзя оставить отклик на заказ, который не опубликован."); } if (_applications.Any(a => a.PerformerId == performerId)) { - throw new InvalidOperationException("Исполнитель уже оставил отклик на этот заказ."); + throw new Nashel.Modules.Order.Domain.Exceptions.OrderDomainException("Вы уже оставили отклик на этот заказ."); } _applications.Add(new OrderApplication(performerId, price, comment)); @@ -200,13 +294,13 @@ public class Order : AggregateRoot { if (Status != OrderStatus.Published) { - throw new InvalidOperationException("Выбрать исполнителя можно только для опубликованного заказа."); + throw new Nashel.Modules.Order.Domain.Exceptions.OrderDomainException("Выбрать исполнителя можно только для опубликованного заказа."); } var app = _applications.FirstOrDefault(a => a.PerformerId == performerId); if (app == null) { - throw new InvalidOperationException("Выбранный исполнитель не оставлял отклик."); + throw new Nashel.Modules.Order.Domain.Exceptions.OrderDomainException("Выбранный исполнитель не оставлял отклик."); } SelectedPerformerId = performerId; diff --git a/src/Modules/Order/Domain/Entities/Dispute.cs b/src/Modules/Order/Domain/Entities/Dispute.cs new file mode 100644 index 0000000..8a8528e --- /dev/null +++ b/src/Modules/Order/Domain/Entities/Dispute.cs @@ -0,0 +1,76 @@ +using Nashel.BuildingBlocks.Domain; +using Nashel.Modules.Order.Domain.Enums; + +namespace Nashel.Modules.Order.Domain.Entities; + +public class Dispute : Entity +{ + public DisputeReason Reason { get; private set; } + public DisputeStatus Status { get; private set; } + public DateTime CreatedAt { get; private set; } + public DateTime? ResolvedAt { get; private set; } + public bool IsResolved { get; private set; } + + private readonly List _messages = new(); + public IReadOnlyCollection Messages => _messages.AsReadOnly(); + + private Dispute() { } + + public Dispute(Guid customerId, DisputeReason reason, string description, string proposedSolution, List evidence) + { + Id = Guid.NewGuid(); + Reason = reason; + CreatedAt = DateTime.UtcNow; + Status = DisputeStatus.WaitingForPerformer; + + _messages.Add(new DisputeMessage(customerId, description, evidence, proposedSolution)); + } + + public void AddPerformerResponse(Guid performerId, string comment, List evidence, string? counterSolution = null) + { + if (IsResolved) throw new InvalidOperationException("Спор уже решен."); + if (Status != DisputeStatus.WaitingForPerformer) + throw new InvalidOperationException("Сейчас не очередь исполнителя отвечать."); + + _messages.Add(new DisputeMessage(performerId, comment, evidence, counterSolution)); + Status = DisputeStatus.WaitingForCustomer; + } + + public void AddCustomerRebuttal(Guid customerId, string comment, List evidence, string? counterSolution = null) + { + if (IsResolved) throw new InvalidOperationException("Спор уже решен."); + if (Status != DisputeStatus.WaitingForCustomer) + throw new InvalidOperationException("Сейчас не очередь заказчика отвечать."); + + _messages.Add(new DisputeMessage(customerId, comment, evidence, counterSolution)); + Status = DisputeStatus.WaitingForPerformer; + } + + public void Resolve() + { + IsResolved = true; + ResolvedAt = DateTime.UtcNow; + Status = DisputeStatus.Resolved; + } +} + +public class DisputeMessage : Entity +{ + public Guid AuthorId { get; private set; } + public string Text { get; private set; } = default!; + public string? ProposedSolution { get; private set; } + public List Evidence { get; private set; } = new(); + public DateTime CreatedAt { get; private set; } + + private DisputeMessage() { } + + public DisputeMessage(Guid authorId, string text, List evidence, string? proposedSolution = null) + { + Id = Guid.NewGuid(); + AuthorId = authorId; + Text = text; + Evidence = evidence; + ProposedSolution = proposedSolution; + CreatedAt = DateTime.UtcNow; + } +} diff --git a/src/Modules/Order/Domain/Enums/DisputeReason.cs b/src/Modules/Order/Domain/Enums/DisputeReason.cs new file mode 100644 index 0000000..1a335cd --- /dev/null +++ b/src/Modules/Order/Domain/Enums/DisputeReason.cs @@ -0,0 +1,18 @@ +namespace Nashel.Modules.Order.Domain.Enums; + +/// +/// Причина спора по заказу. +/// +public enum DisputeReason +{ + /// Работы не выполнены. + WorkNotPerformed, + /// Выполнены не в полном объеме. + NotFullVolume, + /// Выполнены некачественно. + PoorQuality, + /// Нарушен срок. + DeadlineViolated, + /// Увеличилась стоимость. + PriceIncreased +} diff --git a/src/Modules/Order/Domain/Enums/DisputeStatus.cs b/src/Modules/Order/Domain/Enums/DisputeStatus.cs new file mode 100644 index 0000000..0fc000e --- /dev/null +++ b/src/Modules/Order/Domain/Enums/DisputeStatus.cs @@ -0,0 +1,16 @@ +namespace Nashel.Modules.Order.Domain.Enums; + +/// +/// Статус спора. +/// +public enum DisputeStatus +{ + /// Ожидает ответа исполнителя. + WaitingForPerformer, + /// Ожидает ответа заказчика. + WaitingForCustomer, + /// Спор решен. + Resolved, + /// Спор передан в арбитраж (поддержку). + InArbitration +} diff --git a/src/Modules/Order/Domain/Enums/OrderStatus.cs b/src/Modules/Order/Domain/Enums/OrderStatus.cs index ec8a46a..368220e 100644 --- a/src/Modules/Order/Domain/Enums/OrderStatus.cs +++ b/src/Modules/Order/Domain/Enums/OrderStatus.cs @@ -25,6 +25,11 @@ public enum OrderStatus /// InProgress, + /// + /// Ожидает подтверждения выполнения заказчиком (мастер пометил как выполненный). + /// + VerificationPending, + /// /// Завершён успешно. /// @@ -38,5 +43,10 @@ public enum OrderStatus /// /// Истекло время ожидания (мастер не принял заказ в течение 60 минут). /// - Expired + Expired, + + /// + /// Оспорен заказчиком (открыт спор). + /// + Disputed } diff --git a/src/Modules/Order/Domain/Exceptions/OrderDomainException.cs b/src/Modules/Order/Domain/Exceptions/OrderDomainException.cs new file mode 100644 index 0000000..de45ffd --- /dev/null +++ b/src/Modules/Order/Domain/Exceptions/OrderDomainException.cs @@ -0,0 +1,11 @@ +using Nashel.BuildingBlocks.Domain; + +namespace Nashel.Modules.Order.Domain.Exceptions; + +/// +/// Исключение для ошибок в модуле заказов. +/// +public class OrderDomainException : DomainException +{ + public OrderDomainException(string message) : base(message) { } +} diff --git a/src/Modules/Order/Infrastructure/Migrations/20260309200524_AddOrderDisputes.Designer.cs b/src/Modules/Order/Infrastructure/Migrations/20260309200524_AddOrderDisputes.Designer.cs new file mode 100644 index 0000000..715821d --- /dev/null +++ b/src/Modules/Order/Infrastructure/Migrations/20260309200524_AddOrderDisputes.Designer.cs @@ -0,0 +1,253 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nashel.Modules.Order.Infrastructure.Persistence; +using NetTopologySuite.Geometries; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nashel.Modules.Order.Infrastructure.Migrations +{ + [DbContext(typeof(OrderDbContext))] + [Migration("20260309200524_AddOrderDisputes")] + partial class AddOrderDisputes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ordering") + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CancellationReason") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("CustomerName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Deadline") + .HasColumnType("timestamp with time zone"); + + b.Property("PerformerName") + .HasColumnType("text"); + + b.Property("PriceAmount") + .HasColumnType("numeric"); + + b.Property("PriceType") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SelectedPerformerId") + .HasColumnType("uuid"); + + b.Property("ServiceId") + .HasColumnType("uuid"); + + b.Property("ServiceTitle") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("Orders", "ordering"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("PerformerId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.ToTable("OrderApplications", "ordering"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b => + { + b.OwnsOne("Nashel.Modules.Order.Domain.Entities.Dispute", "Dispute", b1 => + { + b1.Property("OrderId") + .HasColumnType("uuid"); + + b1.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b1.PrimitiveCollection>("CustomerEvidence") + .IsRequired() + .HasColumnType("text[]"); + + b1.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("IsResolved") + .HasColumnType("boolean"); + + b1.Property("PerformerComment") + .HasColumnType("text"); + + b1.PrimitiveCollection>("PerformerEvidence") + .IsRequired() + .HasColumnType("text[]"); + + b1.Property("PerformerProposedSolution") + .HasColumnType("text"); + + b1.Property("ProposedSolution") + .IsRequired() + .HasColumnType("text"); + + b1.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b1.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b1.HasKey("OrderId"); + + b1.ToTable("OrderDisputes", "ordering"); + + b1.WithOwner() + .HasForeignKey("OrderId"); + }); + + b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.OrderLocation", "Location", b1 => + { + b1.Property("OrderId") + .HasColumnType("uuid"); + + b1.Property("Address") + .IsRequired() + .HasColumnType("text") + .HasColumnName("Address"); + + b1.Property("Point") + .IsRequired() + .HasColumnType("geography (point)"); + + b1.HasKey("OrderId"); + + b1.HasIndex("Point"); + + NpgsqlIndexBuilderExtensions.HasMethod(b1.HasIndex("Point"), "gist"); + + b1.ToTable("Orders", "ordering"); + + b1.WithOwner() + .HasForeignKey("OrderId"); + }); + + b.Navigation("Dispute"); + + b.Navigation("Location") + .IsRequired(); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b => + { + b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null) + .WithMany("Applications") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade); + + b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.Money", "Price", b1 => + { + b1.Property("OrderApplicationId") + .HasColumnType("uuid"); + + b1.Property("Amount") + .HasColumnType("numeric") + .HasColumnName("PriceAmount"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("PriceCurrency"); + + b1.HasKey("OrderApplicationId"); + + b1.ToTable("OrderApplications", "ordering"); + + b1.WithOwner() + .HasForeignKey("OrderApplicationId"); + }); + + b.Navigation("Price") + .IsRequired(); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b => + { + b.Navigation("Applications"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Order/Infrastructure/Migrations/20260309200524_AddOrderDisputes.cs b/src/Modules/Order/Infrastructure/Migrations/20260309200524_AddOrderDisputes.cs new file mode 100644 index 0000000..f77ff0b --- /dev/null +++ b/src/Modules/Order/Infrastructure/Migrations/20260309200524_AddOrderDisputes.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nashel.Modules.Order.Infrastructure.Migrations +{ + /// + public partial class AddOrderDisputes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "OrderDisputes", + schema: "ordering", + columns: table => new + { + OrderId = table.Column(type: "uuid", nullable: false), + Reason = table.Column(type: "text", nullable: false), + Description = table.Column(type: "text", nullable: false), + ProposedSolution = table.Column(type: "text", nullable: false), + CustomerEvidence = table.Column>(type: "text[]", nullable: false), + PerformerComment = table.Column(type: "text", nullable: true), + PerformerEvidence = table.Column>(type: "text[]", nullable: false), + PerformerProposedSolution = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + ResolvedAt = table.Column(type: "timestamp with time zone", nullable: true), + IsResolved = table.Column(type: "boolean", nullable: false), + Id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_OrderDisputes", x => x.OrderId); + table.ForeignKey( + name: "FK_OrderDisputes_Orders_OrderId", + column: x => x.OrderId, + principalSchema: "ordering", + principalTable: "Orders", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "OrderDisputes", + schema: "ordering"); + } + } +} diff --git a/src/Modules/Order/Infrastructure/Migrations/20260309204912_UpdateDisputeHistory.Designer.cs b/src/Modules/Order/Infrastructure/Migrations/20260309204912_UpdateDisputeHistory.Designer.cs new file mode 100644 index 0000000..9ca4a52 --- /dev/null +++ b/src/Modules/Order/Infrastructure/Migrations/20260309204912_UpdateDisputeHistory.Designer.cs @@ -0,0 +1,290 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nashel.Modules.Order.Infrastructure.Persistence; +using NetTopologySuite.Geometries; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nashel.Modules.Order.Infrastructure.Migrations +{ + [DbContext(typeof(OrderDbContext))] + [Migration("20260309204912_UpdateDisputeHistory")] + partial class UpdateDisputeHistory + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ordering") + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CancellationReason") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("CustomerName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Deadline") + .HasColumnType("timestamp with time zone"); + + b.Property("PerformerName") + .HasColumnType("text"); + + b.Property("PriceAmount") + .HasColumnType("numeric"); + + b.Property("PriceType") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SelectedPerformerId") + .HasColumnType("uuid"); + + b.Property("ServiceId") + .HasColumnType("uuid"); + + b.Property("ServiceTitle") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("Orders", "ordering"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsResolved") + .HasColumnType("boolean"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrderId") + .IsUnique(); + + b.ToTable("OrderDisputes", "ordering"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.DisputeMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisputeId") + .HasColumnType("uuid"); + + b.PrimitiveCollection>("Evidence") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("ProposedSolution") + .HasColumnType("text"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DisputeId"); + + b.ToTable("DisputeMessages", "ordering"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("PerformerId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.ToTable("OrderApplications", "ordering"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b => + { + b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.OrderLocation", "Location", b1 => + { + b1.Property("OrderId") + .HasColumnType("uuid"); + + b1.Property("Address") + .IsRequired() + .HasColumnType("text") + .HasColumnName("Address"); + + b1.Property("Point") + .IsRequired() + .HasColumnType("geography (point)"); + + b1.HasKey("OrderId"); + + b1.HasIndex("Point"); + + NpgsqlIndexBuilderExtensions.HasMethod(b1.HasIndex("Point"), "gist"); + + b1.ToTable("Orders", "ordering"); + + b1.WithOwner() + .HasForeignKey("OrderId"); + }); + + b.Navigation("Location") + .IsRequired(); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b => + { + b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null) + .WithOne("Dispute") + .HasForeignKey("Nashel.Modules.Order.Domain.Entities.Dispute", "OrderId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.DisputeMessage", b => + { + b.HasOne("Nashel.Modules.Order.Domain.Entities.Dispute", null) + .WithMany("Messages") + .HasForeignKey("DisputeId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b => + { + b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null) + .WithMany("Applications") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade); + + b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.Money", "Price", b1 => + { + b1.Property("OrderApplicationId") + .HasColumnType("uuid"); + + b1.Property("Amount") + .HasColumnType("numeric") + .HasColumnName("PriceAmount"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("PriceCurrency"); + + b1.HasKey("OrderApplicationId"); + + b1.ToTable("OrderApplications", "ordering"); + + b1.WithOwner() + .HasForeignKey("OrderApplicationId"); + }); + + b.Navigation("Price") + .IsRequired(); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b => + { + b.Navigation("Applications"); + + b.Navigation("Dispute"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b => + { + b.Navigation("Messages"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Order/Infrastructure/Migrations/20260309204912_UpdateDisputeHistory.cs b/src/Modules/Order/Infrastructure/Migrations/20260309204912_UpdateDisputeHistory.cs new file mode 100644 index 0000000..bfeea59 --- /dev/null +++ b/src/Modules/Order/Infrastructure/Migrations/20260309204912_UpdateDisputeHistory.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nashel.Modules.Order.Infrastructure.Migrations +{ + /// + public partial class UpdateDisputeHistory : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropPrimaryKey( + name: "PK_OrderDisputes", + schema: "ordering", + table: "OrderDisputes"); + + migrationBuilder.DropColumn( + name: "CustomerEvidence", + schema: "ordering", + table: "OrderDisputes"); + + migrationBuilder.DropColumn( + name: "Description", + schema: "ordering", + table: "OrderDisputes"); + + migrationBuilder.DropColumn( + name: "PerformerComment", + schema: "ordering", + table: "OrderDisputes"); + + migrationBuilder.DropColumn( + name: "PerformerEvidence", + schema: "ordering", + table: "OrderDisputes"); + + migrationBuilder.DropColumn( + name: "PerformerProposedSolution", + schema: "ordering", + table: "OrderDisputes"); + + migrationBuilder.RenameColumn( + name: "ProposedSolution", + schema: "ordering", + table: "OrderDisputes", + newName: "Status"); + + migrationBuilder.AlterColumn( + name: "OrderId", + schema: "ordering", + table: "OrderDisputes", + type: "uuid", + nullable: true, + oldClrType: typeof(Guid), + oldType: "uuid"); + + migrationBuilder.AddPrimaryKey( + name: "PK_OrderDisputes", + schema: "ordering", + table: "OrderDisputes", + column: "Id"); + + migrationBuilder.CreateTable( + name: "DisputeMessages", + schema: "ordering", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + AuthorId = table.Column(type: "uuid", nullable: false), + Text = table.Column(type: "text", nullable: false), + ProposedSolution = table.Column(type: "text", nullable: true), + Evidence = table.Column>(type: "text[]", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + DisputeId = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_DisputeMessages", x => x.Id); + table.ForeignKey( + name: "FK_DisputeMessages_OrderDisputes_DisputeId", + column: x => x.DisputeId, + principalSchema: "ordering", + principalTable: "OrderDisputes", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_OrderDisputes_OrderId", + schema: "ordering", + table: "OrderDisputes", + column: "OrderId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_DisputeMessages_DisputeId", + schema: "ordering", + table: "DisputeMessages", + column: "DisputeId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DisputeMessages", + schema: "ordering"); + + migrationBuilder.DropPrimaryKey( + name: "PK_OrderDisputes", + schema: "ordering", + table: "OrderDisputes"); + + migrationBuilder.DropIndex( + name: "IX_OrderDisputes_OrderId", + schema: "ordering", + table: "OrderDisputes"); + + migrationBuilder.RenameColumn( + name: "Status", + schema: "ordering", + table: "OrderDisputes", + newName: "ProposedSolution"); + + migrationBuilder.AlterColumn( + name: "OrderId", + schema: "ordering", + table: "OrderDisputes", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "uuid", + oldNullable: true); + + migrationBuilder.AddColumn>( + name: "CustomerEvidence", + schema: "ordering", + table: "OrderDisputes", + type: "text[]", + nullable: false); + + migrationBuilder.AddColumn( + name: "Description", + schema: "ordering", + table: "OrderDisputes", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "PerformerComment", + schema: "ordering", + table: "OrderDisputes", + type: "text", + nullable: true); + + migrationBuilder.AddColumn>( + name: "PerformerEvidence", + schema: "ordering", + table: "OrderDisputes", + type: "text[]", + nullable: false); + + migrationBuilder.AddColumn( + name: "PerformerProposedSolution", + schema: "ordering", + table: "OrderDisputes", + type: "text", + nullable: true); + + migrationBuilder.AddPrimaryKey( + name: "PK_OrderDisputes", + schema: "ordering", + table: "OrderDisputes", + column: "OrderId"); + } + } +} diff --git a/src/Modules/Order/Infrastructure/Migrations/20260309210444_FixDisputeMappingAndHistory.Designer.cs b/src/Modules/Order/Infrastructure/Migrations/20260309210444_FixDisputeMappingAndHistory.Designer.cs new file mode 100644 index 0000000..4a1148a --- /dev/null +++ b/src/Modules/Order/Infrastructure/Migrations/20260309210444_FixDisputeMappingAndHistory.Designer.cs @@ -0,0 +1,290 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nashel.Modules.Order.Infrastructure.Persistence; +using NetTopologySuite.Geometries; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nashel.Modules.Order.Infrastructure.Migrations +{ + [DbContext(typeof(OrderDbContext))] + [Migration("20260309210444_FixDisputeMappingAndHistory")] + partial class FixDisputeMappingAndHistory + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ordering") + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CancellationReason") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("CustomerName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Deadline") + .HasColumnType("timestamp with time zone"); + + b.Property("PerformerName") + .HasColumnType("text"); + + b.Property("PriceAmount") + .HasColumnType("numeric"); + + b.Property("PriceType") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SelectedPerformerId") + .HasColumnType("uuid"); + + b.Property("ServiceId") + .HasColumnType("uuid"); + + b.Property("ServiceTitle") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("Orders", "ordering"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsResolved") + .HasColumnType("boolean"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrderId") + .IsUnique(); + + b.ToTable("OrderDisputes", "ordering"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.DisputeMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisputeId") + .HasColumnType("uuid"); + + b.PrimitiveCollection>("Evidence") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("ProposedSolution") + .HasColumnType("text"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DisputeId"); + + b.ToTable("DisputeMessages", "ordering"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("PerformerId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.ToTable("OrderApplications", "ordering"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b => + { + b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.OrderLocation", "Location", b1 => + { + b1.Property("OrderId") + .HasColumnType("uuid"); + + b1.Property("Address") + .IsRequired() + .HasColumnType("text") + .HasColumnName("Address"); + + b1.Property("Point") + .IsRequired() + .HasColumnType("geography (point)"); + + b1.HasKey("OrderId"); + + b1.HasIndex("Point"); + + NpgsqlIndexBuilderExtensions.HasMethod(b1.HasIndex("Point"), "gist"); + + b1.ToTable("Orders", "ordering"); + + b1.WithOwner() + .HasForeignKey("OrderId"); + }); + + b.Navigation("Location") + .IsRequired(); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b => + { + b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null) + .WithOne("Dispute") + .HasForeignKey("Nashel.Modules.Order.Domain.Entities.Dispute", "OrderId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.DisputeMessage", b => + { + b.HasOne("Nashel.Modules.Order.Domain.Entities.Dispute", null) + .WithMany("Messages") + .HasForeignKey("DisputeId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b => + { + b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null) + .WithMany("Applications") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade); + + b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.Money", "Price", b1 => + { + b1.Property("OrderApplicationId") + .HasColumnType("uuid"); + + b1.Property("Amount") + .HasColumnType("numeric") + .HasColumnName("PriceAmount"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("PriceCurrency"); + + b1.HasKey("OrderApplicationId"); + + b1.ToTable("OrderApplications", "ordering"); + + b1.WithOwner() + .HasForeignKey("OrderApplicationId"); + }); + + b.Navigation("Price") + .IsRequired(); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b => + { + b.Navigation("Applications"); + + b.Navigation("Dispute"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b => + { + b.Navigation("Messages"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Order/Infrastructure/Migrations/20260309210444_FixDisputeMappingAndHistory.cs b/src/Modules/Order/Infrastructure/Migrations/20260309210444_FixDisputeMappingAndHistory.cs new file mode 100644 index 0000000..670c79b --- /dev/null +++ b/src/Modules/Order/Infrastructure/Migrations/20260309210444_FixDisputeMappingAndHistory.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nashel.Modules.Order.Infrastructure.Migrations +{ + /// + public partial class FixDisputeMappingAndHistory : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/src/Modules/Order/Infrastructure/Migrations/20260309224622_UseIsRowVersion.Designer.cs b/src/Modules/Order/Infrastructure/Migrations/20260309224622_UseIsRowVersion.Designer.cs new file mode 100644 index 0000000..a32f46f --- /dev/null +++ b/src/Modules/Order/Infrastructure/Migrations/20260309224622_UseIsRowVersion.Designer.cs @@ -0,0 +1,290 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nashel.Modules.Order.Infrastructure.Persistence; +using NetTopologySuite.Geometries; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nashel.Modules.Order.Infrastructure.Migrations +{ + [DbContext(typeof(OrderDbContext))] + [Migration("20260309224622_UseIsRowVersion")] + partial class UseIsRowVersion + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ordering") + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CancellationReason") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("CustomerName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Deadline") + .HasColumnType("timestamp with time zone"); + + b.Property("PerformerName") + .HasColumnType("text"); + + b.Property("PriceAmount") + .HasColumnType("numeric"); + + b.Property("PriceType") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SelectedPerformerId") + .HasColumnType("uuid"); + + b.Property("ServiceId") + .HasColumnType("uuid"); + + b.Property("ServiceTitle") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("Orders", "ordering"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsResolved") + .HasColumnType("boolean"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrderId") + .IsUnique(); + + b.ToTable("OrderDisputes", "ordering"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.DisputeMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisputeId") + .HasColumnType("uuid"); + + b.PrimitiveCollection>("Evidence") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("ProposedSolution") + .HasColumnType("text"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DisputeId"); + + b.ToTable("DisputeMessages", "ordering"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("PerformerId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.ToTable("OrderApplications", "ordering"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b => + { + b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.OrderLocation", "Location", b1 => + { + b1.Property("OrderId") + .HasColumnType("uuid"); + + b1.Property("Address") + .IsRequired() + .HasColumnType("text") + .HasColumnName("Address"); + + b1.Property("Point") + .IsRequired() + .HasColumnType("geography (point)"); + + b1.HasKey("OrderId"); + + b1.HasIndex("Point"); + + NpgsqlIndexBuilderExtensions.HasMethod(b1.HasIndex("Point"), "gist"); + + b1.ToTable("Orders", "ordering"); + + b1.WithOwner() + .HasForeignKey("OrderId"); + }); + + b.Navigation("Location") + .IsRequired(); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b => + { + b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null) + .WithOne("Dispute") + .HasForeignKey("Nashel.Modules.Order.Domain.Entities.Dispute", "OrderId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.DisputeMessage", b => + { + b.HasOne("Nashel.Modules.Order.Domain.Entities.Dispute", null) + .WithMany("Messages") + .HasForeignKey("DisputeId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b => + { + b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null) + .WithMany("Applications") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade); + + b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.Money", "Price", b1 => + { + b1.Property("OrderApplicationId") + .HasColumnType("uuid"); + + b1.Property("Amount") + .HasColumnType("numeric") + .HasColumnName("PriceAmount"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("PriceCurrency"); + + b1.HasKey("OrderApplicationId"); + + b1.ToTable("OrderApplications", "ordering"); + + b1.WithOwner() + .HasForeignKey("OrderApplicationId"); + }); + + b.Navigation("Price") + .IsRequired(); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b => + { + b.Navigation("Applications"); + + b.Navigation("Dispute"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b => + { + b.Navigation("Messages"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Order/Infrastructure/Migrations/20260309224622_UseIsRowVersion.cs b/src/Modules/Order/Infrastructure/Migrations/20260309224622_UseIsRowVersion.cs new file mode 100644 index 0000000..18088bb --- /dev/null +++ b/src/Modules/Order/Infrastructure/Migrations/20260309224622_UseIsRowVersion.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nashel.Modules.Order.Infrastructure.Migrations +{ + /// + public partial class UseIsRowVersion : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/src/Modules/Order/Infrastructure/Migrations/20260310000000_FixDisputeData.Designer.cs b/src/Modules/Order/Infrastructure/Migrations/20260310000000_FixDisputeData.Designer.cs new file mode 100644 index 0000000..4ec0e2c --- /dev/null +++ b/src/Modules/Order/Infrastructure/Migrations/20260310000000_FixDisputeData.Designer.cs @@ -0,0 +1,290 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nashel.Modules.Order.Infrastructure.Persistence; +using NetTopologySuite.Geometries; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nashel.Modules.Order.Infrastructure.Migrations +{ + [DbContext(typeof(OrderDbContext))] + [Migration("20260310000000_FixDisputeData")] + partial class FixDisputeData + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ordering") + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CancellationReason") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("CustomerName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Deadline") + .HasColumnType("timestamp with time zone"); + + b.Property("PerformerName") + .HasColumnType("text"); + + b.Property("PriceAmount") + .HasColumnType("numeric"); + + b.Property("PriceType") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SelectedPerformerId") + .HasColumnType("uuid"); + + b.Property("ServiceId") + .HasColumnType("uuid"); + + b.Property("ServiceTitle") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("Orders", "ordering"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsResolved") + .HasColumnType("boolean"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrderId") + .IsUnique(); + + b.ToTable("OrderDisputes", "ordering"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.DisputeMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisputeId") + .HasColumnType("uuid"); + + b.PrimitiveCollection>("Evidence") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("ProposedSolution") + .HasColumnType("text"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DisputeId"); + + b.ToTable("DisputeMessages", "ordering"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("PerformerId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.ToTable("OrderApplications", "ordering"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b => + { + b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.OrderLocation", "Location", b1 => + { + b1.Property("OrderId") + .HasColumnType("uuid"); + + b1.Property("Address") + .IsRequired() + .HasColumnType("text") + .HasColumnName("Address"); + + b1.Property("Point") + .IsRequired() + .HasColumnType("geography (point)"); + + b1.HasKey("OrderId"); + + b1.HasIndex("Point"); + + NpgsqlIndexBuilderExtensions.HasMethod(b1.HasIndex("Point"), "gist"); + + b1.ToTable("Orders", "ordering"); + + b1.WithOwner() + .HasForeignKey("OrderId"); + }); + + b.Navigation("Location") + .IsRequired(); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b => + { + b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null) + .WithOne("Dispute") + .HasForeignKey("Nashel.Modules.Order.Domain.Entities.Dispute", "OrderId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.DisputeMessage", b => + { + b.HasOne("Nashel.Modules.Order.Domain.Entities.Dispute", null) + .WithMany("Messages") + .HasForeignKey("DisputeId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b => + { + b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null) + .WithMany("Applications") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade); + + b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.Money", "Price", b1 => + { + b1.Property("OrderApplicationId") + .HasColumnType("uuid"); + + b1.Property("Amount") + .HasColumnType("numeric") + .HasColumnName("PriceAmount"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("PriceCurrency"); + + b1.HasKey("OrderApplicationId"); + + b1.ToTable("OrderApplications", "ordering"); + + b1.WithOwner() + .HasForeignKey("OrderApplicationId"); + }); + + b.Navigation("Price") + .IsRequired(); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b => + { + b.Navigation("Applications"); + + b.Navigation("Dispute"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b => + { + b.Navigation("Messages"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Order/Infrastructure/Migrations/20260310000000_FixDisputeData.cs b/src/Modules/Order/Infrastructure/Migrations/20260310000000_FixDisputeData.cs new file mode 100644 index 0000000..f06d62b --- /dev/null +++ b/src/Modules/Order/Infrastructure/Migrations/20260310000000_FixDisputeData.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nashel.Modules.Order.Infrastructure.Migrations +{ + public partial class FixDisputeData : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(@" + UPDATE ordering.""OrderDisputes"" + SET ""Status"" = 'WaitingForPerformer' + WHERE ""Status"" NOT IN ('WaitingForPerformer', 'WaitingForCustomer', 'Resolved', 'InArbitration'); + "); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + } + } +} diff --git a/src/Modules/Order/Infrastructure/Migrations/20260310145112_BackendUpdate_Order.Designer.cs b/src/Modules/Order/Infrastructure/Migrations/20260310145112_BackendUpdate_Order.Designer.cs new file mode 100644 index 0000000..7193b03 --- /dev/null +++ b/src/Modules/Order/Infrastructure/Migrations/20260310145112_BackendUpdate_Order.Designer.cs @@ -0,0 +1,284 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nashel.Modules.Order.Infrastructure.Persistence; +using NetTopologySuite.Geometries; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nashel.Modules.Order.Infrastructure.Migrations +{ + [DbContext(typeof(OrderDbContext))] + [Migration("20260310145112_BackendUpdate_Order")] + partial class BackendUpdate_Order + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ordering") + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CancellationReason") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("CustomerName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Deadline") + .HasColumnType("timestamp with time zone"); + + b.Property("PerformerName") + .HasColumnType("text"); + + b.Property("PriceAmount") + .HasColumnType("numeric"); + + b.Property("PriceType") + .HasColumnType("integer"); + + b.Property("SelectedPerformerId") + .HasColumnType("uuid"); + + b.Property("ServiceId") + .HasColumnType("uuid"); + + b.Property("ServiceTitle") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("Orders", "ordering"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsResolved") + .HasColumnType("boolean"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrderId") + .IsUnique(); + + b.ToTable("OrderDisputes", "ordering"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.DisputeMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisputeId") + .HasColumnType("uuid"); + + b.PrimitiveCollection>("Evidence") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("ProposedSolution") + .HasColumnType("text"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DisputeId"); + + b.ToTable("DisputeMessages", "ordering"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("PerformerId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.ToTable("OrderApplications", "ordering"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b => + { + b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.OrderLocation", "Location", b1 => + { + b1.Property("OrderId") + .HasColumnType("uuid"); + + b1.Property("Address") + .IsRequired() + .HasColumnType("text") + .HasColumnName("Address"); + + b1.Property("Point") + .IsRequired() + .HasColumnType("geography (point)"); + + b1.HasKey("OrderId"); + + b1.HasIndex("Point"); + + NpgsqlIndexBuilderExtensions.HasMethod(b1.HasIndex("Point"), "gist"); + + b1.ToTable("Orders", "ordering"); + + b1.WithOwner() + .HasForeignKey("OrderId"); + }); + + b.Navigation("Location") + .IsRequired(); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b => + { + b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null) + .WithOne("Dispute") + .HasForeignKey("Nashel.Modules.Order.Domain.Entities.Dispute", "OrderId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.DisputeMessage", b => + { + b.HasOne("Nashel.Modules.Order.Domain.Entities.Dispute", null) + .WithMany("Messages") + .HasForeignKey("DisputeId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b => + { + b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null) + .WithMany("Applications") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade); + + b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.Money", "Price", b1 => + { + b1.Property("OrderApplicationId") + .HasColumnType("uuid"); + + b1.Property("Amount") + .HasColumnType("numeric") + .HasColumnName("PriceAmount"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("PriceCurrency"); + + b1.HasKey("OrderApplicationId"); + + b1.ToTable("OrderApplications", "ordering"); + + b1.WithOwner() + .HasForeignKey("OrderApplicationId"); + }); + + b.Navigation("Price") + .IsRequired(); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b => + { + b.Navigation("Applications"); + + b.Navigation("Dispute"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b => + { + b.Navigation("Messages"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Order/Infrastructure/Migrations/20260310145112_BackendUpdate_Order.cs b/src/Modules/Order/Infrastructure/Migrations/20260310145112_BackendUpdate_Order.cs new file mode 100644 index 0000000..ec39e84 --- /dev/null +++ b/src/Modules/Order/Infrastructure/Migrations/20260310145112_BackendUpdate_Order.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nashel.Modules.Order.Infrastructure.Migrations +{ + /// + public partial class BackendUpdate_Order : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "xmin", + schema: "ordering", + table: "Orders"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "xmin", + schema: "ordering", + table: "Orders", + type: "xid", + rowVersion: true, + nullable: false, + defaultValue: 0u); + } + } +} diff --git a/src/Modules/Order/Infrastructure/Migrations/OrderDbContextModelSnapshot.cs b/src/Modules/Order/Infrastructure/Migrations/OrderDbContextModelSnapshot.cs index 015ad85..d63794b 100644 --- a/src/Modules/Order/Infrastructure/Migrations/OrderDbContextModelSnapshot.cs +++ b/src/Modules/Order/Infrastructure/Migrations/OrderDbContextModelSnapshot.cs @@ -1,5 +1,6 @@ // using System; +using System.Collections.Generic; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -63,12 +64,6 @@ namespace Nashel.Modules.Order.Infrastructure.Migrations b.Property("PriceType") .HasColumnType("integer"); - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - b.Property("SelectedPerformerId") .HasColumnType("uuid"); @@ -94,6 +89,73 @@ namespace Nashel.Modules.Order.Infrastructure.Migrations b.ToTable("Orders", "ordering"); }); + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsResolved") + .HasColumnType("boolean"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrderId") + .IsUnique(); + + b.ToTable("OrderDisputes", "ordering"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.DisputeMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisputeId") + .HasColumnType("uuid"); + + b.PrimitiveCollection>("Evidence") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("ProposedSolution") + .HasColumnType("text"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DisputeId"); + + b.ToTable("DisputeMessages", "ordering"); + }); + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b => { b.Property("Id") @@ -152,6 +214,22 @@ namespace Nashel.Modules.Order.Infrastructure.Migrations .IsRequired(); }); + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b => + { + b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null) + .WithOne("Dispute") + .HasForeignKey("Nashel.Modules.Order.Domain.Entities.Dispute", "OrderId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.DisputeMessage", b => + { + b.HasOne("Nashel.Modules.Order.Domain.Entities.Dispute", null) + .WithMany("Messages") + .HasForeignKey("DisputeId") + .OnDelete(DeleteBehavior.Cascade); + }); + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b => { b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null) @@ -189,6 +267,13 @@ namespace Nashel.Modules.Order.Infrastructure.Migrations modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b => { b.Navigation("Applications"); + + b.Navigation("Dispute"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b => + { + b.Navigation("Messages"); }); #pragma warning restore 612, 618 } diff --git a/src/Modules/Order/Infrastructure/Persistence/Configurations/OrderConfiguration.cs b/src/Modules/Order/Infrastructure/Persistence/Configurations/OrderConfiguration.cs index 6b24ef9..5282868 100644 --- a/src/Modules/Order/Infrastructure/Persistence/Configurations/OrderConfiguration.cs +++ b/src/Modules/Order/Infrastructure/Persistence/Configurations/OrderConfiguration.cs @@ -12,12 +12,8 @@ public class OrderConfiguration : IEntityTypeConfiguration o.Id); - // Optimistic Concurrency через PostgreSQL системный столбец xmin - builder.Property(o => o.RowVersion) - .HasColumnName("xmin") - .HasColumnType("xid") - .ValueGeneratedOnAddOrUpdate() - .IsConcurrencyToken(); + // Optimistic Concurrency temporarily disabled + builder.Ignore(o => o.RowVersion); builder.Property(o => o.Type) .HasConversion(); @@ -51,11 +47,44 @@ public class OrderConfiguration : IEntityTypeConfiguration o.Dispute) + .WithOne() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade); + // Индекс для SLA-таймера (быстрый поиск просроченных заказов) builder.HasIndex(o => new { o.Status, o.CreatedAt }); } } +public class DisputeMessageConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("DisputeMessages", "ordering"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Evidence).HasColumnType("text[]"); + } +} + +public class DisputeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("OrderDisputes", "ordering"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Reason).HasConversion(); + builder.Property(x => x.Status).HasConversion(); + + builder.HasMany(x => x.Messages) + .WithOne() + .HasForeignKey("DisputeId") + .OnDelete(DeleteBehavior.Cascade); + } +} + public class OrderApplicationConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) diff --git a/src/Modules/Order/Infrastructure/Persistence/Repositories/OrderRepository.cs b/src/Modules/Order/Infrastructure/Persistence/Repositories/OrderRepository.cs index 819b6ca..a9fb350 100644 --- a/src/Modules/Order/Infrastructure/Persistence/Repositories/OrderRepository.cs +++ b/src/Modules/Order/Infrastructure/Persistence/Repositories/OrderRepository.cs @@ -24,6 +24,8 @@ public class OrderRepository : IOrderRepository { return await _context.Orders .Include(o => o.Applications) + .Include(o => o.Dispute) + .ThenInclude(d => d!.Messages) .FirstOrDefaultAsync(o => o.Id == id, cancellationToken); } @@ -31,6 +33,8 @@ public class OrderRepository : IOrderRepository { return await _context.Orders .Include(o => o.Applications) + .Include(o => o.Dispute) + .ThenInclude(d => d!.Messages) .Where(o => o.CustomerId == customerId) .OrderByDescending(o => o.CreatedAt) .ToListAsync(cancellationToken); @@ -40,6 +44,8 @@ public class OrderRepository : IOrderRepository { return await _context.Orders .Include(o => o.Applications) + .Include(o => o.Dispute) + .ThenInclude(d => d!.Messages) .Where(o => o.SelectedPerformerId == performerId || o.Applications.Any(a => a.PerformerId == performerId)) .OrderByDescending(o => o.CreatedAt) .ToListAsync(cancellationToken); @@ -62,8 +68,8 @@ public class OrderRepository : IOrderRepository var activeStatuses = new[] { OrderStatus.PendingAcceptance, OrderStatus.InProgress }; return await _context.Orders - .Where(o => o.CustomerId == customerId && - o.SelectedPerformerId == performerId && + .Where(o => o.CustomerId == customerId && + o.SelectedPerformerId == performerId && o.ServiceId == serviceId && activeStatuses.Contains(o.Status)) .FirstOrDefaultAsync(cancellationToken); diff --git a/src/Modules/Order/Presentation/OrderEndpoints.cs b/src/Modules/Order/Presentation/OrderEndpoints.cs index 6f26e3e..b46254c 100644 --- a/src/Modules/Order/Presentation/OrderEndpoints.cs +++ b/src/Modules/Order/Presentation/OrderEndpoints.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Nashel.Modules.Order.Application.Commands; using Nashel.Modules.Order.Application.Queries; +using Nashel.Modules.Order.Domain.Entities; using Nashel.Modules.Order.Domain.Enums; namespace Nashel.Modules.Order.Presentation; @@ -78,9 +79,9 @@ public static class OrderEndpoints }); // ─── Принять заказ (мастер) ────────────────────────────────────────────────── - group.MapPost("/{id:guid}/accept", async (Guid id, AcceptOrderRequest request, ISender sender) => + group.MapPost("/{id:guid}/accept", async (Guid id, ISender sender) => { - var command = new AcceptOrderCommand(id, request.PerformerId); + var command = new AcceptOrderCommand(id); await sender.Send(command); return Results.Ok(); }) @@ -91,18 +92,104 @@ public static class OrderEndpoints Description = "Исполнитель принимает заказ. Переводит заказ в статус «В работе»." }); - // ─── Завершить заказ ───────────────────────────────────────────────────────── - group.MapPost("/{id:guid}/complete", async (Guid id, CompleteOrderRequest request, ISender sender) => + // ─── Пометить выполненным (мастер) ────────────────────────────────────────── + group.MapPost("/{id:guid}/finish", async (Guid id, FinishOrderRequest request, ISender sender) => { - var command = new CompleteOrderCommand(id, request.RequesterId); + var command = new FinishOrderCommand(id, request.PerformerId); await sender.Send(command); return Results.Ok(); }) - .WithName("CompleteOrder") + .WithName("FinishOrder") .WithOpenApi(op => new(op) { - Summary = "Завершить заказ", - Description = "Заказчик или исполнитель завершает заказ. Переводит в статус «Завершён»." + Summary = "Завершить работу (мастер)", + Description = "Исполнитель помечает работу как выполненную. Заказ переходит в статус подтверждения заказчиком." + }); + + // ─── Подтвердить выполнение (заказчик) ─────────────────────────────────────── + group.MapPost("/{id:guid}/confirm", async (Guid id, ConfirmOrderCompletionRequest request, ISender sender) => + { + var command = new ConfirmOrderCompletionCommand(id, request.CustomerId); + await sender.Send(command); + return Results.Ok(); + }) + .WithName("ConfirmOrderCompletion") + .WithOpenApi(op => new(op) + { + Summary = "Подтвердить выполнение (заказчик)", + Description = "Заказчик подтверждает, что работа выполнена успешно. Статус заказа становится «Завершён»." + }); + + // ─── Открыть спор (заказчик) ────────────────────────────────────────────────── + group.MapPost("/{id:guid}/dispute", async (Guid id, OpenDisputeRequest request, ISender sender) => + { + var command = new OpenOrderDisputeCommand( + id, + request.CustomerId, + request.Reason, + request.Description, + request.ProposedSolution, + request.Evidence); + await sender.Send(command); + return Results.Ok(); + }) + .WithName("OpenOrderDispute") + .WithOpenApi(op => new(op) + { + Summary = "Открыть спор", + Description = "Заказчик открывает спор, если работа выполнена некачественно или не в полном объеме." + }); + + // ─── Ответить на спор (мастер) ─────────────────────────────────────────────── + group.MapPost("/{id:guid}/dispute/respond", async (Guid id, RespondToDisputeRequest request, ISender sender) => + { + var command = new RespondToOrderDisputeCommand( + id, + request.PerformerId, + request.Position, + request.Evidence, + request.CounterSolution); + await sender.Send(command); + return Results.Ok(); + }) + .WithName("RespondToDispute") + .WithOpenApi(op => new(op) + { + Summary = "Ответить на спор", + Description = "Исполнитель предоставляет свою позицию и доказательства в рамках открытого спора." + }); + + // ─── Возразить на ответ исполнителя (заказчик) ────────────────────────────── + group.MapPost("/{id:guid}/dispute/rebut", async (Guid id, RebutDisputeRequest request, ISender sender) => + { + var command = new RebutDisputeCommand( + id, + request.CustomerId, + request.Comment, + request.Evidence, + request.CounterSolution); + await sender.Send(command); + return Results.Ok(); + }) + .WithName("RebutDispute") + .WithOpenApi(op => new(op) + { + Summary = "Возразить на ответ исполнителя", + Description = "Заказчик добавляет новые доказательства или требования в ответ на позицию исполнителя." + }); + + // ─── Принять условия спора (заказчик) ────────────────────────────────────── + group.MapPost("/{id:guid}/dispute/accept", async (Guid id, AcceptDisputeTermsRequest request, ISender sender) => + { + var command = new AcceptDisputeTermsCommand(id, request.CustomerId); + await sender.Send(command); + return Results.Ok(); + }) + .WithName("AcceptDisputeTerms") + .WithOpenApi(op => new(op) + { + Summary = "Принять условия спора", + Description = "Заказчик принимает предложенное исполнителем решение. Заказ завершается." }); // ─── Отменить заказ ────────────────────────────────────────────────────────── @@ -199,15 +286,9 @@ public record CreateOrderRequest } /// -/// Запрос принятия заказа исполнителем. +/// Запрос принятия заказа исполнителем (Пустой, так как ID берется из токена). /// -public record AcceptOrderRequest -{ - /// Идентификатор исполнителя, принимающего заказ. - public Guid PerformerId { get; init; } - - public AcceptOrderRequest() { } -} +public record AcceptOrderRequest(); /// /// Запрос завершения заказа. @@ -264,3 +345,45 @@ public record SelectPerformerRequest public SelectPerformerRequest() { } } +/// +/// Запрос пометки выполнения заказа. +/// +public record FinishOrderRequest(Guid PerformerId); + +/// +/// Запрос подтверждения выполнения. +/// +public record ConfirmOrderCompletionRequest(Guid CustomerId); + +/// +/// Запрос открытия спора. +/// +public record OpenDisputeRequest( + Guid CustomerId, + DisputeReason Reason, + string Description, + string ProposedSolution, + List Evidence); + +/// +/// Запрос ответа на спор. +/// +public record RespondToDisputeRequest( + Guid PerformerId, + string Position, + List Evidence, + string? CounterSolution); + +/// +/// Запрос на возражение по спору. +/// +public record RebutDisputeRequest( + Guid CustomerId, + string Comment, + List Evidence, + string? CounterSolution); + +/// +/// Запрос на принятие условий спора. +/// +public record AcceptDisputeTermsRequest(Guid CustomerId); diff --git a/src/Modules/Order/Tests/Application/AcceptOrderCommandTests.cs b/src/Modules/Order/Tests/Application/AcceptOrderCommandTests.cs new file mode 100644 index 0000000..5e1164a --- /dev/null +++ b/src/Modules/Order/Tests/Application/AcceptOrderCommandTests.cs @@ -0,0 +1,77 @@ +using FluentAssertions; +using NSubstitute; +using Nashel.BuildingBlocks.Application.Abstractions; +using Nashel.BuildingBlocks.Application.Exceptions; +using Nashel.Modules.Order.Application.Commands; +using Nashel.Modules.Order.Domain.Aggregates; +using Nashel.Modules.Order.Domain.Enums; +using Nashel.Modules.Order.Domain.Repositories; +using Nashel.Modules.Order.Domain.ValueObjects; +using NetTopologySuite.Geometries; +using Xunit; + +namespace Nashel.Modules.Order.Tests.Application; + +public class AcceptOrderCommandTests +{ + private readonly IOrderRepository _repository; + private readonly ICurrentUserService _currentUserService; + private readonly AcceptOrderHandler _handler; + + public AcceptOrderCommandTests() + { + _repository = Substitute.For(); + _currentUserService = Substitute.For(); + _handler = new AcceptOrderHandler(_repository, _currentUserService); + } + + [Fact] + public async Task Handle_Should_AcceptOrder_WhenCurrentUserIsSelectedPerformer() + { + // Arrange (Подготовка) + var customerId = Guid.NewGuid(); + var performerId = Guid.NewGuid(); + var location = new OrderLocation("Test St", new Point(10, 20)); + + // Создаем прямой заказ (он сразу выбран для этого исполнителя) + var order = Nashel.Modules.Order.Domain.Aggregates.Order.CreateDirect( + customerId, "Customer", Guid.NewGuid(), "Service", 100, 0, performerId, "Performer", location, null); + + _repository.GetByIdAsync(order.Id, Arg.Any()).Returns(order); + _currentUserService.UserId.Returns(performerId); + + var command = new AcceptOrderCommand(order.Id); + + // Act (Действие) + await _handler.Handle(command, CancellationToken.None); + + // Assert (Проверка) + order.Status.Should().Be(OrderStatus.InProgress); + await _repository.Received(1).SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Handle_Should_ThrowForbidden_WhenCurrentUserIsNOTSelectedPerformer() + { + // Arrange (Подготовка) + var customerId = Guid.NewGuid(); + var performerId = Guid.NewGuid(); + var strangerId = Guid.NewGuid(); + var location = new OrderLocation("Test St", new Point(10, 20)); + + var order = Nashel.Modules.Order.Domain.Aggregates.Order.CreateDirect( + customerId, "Customer", Guid.NewGuid(), "Service", 100, 0, performerId, "Performer", location, null); + + _repository.GetByIdAsync(order.Id, Arg.Any()).Returns(order); + _currentUserService.UserId.Returns(strangerId); + + var command = new AcceptOrderCommand(order.Id); + + // Act (Действие) + var act = async () => await _handler.Handle(command, CancellationToken.None); + + // Assert (Проверка) + await act.Should().ThrowAsync(); + order.Status.Should().Be(OrderStatus.PendingAcceptance); + } +} diff --git a/src/Modules/Order/Tests/Application/SelectPerformerHandlerTests.cs b/src/Modules/Order/Tests/Application/SelectPerformerHandlerTests.cs index e354df4..8898d8a 100644 --- a/src/Modules/Order/Tests/Application/SelectPerformerHandlerTests.cs +++ b/src/Modules/Order/Tests/Application/SelectPerformerHandlerTests.cs @@ -27,7 +27,7 @@ public class SelectPerformerHandlerTests [Fact] public async Task Handle_Should_Call_SlaTimer() { - // Arrange + // Arrange (Подготовка) var customerId = Guid.NewGuid(); var serviceId = Guid.NewGuid(); var performerId = Guid.NewGuid(); @@ -45,10 +45,10 @@ public class SelectPerformerHandlerTests var command = new SelectPerformerCommand(order.Id, performerId); - // Act + // Act (Действие) await _handler.Handle(command, CancellationToken.None); - // Assert + // Assert (Проверка) // Проверяем изменение статуса order.Status.Should().Be(OrderStatus.PendingAcceptance); diff --git a/src/Modules/Order/Tests/Domain/OrderAggregateTests.cs b/src/Modules/Order/Tests/Domain/OrderAggregateTests.cs index e27f0bf..93d317e 100644 --- a/src/Modules/Order/Tests/Domain/OrderAggregateTests.cs +++ b/src/Modules/Order/Tests/Domain/OrderAggregateTests.cs @@ -1,6 +1,7 @@ using FluentAssertions; using Nashel.Modules.Order.Domain.Enums; using Nashel.Modules.Order.Domain.ValueObjects; +using Nashel.Modules.Order.Domain.Exceptions; using NetTopologySuite.Geometries; using Xunit; @@ -11,17 +12,17 @@ public class OrderAggregateTests [Fact] public void CreateDirect_Should_SetStatus_PendingAcceptance() { - // Arrange + // Arrange (Подготовка) var customerId = Guid.NewGuid(); var serviceId = Guid.NewGuid(); var performerId = Guid.NewGuid(); var location = new OrderLocation("Test St", new Point(10, 20)); - // Act + // Act (Действие) var order = Nashel.Modules.Order.Domain.Aggregates.Order.CreateDirect( customerId, "Customer", serviceId, "Service", 100, 0, performerId, "Performer", location, DateTime.UtcNow.AddDays(1)); - // Assert + // Assert (Проверка) order.Status.Should().Be(OrderStatus.PendingAcceptance); order.SelectedPerformerId.Should().Be(performerId); order.Type.Should().Be(OrderType.Direct); @@ -30,27 +31,28 @@ public class OrderAggregateTests [Fact] public void AddApplication_Should_Throw_If_OrderNotPublished() { - // Arrange + // Arrange (Подготовка) var customerId = Guid.NewGuid(); var serviceId = Guid.NewGuid(); var performerId = Guid.NewGuid(); var location = new OrderLocation("Test St", new Point(10, 20)); - // Direct order created in PendingAcceptance + // Прямой заказ создается со статусом PendingAcceptance var order = Nashel.Modules.Order.Domain.Aggregates.Order.CreateDirect( customerId, "Customer", serviceId, "Service", 100, 0, performerId, "Performer", location, null); - // Act + // Act (Действие) var action = () => order.AddApplication(Guid.NewGuid(), new Money(100, "RUB"), "Comment"); - // Assert - action.Should().Throw() - .WithMessage("Нельзя оставить отклик на заказ, который не опубликован.*"); // Regex match + // Assert (Проверка) + // Теперь должно выбрасываться доменное исключение OrderDomainException + action.Should().Throw() + .WithMessage("Нельзя оставить отклик на заказ, который не опубликован."); } [Fact] public void SelectPerformer_Should_ChangeStatus_To_Pending() { - // Arrange + // Arrange (Подготовка) var customerId = Guid.NewGuid(); var serviceId = Guid.NewGuid(); var location = new OrderLocation("Test St", new Point(10, 20)); @@ -60,10 +62,10 @@ public class OrderAggregateTests var performerId = Guid.NewGuid(); order.AddApplication(performerId, new Money(100, "RUB"), "Comment"); - // Act + // Act (Действие) order.SelectPerformer(performerId); - // Assert + // Assert (Проверка) order.Status.Should().Be(OrderStatus.PendingAcceptance); order.SelectedPerformerId.Should().Be(performerId); } diff --git a/src/Modules/Reputation/Application/Commands/AddReviewAdditionCommand.cs b/src/Modules/Reputation/Application/Commands/AddReviewAdditionCommand.cs new file mode 100644 index 0000000..211dd63 --- /dev/null +++ b/src/Modules/Reputation/Application/Commands/AddReviewAdditionCommand.cs @@ -0,0 +1,36 @@ +using MediatR; +using Nashel.Modules.Reputation.Domain.Repositories; + +namespace Nashel.Modules.Reputation.Application.Commands; + +public record AddReviewAdditionCommand( + Guid ReviewId, + Guid AuthorId, + string Comment, + List? MediaUrls = null) : IRequest; + +public class AddReviewAdditionHandler : IRequestHandler +{ + private readonly IReviewRepository _repository; + + public AddReviewAdditionHandler(IReviewRepository repository) + { + _repository = repository; + } + + public async Task Handle(AddReviewAdditionCommand request, CancellationToken cancellationToken) + { + var review = await _repository.GetByIdAsync(request.ReviewId, cancellationToken); + if (review == null) throw new KeyNotFoundException("Отзыв не найден."); + + if (review.AuthorId != request.AuthorId) + { + throw new UnauthorizedAccessException("Только автор может дополнять отзыв."); + } + + review.AddAddition(request.Comment, request.MediaUrls); + await _repository.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } +} diff --git a/src/Modules/Reputation/Application/Commands/CreateReviewCommand.cs b/src/Modules/Reputation/Application/Commands/CreateReviewCommand.cs deleted file mode 100644 index 58c4c1a..0000000 --- a/src/Modules/Reputation/Application/Commands/CreateReviewCommand.cs +++ /dev/null @@ -1,46 +0,0 @@ -using MediatR; -using Nashel.Modules.Reputation.Domain.Entities; -using Nashel.Modules.Reputation.Domain.Repositories; - -namespace Nashel.Modules.Reputation.Application.Commands; - -/// -/// Команда создания отзыва. -/// -public record CreateReviewCommand(Guid OrderId, Guid AuthorId, Guid TargetId, int Rating, string Text) : IRequest; - -public class CreateReviewHandler : IRequestHandler -{ - private readonly IReviewRepository _repository; - private readonly IMediator _mediator; - - public CreateReviewHandler(IReviewRepository repository, IMediator mediator) - { - _repository = repository; - _mediator = mediator; - } - - public async Task Handle(CreateReviewCommand request, CancellationToken cancellationToken) - { - // В реальном приложении здесь должна быть проверка существования заказа и ролей. - - var review = Review.Create( - request.OrderId, - request.AuthorId, - request.TargetId, - request.Rating, - request.Text - ); - - await _repository.AddAsync(review, cancellationToken); - - // Публикация доменных событий - foreach (var domainEvent in review.DomainEvents) - { - await _mediator.Publish(domainEvent, cancellationToken); - } - review.ClearDomainEvents(); - - return review.Id; - } -} diff --git a/src/Modules/Reputation/Application/Commands/LeaveReviewCommand.cs b/src/Modules/Reputation/Application/Commands/LeaveReviewCommand.cs new file mode 100644 index 0000000..8532cc4 --- /dev/null +++ b/src/Modules/Reputation/Application/Commands/LeaveReviewCommand.cs @@ -0,0 +1,59 @@ +using MediatR; +using Nashel.Modules.Reputation.Domain.Entities; +using Nashel.Modules.Reputation.Domain.Repositories; + +namespace Nashel.Modules.Reputation.Application.Commands; + +/// +/// Команда создания отзыва. +/// +public record LeaveReviewCommand(Guid OrderId, Guid OfferId, Guid AuthorId, Guid TargetId, int Rating, string? Comment, List MediaUrls) : IRequest; + +public class LeaveReviewHandler : IRequestHandler +{ + private readonly IReviewRepository _repository; + private readonly IMediator _mediator; + + public LeaveReviewHandler(IReviewRepository repository, IMediator mediator) + { + _repository = repository; + _mediator = mediator; + } + + public async Task Handle(LeaveReviewCommand request, CancellationToken cancellationToken) + { + // 1. Проверка на дубликат через репозиторий + var exists = await _repository.ExistsAsync(request.OrderId, request.AuthorId, cancellationToken); + if (exists) + { + throw new InvalidOperationException("Вы уже оставили отзыв на этот заказ."); + } + + // 2. Создание отзыва + var review = Review.Create( + request.OrderId, + request.OfferId, + request.AuthorId, + request.TargetId, + request.Rating, + request.Comment, + isAutoGenerated: false + ); + + if (request.MediaUrls.Any()) + { + review.AddMedia(request.MediaUrls); + } + + await _repository.AddAsync(review, cancellationToken); + + // 3. Публикация событий + foreach (var domainEvent in review.DomainEvents) + { + await _mediator.Publish(domainEvent, cancellationToken); + } + review.ClearDomainEvents(); + + return review.Id; + } +} diff --git a/src/Modules/Reputation/Application/Nashel.Modules.Reputation.Application.csproj b/src/Modules/Reputation/Application/Nashel.Modules.Reputation.Application.csproj index 54e5113..13fc9ed 100644 --- a/src/Modules/Reputation/Application/Nashel.Modules.Reputation.Application.csproj +++ b/src/Modules/Reputation/Application/Nashel.Modules.Reputation.Application.csproj @@ -2,6 +2,7 @@ + diff --git a/src/Modules/Reputation/Application/Queries/GetOfferRatingQuery.cs b/src/Modules/Reputation/Application/Queries/GetOfferRatingQuery.cs new file mode 100644 index 0000000..7bb5c75 --- /dev/null +++ b/src/Modules/Reputation/Application/Queries/GetOfferRatingQuery.cs @@ -0,0 +1,25 @@ +using MediatR; +using Nashel.Modules.Reputation.Domain.Repositories; + +namespace Nashel.Modules.Reputation.Application.Queries; + +/// +/// Запрос получения агрегированного рейтинга для конкретной услуги. +/// +public record GetOfferRatingQuery(Guid OfferId) : IRequest; + +public class GetOfferRatingHandler : IRequestHandler +{ + private readonly IReviewRepository _repository; + + public GetOfferRatingHandler(IReviewRepository repository) + { + _repository = repository; + } + + public async Task Handle(GetOfferRatingQuery request, CancellationToken cancellationToken) + { + var (avg, total) = await _repository.GetOfferStatsAsync(request.OfferId, cancellationToken); + return new ProfileRatingDto(avg, total); + } +} diff --git a/src/Modules/Reputation/Application/Queries/GetProfileRatingQuery.cs b/src/Modules/Reputation/Application/Queries/GetProfileRatingQuery.cs new file mode 100644 index 0000000..0c3a530 --- /dev/null +++ b/src/Modules/Reputation/Application/Queries/GetProfileRatingQuery.cs @@ -0,0 +1,25 @@ +using MediatR; +using Nashel.Modules.Reputation.Domain.Repositories; + +namespace Nashel.Modules.Reputation.Application.Queries; + +/// +/// Запрос получения агрегированного рейтинга. +/// +public record GetProfileRatingQuery(Guid UserId) : IRequest; + +public class GetProfileRatingHandler : IRequestHandler +{ + private readonly IReviewRepository _repository; + + public GetProfileRatingHandler(IReviewRepository repository) + { + _repository = repository; + } + + public async Task Handle(GetProfileRatingQuery request, CancellationToken cancellationToken) + { + var (avg, total) = await _repository.GetStatsAsync(request.UserId, cancellationToken); + return new ProfileRatingDto(avg, total); + } +} diff --git a/src/Modules/Reputation/Application/Queries/GetProfileReviewsQuery.cs b/src/Modules/Reputation/Application/Queries/GetProfileReviewsQuery.cs new file mode 100644 index 0000000..b173b1f --- /dev/null +++ b/src/Modules/Reputation/Application/Queries/GetProfileReviewsQuery.cs @@ -0,0 +1,76 @@ +using MediatR; +using Nashel.Modules.Reputation.Domain.Repositories; + +namespace Nashel.Modules.Reputation.Application.Queries; + +public record AuthorDto(Guid Id, string FirstName, string LastName); + +public record ReviewAdditionDto(string Comment, List MediaUrls, DateTime CreatedAt); + +/// +/// DTO конкретного отзыва. +/// +public record ProfileReviewDto( + Guid Id, + Guid OrderId, + Guid AuthorId, + Guid TargetId, + int Rating, + string? Comment, + DateTime CreatedAt, + bool IsAutoGenerated, + List MediaUrls, + List Additions, + AuthorDto? Author = null); + +/// +/// Запрос списка отзывов с пагинацией. +/// +public record GetProfileReviewsQuery(Guid TargetId, int Page, int PageSize) : IRequest; + +public record ProfileReviewsResult(List Reviews, int TotalCount); + +public class GetProfileReviewsHandler : IRequestHandler +{ + private readonly IReviewRepository _repository; + private readonly IMediator _mediator; + + public GetProfileReviewsHandler(IReviewRepository repository, IMediator mediator) + { + _repository = repository; + _mediator = mediator; + } + + public async Task Handle(GetProfileReviewsQuery request, CancellationToken cancellationToken) + { + var (items, total) = await _repository.GetPaginatedAsync(request.TargetId, request.Page, request.PageSize, cancellationToken); + + var authorIds = items.Select(r => r.AuthorId).Distinct().ToList(); + var authorsDetailsResult = await _mediator.Send(new Nashel.Modules.Identity.Application.Queries.GetUsersDetailsQuery(authorIds), cancellationToken); + var authorsMap = (authorsDetailsResult.IsSuccess && authorsDetailsResult.Value != null) + ? authorsDetailsResult.Value.ToDictionary(a => a.Id) + : new Dictionary(); + + var reviews = items.Select(r => + { + var authorDetails = authorsMap.GetValueOrDefault(r.AuthorId); + return new ProfileReviewDto( + r.Id, + r.OrderId, + r.AuthorId, + r.TargetId, + r.Rating, + r.Comment, + r.CreatedAt, + r.IsAutoGenerated, + r.MediaUrls, + r.Additions.Select(a => new ReviewAdditionDto(a.Comment, a.MediaUrls, a.CreatedAt)).ToList(), + Author: authorDetails != null + ? new AuthorDto(authorDetails.Id, authorDetails.FullName ?? "Пользователь", string.Empty) + : null + ); + }).ToList(); + + return new ProfileReviewsResult(reviews, total); + } +} diff --git a/src/Modules/Reputation/Application/Queries/GetReviewsByTargetQuery.cs b/src/Modules/Reputation/Application/Queries/GetReviewsByTargetQuery.cs index f8849a6..57d3fb8 100644 --- a/src/Modules/Reputation/Application/Queries/GetReviewsByTargetQuery.cs +++ b/src/Modules/Reputation/Application/Queries/GetReviewsByTargetQuery.cs @@ -10,7 +10,7 @@ namespace Nashel.Modules.Reputation.Application.Queries; /// ID пользователя. public record GetReviewsByTargetQuery(Guid TargetId) : IRequest>; -public record ReviewDto(Guid ReviewId, int Rating, string Text, string[] MediaUrls, DateTime CreatedAt); +public record ReviewDto(Guid ReviewId, int Rating, string? Text, string[] MediaUrls, DateTime CreatedAt); public class GetReviewsByTargetHandler : IRequestHandler> { @@ -24,11 +24,11 @@ public class GetReviewsByTargetHandler : IRequestHandler> Handle(GetReviewsByTargetQuery request, CancellationToken cancellationToken) { var reviews = await _repository.GetByTargetIdAsync(request.TargetId, cancellationToken); - + return reviews.Select(r => new ReviewDto( r.Id, r.Rating, - r.Text, + r.Comment, r.MediaUrls.ToArray(), r.CreatedAt )).ToList(); diff --git a/src/Modules/Reputation/Application/Queries/GetUsersRatingQuery.cs b/src/Modules/Reputation/Application/Queries/GetUsersRatingQuery.cs new file mode 100644 index 0000000..d26023c --- /dev/null +++ b/src/Modules/Reputation/Application/Queries/GetUsersRatingQuery.cs @@ -0,0 +1,25 @@ +using MediatR; +using Nashel.Modules.Reputation.Domain.Repositories; + +namespace Nashel.Modules.Reputation.Application.Queries; + +public record GetUsersRatingQuery(List UserIds) : IRequest>; + +public class GetUsersRatingHandler : IRequestHandler> +{ + private readonly IReviewRepository _repository; + + public GetUsersRatingHandler(IReviewRepository repository) + { + _repository = repository; + } + + public async Task> Handle(GetUsersRatingQuery request, CancellationToken cancellationToken) + { + var stats = await _repository.GetStatsForUsersAsync(request.UserIds, cancellationToken); + return stats.ToDictionary( + s => s.Key, + s => new ProfileRatingDto(s.Value.avg, s.Value.total) + ); + } +} diff --git a/src/Modules/Reputation/Application/Queries/ProfileRatingDto.cs b/src/Modules/Reputation/Application/Queries/ProfileRatingDto.cs new file mode 100644 index 0000000..2caf308 --- /dev/null +++ b/src/Modules/Reputation/Application/Queries/ProfileRatingDto.cs @@ -0,0 +1,6 @@ +namespace Nashel.Modules.Reputation.Application.Queries; + +/// +/// DTO рейтинга пользователя или услуги. +/// +public record ProfileRatingDto(double AverageRating, int TotalReviews); diff --git a/src/Modules/Reputation/Domain/Entities/Review.cs b/src/Modules/Reputation/Domain/Entities/Review.cs index 62738d2..e2b101f 100644 --- a/src/Modules/Reputation/Domain/Entities/Review.cs +++ b/src/Modules/Reputation/Domain/Entities/Review.cs @@ -8,13 +8,16 @@ namespace Nashel.Modules.Reputation.Domain.Entities; /// public class Review : AggregateRoot { - private DateTime _createdAt; - /// /// ID заказа, к которому относится отзыв. /// public Guid OrderId { get; private set; } + /// + /// ID оффера/услуги, к которой относится отзыв. + /// + public Guid OfferId { get; private set; } + /// /// ID автора отзыва. /// @@ -31,9 +34,9 @@ public class Review : AggregateRoot public int Rating { get; private set; } /// - /// Текст отзыва. + /// Текст комментария. /// - public string Text { get; private set; } + public string? Comment { get; private set; } /// /// Список ссылок на медиа-файлы (фото). @@ -48,35 +51,44 @@ public class Review : AggregateRoot /// /// Дата создания отзыва. /// - public DateTime CreatedAt => _createdAt; + public DateTime CreatedAt { get; private set; } + + /// + /// Дополнения к отзыву. + /// + public List Additions { get; private set; } = new(); // Для EF Core private Review() { - Text = null!; MediaUrls = null!; } - private Review(Guid id, Guid orderId, Guid authorId, Guid targetId, int rating, string text, bool isAutoGenerated) + private Review(Guid id, Guid orderId, Guid offerId, Guid authorId, Guid targetId, int rating, string? comment, bool isAutoGenerated) { Id = id; OrderId = orderId; + OfferId = offerId; AuthorId = authorId; TargetId = targetId; Rating = rating; - Text = text; + Comment = comment; IsAutoGenerated = isAutoGenerated; MediaUrls = new List(); - _createdAt = DateTime.UtcNow; + CreatedAt = DateTime.UtcNow; if (rating < 1 || rating > 5) { throw new ArgumentOutOfRangeException(nameof(rating), "Рейтинг должен быть от 1 до 5."); } } - public static Review Create(Guid orderId, Guid authorId, Guid targetId, int rating, string text, bool isAutoGenerated = false) + + /// + /// Создает новый отзыв. + /// + public static Review Create(Guid orderId, Guid offerId, Guid authorId, Guid targetId, int rating, string? comment, bool isAutoGenerated = false) { - var review = new Review(Guid.NewGuid(), orderId, authorId, targetId, rating, text, isAutoGenerated); + var review = new Review(Guid.NewGuid(), orderId, offerId, authorId, targetId, rating, comment, isAutoGenerated); review.AddDomainEvent(new ReviewCreatedEvent(review.Id, targetId, rating)); return review; } @@ -86,7 +98,6 @@ public class Review : AggregateRoot /// public void AddMedia(List urls) { - // Можно проверить время редактирования CheckIfEditable(); MediaUrls.AddRange(urls); } @@ -94,25 +105,53 @@ public class Review : AggregateRoot /// /// Обновляет текст и рейтинг. /// - public void Update(string text, int rating) + public void Update(string? comment, int rating) { CheckIfEditable(); if (rating < 1 || rating > 5) throw new ArgumentOutOfRangeException(nameof(rating), "Рейтинг должен быть от 1 до 5."); - Text = text; + Comment = comment; Rating = rating; } + /// + /// Добавляет дополнение к отзыву. + /// + public void AddAddition(string comment, List? mediaUrls = null) + { + Additions.Add(new ReviewAddition(comment, mediaUrls ?? new List())); + } + /// /// Проверяет, можно ли редактировать отзыв (доступно только в течение 3 дней). /// private void CheckIfEditable() { - if (DateTime.UtcNow > _createdAt.AddDays(3)) + if (DateTime.UtcNow > CreatedAt.AddDays(3)) { - throw new InvalidOperationException("Редактирование отзыва доступно только в течение 3 дней после создания."); + throw new InvalidOperationException("Редактирование первоначального отзыва доступно только в течение 3 дней после создания. Используйте дополнения."); } } } + +public class ReviewAddition +{ + public string Comment { get; private set; } + public List MediaUrls { get; private set; } + public DateTime CreatedAt { get; private set; } + + private ReviewAddition() + { + Comment = null!; + MediaUrls = null!; + } + + public ReviewAddition(string comment, List mediaUrls) + { + Comment = comment; + MediaUrls = mediaUrls; + CreatedAt = DateTime.UtcNow; + } +} diff --git a/src/Modules/Reputation/Domain/Repositories/IRepositories.cs b/src/Modules/Reputation/Domain/Repositories/IRepositories.cs index e80697d..6404794 100644 --- a/src/Modules/Reputation/Domain/Repositories/IRepositories.cs +++ b/src/Modules/Reputation/Domain/Repositories/IRepositories.cs @@ -11,7 +11,14 @@ namespace Nashel.Modules.Reputation.Domain.Repositories Task AddAsync(Review review, CancellationToken cancellationToken = default); Task> GetByTargetIdAsync(Guid targetId, CancellationToken cancellationToken = default); Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); - // Для пересчета среднего рейтинга можно использовать SQL-запрос, но здесь пока просто GetByTargetId + + // Новые методы + Task ExistsAsync(Guid orderId, Guid authorId, CancellationToken cancellationToken = default); + Task<(double avg, int total)> GetStatsAsync(Guid userId, CancellationToken cancellationToken = default); + Task> GetStatsForUsersAsync(IEnumerable userIds, CancellationToken cancellationToken = default); + Task<(double avg, int total)> GetOfferStatsAsync(Guid offerId, CancellationToken cancellationToken = default); + Task<(List items, int total)> GetPaginatedAsync(Guid targetId, int page, int pageSize, CancellationToken cancellationToken = default); + Task SaveChangesAsync(CancellationToken cancellationToken = default); } public interface IDisputeRepository diff --git a/src/Modules/Reputation/Infrastructure/BackgroundJobs/AutoReviewBackgroundService.cs b/src/Modules/Reputation/Infrastructure/BackgroundJobs/AutoReviewBackgroundService.cs new file mode 100644 index 0000000..c2124e5 --- /dev/null +++ b/src/Modules/Reputation/Infrastructure/BackgroundJobs/AutoReviewBackgroundService.cs @@ -0,0 +1,85 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Nashel.Modules.Order.Domain.Enums; +using Nashel.Modules.Order.Infrastructure.Persistence; +using Nashel.Modules.Reputation.Domain.Entities; +using Nashel.Modules.Reputation.Infrastructure.Persistence; + +namespace Nashel.Modules.Reputation.Infrastructure.BackgroundJobs; + +/// +/// Фоновая служба для автоматического завершения отзывов через 7 дней. +/// +public class AutoReviewBackgroundService : BackgroundService +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public AutoReviewBackgroundService(IServiceProvider serviceProvider, ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Запуск AutoReviewBackgroundService..."); + while (!stoppingToken.IsCancellationRequested) + { + try + { + await ProcessAutoReviewsAsync(stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Ошибка при выполнении AutoReviewBackgroundService."); + } + + // Ждем 24 часа. Можно использовать Timer или Cron. + await Task.Delay(TimeSpan.FromDays(1), stoppingToken); + } + } + + private async Task ProcessAutoReviewsAsync(CancellationToken stoppingToken) + { + using var scope = _serviceProvider.CreateScope(); + var orderContext = scope.ServiceProvider.GetRequiredService(); + var reputationContext = scope.ServiceProvider.GetRequiredService(); + + var sevenDaysAgo = DateTime.UtcNow.AddDays(-7); + + // 1. Ищем завершенные заказы старше 7 дней + var completedOrders = await orderContext.Orders + .Where(o => o.Status == OrderStatus.Completed + && o.CompletedAt < sevenDaysAgo) + .ToListAsync(stoppingToken); + + foreach (var order in completedOrders) + { + // 2. Проверяем, есть ли уже отзыв + var existingReview = await reputationContext.Reviews + .AnyAsync(r => r.OrderId == order.Id && r.AuthorId == order.CustomerId, stoppingToken); + + if (!existingReview) + { + _logger.LogInformation("Генерация авто-отзыва для заказа {OrderId}", order.Id); + + var review = Review.Create( + order.Id, + order.ServiceId, + order.CustomerId, + order.SelectedPerformerId ?? Guid.Empty, // Кого оцениваем + 5, + "Заказ завершен успешно", + true + ); + + reputationContext.Reviews.Add(review); + } + } + + await reputationContext.SaveChangesAsync(stoppingToken); + } +} diff --git a/src/Modules/Reputation/Infrastructure/DependencyInjection.cs b/src/Modules/Reputation/Infrastructure/DependencyInjection.cs index 021afc3..1b84cbf 100644 --- a/src/Modules/Reputation/Infrastructure/DependencyInjection.cs +++ b/src/Modules/Reputation/Infrastructure/DependencyInjection.cs @@ -29,7 +29,7 @@ public static class DependencyInjection }); // Фоновые задачи - services.AddHostedService(); + services.AddHostedService(); return services; } diff --git a/src/Modules/Reputation/Infrastructure/Migrations/20260308201003_FixReviewFields.Designer.cs b/src/Modules/Reputation/Infrastructure/Migrations/20260308201003_FixReviewFields.Designer.cs new file mode 100644 index 0000000..711e6f7 --- /dev/null +++ b/src/Modules/Reputation/Infrastructure/Migrations/20260308201003_FixReviewFields.Designer.cs @@ -0,0 +1,136 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nashel.Modules.Reputation.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nashel.Modules.Reputation.Infrastructure.Migrations +{ + [DbContext(typeof(ReputationDbContext))] + [Migration("20260308201003_FixReviewFields")] + partial class FixReviewFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("reputation") + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Dispute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Disputes", "reputation"); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.DisputeEvidence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisputeId") + .HasColumnType("uuid"); + + b.Property("FileType") + .IsRequired() + .HasColumnType("text"); + + b.Property("FileUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("UploaderId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DisputeId"); + + b.ToTable("DisputeEvidence", "reputation"); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Review", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("Comment") + .HasColumnType("text"); + + b.Property("IsAutoGenerated") + .HasColumnType("boolean"); + + b.PrimitiveCollection>("MediaUrls") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetId"); + + b.HasIndex("OrderId", "AuthorId") + .IsUnique(); + + b.ToTable("Reviews", "reputation"); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.DisputeEvidence", b => + { + b.HasOne("Nashel.Modules.Reputation.Domain.Entities.Dispute", null) + .WithMany("Evidences") + .HasForeignKey("DisputeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Dispute", b => + { + b.Navigation("Evidences"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Reputation/Infrastructure/Migrations/20260308201003_FixReviewFields.cs b/src/Modules/Reputation/Infrastructure/Migrations/20260308201003_FixReviewFields.cs new file mode 100644 index 0000000..4751fa4 --- /dev/null +++ b/src/Modules/Reputation/Infrastructure/Migrations/20260308201003_FixReviewFields.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nashel.Modules.Reputation.Infrastructure.Migrations +{ + /// + public partial class FixReviewFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Text", + schema: "reputation", + table: "Reviews"); + + migrationBuilder.AddColumn( + name: "Comment", + schema: "reputation", + table: "Reviews", + type: "text", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Reviews_OrderId_AuthorId", + schema: "reputation", + table: "Reviews", + columns: new[] { "OrderId", "AuthorId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Reviews_OrderId_AuthorId", + schema: "reputation", + table: "Reviews"); + + migrationBuilder.DropColumn( + name: "Comment", + schema: "reputation", + table: "Reviews"); + + migrationBuilder.AddColumn( + name: "Text", + schema: "reputation", + table: "Reviews", + type: "text", + nullable: false, + defaultValue: ""); + } + } +} diff --git a/src/Modules/Reputation/Infrastructure/Migrations/20260308224208_AddMediaUrlsToReviews.Designer.cs b/src/Modules/Reputation/Infrastructure/Migrations/20260308224208_AddMediaUrlsToReviews.Designer.cs new file mode 100644 index 0000000..771c3fe --- /dev/null +++ b/src/Modules/Reputation/Infrastructure/Migrations/20260308224208_AddMediaUrlsToReviews.Designer.cs @@ -0,0 +1,136 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nashel.Modules.Reputation.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nashel.Modules.Reputation.Infrastructure.Migrations +{ + [DbContext(typeof(ReputationDbContext))] + [Migration("20260308224208_AddMediaUrlsToReviews")] + partial class AddMediaUrlsToReviews + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("reputation") + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Dispute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Disputes", "reputation"); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.DisputeEvidence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisputeId") + .HasColumnType("uuid"); + + b.Property("FileType") + .IsRequired() + .HasColumnType("text"); + + b.Property("FileUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("UploaderId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DisputeId"); + + b.ToTable("DisputeEvidence", "reputation"); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Review", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("Comment") + .HasColumnType("text"); + + b.Property("IsAutoGenerated") + .HasColumnType("boolean"); + + b.PrimitiveCollection>("MediaUrls") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetId"); + + b.HasIndex("OrderId", "AuthorId") + .IsUnique(); + + b.ToTable("Reviews", "reputation"); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.DisputeEvidence", b => + { + b.HasOne("Nashel.Modules.Reputation.Domain.Entities.Dispute", null) + .WithMany("Evidences") + .HasForeignKey("DisputeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Dispute", b => + { + b.Navigation("Evidences"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Reputation/Infrastructure/Migrations/20260308224208_AddMediaUrlsToReviews.cs b/src/Modules/Reputation/Infrastructure/Migrations/20260308224208_AddMediaUrlsToReviews.cs new file mode 100644 index 0000000..c699cd2 --- /dev/null +++ b/src/Modules/Reputation/Infrastructure/Migrations/20260308224208_AddMediaUrlsToReviews.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nashel.Modules.Reputation.Infrastructure.Migrations +{ + /// + public partial class AddMediaUrlsToReviews : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/src/Modules/Reputation/Infrastructure/Migrations/20260308224700_AddCreatedAtToReviews.Designer.cs b/src/Modules/Reputation/Infrastructure/Migrations/20260308224700_AddCreatedAtToReviews.Designer.cs new file mode 100644 index 0000000..9c6fba8 --- /dev/null +++ b/src/Modules/Reputation/Infrastructure/Migrations/20260308224700_AddCreatedAtToReviews.Designer.cs @@ -0,0 +1,139 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nashel.Modules.Reputation.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nashel.Modules.Reputation.Infrastructure.Migrations +{ + [DbContext(typeof(ReputationDbContext))] + [Migration("20260308224700_AddCreatedAtToReviews")] + partial class AddCreatedAtToReviews + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("reputation") + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Dispute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Disputes", "reputation"); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.DisputeEvidence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisputeId") + .HasColumnType("uuid"); + + b.Property("FileType") + .IsRequired() + .HasColumnType("text"); + + b.Property("FileUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("UploaderId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DisputeId"); + + b.ToTable("DisputeEvidence", "reputation"); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Review", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("Comment") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsAutoGenerated") + .HasColumnType("boolean"); + + b.PrimitiveCollection>("MediaUrls") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetId"); + + b.HasIndex("OrderId", "AuthorId") + .IsUnique(); + + b.ToTable("Reviews", "reputation"); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.DisputeEvidence", b => + { + b.HasOne("Nashel.Modules.Reputation.Domain.Entities.Dispute", null) + .WithMany("Evidences") + .HasForeignKey("DisputeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Dispute", b => + { + b.Navigation("Evidences"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Reputation/Infrastructure/Migrations/20260308224700_AddCreatedAtToReviews.cs b/src/Modules/Reputation/Infrastructure/Migrations/20260308224700_AddCreatedAtToReviews.cs new file mode 100644 index 0000000..107bfb3 --- /dev/null +++ b/src/Modules/Reputation/Infrastructure/Migrations/20260308224700_AddCreatedAtToReviews.cs @@ -0,0 +1,32 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nashel.Modules.Reputation.Infrastructure.Migrations +{ + /// + public partial class AddCreatedAtToReviews : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CreatedAt", + schema: "reputation", + table: "Reviews", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CreatedAt", + schema: "reputation", + table: "Reviews"); + } + } +} diff --git a/src/Modules/Reputation/Infrastructure/Migrations/20260308232944_AddOfferIdToReviews.Designer.cs b/src/Modules/Reputation/Infrastructure/Migrations/20260308232944_AddOfferIdToReviews.Designer.cs new file mode 100644 index 0000000..606b016 --- /dev/null +++ b/src/Modules/Reputation/Infrastructure/Migrations/20260308232944_AddOfferIdToReviews.Designer.cs @@ -0,0 +1,144 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nashel.Modules.Reputation.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nashel.Modules.Reputation.Infrastructure.Migrations +{ + [DbContext(typeof(ReputationDbContext))] + [Migration("20260308232944_AddOfferIdToReviews")] + partial class AddOfferIdToReviews + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("reputation") + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Dispute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Disputes", "reputation"); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.DisputeEvidence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisputeId") + .HasColumnType("uuid"); + + b.Property("FileType") + .IsRequired() + .HasColumnType("text"); + + b.Property("FileUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("UploaderId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DisputeId"); + + b.ToTable("DisputeEvidence", "reputation"); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Review", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("Comment") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsAutoGenerated") + .HasColumnType("boolean"); + + b.PrimitiveCollection>("MediaUrls") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("OfferId") + .HasColumnType("uuid"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OfferId"); + + b.HasIndex("TargetId"); + + b.HasIndex("OrderId", "AuthorId") + .IsUnique(); + + b.ToTable("Reviews", "reputation"); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.DisputeEvidence", b => + { + b.HasOne("Nashel.Modules.Reputation.Domain.Entities.Dispute", null) + .WithMany("Evidences") + .HasForeignKey("DisputeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Dispute", b => + { + b.Navigation("Evidences"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Reputation/Infrastructure/Migrations/20260308232944_AddOfferIdToReviews.cs b/src/Modules/Reputation/Infrastructure/Migrations/20260308232944_AddOfferIdToReviews.cs new file mode 100644 index 0000000..76d89f0 --- /dev/null +++ b/src/Modules/Reputation/Infrastructure/Migrations/20260308232944_AddOfferIdToReviews.cs @@ -0,0 +1,43 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nashel.Modules.Reputation.Infrastructure.Migrations +{ + /// + public partial class AddOfferIdToReviews : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "OfferId", + schema: "reputation", + table: "Reviews", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.CreateIndex( + name: "IX_Reviews_OfferId", + schema: "reputation", + table: "Reviews", + column: "OfferId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Reviews_OfferId", + schema: "reputation", + table: "Reviews"); + + migrationBuilder.DropColumn( + name: "OfferId", + schema: "reputation", + table: "Reviews"); + } + } +} diff --git a/src/Modules/Reputation/Infrastructure/Migrations/20260309201135_AddReviewAdditions.Designer.cs b/src/Modules/Reputation/Infrastructure/Migrations/20260309201135_AddReviewAdditions.Designer.cs new file mode 100644 index 0000000..64e2f05 --- /dev/null +++ b/src/Modules/Reputation/Infrastructure/Migrations/20260309201135_AddReviewAdditions.Designer.cs @@ -0,0 +1,179 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nashel.Modules.Reputation.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nashel.Modules.Reputation.Infrastructure.Migrations +{ + [DbContext(typeof(ReputationDbContext))] + [Migration("20260309201135_AddReviewAdditions")] + partial class AddReviewAdditions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("reputation") + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Dispute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Disputes", "reputation"); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.DisputeEvidence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisputeId") + .HasColumnType("uuid"); + + b.Property("FileType") + .IsRequired() + .HasColumnType("text"); + + b.Property("FileUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("UploaderId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DisputeId"); + + b.ToTable("DisputeEvidence", "reputation"); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Review", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("Comment") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsAutoGenerated") + .HasColumnType("boolean"); + + b.PrimitiveCollection>("MediaUrls") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("OfferId") + .HasColumnType("uuid"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OfferId"); + + b.HasIndex("TargetId"); + + b.HasIndex("OrderId", "AuthorId") + .IsUnique(); + + b.ToTable("Reviews", "reputation"); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.DisputeEvidence", b => + { + b.HasOne("Nashel.Modules.Reputation.Domain.Entities.Dispute", null) + .WithMany("Evidences") + .HasForeignKey("DisputeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Review", b => + { + b.OwnsMany("Nashel.Modules.Reputation.Domain.Entities.ReviewAddition", "Additions", b1 => + { + b1.Property("ReviewId") + .HasColumnType("uuid"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b1.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b1.PrimitiveCollection>("MediaUrls") + .IsRequired() + .HasColumnType("text[]"); + + b1.HasKey("ReviewId", "Id"); + + b1.ToTable("ReviewAdditions", "reputation"); + + b1.WithOwner() + .HasForeignKey("ReviewId"); + }); + + b.Navigation("Additions"); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Dispute", b => + { + b.Navigation("Evidences"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Reputation/Infrastructure/Migrations/20260309201135_AddReviewAdditions.cs b/src/Modules/Reputation/Infrastructure/Migrations/20260309201135_AddReviewAdditions.cs new file mode 100644 index 0000000..8f1ead8 --- /dev/null +++ b/src/Modules/Reputation/Infrastructure/Migrations/20260309201135_AddReviewAdditions.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nashel.Modules.Reputation.Infrastructure.Migrations +{ + /// + public partial class AddReviewAdditions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ReviewAdditions", + schema: "reputation", + columns: table => new + { + ReviewId = table.Column(type: "uuid", nullable: false), + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Comment = table.Column(type: "text", nullable: false), + MediaUrls = table.Column>(type: "text[]", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ReviewAdditions", x => new { x.ReviewId, x.Id }); + table.ForeignKey( + name: "FK_ReviewAdditions_Reviews_ReviewId", + column: x => x.ReviewId, + principalSchema: "reputation", + principalTable: "Reviews", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ReviewAdditions", + schema: "reputation"); + } + } +} diff --git a/src/Modules/Reputation/Infrastructure/Migrations/20260310145118_BackendUpdate_Reputation.Designer.cs b/src/Modules/Reputation/Infrastructure/Migrations/20260310145118_BackendUpdate_Reputation.Designer.cs new file mode 100644 index 0000000..c1f0e5f --- /dev/null +++ b/src/Modules/Reputation/Infrastructure/Migrations/20260310145118_BackendUpdate_Reputation.Designer.cs @@ -0,0 +1,179 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nashel.Modules.Reputation.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nashel.Modules.Reputation.Infrastructure.Migrations +{ + [DbContext(typeof(ReputationDbContext))] + [Migration("20260310145118_BackendUpdate_Reputation")] + partial class BackendUpdate_Reputation + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("reputation") + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Dispute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Disputes", "reputation"); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.DisputeEvidence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisputeId") + .HasColumnType("uuid"); + + b.Property("FileType") + .IsRequired() + .HasColumnType("text"); + + b.Property("FileUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("UploaderId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DisputeId"); + + b.ToTable("DisputeEvidence", "reputation"); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Review", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("Comment") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsAutoGenerated") + .HasColumnType("boolean"); + + b.PrimitiveCollection>("MediaUrls") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("OfferId") + .HasColumnType("uuid"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OfferId"); + + b.HasIndex("TargetId"); + + b.HasIndex("OrderId", "AuthorId") + .IsUnique(); + + b.ToTable("Reviews", "reputation"); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.DisputeEvidence", b => + { + b.HasOne("Nashel.Modules.Reputation.Domain.Entities.Dispute", null) + .WithMany("Evidences") + .HasForeignKey("DisputeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Review", b => + { + b.OwnsMany("Nashel.Modules.Reputation.Domain.Entities.ReviewAddition", "Additions", b1 => + { + b1.Property("ReviewId") + .HasColumnType("uuid"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b1.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b1.PrimitiveCollection>("MediaUrls") + .IsRequired() + .HasColumnType("text[]"); + + b1.HasKey("ReviewId", "Id"); + + b1.ToTable("ReviewAdditions", "reputation"); + + b1.WithOwner() + .HasForeignKey("ReviewId"); + }); + + b.Navigation("Additions"); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Dispute", b => + { + b.Navigation("Evidences"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Reputation/Infrastructure/Migrations/20260310145118_BackendUpdate_Reputation.cs b/src/Modules/Reputation/Infrastructure/Migrations/20260310145118_BackendUpdate_Reputation.cs new file mode 100644 index 0000000..472e83a --- /dev/null +++ b/src/Modules/Reputation/Infrastructure/Migrations/20260310145118_BackendUpdate_Reputation.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nashel.Modules.Reputation.Infrastructure.Migrations +{ + /// + public partial class BackendUpdate_Reputation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/src/Modules/Reputation/Infrastructure/Migrations/ReputationDbContextModelSnapshot.cs b/src/Modules/Reputation/Infrastructure/Migrations/ReputationDbContextModelSnapshot.cs index f0c7dc2..6eda072 100644 --- a/src/Modules/Reputation/Infrastructure/Migrations/ReputationDbContextModelSnapshot.cs +++ b/src/Modules/Reputation/Infrastructure/Migrations/ReputationDbContextModelSnapshot.cs @@ -85,6 +85,12 @@ namespace Nashel.Modules.Reputation.Infrastructure.Migrations b.Property("AuthorId") .HasColumnType("uuid"); + b.Property("Comment") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + b.Property("IsAutoGenerated") .HasColumnType("boolean"); @@ -92,6 +98,9 @@ namespace Nashel.Modules.Reputation.Infrastructure.Migrations .IsRequired() .HasColumnType("text[]"); + b.Property("OfferId") + .HasColumnType("uuid"); + b.Property("OrderId") .HasColumnType("uuid"); @@ -101,14 +110,15 @@ namespace Nashel.Modules.Reputation.Infrastructure.Migrations b.Property("TargetId") .HasColumnType("uuid"); - b.Property("Text") - .IsRequired() - .HasColumnType("text"); - b.HasKey("Id"); + b.HasIndex("OfferId"); + b.HasIndex("TargetId"); + b.HasIndex("OrderId", "AuthorId") + .IsUnique(); + b.ToTable("Reviews", "reputation"); }); @@ -121,6 +131,41 @@ namespace Nashel.Modules.Reputation.Infrastructure.Migrations .IsRequired(); }); + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Review", b => + { + b.OwnsMany("Nashel.Modules.Reputation.Domain.Entities.ReviewAddition", "Additions", b1 => + { + b1.Property("ReviewId") + .HasColumnType("uuid"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b1.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b1.PrimitiveCollection>("MediaUrls") + .IsRequired() + .HasColumnType("text[]"); + + b1.HasKey("ReviewId", "Id"); + + b1.ToTable("ReviewAdditions", "reputation"); + + b1.WithOwner() + .HasForeignKey("ReviewId"); + }); + + b.Navigation("Additions"); + }); + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Dispute", b => { b.Navigation("Evidences"); diff --git a/src/Modules/Reputation/Infrastructure/Nashel.Modules.Reputation.Infrastructure.csproj b/src/Modules/Reputation/Infrastructure/Nashel.Modules.Reputation.Infrastructure.csproj index e1e7c63..6d5e35a 100644 --- a/src/Modules/Reputation/Infrastructure/Nashel.Modules.Reputation.Infrastructure.csproj +++ b/src/Modules/Reputation/Infrastructure/Nashel.Modules.Reputation.Infrastructure.csproj @@ -3,10 +3,12 @@ + + - + Nashel.Modules.Reputation.Infrastructure diff --git a/src/Modules/Reputation/Infrastructure/Persistence/Configurations/Configurations.cs b/src/Modules/Reputation/Infrastructure/Persistence/Configurations/Configurations.cs index 468045f..47a65a4 100644 --- a/src/Modules/Reputation/Infrastructure/Persistence/Configurations/Configurations.cs +++ b/src/Modules/Reputation/Infrastructure/Persistence/Configurations/Configurations.cs @@ -9,14 +9,24 @@ public class ReviewConfiguration : IEntityTypeConfiguration public void Configure(EntityTypeBuilder builder) { builder.HasKey(r => r.Id); - - // Индекс для быстрого поиска по TargetId - builder.HasIndex(r => r.TargetId); - // MediaUrls хранится как простой JSON или Primitive Collection - // В EF Core 8+ есть поддержка Primitive Collections. - // Для Postgres Npgsql это text[] array по умолчанию. - // В 10-й версии это должно работать из коробки. + // Индекс для быстрого поиска по TargetId (исполнителю) и OfferId (услуге) + builder.HasIndex(r => r.TargetId); + builder.HasIndex(r => r.OfferId); + + // Ограничение: один отзыв на заказ от одного автора + builder.HasIndex(r => new { r.OrderId, r.AuthorId }).IsUnique(); + + // Маппинг списка строк для PostgreSQL + builder.Property(r => r.MediaUrls) + .HasColumnType("text[]"); + + // Маппинг Дополнений к отзыву + builder.OwnsMany(r => r.Additions, a => + { + a.ToTable("ReviewAdditions", "reputation"); + a.Property(x => x.MediaUrls).HasColumnType("text[]"); + }); } } diff --git a/src/Modules/Reputation/Infrastructure/Persistence/Repositories/ReviewRepository.cs b/src/Modules/Reputation/Infrastructure/Persistence/Repositories/ReviewRepository.cs index c210ac3..a32f8f8 100644 --- a/src/Modules/Reputation/Infrastructure/Persistence/Repositories/ReviewRepository.cs +++ b/src/Modules/Reputation/Infrastructure/Persistence/Repositories/ReviewRepository.cs @@ -31,4 +31,65 @@ public class ReviewRepository : IReviewRepository .OrderByDescending(r => r.CreatedAt) .ToListAsync(cancellationToken); } + + public async Task ExistsAsync(Guid orderId, Guid authorId, CancellationToken cancellationToken = default) + { + return await _context.Reviews.AnyAsync(r => r.OrderId == orderId && r.AuthorId == authorId, cancellationToken); + } + + public async Task<(double avg, int total)> GetStatsAsync(Guid userId, CancellationToken cancellationToken = default) + { + var reviews = await _context.Reviews + .Where(r => r.TargetId == userId) + .Select(r => r.Rating) + .ToListAsync(cancellationToken); + + if (!reviews.Any()) return (0, 0); + + return (Math.Round(reviews.Average(), 1), reviews.Count); + } + + public async Task> GetStatsForUsersAsync(IEnumerable userIds, CancellationToken cancellationToken = default) + { + var stats = await _context.Reviews + .Where(r => userIds.Contains(r.TargetId)) + .GroupBy(r => r.TargetId) + .Select(g => new { UserId = g.Key, Avg = (double?)g.Average(r => r.Rating) ?? 0, Count = g.Count() }) + .ToListAsync(cancellationToken); + + return stats.ToDictionary( + s => s.UserId, + s => (Math.Round(s.Avg, 1), s.Count) + ); + } + + public async Task<(double avg, int total)> GetOfferStatsAsync(Guid offerId, CancellationToken cancellationToken = default) + { + var reviews = await _context.Reviews + .Where(r => r.OfferId == offerId) + .Select(r => r.Rating) + .ToListAsync(cancellationToken); + + if (!reviews.Any()) return (0, 0); + + return (Math.Round(reviews.Average(), 1), reviews.Count); + } + + public async Task<(List items, int total)> GetPaginatedAsync(Guid targetId, int page, int pageSize, CancellationToken cancellationToken = default) + { + var query = _context.Reviews.Where(r => r.TargetId == targetId); + var total = await query.CountAsync(cancellationToken); + var items = await query + .OrderByDescending(r => r.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return (items, total); + } + + public async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + await _context.SaveChangesAsync(cancellationToken); + } } diff --git a/src/Modules/Reputation/Presentation/ReputationEndpoints.cs b/src/Modules/Reputation/Presentation/ReputationEndpoints.cs index 76fb0d4..bc04db1 100644 --- a/src/Modules/Reputation/Presentation/ReputationEndpoints.cs +++ b/src/Modules/Reputation/Presentation/ReputationEndpoints.cs @@ -1,4 +1,5 @@ using MediatR; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -8,139 +9,118 @@ using Nashel.Modules.Reputation.Application.Queries; namespace Nashel.Modules.Reputation.Presentation; +/// +/// Конечные точки модуля репутации. +/// public static class ReputationEndpoints { + /// + /// Регистрирует конечные точки модуля репутации. + /// public static void MapReputationEndpoints(this IEndpointRouteBuilder app) { - var group = app.MapGroup("/api/reputation").WithTags("Reputation"); + var group = app.MapGroup("/api/reputation") + .WithTags("Reputation (Репутация)") + .AllowAnonymous(); // Публичный доступ для чтения рейтинга - // POST /api/reputation/reviews - group.MapPost("/reviews", async ([FromBody] CreateReviewRequest request, ISender sender) => + // POST /api/reputation/reviews - требует авторизации + group.MapPost("/reviews", async ([FromBody] LeaveReviewRequest request, ISender sender) => { - var command = new CreateReviewCommand( + var command = new LeaveReviewCommand( request.OrderId, + request.OfferId, request.AuthorId, request.TargetId, request.Rating, - request.Text + request.Comment, + request.MediaUrls ?? new List() ); var reviewId = await sender.Send(command); return Results.Ok(reviewId); }) - .WithName("CreateReview") - .WithOpenApi(operation => new(operation) { Summary = "Создать отзыв", Description = "Оставить отзыв о пользователе по завершенному заказу." }); - - // GET /api/reputation/reviews/{userId} - group.MapGet("/reviews/{userId:guid}", async (Guid userId, ISender sender) => + .WithName("LeaveReview") + .WithOpenApi(operation => new(operation) { - var query = new GetReviewsByTargetQuery(userId); + Summary = "Оставить отзыв", + Description = "Создает новый отзыв для пользователя по завершенному заказу." + }); + + // GET /api/reputation/{userId}/rating + group.MapGet("/{userId:guid}/rating", async (Guid userId, ISender sender) => + { + var query = new GetProfileRatingQuery(userId); var result = await sender.Send(query); return Results.Ok(result); }) - .WithName("GetUserReviews") - .WithOpenApi(operation => new(operation) { Summary = "Получить отзывы пользователя", Description = "Возвращает список отзывов для конкретного пользователя." }); - - // POST /api/reputation/disputes - group.MapPost("/disputes", async ([FromBody] OpenDisputeRequest request, ISender sender) => + .WithName("GetProfileRating") + .WithOpenApi(operation => new(operation) { - var command = new OpenDisputeCommand(request.OrderId, request.InitiatorId, request.Reason); - var disputeId = await sender.Send(command); - return Results.Ok(disputeId); + Summary = "Получить рейтинг профиля", + Description = "Возвращает среднюю оценку и общее количество отзывов пользователя." + }); + + // GET /api/reputation/offer/{offerId}/rating + group.MapGet("/offer/{offerId:guid}/rating", async (Guid offerId, ISender sender) => + { + var query = new GetOfferRatingQuery(offerId); + var result = await sender.Send(query); + return Results.Ok(result); }) - .WithName("OpenDispute") - .WithOpenApi(operation => new(operation) { Summary = "Открыть спор (арбитраж)", Description = "Начинает процедуру спора по заказу. Блокирует выплаты." }); - - // POST /api/reputation/disputes/{id}/evidence - group.MapPost("/disputes/{id:guid}/evidence", async (Guid id, [FromBody] AddEvidenceRequest request, ISender sender) => + .WithName("GetOfferRating") + .WithOpenApi(operation => new(operation) { - var command = new AddEvidenceCommand(id, request.UploaderId, request.FileUrl); + Summary = "Получить рейтинг услуги", + Description = "Возвращает среднюю оценку и количество отзывов для конкретной услуги." + }); + + // GET /api/reputation/{userId}/reviews + group.MapGet("/{userId:guid}/reviews", async (Guid userId, ISender sender, [FromQuery] int page = 1, [FromQuery] int pageSize = 10) => + { + var query = new GetProfileReviewsQuery(userId, page, pageSize); + var result = await sender.Send(query); + return Results.Ok(result); + }) + .WithName("GetProfileReviews") + .WithOpenApi(operation => new(operation) + { + Summary = "Получить отзывы профиля", + Description = "Возвращает список отзывов о пользователе с пагинацией." + }); + + // POST /api/reputation/reviews/{reviewId}/additions + group.MapPost("/reviews/{reviewId:guid}/additions", async (Guid reviewId, [FromBody] AddReviewAdditionRequest request, ISender sender) => + { + var command = new AddReviewAdditionCommand( + reviewId, + request.AuthorId, + request.Comment, + request.MediaUrls + ); await sender.Send(command); return Results.Ok(); }) - .WithName("AddDisputeEvidence") - .WithOpenApi(operation => new(operation) { Summary = "Добавить улики к спору", Description = "Добавляет фото/видео доказательства. Доступно только в статусе EvidenceCollection." }); - - // POST /api/reputation/disputes/{id}/resolve - group.MapPost("/disputes/{id:guid}/resolve", async (Guid id, [FromBody] ResolveDisputeRequest request, ISender sender) => + .WithName("AddReviewAddition") + .WithOpenApi(operation => new(operation) { - var command = new ResolveDisputeCommand(id, request.ResolutionDetails); - await sender.Send(command); - return Results.Ok(); - }) - .WithName("ResolveDispute") - .WithOpenApi(operation => new(operation) { Summary = "Разрешить спор (Admin)", Description = "Принимает решение по спору. Доступно только администратору." }); + Summary = "Дополнить отзыв", + Description = "Позволяет автору добавить дополнительный комментарий к своему отзыву в будущем." + }); } } -/// -/// Запрос на разрешение спора. -/// -public record ResolveDisputeRequest(string ResolutionDetails); - /// /// Запрос на создание отзыва. /// -public record CreateReviewRequest -{ - /// - /// ID заказа. - /// - public Guid OrderId { get; init; } - - /// - /// ID автора отзыва. - /// - public Guid AuthorId { get; init; } - - /// - /// ID получателя отзыва. - /// - public Guid TargetId { get; init; } - - /// - /// Оценка (1-5). - /// - public int Rating { get; init; } - - /// - /// Текст отзыва. - /// - public string Text { get; init; } = default!; -} +/// ID заказа +/// ID оффера/услуги +/// ID автора +/// ID цели (кому отзыв) +/// Рейтинг (1-5) +/// Комментарий +/// Ссылки на медиа +public record LeaveReviewRequest(Guid OrderId, Guid OfferId, Guid AuthorId, Guid TargetId, int Rating, string? Comment, List? MediaUrls = null); /// -/// Запрос на открытие спора. +/// Запрос на дополнение отзыва. /// -public record OpenDisputeRequest -{ - /// - /// ID заказа. - /// - public Guid OrderId { get; init; } - - /// - /// ID инициатора. - /// - public Guid InitiatorId { get; init; } - - /// - /// Причина спора. - /// - public string Reason { get; init; } = default!; -} - -/// -/// Запрос на добавление улики. -/// -public record AddEvidenceRequest -{ - /// - /// ID загрузившего. - /// - public Guid UploaderId { get; init; } - - /// - /// Ссылка на файл. - /// - public string FileUrl { get; init; } = default!; -} +public record AddReviewAdditionRequest(Guid AuthorId, string Comment, List? MediaUrls = null); diff --git a/src/Modules/Reputation/Tests/Application/CreateReviewTests.cs b/src/Modules/Reputation/Tests/Application/CreateReviewTests.cs index 04fbbad..ca8f4b3 100644 --- a/src/Modules/Reputation/Tests/Application/CreateReviewTests.cs +++ b/src/Modules/Reputation/Tests/Application/CreateReviewTests.cs @@ -1,6 +1,6 @@ using FluentAssertions; using MediatR; -using Moq; +using NSubstitute; using Nashel.Modules.Reputation.Application.Commands; using Nashel.Modules.Reputation.Domain.Entities; using Nashel.Modules.Reputation.Domain.Repositories; @@ -10,35 +10,38 @@ namespace Nashel.Modules.Reputation.Tests.Application; public class ReviewApplicationTests { - private readonly Mock _repositoryMock; - private readonly Mock _mediatorMock; - private readonly CreateReviewHandler _handler; + private readonly IReviewRepository _repository; + private readonly IMediator _mediator; + private readonly LeaveReviewHandler _handler; public ReviewApplicationTests() { - _repositoryMock = new Mock(); - _mediatorMock = new Mock(); - _handler = new CreateReviewHandler(_repositoryMock.Object, _mediatorMock.Object); + // Инициализация моков через NSubstitute + _repository = Substitute.For(); + _mediator = Substitute.For(); + _handler = new LeaveReviewHandler(_repository, _mediator); } [Fact] public async Task СозданиеОтзыва_Должно_Сохранить_Отзыв_И_Опубликовать_События() { - // 1. Arrange - var command = new CreateReviewCommand( + // Arrange (Подготовка) + var command = new LeaveReviewCommand( OrderId: Guid.NewGuid(), + OfferId: Guid.NewGuid(), AuthorId: Guid.NewGuid(), TargetId: Guid.NewGuid(), Rating: 5, - Text: "Отличный опыт!" + Comment: "Отличный опыт!", + MediaUrls: new List() ); - // 2. Act + // Act (Действие) var result = await _handler.Handle(command, CancellationToken.None); - // 3. Assert + // Assert (Проверка) result.Should().NotBeEmpty(); - _repositoryMock.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Once); - _mediatorMock.Verify(m => m.Publish(It.IsAny(), It.IsAny()), Times.AtLeastOnce); + await _repository.Received(1).AddAsync(Arg.Any(), Arg.Any()); + await _mediator.Received().Publish(Arg.Any(), Arg.Any()); } } diff --git a/src/Modules/Reputation/Tests/Application/OpenDisputeTests.cs b/src/Modules/Reputation/Tests/Application/OpenDisputeTests.cs index abec75a..28026b4 100644 --- a/src/Modules/Reputation/Tests/Application/OpenDisputeTests.cs +++ b/src/Modules/Reputation/Tests/Application/OpenDisputeTests.cs @@ -1,6 +1,6 @@ using FluentAssertions; using MediatR; -using Moq; +using NSubstitute; using Nashel.Modules.Reputation.Application.Commands; using Nashel.Modules.Reputation.Domain.Entities; using Nashel.Modules.Reputation.Domain.Repositories; @@ -11,29 +11,30 @@ namespace Nashel.Modules.Reputation.Tests.Application; public class OpenDisputeTests { - private readonly Mock _repositoryMock; - private readonly Mock _publisherMock; + private readonly IDisputeRepository _repository; + private readonly IPublisher _publisher; private readonly OpenDisputeHandler _handler; public OpenDisputeTests() { - _repositoryMock = new Mock(); - _publisherMock = new Mock(); - _handler = new OpenDisputeHandler(_repositoryMock.Object, _publisherMock.Object); + // Инициализация моков через NSubstitute + _repository = Substitute.For(); + _publisher = Substitute.For(); + _handler = new OpenDisputeHandler(_repository, _publisher); } [Fact] public async Task Обработка_Должна_Создать_Спор_И_Опубликовать_Событие() { - // Arrange + // Arrange (Подготовка) var request = new OpenDisputeCommand(Guid.NewGuid(), Guid.NewGuid(), "Причина"); - // Act + // Act (Действие) var result = await _handler.Handle(request, CancellationToken.None); - // Assert + // Assert (Проверка) result.Should().NotBeEmpty(); - _repositoryMock.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Once); - _publisherMock.Verify(p => p.Publish(It.IsAny(), It.IsAny()), Times.Once); + await _repository.Received(1).AddAsync(Arg.Any(), Arg.Any()); + await _publisher.Received(1).Publish(Arg.Any(), Arg.Any()); } } diff --git a/src/Modules/Reputation/Tests/Domain/DisputeTests.cs b/src/Modules/Reputation/Tests/Domain/DisputeTests.cs index 45aa99a..f7fcf0c 100644 --- a/src/Modules/Reputation/Tests/Domain/DisputeTests.cs +++ b/src/Modules/Reputation/Tests/Domain/DisputeTests.cs @@ -10,10 +10,10 @@ public class DisputeTests [Fact] public void Открытие_Должно_Установить_Статус_Сбор_Улик() { - // Arrange + // Arrange & Act (Подготовка и Действие) var dispute = Dispute.Open(Guid.NewGuid(), Guid.NewGuid(), "Проблема"); - // Assert + // Assert (Проверка) dispute.Status.Should().Be(DisputeStatus.EvidenceCollection); dispute.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); } @@ -21,17 +21,17 @@ public class DisputeTests [Fact] public void ДобавлениеУлик_Должно_Провалиться_Если_Статус_Не_СборУлик() { - // Arrange + // Arrange (Подготовка) var dispute = Dispute.Open(Guid.NewGuid(), Guid.NewGuid(), "Проблема"); // Принудительно меняем статус через разрешение спора dispute.Resolve("Решено"); - // Act + // Act (Действие) var evidence = DisputeEvidence.Create(dispute.Id, Guid.NewGuid(), "url.com"); var action = () => dispute.AddEvidence(evidence); - // Assert + // Assert (Проверка) action.Should().Throw() .WithMessage("Добавление улик возможно только на этапе сбора доказательств."); } diff --git a/src/Modules/Reputation/Tests/Domain/ReviewTests.cs b/src/Modules/Reputation/Tests/Domain/ReviewTests.cs index dca9afe..1bcba65 100644 --- a/src/Modules/Reputation/Tests/Domain/ReviewTests.cs +++ b/src/Modules/Reputation/Tests/Domain/ReviewTests.cs @@ -10,17 +10,17 @@ public class ReviewTests [Fact] public void Создание_Должно_Установить_Правильные_Свойства() { - // Arrange + // Arrange (Подготовка) var orderId = Guid.NewGuid(); var authorId = Guid.NewGuid(); var targetId = Guid.NewGuid(); - // Act - var review = Review.Create(orderId, authorId, targetId, 5, "Отличная работа"); + // Act (Действие) + var review = Review.Create(orderId, Guid.NewGuid(), authorId, targetId, 5, "Отличная работа"); - // Assert + // Assert (Проверка) review.Rating.Should().Be(5); - review.Text.Should().Be("Отличная работа"); + review.Comment.Should().Be("Отличная работа"); review.IsAutoGenerated.Should().BeFalse(); review.DomainEvents.Should().ContainSingle(e => e is ReviewCreatedEvent); } @@ -30,28 +30,20 @@ public class ReviewTests [InlineData(6)] public void Создание_Должно_Выбросить_Исключение_Когда_Рейтинг_Недействителен(int invalidRating) { - // Act - var action = () => Review.Create(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), invalidRating, "текст"); + // Act (Действие) + var action = () => Review.Create(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), invalidRating, "текст"); - // Assert + // Assert (Проверка) action.Should().Throw(); } [Fact] - public void Обновление_Должно_Выбросить_Исключение_Через_3_Дня() + public void Создание_Должно_Установить_CreatedAt() { - // Arrange - var review = Review.Create(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), 5, "Текст"); + // Arrange & Act (Подготовка и Действие) + var review = Review.Create(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), 5, "Текст"); - // Используем рефлексию для изменения даты создания - var createdAtField = typeof(Review).GetField("_createdAt", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); - createdAtField?.SetValue(review, DateTime.UtcNow.AddDays(-4)); - - // Act - var action = () => review.Update("Новый текст", 4); - - // Assert - action.Should().Throw() - .WithMessage("Редактирование отзыва доступно только в течение 3 дней после создания."); + // Assert (Проверка) + review.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); } } diff --git a/src/Modules/Reputation/Tests/Nashel.Modules.Reputation.Tests.csproj b/src/Modules/Reputation/Tests/Nashel.Modules.Reputation.Tests.csproj index 6327b3f..0728cc0 100644 --- a/src/Modules/Reputation/Tests/Nashel.Modules.Reputation.Tests.csproj +++ b/src/Modules/Reputation/Tests/Nashel.Modules.Reputation.Tests.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -13,7 +13,7 @@ - + diff --git a/src/Modules/Reputation/Tests/Services/RatingCalculatorTests.cs b/src/Modules/Reputation/Tests/Services/RatingCalculatorTests.cs index 44f9ea1..eddb8bf 100644 --- a/src/Modules/Reputation/Tests/Services/RatingCalculatorTests.cs +++ b/src/Modules/Reputation/Tests/Services/RatingCalculatorTests.cs @@ -1,5 +1,5 @@ using FluentAssertions; -using Moq; +using NSubstitute; using Nashel.Modules.Reputation.Domain.Entities; using Nashel.Modules.Reputation.Domain.Repositories; using Nashel.Modules.Reputation.Domain.Services; @@ -9,50 +9,51 @@ namespace Nashel.Modules.Reputation.Tests.Services; public class RatingCalculatorTests { - private readonly Mock _repositoryMock; + private readonly IReviewRepository _repository; private readonly RatingCalculator _service; public RatingCalculatorTests() { - _repositoryMock = new Mock(); - _service = new RatingCalculator(_repositoryMock.Object); + // Инициализация мока через NSubstitute + _repository = Substitute.For(); + _service = new RatingCalculator(_repository); } [Fact] public async Task РасчетСреднего_Должен_Вернуть_Верное_Значение() { - // 1. Arrange + // Arrange (Подготовка) var targetId = Guid.NewGuid(); var reviews = new List { - Review.Create(Guid.NewGuid(), Guid.NewGuid(), targetId, 5, "Хорошо"), - Review.Create(Guid.NewGuid(), Guid.NewGuid(), targetId, 3, "Средне") + Review.Create(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), targetId, 5, "Хорошо"), + Review.Create(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), targetId, 3, "Средне") }; // 5 + 3 = 8 / 2 = 4.0 - _repositoryMock.Setup(r => r.GetByTargetIdAsync(targetId, It.IsAny())) - .ReturnsAsync(reviews); + _repository.GetByTargetIdAsync(targetId, Arg.Any()) + .Returns(reviews); - // 2. Act + // Act (Действие) var average = await _service.CalculateAverage(targetId); - // 3. Assert + // Assert (Проверка) average.Should().Be(4.0, "Средний рейтинг должен рассчитываться правильно."); } [Fact] public async Task РасчетСреднего_Должен_Вернуть_Ноль_Если_Отзывов_Нет() { - // 1. Arrange + // Arrange (Подготовка) var targetId = Guid.NewGuid(); - _repositoryMock.Setup(r => r.GetByTargetIdAsync(targetId, It.IsAny())) - .ReturnsAsync(new List()); + _repository.GetByTargetIdAsync(targetId, Arg.Any()) + .Returns(new List()); - // 2. Act + // Act (Действие) var average = await _service.CalculateAverage(targetId); - // 3. Assert + // Assert (Проверка) average.Should().Be(0.0, "Рейтинг должен быть 0, если отзывов нет."); } } diff --git a/tests/Nashel.Host.Tests/GlobalExceptionHandlerTests.cs b/tests/Nashel.Host.Tests/GlobalExceptionHandlerTests.cs new file mode 100644 index 0000000..1cac6ff --- /dev/null +++ b/tests/Nashel.Host.Tests/GlobalExceptionHandlerTests.cs @@ -0,0 +1,93 @@ +using System.Text.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Nashel.BuildingBlocks.Application.Exceptions; +using Nashel.Host.Infrastructure.Exceptions; +using Xunit; + +namespace Nashel.Host.Tests; + +public class GlobalExceptionHandlerTests +{ + private readonly ILogger _logger; + private readonly GlobalExceptionHandler _handler; + + public GlobalExceptionHandlerTests() + { + _logger = Substitute.For>(); + _handler = new GlobalExceptionHandler(_logger); + } + + [Fact] + public async Task TryHandleAsync_Should_Return404_When_NotFoundException() + { + // Arrange (Подготовка) + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + var exception = new NotFoundException("User", "123"); + + // Act (Действие) + var result = await _handler.TryHandleAsync(context, exception, CancellationToken.None); + + // Assert (Проверка) + result.Should().BeTrue(); + context.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound); + + context.Response.Body.Seek(0, SeekOrigin.Begin); + var responseBody = await new StreamReader(context.Response.Body).ReadToEndAsync(); + var problemDetails = JsonSerializer.Deserialize(responseBody, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + problemDetails.Should().NotBeNull(); + problemDetails!.Title.Should().Be("Ресурс не найден"); + problemDetails.Detail.Should().Contain("User"); + problemDetails.Detail.Should().Contain("123"); + } + + [Fact] + public async Task TryHandleAsync_Should_Return403_When_ForbiddenAccessException() + { + // Arrange (Подготовка) + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + var exception = new ForbiddenAccessException(); + + // Act (Действие) + var result = await _handler.TryHandleAsync(context, exception, CancellationToken.None); + + // Assert (Проверка) + result.Should().BeTrue(); + context.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + + context.Response.Body.Seek(0, SeekOrigin.Begin); + var responseBody = await new StreamReader(context.Response.Body).ReadToEndAsync(); + var problemDetails = JsonSerializer.Deserialize(responseBody, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + problemDetails!.Title.Should().Be("Доступ запрещен"); + } + + [Fact] + public async Task TryHandleAsync_Should_Return500_When_UnhandledException() + { + // Arrange (Подготовка) + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + var exception = new Exception("Unknown error"); + + // Act (Действие) + var result = await _handler.TryHandleAsync(context, exception, CancellationToken.None); + + // Assert (Проверка) + result.Should().BeTrue(); + context.Response.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); + + context.Response.Body.Seek(0, SeekOrigin.Begin); + var responseBody = await new StreamReader(context.Response.Body).ReadToEndAsync(); + var problemDetails = JsonSerializer.Deserialize(responseBody, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + problemDetails!.Title.Should().Be("Внутренняя ошибка сервера"); + problemDetails.Detail.Should().Be("Произошла непредвиденная ошибка на сервере. Пожалуйста, попробуйте позже."); + } +} diff --git a/tests/Nashel.Host.Tests/Nashel.Host.Tests.csproj b/tests/Nashel.Host.Tests/Nashel.Host.Tests.csproj new file mode 100644 index 0000000..0b25cbd --- /dev/null +++ b/tests/Nashel.Host.Tests/Nashel.Host.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + +