From b2db2e2d176181e9119d0650e7ba093116d7e93b Mon Sep 17 00:00:00 2001 From: egorvts Date: Fri, 19 Dec 2025 16:09:01 +0500 Subject: [PATCH] chore(release): v0.0.1-alpha1 --- .DS_Store | Bin 0 -> 8196 bytes .dockerignore | 16 + .github/workflows/commitlint.yml | 36 + .github/workflows/deploy.yml | 49 + .github/workflows/format-check.yml | 33 + .gitignore | 11 +- .husky/commit-msg | 4 + CONTRIBUTING.md | 50 + Dockerfile | 29 + KontoApi.sln | 113 + KontoApi.sln.DotSettings.user | 14 + README.md | 86 +- commitlint.config.js | 26 + docker-compose.yml | 27 + global.json | 7 + key.json | 8 + package-lock.json | 2925 +++++++++++++++++ package.json | 22 + src/.DS_Store | Bin 0 -> 6148 bytes src/Api/Contracts/ErrorResponse.cs | 8 + src/Api/Controllers/AccountController.cs | 49 + src/Api/Controllers/AuthController.cs | 26 + src/Api/Controllers/BaseController.cs | 23 + src/Api/Controllers/BudgetsController.cs | 81 + src/Api/Controllers/CategoriesController.cs | 72 + src/Api/Controllers/TransactionController.cs | 104 + src/Api/Controllers/UserController.cs | 48 + src/Api/IntegrationTestStartup.cs | 218 ++ src/Api/KontoApi.Api.csproj | 38 + .../Middleware/ExceptionHandlingMiddleware.cs | 59 + src/Api/Program.cs | 250 ++ src/Api/Properties/launchSettings.json | 23 + src/Api/Services/CurrentUserService.cs | 22 + src/Api/TestAuth/TestAuthenticationHandler.cs | 64 + src/Api/appsettings.Development.json | 26 + src/Api/appsettings.json | 37 + .../Common/Behaviors/ValidationBehavior.cs | 32 + .../Common/Exceptions/BadRequestException.cs | 3 + .../Common/Exceptions/ConflictException.cs | 3 + .../Common/Exceptions/ForbiddenException.cs | 3 + .../Common/Exceptions/NotFoundException.cs | 9 + .../Exceptions/UnauthorizedException.cs | 3 + .../Common/Exceptions/ValidationException.cs | 16 + .../Common/Interfaces/IAccountRepository.cs | 13 + .../Interfaces/IApplicationDbContext.cs | 15 + .../Common/Interfaces/IBudgetRepository.cs | 13 + .../Common/Interfaces/ICategoryRepository.cs | 14 + .../Common/Interfaces/ICurrentUserService.cs | 6 + .../Common/Interfaces/IJwtProvider.cs | 9 + .../Common/Interfaces/IPasswordHasher.cs | 7 + .../Common/Interfaces/IStatementParser.cs | 9 + .../Common/Interfaces/IUserRepository.cs | 12 + .../Common/Models/ParsedTransaction.cs | 12 + src/Application/DependencyInjection.cs | 25 + .../CreateAccount/CreateAccountCommand.cs | 5 + .../CreateAccount/CreateAccountHandler.cs | 23 + .../CreateAccount/CreateAccountValidator.cs | 14 + .../DeleteAccount/DeleteAccountCommand.cs | 5 + .../DeleteAccount/DeleteAccountHandler.cs | 18 + .../GetAccountOverview/AccountOverviewDto.cs | 15 + .../GetAccountOverviewHandler.cs | 29 + .../GetAccountOverviewQuery.cs | 5 + .../Auth/Commands/Login/LoginCommand.cs | 5 + .../Auth/Commands/Login/LoginHandler.cs | 24 + .../Auth/Commands/Login/LoginResponse.cs | 8 + .../Auth/Commands/Login/LoginValidator.cs | 16 + .../Auth/Commands/Register/RegisterCommand.cs | 9 + .../Auth/Commands/Register/RegisterHandler.cs | 25 + .../Commands/Register/RegisterValidator.cs | 21 + .../CreateBudget/CreateBudgetCommand.cs | 10 + .../CreateBudget/CreateBudgetHandler.cs | 24 + .../CreateBudget/CreateBudgetValidator.cs | 13 + .../DeleteBudget/DeleteBudgetCommand.cs | 5 + .../DeleteBudget/DeleteBudgetHandler.cs | 17 + .../RenameBudget/RenameBudgetCommand.cs | 5 + .../RenameBudget/RenameBudgetHandler.cs | 19 + .../RenameBudget/RenameBudgetValidator.cs | 16 + .../GetBudgetDetails/BudgetDetailsDto.cs | 9 + .../GetBudgetDetailsHandler.cs | 33 + .../GetBudgetDetails/GetBudgetDetailsQuery.cs | 5 + .../GetBudgetDetails/TransactionDto.cs | 13 + .../GetBudgetsList/BudgetSummaryDto.cs | 8 + .../GetBudgetsList/GetBudgetsListHandler.cs | 23 + .../GetBudgetsList/GetBudgetsListQuery.cs | 5 + .../CreateCategory/CreateCategoryCommand.cs | 5 + .../CreateCategory/CreateCategoryHandler.cs | 18 + .../CreateCategory/CreateCategoryValidator.cs | 13 + .../DeleteCategory/DeleteCategoryCommand.cs | 5 + .../DeleteCategory/DeleteCategoryHandler.cs | 18 + .../RenameCategory/RenameCategoryCommand.cs | 5 + .../RenameCategory/RenameCategoryHandler.cs | 21 + .../RenameCategory/RenameCategoryValidator.cs | 12 + .../Features/Categories/DTOs/CategoryDto.cs | 3 + .../GetCategories/GetCategoriesHandler.cs | 19 + .../GetCategories/GetCategoriesQuery.cs | 6 + .../GetCategoryById/GetCategoryByIdHandler.cs | 23 + .../GetCategoryById/GetCategoryByIdQuery.cs | 6 + .../AddTransaction/AddTransactionCommand.cs | 14 + .../AddTransaction/AddTransactionHandler.cs | 28 + .../AddTransaction/AddTransactionValidator.cs | 17 + .../DeleteTransactionCommand.cs | 5 + .../DeleteTransactionHandler.cs | 25 + .../DeleteTransactionValidator.cs | 12 + .../ImportTransactions/ImportResultDto.cs | 8 + .../ImportTransactionsCommand.cs | 9 + .../ImportTransactionsHandler.cs | 53 + .../ImportTransactionsValidator.cs | 18 + .../GetTransactionByIdHandler.cs | 33 + .../GetTransactionByIdQuery.cs | 5 + .../TransactionDetailDto.cs | 14 + .../ChangePassword/ChangePasswordCommand.cs | 9 + .../ChangePassword/ChangePasswordHandler.cs | 28 + .../ChangePassword/ChangePasswordValidator.cs | 20 + .../Users/Queries/GetUser/GetUserQuery.cs | 5 + .../Features/Users/Queries/GetUser/UserDto.cs | 8 + .../Users/Queries/GetUser/UserHandlers.cs | 24 + src/Application/KontoApi.Application.csproj | 51 + src/Domain/Account.cs | 85 + src/Domain/Budget.cs | 112 + src/Domain/Category.cs | 65 + src/Domain/DateRange.cs | 53 + src/Domain/KontoApi.Domain.csproj | 9 + src/Domain/Money.cs | 79 + src/Domain/Transaction.cs | 53 + src/Domain/User.cs | 81 + src/Infrastructure/Auth/JwtProvider.cs | 76 + src/Infrastructure/Auth/JwtSettings.cs | 11 + src/Infrastructure/CategorySeeder.cs | 34 + src/Infrastructure/DependencyInjection.cs | 46 + .../KontoApi.Infrastructure.csproj | 42 + .../20251217042156_InitialCreate.Designer.cs | 255 ++ .../20251217042156_InitialCreate.cs | 159 + .../Migrations/KontoDbContextModelSnapshot.cs | 254 ++ .../Configurations/AccountConfiguration.cs | 30 + .../Configurations/BudgetConfiguration.cs | 36 + .../Configurations/CategoryConfiguration.cs | 17 + .../TransactionConfiguration.cs | 36 + .../Configurations/UserConfiguration.cs | 27 + .../Persistence/KontoDbContext.cs | 21 + .../Repositories/AccountRepository.cs | 89 + .../Repositories/BudgetRepository.cs | 60 + .../Repositories/CategoryRepository.cs | 62 + .../Repositories/UserRepository.cs | 39 + src/Infrastructure/Services/PasswordHasher.cs | 14 + .../Services/StatementParser.cs | 157 + src/src.sln | 42 + .../AccountApiIntegrationTests.cs | 125 + .../AuthController/AuthIntegrationTests.cs | 90 + .../BudgetsIntegrationTests.cs | 157 + .../CategoriesIntegrationTests.cs | 90 + .../KontoApi.IntegrationTestsNet8.csproj | 37 + .../TransactionsIntegrationTests.cs | 210 ++ .../UserController/UserIntegrationTests.cs | 42 + .../KontoApi.TestEntryPoint.csproj | 11 + tests/KontoApi.TestEntryPoint/Program.cs | 38 + .../CategoryTests/CategoryRepositoryTests.cs | 111 + .../CategoryTests/CategorySeederTests.cs | 46 + .../CategoryTests/DbContextFactory.cs | 24 + tests/KontoApi.Tests/KontoApi.Tests.csproj | 41 + tests/KontoApi.Tests/TokenServiceTests.cs | 75 + 160 files changed, 8914 insertions(+), 2 deletions(-) create mode 100644 .DS_Store create mode 100644 .dockerignore create mode 100644 .github/workflows/commitlint.yml create mode 100644 .github/workflows/deploy.yml create mode 100644 .github/workflows/format-check.yml create mode 100755 .husky/commit-msg create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 KontoApi.sln create mode 100644 KontoApi.sln.DotSettings.user create mode 100644 commitlint.config.js create mode 100644 docker-compose.yml create mode 100644 global.json create mode 100644 key.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/.DS_Store create mode 100644 src/Api/Contracts/ErrorResponse.cs create mode 100644 src/Api/Controllers/AccountController.cs create mode 100644 src/Api/Controllers/AuthController.cs create mode 100644 src/Api/Controllers/BaseController.cs create mode 100644 src/Api/Controllers/BudgetsController.cs create mode 100644 src/Api/Controllers/CategoriesController.cs create mode 100644 src/Api/Controllers/TransactionController.cs create mode 100644 src/Api/Controllers/UserController.cs create mode 100644 src/Api/IntegrationTestStartup.cs create mode 100644 src/Api/KontoApi.Api.csproj create mode 100644 src/Api/Middleware/ExceptionHandlingMiddleware.cs create mode 100644 src/Api/Program.cs create mode 100644 src/Api/Properties/launchSettings.json create mode 100644 src/Api/Services/CurrentUserService.cs create mode 100644 src/Api/TestAuth/TestAuthenticationHandler.cs create mode 100644 src/Api/appsettings.Development.json create mode 100644 src/Api/appsettings.json create mode 100644 src/Application/Common/Behaviors/ValidationBehavior.cs create mode 100644 src/Application/Common/Exceptions/BadRequestException.cs create mode 100644 src/Application/Common/Exceptions/ConflictException.cs create mode 100644 src/Application/Common/Exceptions/ForbiddenException.cs create mode 100644 src/Application/Common/Exceptions/NotFoundException.cs create mode 100644 src/Application/Common/Exceptions/UnauthorizedException.cs create mode 100644 src/Application/Common/Exceptions/ValidationException.cs create mode 100644 src/Application/Common/Interfaces/IAccountRepository.cs create mode 100644 src/Application/Common/Interfaces/IApplicationDbContext.cs create mode 100644 src/Application/Common/Interfaces/IBudgetRepository.cs create mode 100644 src/Application/Common/Interfaces/ICategoryRepository.cs create mode 100644 src/Application/Common/Interfaces/ICurrentUserService.cs create mode 100644 src/Application/Common/Interfaces/IJwtProvider.cs create mode 100644 src/Application/Common/Interfaces/IPasswordHasher.cs create mode 100644 src/Application/Common/Interfaces/IStatementParser.cs create mode 100644 src/Application/Common/Interfaces/IUserRepository.cs create mode 100644 src/Application/Common/Models/ParsedTransaction.cs create mode 100644 src/Application/DependencyInjection.cs create mode 100644 src/Application/Features/Accounts/Commands/CreateAccount/CreateAccountCommand.cs create mode 100644 src/Application/Features/Accounts/Commands/CreateAccount/CreateAccountHandler.cs create mode 100644 src/Application/Features/Accounts/Commands/CreateAccount/CreateAccountValidator.cs create mode 100644 src/Application/Features/Accounts/Commands/DeleteAccount/DeleteAccountCommand.cs create mode 100644 src/Application/Features/Accounts/Commands/DeleteAccount/DeleteAccountHandler.cs create mode 100644 src/Application/Features/Accounts/Queries/GetAccountOverview/AccountOverviewDto.cs create mode 100644 src/Application/Features/Accounts/Queries/GetAccountOverview/GetAccountOverviewHandler.cs create mode 100644 src/Application/Features/Accounts/Queries/GetAccountOverview/GetAccountOverviewQuery.cs create mode 100644 src/Application/Features/Auth/Commands/Login/LoginCommand.cs create mode 100644 src/Application/Features/Auth/Commands/Login/LoginHandler.cs create mode 100644 src/Application/Features/Auth/Commands/Login/LoginResponse.cs create mode 100644 src/Application/Features/Auth/Commands/Login/LoginValidator.cs create mode 100644 src/Application/Features/Auth/Commands/Register/RegisterCommand.cs create mode 100644 src/Application/Features/Auth/Commands/Register/RegisterHandler.cs create mode 100644 src/Application/Features/Auth/Commands/Register/RegisterValidator.cs create mode 100644 src/Application/Features/Budgets/Commands/CreateBudget/CreateBudgetCommand.cs create mode 100644 src/Application/Features/Budgets/Commands/CreateBudget/CreateBudgetHandler.cs create mode 100644 src/Application/Features/Budgets/Commands/CreateBudget/CreateBudgetValidator.cs create mode 100644 src/Application/Features/Budgets/Commands/DeleteBudget/DeleteBudgetCommand.cs create mode 100644 src/Application/Features/Budgets/Commands/DeleteBudget/DeleteBudgetHandler.cs create mode 100644 src/Application/Features/Budgets/Commands/RenameBudget/RenameBudgetCommand.cs create mode 100644 src/Application/Features/Budgets/Commands/RenameBudget/RenameBudgetHandler.cs create mode 100644 src/Application/Features/Budgets/Commands/RenameBudget/RenameBudgetValidator.cs create mode 100644 src/Application/Features/Budgets/Queries/GetBudgetDetails/BudgetDetailsDto.cs create mode 100644 src/Application/Features/Budgets/Queries/GetBudgetDetails/GetBudgetDetailsHandler.cs create mode 100644 src/Application/Features/Budgets/Queries/GetBudgetDetails/GetBudgetDetailsQuery.cs create mode 100644 src/Application/Features/Budgets/Queries/GetBudgetDetails/TransactionDto.cs create mode 100644 src/Application/Features/Budgets/Queries/GetBudgetsList/BudgetSummaryDto.cs create mode 100644 src/Application/Features/Budgets/Queries/GetBudgetsList/GetBudgetsListHandler.cs create mode 100644 src/Application/Features/Budgets/Queries/GetBudgetsList/GetBudgetsListQuery.cs create mode 100644 src/Application/Features/Categories/Commands/CreateCategory/CreateCategoryCommand.cs create mode 100644 src/Application/Features/Categories/Commands/CreateCategory/CreateCategoryHandler.cs create mode 100644 src/Application/Features/Categories/Commands/CreateCategory/CreateCategoryValidator.cs create mode 100644 src/Application/Features/Categories/Commands/DeleteCategory/DeleteCategoryCommand.cs create mode 100644 src/Application/Features/Categories/Commands/DeleteCategory/DeleteCategoryHandler.cs create mode 100644 src/Application/Features/Categories/Commands/RenameCategory/RenameCategoryCommand.cs create mode 100644 src/Application/Features/Categories/Commands/RenameCategory/RenameCategoryHandler.cs create mode 100644 src/Application/Features/Categories/Commands/RenameCategory/RenameCategoryValidator.cs create mode 100644 src/Application/Features/Categories/DTOs/CategoryDto.cs create mode 100644 src/Application/Features/Categories/Queries/GetCategories/GetCategoriesHandler.cs create mode 100644 src/Application/Features/Categories/Queries/GetCategories/GetCategoriesQuery.cs create mode 100644 src/Application/Features/Categories/Queries/GetCategoryById/GetCategoryByIdHandler.cs create mode 100644 src/Application/Features/Categories/Queries/GetCategoryById/GetCategoryByIdQuery.cs create mode 100644 src/Application/Features/Transactions/Commands/AddTransaction/AddTransactionCommand.cs create mode 100644 src/Application/Features/Transactions/Commands/AddTransaction/AddTransactionHandler.cs create mode 100644 src/Application/Features/Transactions/Commands/AddTransaction/AddTransactionValidator.cs create mode 100644 src/Application/Features/Transactions/Commands/DeleteTransaction/DeleteTransactionCommand.cs create mode 100644 src/Application/Features/Transactions/Commands/DeleteTransaction/DeleteTransactionHandler.cs create mode 100644 src/Application/Features/Transactions/Commands/DeleteTransaction/DeleteTransactionValidator.cs create mode 100644 src/Application/Features/Transactions/Commands/ImportTransactions/ImportResultDto.cs create mode 100644 src/Application/Features/Transactions/Commands/ImportTransactions/ImportTransactionsCommand.cs create mode 100644 src/Application/Features/Transactions/Commands/ImportTransactions/ImportTransactionsHandler.cs create mode 100644 src/Application/Features/Transactions/Commands/ImportTransactions/ImportTransactionsValidator.cs create mode 100644 src/Application/Features/Transactions/Queries/GetTransactionById/GetTransactionByIdHandler.cs create mode 100644 src/Application/Features/Transactions/Queries/GetTransactionById/GetTransactionByIdQuery.cs create mode 100644 src/Application/Features/Transactions/Queries/GetTransactionById/TransactionDetailDto.cs create mode 100644 src/Application/Features/Users/Commands/ChangePassword/ChangePasswordCommand.cs create mode 100644 src/Application/Features/Users/Commands/ChangePassword/ChangePasswordHandler.cs create mode 100644 src/Application/Features/Users/Commands/ChangePassword/ChangePasswordValidator.cs create mode 100644 src/Application/Features/Users/Queries/GetUser/GetUserQuery.cs create mode 100644 src/Application/Features/Users/Queries/GetUser/UserDto.cs create mode 100644 src/Application/Features/Users/Queries/GetUser/UserHandlers.cs create mode 100644 src/Application/KontoApi.Application.csproj create mode 100644 src/Domain/Account.cs create mode 100644 src/Domain/Budget.cs create mode 100644 src/Domain/Category.cs create mode 100644 src/Domain/DateRange.cs create mode 100644 src/Domain/KontoApi.Domain.csproj create mode 100644 src/Domain/Money.cs create mode 100644 src/Domain/Transaction.cs create mode 100644 src/Domain/User.cs create mode 100644 src/Infrastructure/Auth/JwtProvider.cs create mode 100644 src/Infrastructure/Auth/JwtSettings.cs create mode 100644 src/Infrastructure/CategorySeeder.cs create mode 100644 src/Infrastructure/DependencyInjection.cs create mode 100644 src/Infrastructure/KontoApi.Infrastructure.csproj create mode 100644 src/Infrastructure/Migrations/20251217042156_InitialCreate.Designer.cs create mode 100644 src/Infrastructure/Migrations/20251217042156_InitialCreate.cs create mode 100644 src/Infrastructure/Migrations/KontoDbContextModelSnapshot.cs create mode 100644 src/Infrastructure/Persistence/Configurations/AccountConfiguration.cs create mode 100644 src/Infrastructure/Persistence/Configurations/BudgetConfiguration.cs create mode 100644 src/Infrastructure/Persistence/Configurations/CategoryConfiguration.cs create mode 100644 src/Infrastructure/Persistence/Configurations/TransactionConfiguration.cs create mode 100644 src/Infrastructure/Persistence/Configurations/UserConfiguration.cs create mode 100644 src/Infrastructure/Persistence/KontoDbContext.cs create mode 100644 src/Infrastructure/Persistence/Repositories/AccountRepository.cs create mode 100644 src/Infrastructure/Persistence/Repositories/BudgetRepository.cs create mode 100644 src/Infrastructure/Persistence/Repositories/CategoryRepository.cs create mode 100644 src/Infrastructure/Persistence/Repositories/UserRepository.cs create mode 100644 src/Infrastructure/Services/PasswordHasher.cs create mode 100644 src/Infrastructure/Services/StatementParser.cs create mode 100644 src/src.sln create mode 100644 tests/KontoApi.IntegrationTestsNet8/AccountTests/AccountApiIntegrationTests.cs create mode 100644 tests/KontoApi.IntegrationTestsNet8/AuthController/AuthIntegrationTests.cs create mode 100644 tests/KontoApi.IntegrationTestsNet8/BudgetsController/BudgetsIntegrationTests.cs create mode 100644 tests/KontoApi.IntegrationTestsNet8/CategoriesController/CategoriesIntegrationTests.cs create mode 100644 tests/KontoApi.IntegrationTestsNet8/KontoApi.IntegrationTestsNet8.csproj create mode 100644 tests/KontoApi.IntegrationTestsNet8/TransactionController/TransactionsIntegrationTests.cs create mode 100644 tests/KontoApi.IntegrationTestsNet8/UserController/UserIntegrationTests.cs create mode 100644 tests/KontoApi.TestEntryPoint/KontoApi.TestEntryPoint.csproj create mode 100644 tests/KontoApi.TestEntryPoint/Program.cs create mode 100644 tests/KontoApi.Tests/CategoryTests/CategoryRepositoryTests.cs create mode 100644 tests/KontoApi.Tests/CategoryTests/CategorySeederTests.cs create mode 100644 tests/KontoApi.Tests/CategoryTests/DbContextFactory.cs create mode 100644 tests/KontoApi.Tests/KontoApi.Tests.csproj create mode 100644 tests/KontoApi.Tests/TokenServiceTests.cs diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5cd25c7cf68786e9c389f17ca09bf19710da9b50 GIT binary patch literal 8196 zcmeHM&u<$=82!dc$(p2O(>f(ZAg$^PQbP!BDIkPuoJ7Txk|+)(p)F>2J#n{L&se*g zm=ZCwUSbU3kEjyAUnSOrcipw8|U3Q1BIf1~I3 zC0#3M7quN)qKA|=Vi^Xp48T#3SsiKVPi^psW@&}i>3!OwO=_U^19%OTZ-U*Wc7gp} zfjx~;V$35TAE?JeNt|S{O9j>p-BMBb-XQ61>S3-+l!9O6eO2I1(R-NN3aw#&OsYOA44oHq$E9lS;B-(KdInkb`}VKeFK-Dcc)41; zWttqij9c|xJWvm)PhZm4^esK6ALwWLjs9Sh>;k*QF0*UwEp~&w!y4=(7P2;r*u$J( z8eZZTOn(Mg3ddaHgJvRPDLSZ8(P!}BbKLM*adTqofgio$QM6_iunHV2Fp;|p>ioYp z{`>zi!FpB!tH6Iw0XDhXTvb2jhwtCw3f0eH59t#mWh!ovqoyEJA*x;<$AN$U!w|k_ X+G+*|Iidw-KLltQY-SbsM-})BiQZE} literal 0 HcmV?d00001 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7b6d2cb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +bin/ +obj/ +*.mdf +*.ldf +*.db +*.suo +*.user +*.userosscache +*.sln.docstates +.vs/ +logs/ +*.log +out/ +Dockerfile +docker-compose.yml +.env diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml new file mode 100644 index 0000000..5bc12b0 --- /dev/null +++ b/.github/workflows/commitlint.yml @@ -0,0 +1,36 @@ +name: Commit Lint + +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: + - main + - develop + +jobs: + commitlint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Lint commit messages + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "Linting PR commits from ${{ github.event.pull_request.base.sha }} to ${{ github.event.pull_request.head.sha }}" + npx commitlint --from=${{ github.event.pull_request.base.sha }} --to=${{ github.event.pull_request.head.sha }} --verbose + else + echo "Linting last commit" + npx commitlint --from=HEAD~1 --to=HEAD --verbose + fi diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..3217215 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,49 @@ +name: Deploy to Yandex Cloud + +on: + push: + branches: + - main + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + # Login to Yandex Cloud + - name: Yandex Cloud Login + uses: yandex-cloud/github-action@v3.0.0 + with: + service-account-key: ${{ secrets.YC_SA_JSON_CREDENTIALS }} + + # Login to Container Registry + - name: Login to Yandex Container Registry + run: | + yc container registry configure-docker + + # Build Docker Image (Force AMD64 architecture) + - name: Build and Push Docker Image + run: | + # Use the Commit Hash as the tag so every build is unique + IMAGE_URL=cr.yandex/${{ secrets.YC_REGISTRY_ID }}/konto-api:${{ github.sha }} + + docker build --platform linux/amd64 -t $IMAGE_URL . + docker push $IMAGE_URL + + # Save the image URL for the next step + echo "IMAGE_URL=$IMAGE_URL" >> $GITHUB_ENV + + # Deploy to Serverless Container + - name: Deploy Revision + run: | + yc serverless container revision deploy \ + --container-id ${{ secrets.YC_CONTAINER_ID }} \ + --image-url ${{ env.IMAGE_URL }} \ + --service-account-id ${{ secrets.YC_SERVICE_ACCOUNT_ID }} \ + --cores 1 \ + --memory 512M \ + --execution-timeout 30s \ + --concurrency 4 \ No newline at end of file diff --git a/.github/workflows/format-check.yml b/.github/workflows/format-check.yml new file mode 100644 index 0000000..3ffed8f --- /dev/null +++ b/.github/workflows/format-check.yml @@ -0,0 +1,33 @@ +name: Format Check + +on: + pull_request: + branches: [develop, main] + push: + branches: [develop, main] + +jobs: + format: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "9.0.x" + + - name: Restore dependencies + run: dotnet restore KontoApi.sln + + - name: Format code + run: dotnet format KontoApi.sln + + - name: Check for changes + run: | + if [[ -n $(git status --porcelain) ]]; then + echo "Code formatting issues detected. Please run 'dotnet format' locally" + git diff + exit 1 + fi diff --git a/.gitignore b/.gitignore index 35063fc..7b34c86 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ ## ## Get latest from https://github.com/github/gitignore/blob/main/Dotnet.gitignore +# JetBrains IDE +.idea/ + # Build results [Dd]ebug/ [Dd]ebugPublic/ @@ -51,4 +54,10 @@ CodeCoverage/ # NUnit *.VisualState.xml TestResult.xml -nunit-*.xml \ No newline at end of file +nunit-*.xml + +# Node.js (for commit linting tools) +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 0000000..b567676 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx --no -- commitlint --edit "$1" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ec18b30 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,50 @@ +# Contributing Guide + +## Setup + +After cloning the repository, install the commit linting tools: + +```bash +npm install +``` + +This will install Husky git hooks that automatically enforce Conventional Commits on every commit + +## Workflow + +1. Pick a task from [Kanban Board](https://github.com/orgs/project-konto/projects/1/views/2) +2. Create a new branch from `development` (name it `feature/` or `bug/`) +3. Commit your changes using Conventional Commits format: + - Format: `(): #` + - Example: `feat(api): add transaction import #23` + - Allowed types: `feat`, `fix`, `docs`, `chore`, `refactor`, `test`, `perf`, `build`, `ci`, `revert`, `style` +4. Push your branch and open a Pull Request against `development` +5. Wait for workflows and code review and make any requested changes +6. Sync with `development` + +## Commit Message Format + +All commits must follow the [Conventional Commits](https://www.conventionalcommits.org/) specification. The commit message hook will prevent invalid commits locally, and CI will verify all commits in pull requests + +## Logging + +We use **Serilog** for structured logging. Inject `ILogger` in your class: + +```csharp +public class Service +{ + private readonly ILogger loger; + + public Service(ILogger logger) + { + this.loger = logger; + } + + public void DoWork(int userId) + { + loger.LogInformation("Processing user {UserId}", userId); + } +} +``` + +Available log levels: `LogDebug`, `LogInformation`, `LogWarning`, `LogError`, `LogCritical` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d81dd46 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# Build stage +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /app + +# Yandex Cloud CA certificate +RUN mkdir -p /app/certs +ADD https://storage.yandexcloud.net/cloud-certs/CA.pem /app/certs/root.crt +RUN chmod 644 /app/certs/root.crt + +# Copy csproj and restore as distinct layers +COPY src/Api/KontoApi.Api.csproj src/Api/ +COPY src/Application/KontoApi.Application.csproj src/Application/ +COPY src/Domain/KontoApi.Domain.csproj src/Domain/ +COPY src/Infrastructure/KontoApi.Infrastructure.csproj src/Infrastructure/ +RUN dotnet restore src/Api/KontoApi.Api.csproj + +# Copy everything else and build +COPY . . +RUN dotnet publish src/Api/KontoApi.Api.csproj -c Release -o out + +# Runtime stage +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app +COPY --from=build /app/out . +COPY --from=build /app/certs /app/certs + +EXPOSE 80 + +ENTRYPOINT ["sh", "-c", "dotnet KontoApi.Api.dll --urls http://0.0.0.0:${PORT:-80}"] diff --git a/KontoApi.sln b/KontoApi.sln new file mode 100644 index 0000000..5dfa9df --- /dev/null +++ b/KontoApi.sln @@ -0,0 +1,113 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Api", "Api", "{81034408-37C8-1011-444E-4C15C2FADA8E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KontoApi.Api", "src\Api\KontoApi.Api.csproj", "{F48668CC-EDBB-468D-A81C-23E1B428D687}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Domain", "Domain", "{8B290487-4C16-E85E-E807-F579CBE9FC4D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KontoApi.Domain", "src\Domain\KontoApi.Domain.csproj", "{CA516A11-7E7D-4140-B077-4B37BCB3523B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Infrastructure", "Infrastructure", "{9048EB7F-3875-A59E-E36B-5BD4C6F2A282}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KontoApi.Infrastructure", "src\Infrastructure\KontoApi.Infrastructure.csproj", "{B3837337-6390-4516-8848-86C6E300B39F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Application", "Application", "{22BAF98C-8415-17C4-B26A-D537657BC863}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KontoApi.Application", "src\Application\KontoApi.Application.csproj", "{87223133-95CD-4D6B-9710-4DC1B958EC4F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KontoApi.Tests", "tests\KontoApi.Tests\KontoApi.Tests.csproj", "{DC598036-8206-4BA0-A562-DF4DC6088302}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {F48668CC-EDBB-468D-A81C-23E1B428D687}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F48668CC-EDBB-468D-A81C-23E1B428D687}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F48668CC-EDBB-468D-A81C-23E1B428D687}.Debug|x64.ActiveCfg = Debug|Any CPU + {F48668CC-EDBB-468D-A81C-23E1B428D687}.Debug|x64.Build.0 = Debug|Any CPU + {F48668CC-EDBB-468D-A81C-23E1B428D687}.Debug|x86.ActiveCfg = Debug|Any CPU + {F48668CC-EDBB-468D-A81C-23E1B428D687}.Debug|x86.Build.0 = Debug|Any CPU + {F48668CC-EDBB-468D-A81C-23E1B428D687}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F48668CC-EDBB-468D-A81C-23E1B428D687}.Release|Any CPU.Build.0 = Release|Any CPU + {F48668CC-EDBB-468D-A81C-23E1B428D687}.Release|x64.ActiveCfg = Release|Any CPU + {F48668CC-EDBB-468D-A81C-23E1B428D687}.Release|x64.Build.0 = Release|Any CPU + {F48668CC-EDBB-468D-A81C-23E1B428D687}.Release|x86.ActiveCfg = Release|Any CPU + {F48668CC-EDBB-468D-A81C-23E1B428D687}.Release|x86.Build.0 = Release|Any CPU + {CA516A11-7E7D-4140-B077-4B37BCB3523B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CA516A11-7E7D-4140-B077-4B37BCB3523B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CA516A11-7E7D-4140-B077-4B37BCB3523B}.Debug|x64.ActiveCfg = Debug|Any CPU + {CA516A11-7E7D-4140-B077-4B37BCB3523B}.Debug|x64.Build.0 = Debug|Any CPU + {CA516A11-7E7D-4140-B077-4B37BCB3523B}.Debug|x86.ActiveCfg = Debug|Any CPU + {CA516A11-7E7D-4140-B077-4B37BCB3523B}.Debug|x86.Build.0 = Debug|Any CPU + {CA516A11-7E7D-4140-B077-4B37BCB3523B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CA516A11-7E7D-4140-B077-4B37BCB3523B}.Release|Any CPU.Build.0 = Release|Any CPU + {CA516A11-7E7D-4140-B077-4B37BCB3523B}.Release|x64.ActiveCfg = Release|Any CPU + {CA516A11-7E7D-4140-B077-4B37BCB3523B}.Release|x64.Build.0 = Release|Any CPU + {CA516A11-7E7D-4140-B077-4B37BCB3523B}.Release|x86.ActiveCfg = Release|Any CPU + {CA516A11-7E7D-4140-B077-4B37BCB3523B}.Release|x86.Build.0 = Release|Any CPU + {B3837337-6390-4516-8848-86C6E300B39F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B3837337-6390-4516-8848-86C6E300B39F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B3837337-6390-4516-8848-86C6E300B39F}.Debug|x64.ActiveCfg = Debug|Any CPU + {B3837337-6390-4516-8848-86C6E300B39F}.Debug|x64.Build.0 = Debug|Any CPU + {B3837337-6390-4516-8848-86C6E300B39F}.Debug|x86.ActiveCfg = Debug|Any CPU + {B3837337-6390-4516-8848-86C6E300B39F}.Debug|x86.Build.0 = Debug|Any CPU + {B3837337-6390-4516-8848-86C6E300B39F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B3837337-6390-4516-8848-86C6E300B39F}.Release|Any CPU.Build.0 = Release|Any CPU + {B3837337-6390-4516-8848-86C6E300B39F}.Release|x64.ActiveCfg = Release|Any CPU + {B3837337-6390-4516-8848-86C6E300B39F}.Release|x64.Build.0 = Release|Any CPU + {B3837337-6390-4516-8848-86C6E300B39F}.Release|x86.ActiveCfg = Release|Any CPU + {B3837337-6390-4516-8848-86C6E300B39F}.Release|x86.Build.0 = Release|Any CPU + {87223133-95CD-4D6B-9710-4DC1B958EC4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {87223133-95CD-4D6B-9710-4DC1B958EC4F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {87223133-95CD-4D6B-9710-4DC1B958EC4F}.Debug|x64.ActiveCfg = Debug|Any CPU + {87223133-95CD-4D6B-9710-4DC1B958EC4F}.Debug|x64.Build.0 = Debug|Any CPU + {87223133-95CD-4D6B-9710-4DC1B958EC4F}.Debug|x86.ActiveCfg = Debug|Any CPU + {87223133-95CD-4D6B-9710-4DC1B958EC4F}.Debug|x86.Build.0 = Debug|Any CPU + {87223133-95CD-4D6B-9710-4DC1B958EC4F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {87223133-95CD-4D6B-9710-4DC1B958EC4F}.Release|Any CPU.Build.0 = Release|Any CPU + {87223133-95CD-4D6B-9710-4DC1B958EC4F}.Release|x64.ActiveCfg = Release|Any CPU + {87223133-95CD-4D6B-9710-4DC1B958EC4F}.Release|x64.Build.0 = Release|Any CPU + {87223133-95CD-4D6B-9710-4DC1B958EC4F}.Release|x86.ActiveCfg = Release|Any CPU + {87223133-95CD-4D6B-9710-4DC1B958EC4F}.Release|x86.Build.0 = Release|Any CPU + {DC598036-8206-4BA0-A562-DF4DC6088302}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DC598036-8206-4BA0-A562-DF4DC6088302}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DC598036-8206-4BA0-A562-DF4DC6088302}.Debug|x64.ActiveCfg = Debug|Any CPU + {DC598036-8206-4BA0-A562-DF4DC6088302}.Debug|x64.Build.0 = Debug|Any CPU + {DC598036-8206-4BA0-A562-DF4DC6088302}.Debug|x86.ActiveCfg = Debug|Any CPU + {DC598036-8206-4BA0-A562-DF4DC6088302}.Debug|x86.Build.0 = Debug|Any CPU + {DC598036-8206-4BA0-A562-DF4DC6088302}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DC598036-8206-4BA0-A562-DF4DC6088302}.Release|Any CPU.Build.0 = Release|Any CPU + {DC598036-8206-4BA0-A562-DF4DC6088302}.Release|x64.ActiveCfg = Release|Any CPU + {DC598036-8206-4BA0-A562-DF4DC6088302}.Release|x64.Build.0 = Release|Any CPU + {DC598036-8206-4BA0-A562-DF4DC6088302}.Release|x86.ActiveCfg = Release|Any CPU + {DC598036-8206-4BA0-A562-DF4DC6088302}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {81034408-37C8-1011-444E-4C15C2FADA8E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {F48668CC-EDBB-468D-A81C-23E1B428D687} = {81034408-37C8-1011-444E-4C15C2FADA8E} + {8B290487-4C16-E85E-E807-F579CBE9FC4D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {CA516A11-7E7D-4140-B077-4B37BCB3523B} = {8B290487-4C16-E85E-E807-F579CBE9FC4D} + {9048EB7F-3875-A59E-E36B-5BD4C6F2A282} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {B3837337-6390-4516-8848-86C6E300B39F} = {9048EB7F-3875-A59E-E36B-5BD4C6F2A282} + {22BAF98C-8415-17C4-B26A-D537657BC863} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {87223133-95CD-4D6B-9710-4DC1B958EC4F} = {22BAF98C-8415-17C4-B26A-D537657BC863} + {DC598036-8206-4BA0-A562-DF4DC6088302} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection +EndGlobal diff --git a/KontoApi.sln.DotSettings.user b/KontoApi.sln.DotSettings.user new file mode 100644 index 0000000..59c04bf --- /dev/null +++ b/KontoApi.sln.DotSettings.user @@ -0,0 +1,14 @@ + + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + /Users/egorvetoskin/Library/Caches/JetBrains/Rider2025.2/resharper-host/temp/Rider/vAny/CoverageData/_KontoApi.-1767776553/Snapshot/snapshot.utdcvr + <SessionState ContinuousTestingMode="0" IsActive="True" Name="CategorySeederTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <TestAncestor> + <TestId>xUnit::DC598036-8206-4BA0-A562-DF4DC6088302::net10.0::KontoApi.Tests.CategoryTests.CategorySeederTests</TestId> + <TestId>xUnit::DC598036-8206-4BA0-A562-DF4DC6088302::net10.0::KontoApi.Tests.CategoryTests.CategoryRepositoryTests</TestId> + <TestId>xUnit::DC598036-8206-4BA0-A562-DF4DC6088302::net10.0::KontoApi.Tests.Integration.AccountApiIntegrationTests</TestId> + </TestAncestor> +</SessionState> \ No newline at end of file diff --git a/README.md b/README.md index 1650d7c..e42d17b 100644 --- a/README.md +++ b/README.md @@ -1 +1,85 @@ -# backend \ No newline at end of file +# KontoApi + +A personal finance management API built with .NET 9 + +## Overview + +KontoApi is a RESTful API for managing personal finances, including: + +- User authentication with JWT tokens +- Budget management +- Transaction tracking (income, expenses, transfers) +- Bank statement import + +## Tech Stack + +- **.NET 9** - Runtime +- **ASP.NET Core** - Web framework +- **Entity Framework Core** - ORM +- **PostgreSQL** - Database +- **FluentValidation** - Request validation +- **Serilog** - Structured logging +- **xUnit + Moq** - Testing + +## Project Structure + +```bash +src/ +├── Api/ # Controllers, middleware, validators +├── Application/ # Use cases, DTOs, interfaces +├── Domain/ # Entities, value objects +└── Infrastructure/ # Repositories, external services +tests/ +└── KontoApi.Tests/ # Unit tests +``` + +## Getting Started + +### Prerequisites + +- [.NET 9 SDK](https://dotnet.microsoft.com/download) +- [PostgreSQL](https://www.postgresql.org/download/) +- [Node.js](https://nodejs.org/) (for commit linting) + +### Setup + +1. Clone the repository +2. Configure the database connection in `src/Infrastructure/KontoDbContext.cs` +3. Set up JWT secret: + + ```bash + cd src/Api + dotnet user-secrets set "Jwt:Key" "your-secret-key-min-32-chars" + ``` + +4. Install commit hooks: + + ```bash + npm install + ``` + +### Run + +```bash +dotnet run --project src/Api +``` + +The API will be available at `http://localhost:5076` (or `https://localhost:7049`) with Swagger UI at `/swagger`. + +### Test + +```bash +dotnet test +``` + +## API Endpoints + +See interactive documentation and try out requests in [Swagger UI](http://localhost:5001/swagger) + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines + +## License + +Apache 2.0, see [LICENSE](LICENSE) for details diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 0000000..e756ccb --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,26 @@ +module.exports = { + extends: ['@commitlint/config-conventional'], + rules: { + 'type-enum': [ + 2, + 'always', + [ + 'feat', + 'fix', + 'docs', + 'chore', + 'refactor', + 'test', + 'perf', + 'build', + 'ci', + 'revert', + 'style' + ] + ], + 'subject-max-length': [2, 'always', 100], + 'header-max-length': [2, 'always', 100], + 'scope-empty': [2, 'never'], + 'references-empty': [2, 'never'] + } +}; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2c20e9e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +services: + db: + image: postgres:13 + restart: always + environment: + POSTGRES_USER: konto + POSTGRES_PASSWORD: postgres + POSTGRES_DB: konto + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + api: + build: + context: . + dockerfile: Dockerfile + depends_on: + - db + environment: + ASPNETCORE_ENVIRONMENT: Development + ConnectionStrings__DefaultConnection: "Host=db;Port=5432;Database=konto;Username=konto;Password=postgres" + ports: + - "5001:80" + volumes: + - ./src/Api/logs:/app/logs +volumes: + pgdata: diff --git a/global.json b/global.json new file mode 100644 index 0000000..e69d70f --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestMajor", + "allowPrerelease": true + } +} \ No newline at end of file diff --git a/key.json b/key.json new file mode 100644 index 0000000..01cd76e --- /dev/null +++ b/key.json @@ -0,0 +1,8 @@ +{ + "id": "ajegq0o3dodq5ctsu9fc", + "service_account_id": "ajev39uspi5q0mjjt5e3", + "created_at": "2025-12-19T10:34:21.096060843Z", + "key_algorithm": "RSA_2048", + "public_key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2ylEvYEjg4PWrKyWSY9b\nLZCj2PXrlWWcNtsXCWi7bzSYhcPkuR2/lqmxMlGcmkkI5MvF1vXOReYwMoM3oybD\nIfuLBZfrUriqHF5mfHtAn2bUvGBJLU+2KqKD8LLi29jFCyyfvDWlzM9KwMn0yK3m\nDFKmTX9XJNYq1bAVo3KVynkypd/FQUkcYyhTvbIsHQrcYEX5uhlFtPjjdKYNhNBs\nbZnp6SOeReD4imPybTQonYXCJIcwI1l131pTAZe7l4WUHtUyZMLGgnH8A9+LUK6I\nv5OQVJfhAkcKX2vb4pwld/ll8govv5WziWMTaTIPtpIiJmVXNSopSZ7R0blMVYDB\n1QIDAQAB\n-----END PUBLIC KEY-----\n", + "private_key": "PLEASE DO NOT REMOVE THIS LINE! Yandex.Cloud SA Key ID \u003cajegq0o3dodq5ctsu9fc\u003e\n-----BEGIN PRIVATE KEY-----\nMIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDbKUS9gSODg9as\nrJZJj1stkKPY9euVZZw22xcJaLtvNJiFw+S5Hb+WqbEyUZyaSQjky8XW9c5F5jAy\ngzejJsMh+4sFl+tSuKocXmZ8e0CfZtS8YEktT7YqooPwsuLb2MULLJ+8NaXMz0rA\nyfTIreYMUqZNf1ck1irVsBWjcpXKeTKl38VBSRxjKFO9siwdCtxgRfm6GUW0+ON0\npg2E0GxtmenpI55F4PiKY/JtNCidhcIkhzAjWXXfWlMBl7uXhZQe1TJkwsaCcfwD\n34tQroi/k5BUl+ECRwpfa9vinCV3+WXyCi+/lbOJYxNpMg+2kiImZVc1KilJntHR\nuUxVgMHVAgMBAAECggEAAOBLXA6WG8J9KzeNakHedXIslYN2K6S6WSXLE8iNZ0Zo\njbBYy7yotWt8P90/qQY9uNxvsApPhswCyVDo+gf/o7UjuyP+VthSJcbi1gCQI5L/\nqrkf0cSNz+HScTXl3kchkofdI517SjRR8i9cgSjg0I/MBmdJ6+XHSg9oJr0giCyt\n9wnSbxGolvwXsz56eHoQi5hC9MlBk7ooKJJ0W6Gb40XQt76EA6ubg2bqnRmSesQV\nx/tUXYaKKMBGtKfBj02ra5GTYW/RNIPT6wIwds8DiP3OdSSov31LCJYCEfc6wMoH\nOdw0k8w86Kz3zhGxtrbrXA40cuaEVlH5//E8Uxs3CQKBgQDcP70i5DMIrRKtQSIq\nfRsymWlqQv7QhAIxM/S7Yyfp634QZECXxOMx6YEtaMdWP9vjTh1L0wiv8+4ngkv8\n8r9ASnALU+s1NvU3w680RpuUJfZ3dHHG/gjC7LsRP71aeRnBUf1+s9KAwAW2KX/2\nZ0tt5usXeTx6PKbiNeFHjeaOSwKBgQD+vFQBP67WgKeLxqXIBQgRrp9xIOk/d5Mk\n1pElGd+ygHHX/svPmsPylrHmOQHAcpuoDgAGp17CtEeSKNwlLlJSnqifPJ5Ewa8n\nGQAAFFrsxzQb2yY1OuuxF8eZzowDpivXixvlzmLAjOekinHSjyVpCS9/JILyoS5X\nQrKgyA1cXwKBgQCyEa5fGVTRfOyOuwxegcuNe08pJIVUHIaRNUp2flJ043qG0icP\nDZYKcpmIFyCi8PpcqnkxJqvtZjB0LPo2xiQuXIB+CBkwawCTV1i90SBUBOVh6vla\nQ2TeA/uylHAJR8O08w8ac1SL17RGdQMKOrVXY81KqzBkO8lJNvqumWGARQKBgQCX\n/0XczOQFSBUxOSH4nM/4xQqMVUf4P+BD7egDjFHHUA8eBAIW6VEcckDKj5909q3c\ndCYd0kafxTSjiM/7O42RkOwqv9/sROm/WZJ/eaXbfO2h5X0B7BtwuzC1nOI81y13\n2qCV2jVgkXMy4g0Sx6lZ6Eo4AlBEeFqPZNPzTgty3wKBgQDXGBeuLWXyIoxm2DYv\nrGbYw2iWmYZQnWsOrLvKVeA8hOT6K/e1n+Q1Xu6WlPYvdPI6UewnTMqCBoNPPq+8\nepIMBnSJkUNauoKemBx/kFezFsvsCKdbHrOpNqvpdozwHOHmkQnL55P8PihOm+Hr\nUwDp91cMo1r3mBcZAuu/pAVHmA==\n-----END PRIVATE KEY-----\n" +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0c1d080 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2925 @@ +{ + "name": "konto-api-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "konto-api-backend", + "version": "1.0.0", + "devDependencies": { + "@commitlint/cli": "^18.4.3", + "@commitlint/config-conventional": "^18.4.3", + "commitizen": "^4.3.0", + "cz-conventional-changelog": "^3.3.0", + "husky": "^8.0.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@commitlint/cli": { + "version": "18.6.1", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-18.6.1.tgz", + "integrity": "sha512-5IDE0a+lWGdkOvKH892HHAZgbAjcj1mT5QrfA/SVbLJV/BbBMGyKN0W5mhgjekPJJwEQdVNvhl9PwUacY58Usw==", + "dev": true, + "dependencies": { + "@commitlint/format": "^18.6.1", + "@commitlint/lint": "^18.6.1", + "@commitlint/load": "^18.6.1", + "@commitlint/read": "^18.6.1", + "@commitlint/types": "^18.6.1", + "execa": "^5.0.0", + "lodash.isfunction": "^3.0.9", + "resolve-from": "5.0.0", + "resolve-global": "1.0.0", + "yargs": "^17.0.0" + }, + "bin": { + "commitlint": "cli.js" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/config-conventional": { + "version": "18.6.3", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-18.6.3.tgz", + "integrity": "sha512-8ZrRHqF6je+TRaFoJVwszwnOXb/VeYrPmTwPhf0WxpzpGTcYy1p0SPyZ2eRn/sRi/obnWAcobtDAq6+gJQQNhQ==", + "dev": true, + "dependencies": { + "@commitlint/types": "^18.6.1", + "conventional-changelog-conventionalcommits": "^7.0.2" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/config-validator": { + "version": "18.6.1", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-18.6.1.tgz", + "integrity": "sha512-05uiToBVfPhepcQWE1ZQBR/Io3+tb3gEotZjnI4tTzzPk16NffN6YABgwFQCLmzZefbDcmwWqJWc2XT47q7Znw==", + "dev": true, + "dependencies": { + "@commitlint/types": "^18.6.1", + "ajv": "^8.11.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/ensure": { + "version": "18.6.1", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-18.6.1.tgz", + "integrity": "sha512-BPm6+SspyxQ7ZTsZwXc7TRQL5kh5YWt3euKmEIBZnocMFkJevqs3fbLRb8+8I/cfbVcAo4mxRlpTPfz8zX7SnQ==", + "dev": true, + "dependencies": { + "@commitlint/types": "^18.6.1", + "lodash.camelcase": "^4.3.0", + "lodash.kebabcase": "^4.1.1", + "lodash.snakecase": "^4.1.1", + "lodash.startcase": "^4.4.0", + "lodash.upperfirst": "^4.3.1" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/execute-rule": { + "version": "18.6.1", + "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-18.6.1.tgz", + "integrity": "sha512-7s37a+iWyJiGUeMFF6qBlyZciUkF8odSAnHijbD36YDctLhGKoYltdvuJ/AFfRm6cBLRtRk9cCVPdsEFtt/2rg==", + "dev": true, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/format": { + "version": "18.6.1", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-18.6.1.tgz", + "integrity": "sha512-K8mNcfU/JEFCharj2xVjxGSF+My+FbUHoqR+4GqPGrHNqXOGNio47ziiR4HQUPKtiNs05o8/WyLBoIpMVOP7wg==", + "dev": true, + "dependencies": { + "@commitlint/types": "^18.6.1", + "chalk": "^4.1.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/is-ignored": { + "version": "18.6.1", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-18.6.1.tgz", + "integrity": "sha512-MOfJjkEJj/wOaPBw5jFjTtfnx72RGwqYIROABudOtJKW7isVjFe9j0t8xhceA02QebtYf4P/zea4HIwnXg8rvA==", + "dev": true, + "dependencies": { + "@commitlint/types": "^18.6.1", + "semver": "7.6.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/lint": { + "version": "18.6.1", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-18.6.1.tgz", + "integrity": "sha512-8WwIFo3jAuU+h1PkYe5SfnIOzp+TtBHpFr4S8oJWhu44IWKuVx6GOPux3+9H1iHOan/rGBaiacicZkMZuluhfQ==", + "dev": true, + "dependencies": { + "@commitlint/is-ignored": "^18.6.1", + "@commitlint/parse": "^18.6.1", + "@commitlint/rules": "^18.6.1", + "@commitlint/types": "^18.6.1" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/load": { + "version": "18.6.1", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-18.6.1.tgz", + "integrity": "sha512-p26x8734tSXUHoAw0ERIiHyW4RaI4Bj99D8YgUlVV9SedLf8hlWAfyIFhHRIhfPngLlCe0QYOdRKYFt8gy56TA==", + "dev": true, + "dependencies": { + "@commitlint/config-validator": "^18.6.1", + "@commitlint/execute-rule": "^18.6.1", + "@commitlint/resolve-extends": "^18.6.1", + "@commitlint/types": "^18.6.1", + "chalk": "^4.1.0", + "cosmiconfig": "^8.3.6", + "cosmiconfig-typescript-loader": "^5.0.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "lodash.uniq": "^4.5.0", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/message": { + "version": "18.6.1", + "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-18.6.1.tgz", + "integrity": "sha512-VKC10UTMLcpVjMIaHHsY1KwhuTQtdIKPkIdVEwWV+YuzKkzhlI3aNy6oo1eAN6b/D2LTtZkJe2enHmX0corYRw==", + "dev": true, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/parse": { + "version": "18.6.1", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-18.6.1.tgz", + "integrity": "sha512-eS/3GREtvVJqGZrwAGRwR9Gdno3YcZ6Xvuaa+vUF8j++wsmxrA2En3n0ccfVO2qVOLJC41ni7jSZhQiJpMPGOQ==", + "dev": true, + "dependencies": { + "@commitlint/types": "^18.6.1", + "conventional-changelog-angular": "^7.0.0", + "conventional-commits-parser": "^5.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/read": { + "version": "18.6.1", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-18.6.1.tgz", + "integrity": "sha512-ia6ODaQFzXrVul07ffSgbZGFajpe8xhnDeLIprLeyfz3ivQU1dIoHp7yz0QIorZ6yuf4nlzg4ZUkluDrGN/J/w==", + "dev": true, + "dependencies": { + "@commitlint/top-level": "^18.6.1", + "@commitlint/types": "^18.6.1", + "git-raw-commits": "^2.0.11", + "minimist": "^1.2.6" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/resolve-extends": { + "version": "18.6.1", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-18.6.1.tgz", + "integrity": "sha512-ifRAQtHwK+Gj3Bxj/5chhc4L2LIc3s30lpsyW67yyjsETR6ctHAHRu1FSpt0KqahK5xESqoJ92v6XxoDRtjwEQ==", + "dev": true, + "dependencies": { + "@commitlint/config-validator": "^18.6.1", + "@commitlint/types": "^18.6.1", + "import-fresh": "^3.0.0", + "lodash.mergewith": "^4.6.2", + "resolve-from": "^5.0.0", + "resolve-global": "^1.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/rules": { + "version": "18.6.1", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-18.6.1.tgz", + "integrity": "sha512-kguM6HxZDtz60v/zQYOe0voAtTdGybWXefA1iidjWYmyUUspO1zBPQEmJZ05/plIAqCVyNUTAiRPWIBKLCrGew==", + "dev": true, + "dependencies": { + "@commitlint/ensure": "^18.6.1", + "@commitlint/message": "^18.6.1", + "@commitlint/to-lines": "^18.6.1", + "@commitlint/types": "^18.6.1", + "execa": "^5.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/to-lines": { + "version": "18.6.1", + "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-18.6.1.tgz", + "integrity": "sha512-Gl+orGBxYSNphx1+83GYeNy5N0dQsHBQ9PJMriaLQDB51UQHCVLBT/HBdOx5VaYksivSf5Os55TLePbRLlW50Q==", + "dev": true, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/top-level": { + "version": "18.6.1", + "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-18.6.1.tgz", + "integrity": "sha512-HyiHQZUTf0+r0goTCDs/bbVv/LiiQ7AVtz6KIar+8ZrseB9+YJAIo8HQ2IC2QT1y3N1lbW6OqVEsTHjbT6hGSw==", + "dev": true, + "dependencies": { + "find-up": "^5.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/types": { + "version": "18.6.1", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-18.6.1.tgz", + "integrity": "sha512-gwRLBLra/Dozj2OywopeuHj2ac26gjGkz2cZ+86cTJOdtWfiRRr4+e77ZDAGc6MDWxaWheI+mAV5TLWWRwqrFg==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@types/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", + "dev": true + }, + "node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "dev": true, + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/array-ify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", + "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", + "dev": true + }, + "node_modules/arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/cachedir": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.3.0.tgz", + "integrity": "sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-keys": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz", + "integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==", + "dev": true, + "dependencies": { + "camelcase": "^5.3.1", + "map-obj": "^4.0.0", + "quick-lru": "^4.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", + "dev": true + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/commitizen": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/commitizen/-/commitizen-4.3.1.tgz", + "integrity": "sha512-gwAPAVTy/j5YcOOebcCRIijn+mSjWJC+IYKivTu6aG8Ei/scoXgfsMRnuAk6b0GRste2J4NGxVdMN3ZpfNaVaw==", + "dev": true, + "dependencies": { + "cachedir": "2.3.0", + "cz-conventional-changelog": "3.3.0", + "dedent": "0.7.0", + "detect-indent": "6.1.0", + "find-node-modules": "^2.1.2", + "find-root": "1.1.0", + "fs-extra": "9.1.0", + "glob": "7.2.3", + "inquirer": "8.2.5", + "is-utf8": "^0.2.1", + "lodash": "4.17.21", + "minimist": "1.2.7", + "strip-bom": "4.0.0", + "strip-json-comments": "3.1.1" + }, + "bin": { + "commitizen": "bin/commitizen", + "cz": "bin/git-cz", + "git-cz": "bin/git-cz" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/commitizen/node_modules/minimist": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz", + "integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/compare-func": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", + "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", + "dev": true, + "dependencies": { + "array-ify": "^1.0.0", + "dot-prop": "^5.1.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/conventional-changelog-angular": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz", + "integrity": "sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==", + "dev": true, + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/conventional-changelog-conventionalcommits": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-7.0.2.tgz", + "integrity": "sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w==", + "dev": true, + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/conventional-commit-types": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/conventional-commit-types/-/conventional-commit-types-3.0.0.tgz", + "integrity": "sha512-SmmCYnOniSsAa9GqWOeLqc179lfr5TRu5b4QFDkbsrJ5TZjPJx85wtOr3zn+1dbeNiXDKGPbZ72IKbPhLXh/Lg==", + "dev": true + }, + "node_modules/conventional-commits-parser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz", + "integrity": "sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==", + "dev": true, + "dependencies": { + "is-text-path": "^2.0.0", + "JSONStream": "^1.3.5", + "meow": "^12.0.1", + "split2": "^4.0.0" + }, + "bin": { + "conventional-commits-parser": "cli.mjs" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cosmiconfig-typescript-loader": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-5.1.0.tgz", + "integrity": "sha512-7PtBB+6FdsOvZyJtlF3hEPpACq7RQX6BVGsgC7/lfVXnKMvNCu/XY3ykreqG5w/rBNdu2z8LCIKoF3kpHHdHlA==", + "dev": true, + "dependencies": { + "jiti": "^1.21.6" + }, + "engines": { + "node": ">=v16" + }, + "peerDependencies": { + "@types/node": "*", + "cosmiconfig": ">=8.2", + "typescript": ">=4" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cz-conventional-changelog": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/cz-conventional-changelog/-/cz-conventional-changelog-3.3.0.tgz", + "integrity": "sha512-U466fIzU5U22eES5lTNiNbZ+d8dfcHcssH4o7QsdWaCcRs/feIPCxKYSWkYBNs5mny7MvEfwpTLWjvbm94hecw==", + "dev": true, + "dependencies": { + "chalk": "^2.4.1", + "commitizen": "^4.0.3", + "conventional-commit-types": "^3.0.0", + "lodash.map": "^4.5.1", + "longest": "^2.0.1", + "word-wrap": "^1.0.3" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@commitlint/load": ">6.1.1" + } + }, + "node_modules/cz-conventional-changelog/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cz-conventional-changelog/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cz-conventional-changelog/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/cz-conventional-changelog/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/cz-conventional-changelog/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/cz-conventional-changelog/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/dargs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/dargs/-/dargs-7.0.0.tgz", + "integrity": "sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decamelize-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.1.tgz", + "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==", + "dev": true, + "dependencies": { + "decamelize": "^1.1.0", + "map-obj": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decamelize-keys/node_modules/map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dedent": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", + "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", + "dev": true + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/detect-file": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz", + "integrity": "sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-indent": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", + "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dev": true, + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/expand-tilde": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", + "integrity": "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==", + "dev": true, + "dependencies": { + "homedir-polyfill": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/external-editor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "dev": true, + "dependencies": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dev": true, + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-node-modules": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/find-node-modules/-/find-node-modules-2.1.3.tgz", + "integrity": "sha512-UC2I2+nx1ZuOBclWVNdcnbDR5dlrOdVb7xNjmT/lHE+LsgztWks3dG7boJ37yTS/venXw84B/mAW9uHVoC5QRg==", + "dev": true, + "dependencies": { + "findup-sync": "^4.0.0", + "merge": "^2.1.1" + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "dev": true + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/findup-sync": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-4.0.0.tgz", + "integrity": "sha512-6jvvn/12IC4quLBL1KNokxC7wWTvYncaVUYSoxWw7YykPLuRrnv4qdHcSOywOI5RpkOVGeQRtWM8/q+G6W6qfQ==", + "dev": true, + "dependencies": { + "detect-file": "^1.0.0", + "is-glob": "^4.0.0", + "micromatch": "^4.0.2", + "resolve-dir": "^1.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/git-raw-commits": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-2.0.11.tgz", + "integrity": "sha512-VnctFhw+xfj8Va1xtfEqCUD2XDrbAPSJx+hSrE5K7fGdjZruW7XV+QOrN7LF/RJyvspRiD2I0asWsxFp0ya26A==", + "dev": true, + "dependencies": { + "dargs": "^7.0.0", + "lodash": "^4.17.15", + "meow": "^8.0.0", + "split2": "^3.0.0", + "through2": "^4.0.0" + }, + "bin": { + "git-raw-commits": "cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/git-raw-commits/node_modules/meow": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/meow/-/meow-8.1.2.tgz", + "integrity": "sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q==", + "dev": true, + "dependencies": { + "@types/minimist": "^1.2.0", + "camelcase-keys": "^6.2.2", + "decamelize-keys": "^1.1.0", + "hard-rejection": "^2.1.0", + "minimist-options": "4.1.0", + "normalize-package-data": "^3.0.0", + "read-pkg-up": "^7.0.1", + "redent": "^3.0.0", + "trim-newlines": "^3.0.0", + "type-fest": "^0.18.0", + "yargs-parser": "^20.2.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/git-raw-commits/node_modules/split2": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz", + "integrity": "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==", + "dev": true, + "dependencies": { + "readable-stream": "^3.0.0" + } + }, + "node_modules/git-raw-commits/node_modules/type-fest": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz", + "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/git-raw-commits/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/global-dirs": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz", + "integrity": "sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==", + "dev": true, + "dependencies": { + "ini": "^1.3.4" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/global-modules": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz", + "integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==", + "dev": true, + "dependencies": { + "global-prefix": "^1.0.1", + "is-windows": "^1.0.1", + "resolve-dir": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/global-prefix": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz", + "integrity": "sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==", + "dev": true, + "dependencies": { + "expand-tilde": "^2.0.2", + "homedir-polyfill": "^1.0.1", + "ini": "^1.3.4", + "is-windows": "^1.0.1", + "which": "^1.2.14" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/hard-rejection": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", + "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/homedir-polyfill": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", + "integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==", + "dev": true, + "dependencies": { + "parse-passwd": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/husky": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/husky/-/husky-8.0.3.tgz", + "integrity": "sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==", + "dev": true, + "bin": { + "husky": "lib/bin.js" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true + }, + "node_modules/inquirer": { + "version": "8.2.5", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.5.tgz", + "integrity": "sha512-QAgPDQMEgrDssk1XiwwHoOGYF9BAbUcc1+j+FhEvaOt8/cKRqyLn0U5qA6F74fGhTMGxf92pOvPBeh29jQJDTQ==", + "dev": true, + "dependencies": { + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.1", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "external-editor": "^3.0.3", + "figures": "^3.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", + "ora": "^5.4.1", + "run-async": "^2.4.0", + "rxjs": "^7.5.5", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-text-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-2.0.0.tgz", + "integrity": "sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==", + "dev": true, + "dependencies": { + "text-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-utf8": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", + "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==", + "dev": true + }, + "node_modules/is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ] + }, + "node_modules/JSONStream": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", + "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", + "dev": true, + "dependencies": { + "jsonparse": "^1.2.0", + "through": ">=2.2.7 <3" + }, + "bin": { + "JSONStream": "bin.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "dev": true + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true + }, + "node_modules/lodash.kebabcase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", + "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==", + "dev": true + }, + "node_modules/lodash.map": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.map/-/lodash.map-4.6.0.tgz", + "integrity": "sha512-worNHGKLDetmcEYDvh2stPCrrQRkP20E4l0iIS7F8EvzMqBBi7ltvFN5m1HvTf1P7Jk1txKhvFcmYsCr8O2F1Q==", + "dev": true + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "dev": true + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "dev": true + }, + "node_modules/lodash.startcase": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", + "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", + "dev": true + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "dev": true + }, + "node_modules/lodash.upperfirst": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", + "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==", + "dev": true + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/longest": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/longest/-/longest-2.0.1.tgz", + "integrity": "sha512-Ajzxb8CM6WAnFjgiloPsI3bF+WCxcvhdIG3KNA2KN962+tdBsHcuQ4k4qX/EcS/2CRkcc0iAkR956Nib6aXU/Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/map-obj": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", + "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meow": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", + "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", + "dev": true, + "engines": { + "node": ">=16.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/merge/-/merge-2.1.1.tgz", + "integrity": "sha512-jz+Cfrg9GWOZbQAnDQ4hlVnQky+341Yk5ru8bZSe6sIDTCIg8n9i/u7hSQGSVOF3C7lH6mGtqjkiT9G4wFLL0w==", + "dev": true + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minimist-options": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", + "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", + "dev": true, + "dependencies": { + "arrify": "^1.0.1", + "is-plain-obj": "^1.1.0", + "kind-of": "^6.0.3" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true + }, + "node_modules/normalize-package-data": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", + "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", + "dev": true, + "dependencies": { + "hosted-git-info": "^4.0.1", + "is-core-module": "^2.5.0", + "semver": "^7.3.4", + "validate-npm-package-license": "^3.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-passwd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", + "integrity": "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/quick-lru": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", + "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "dev": true, + "dependencies": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", + "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", + "dev": true, + "dependencies": { + "find-up": "^4.1.0", + "read-pkg": "^5.2.0", + "type-fest": "^0.8.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg/node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "node_modules/read-pkg/node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/read-pkg/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/read-pkg/node_modules/type-fest": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-dir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz", + "integrity": "sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==", + "dev": true, + "dependencies": { + "expand-tilde": "^2.0.0", + "global-modules": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-global": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-global/-/resolve-global-1.0.0.tgz", + "integrity": "sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw==", + "dev": true, + "dependencies": { + "global-dirs": "^0.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.22", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.22.tgz", + "integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==", + "dev": true + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/text-extensions": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-2.4.0.tgz", + "integrity": "sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true + }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "dev": true, + "dependencies": { + "readable-stream": "3" + } + }, + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/trim-newlines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", + "integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "peer": true + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..64edc48 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "konto-api-backend", + "version": "1.0.0", + "private": true, + "description": "Node tooling for commit linting and git hooks", + "scripts": { + "prepare": "husky install", + "commit": "cz" + }, + "devDependencies": { + "@commitlint/cli": "^18.4.3", + "@commitlint/config-conventional": "^18.4.3", + "commitizen": "^4.3.0", + "cz-conventional-changelog": "^3.3.0", + "husky": "^8.0.3" + }, + "config": { + "commitizen": { + "path": "./node_modules/cz-conventional-changelog" + } + } +} diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..c532297d43ab774a68bd4f5fef3ff7e6f14dbaea GIT binary patch literal 6148 zcmeHK%}*0S6n_t@Y(Zf86pSVfy_i500a0TNwP3V*fKY-UVBNMuSy^V9-7OUfNza;i z@aP}lAK;0j7mr>&c+o$=gJ&=L=A!|MUWuB0$;|J~ysyr0XLmXPfayx{3V;m&7Bz{n zR_cBu(k|OEX)-e{geY@}Lf#X38p(8#L<~d>{Ot_LcefdCLmAwRcjWy|ljYJ+C2A4I zD_;%D<}KgPmpwNKM@K(lQ*-Rp=@zTSYO~g|8={&Gov;$59e+iR7KHHfb=}D?x~!VB zd#42t9hWm-1-L~5w->kil9zAJ=Qh19E?#KTk-kYv+6~s>B`Ib#ZeiQ320zwU^ToF7>xs9vooVmr zw7a@{x_Z0%`uh6M4h)_fJfE|B@=js1?Cu7? zc~*RUFtxB#CZFDe36<-v&lq1R`EGG`eLY~|(jk30XLkk#UwGb}2;9&We0en>iZdbG z%J6kT6}O4r*|IbRi_8xQkHsvlc}|{rvpg-rP;4zbzVGl7OYcZenT-}Q=QEi})y|fk zg3C+EsqdP@WNNOiT-1sUNv+UX(OcIHV)A|QtJ32OTyPdorXCoR1;e!Y?n4$fpbD?x z4ZMYY_yC{b8~nf+_Tvx^;{@KqDZGane1ruoqKi*;d{g}qUoiO$U_gp_#M@b)dxDo} zMtZlY&0Yo%TquA8A)x@?u=(GS=i$wN8fM6Ai~QY2UmR-i?SKq$SO=fH1Iq71g(`*A z`@oLY!PgD3G(0&mTPpC Create([FromBody] CreateAccountRequest request, CancellationToken cancellationToken) + { + var command = new CreateAccountCommand(UserId, request.Name); + var accountId = await Mediator.Send(command, cancellationToken); + return CreatedAtAction(nameof(Get), new { }, new { AccountId = accountId }); + } + + // GET api/account + [HttpGet] + [ProducesResponseType(typeof(AccountOverviewDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] + public async Task Get(CancellationToken cancellationToken) + { + var query = new GetAccountOverviewQuery(UserId); + var result = await Mediator.Send(query, cancellationToken); + return Ok(result); + } + + // DELETE api/account + [HttpDelete] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] + public async Task Delete(CancellationToken cancellationToken) + { + var command = new DeleteAccountCommand(UserId); + await Mediator.Send(command, cancellationToken); + return NoContent(); + } +} + +public record CreateAccountRequest(string Name); \ No newline at end of file diff --git a/src/Api/Controllers/AuthController.cs b/src/Api/Controllers/AuthController.cs new file mode 100644 index 0000000..8237d03 --- /dev/null +++ b/src/Api/Controllers/AuthController.cs @@ -0,0 +1,26 @@ +using KontoApi.Application.Features.Auth.Commands.Login; +using KontoApi.Application.Features.Auth.Commands.Register; +using Microsoft.AspNetCore.Mvc; + +namespace KontoApi.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class AuthController : BaseController +{ + // POST api/auth/register + [HttpPost("register")] + public async Task Register(RegisterCommand command) + { + var userId = await Mediator.Send(command); + return Ok(new { UserId = userId }); + } + + // POST api/auth/login + [HttpPost("login")] + public async Task Login(LoginCommand command) + { + var response = await Mediator.Send(command); + return Ok(response); + } +} \ No newline at end of file diff --git a/src/Api/Controllers/BaseController.cs b/src/Api/Controllers/BaseController.cs new file mode 100644 index 0000000..69acf23 --- /dev/null +++ b/src/Api/Controllers/BaseController.cs @@ -0,0 +1,23 @@ +using KontoApi.Application.Common.Exceptions; +using KontoApi.Application.Common.Interfaces; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace KontoApi.Api.Controllers; + +[Route("api/[controller]")] +[ApiController] +public abstract class BaseController : ControllerBase +{ + private ISender? mediator; + private ICurrentUserService? currentUserService; + + protected ISender Mediator + => mediator ??= HttpContext.RequestServices.GetRequiredService(); + + private ICurrentUserService CurrentUser => + currentUserService ??= HttpContext.RequestServices.GetRequiredService(); + + protected Guid UserId => CurrentUser.UserId + ?? throw new UnauthorizedException("User is not authenticated"); +} \ No newline at end of file diff --git a/src/Api/Controllers/BudgetsController.cs b/src/Api/Controllers/BudgetsController.cs new file mode 100644 index 0000000..85b1b88 --- /dev/null +++ b/src/Api/Controllers/BudgetsController.cs @@ -0,0 +1,81 @@ +using KontoApi.Api.Contracts; +using KontoApi.Application.Features.Budgets.Commands.CreateBudget; +using KontoApi.Application.Features.Budgets.Commands.DeleteBudget; +using KontoApi.Application.Features.Budgets.Commands.RenameBudget; +using KontoApi.Application.Features.Budgets.Queries.GetBudgetDetails; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace KontoApi.Api.Controllers; + +[Authorize] +[ApiController] +[Route("api/[controller]")] +public class BudgetsController : BaseController +{ + // POST api/budgets + [HttpPost] + [ProducesResponseType(typeof(object), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] + public async Task CreateBudget( + [FromBody] CreateBudgetRequest request, + CancellationToken cancellationToken) + { + var command = new CreateBudgetCommand( + request.AccountId, + request.Name, + request.InitialBalance, + request.Currency + ); + + var budgetId = await Mediator.Send(command, cancellationToken); + + return CreatedAtAction(nameof(GetBudget), new { id = budgetId }, new { BudgetId = budgetId }); + } + + // GET api/budgets/{id} + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(BudgetDetailsDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] + public async Task GetBudget(Guid id, CancellationToken cancellationToken) + { + var query = new GetBudgetDetailsQuery(id); + var result = await Mediator.Send(query, cancellationToken); + return Ok(result); + } + + // DELETE api/budgets/{id} + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] + public async Task DeleteBudget(Guid id, CancellationToken cancellationToken) + { + await Mediator.Send(new DeleteBudgetCommand(BudgetId: id), cancellationToken); + return NoContent(); + } + + // PATCH api/budgets/{id}/name + [HttpPatch("{id:guid}/name")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] + public async Task RenameBudget( + Guid id, + [FromBody] RenameBudgetRequest request, + CancellationToken cancellationToken) + { + var command = new RenameBudgetCommand(id, request.NewName); + await Mediator.Send(command, cancellationToken); + return NoContent(); + } +} + +public record RenameBudgetRequest(string NewName); + +public record CreateBudgetRequest( + Guid AccountId, + string Name, + decimal InitialBalance, + string Currency +); \ No newline at end of file diff --git a/src/Api/Controllers/CategoriesController.cs b/src/Api/Controllers/CategoriesController.cs new file mode 100644 index 0000000..203df4d --- /dev/null +++ b/src/Api/Controllers/CategoriesController.cs @@ -0,0 +1,72 @@ +using KontoApi.Api.Contracts; // For ErrorResponse +using KontoApi.Application.Features.Categories.Commands.CreateCategory; +using KontoApi.Application.Features.Categories.Commands.DeleteCategory; +using KontoApi.Application.Features.Categories.Commands.RenameCategory; +using KontoApi.Application.Features.Categories.DTOs; +using KontoApi.Application.Features.Categories.Queries.GetCategories; +using KontoApi.Application.Features.Categories.Queries.GetCategoryById; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace KontoApi.Api.Controllers; + +[Authorize] +[ApiController] +[Route("api/[controller]")] +public class CategoriesController : BaseController // 2. Plural Name +{ + // GET api/categories + [HttpGet] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task GetAll(CancellationToken cancellationToken) + => Ok(await Mediator.Send(new GetCategoriesQuery(), cancellationToken)); + + // GET api/categories/{id} + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(CategoryDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] + public async Task GetById(Guid id, CancellationToken cancellationToken) + => Ok(await Mediator.Send(new GetCategoryByIdQuery(id), cancellationToken)); + + // POST api/categories + [HttpPost] + [ProducesResponseType(typeof(object), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] + public async Task Create( + [FromBody] CreateCategoryRequest request, + CancellationToken cancellationToken) + { + var command = new CreateCategoryCommand(request.Name); + var categoryId = await Mediator.Send(command, cancellationToken); + return CreatedAtAction(nameof(GetById), new { id = categoryId }, new { CategoryId = categoryId }); + } + + // PATCH api/categories/{id}/name + [HttpPatch("{id:guid}/name")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] + public async Task Rename( + Guid id, + [FromBody] RenameCategoryRequest request, + CancellationToken cancellationToken) + { + var command = new RenameCategoryCommand(id, request.NewName); + await Mediator.Send(command, cancellationToken); + return NoContent(); + } + + // DELETE api/categories/{id} + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] + public async Task Delete(Guid id, CancellationToken cancellationToken) + { + await Mediator.Send(new DeleteCategoryCommand(id), cancellationToken); + return NoContent(); + } +} + +public record CreateCategoryRequest(string Name); + +public record RenameCategoryRequest(string NewName); \ No newline at end of file diff --git a/src/Api/Controllers/TransactionController.cs b/src/Api/Controllers/TransactionController.cs new file mode 100644 index 0000000..776d02e --- /dev/null +++ b/src/Api/Controllers/TransactionController.cs @@ -0,0 +1,104 @@ +using KontoApi.Api.Contracts; +using KontoApi.Application.Features.Transactions.Commands.AddTransaction; +using KontoApi.Application.Features.Transactions.Commands.DeleteTransaction; +using KontoApi.Application.Features.Transactions.Commands.ImportTransactions; +using KontoApi.Application.Features.Transactions.Queries.GetTransactionById; +using KontoApi.Domain; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace KontoApi.Api.Controllers; + +[Authorize] +[ApiController] +[Route("api/[controller]")] +[Produces("application/json")] +public class TransactionsController : BaseController +{ + // POST api/transactions + [HttpPost] + [ProducesResponseType(typeof(object), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] + public async Task AddTransaction( + [FromBody] AddTransactionRequest request, + CancellationToken cancellationToken) + { + var command = new AddTransactionCommand( + request.BudgetId, + request.Amount, + request.Currency, + request.Type, + request.CategoryId, + request.Date, + request.Description + ); + + var transactionId = await Mediator.Send(command, cancellationToken); + + return CreatedAtAction(nameof(GetTransaction), new { id = transactionId }, + new { TransactionId = transactionId }); + } + + // GET api/transactions/{id} + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(TransactionDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] + public async Task GetTransaction(Guid id, CancellationToken cancellationToken) + { + var result = await Mediator.Send(new GetTransactionByIdQuery(id), cancellationToken); + return Ok(result); + } + + // DELETE api/transactions/{id}?budgetId={budgetId} + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] + public async Task DeleteTransaction( + Guid id, + [FromQuery] Guid budgetId, + CancellationToken cancellationToken) + { + if (budgetId == Guid.Empty) + return BadRequest(new ErrorResponse( + StatusCodes.Status400BadRequest, "budgetId query parameter is required", null + )); + + await Mediator.Send(new DeleteTransactionCommand(BudgetId: budgetId, TransactionId: id), cancellationToken); + return NoContent(); + } + + // POST api/transactions/import + [HttpPost("import")] + [Consumes("multipart/form-data")] + [ProducesResponseType(typeof(ImportResultDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] + public async Task ImportTransactions( + [FromForm] Guid budgetId, + IFormFile? file, + CancellationToken cancellationToken) + { + if (file == null || file.Length == 0) + return BadRequest(new ErrorResponse( + StatusCodes.Status400BadRequest, "No file uploaded", null + )); + + await using var stream = file.OpenReadStream(); + + var command = new ImportTransactionsCommand(budgetId, stream, file.FileName); + var result = await Mediator.Send(command, cancellationToken); + + return Ok(result); + } +} + +public record AddTransactionRequest( + Guid BudgetId, + decimal Amount, + string Currency, + TransactionType Type, + Guid CategoryId, + DateTime Date, + string? Description +); \ No newline at end of file diff --git a/src/Api/Controllers/UserController.cs b/src/Api/Controllers/UserController.cs new file mode 100644 index 0000000..446216a --- /dev/null +++ b/src/Api/Controllers/UserController.cs @@ -0,0 +1,48 @@ +using KontoApi.Api.Contracts; +using KontoApi.Application.Features.Users.Commands.ChangePassword; +using KontoApi.Application.Features.Users.Queries.GetUser; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace KontoApi.Api.Controllers; + +[Authorize] +[ApiController] +[Route("api/[controller]")] +public class UsersController : BaseController +{ + // GET api/users/me + [HttpGet("me")] + [ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] + public async Task GetCurrentUser(CancellationToken cancellationToken) + { + var query = new GetUserQuery(UserId); + + var result = await Mediator.Send(query, cancellationToken); + + return Ok(result); + } + + // PUT api/users/password + [HttpPut("password")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] + public async Task ChangePassword( + [FromBody] ChangePasswordRequest request, + CancellationToken cancellationToken) + { + var command = new ChangePasswordCommand( + UserId, + request.CurrentPassword, + request.NewPassword + ); + + await Mediator.Send(command, cancellationToken); + + return NoContent(); + } +} + +public record ChangePasswordRequest(string CurrentPassword, string NewPassword); \ No newline at end of file diff --git a/src/Api/IntegrationTestStartup.cs b/src/Api/IntegrationTestStartup.cs new file mode 100644 index 0000000..eea3b9a --- /dev/null +++ b/src/Api/IntegrationTestStartup.cs @@ -0,0 +1,218 @@ +using System.Reflection; +using System.Text; +using KontoApi.Api.Middleware; +using KontoApi.Api.Services; +using KontoApi.Application; +using KontoApi.Application.Common.Interfaces; +using KontoApi.Infrastructure; +using Microsoft.EntityFrameworkCore; +using KontoApi.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; +using Serilog; +using KontoApi.Domain; +using KontoApi.Application.Common.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace KontoApi.Api +{ + // Startup-like class used for integration tests host builder + public class IntegrationTestStartup + { + private readonly IConfiguration _configuration; + private readonly IWebHostEnvironment _env; + + public IntegrationTestStartup(IConfiguration configuration, IWebHostEnvironment env) + { + _configuration = configuration; + _env = env; + } + + public void ConfigureServices(IServiceCollection services) + { + try + { + System.IO.File.AppendAllText("/tmp/konto_startup.log", DateTime.UtcNow + " - ConfigureServices invoked\n"); + } + catch { } + + services.AddScoped(); + + services.AddHttpContextAccessor(); + services.AddHealthChecks(); + + services.AddControllers(); + services.AddEndpointsApiExplorer(); + services.AddSwaggerGen(options => + { + options.SwaggerDoc("v1", new() + { + Title = "KontoApi", + Version = "v1", + Description = "Personal Finance Management API" + }); + + options.AddSecurityDefinition("Bearer", new() + { + Description = + "JWT Authorization header using the Bearer scheme. \r\n\r\n Enter 'Bearer' [space] and then your token in the text input below.\r\n\r\nExample: \"Bearer 12345token\"", + Name = "Authorization", + In = ParameterLocation.Header, + Type = SecuritySchemeType.ApiKey, + Scheme = "Bearer" + }); + + options.AddSecurityRequirement(new() + { + { + new() + { + Reference = new() + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + }, + Scheme = "oauth2", + Name = "Bearer", + In = ParameterLocation.Header, + }, + new List() + } + }); + + var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; + var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); + options.IncludeXmlComments(xmlPath); + }); + + System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); + + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = "Test"; + options.DefaultChallengeScheme = "Test"; + }) + .AddScheme("Test", _ => { }) + .AddJwtBearer(options => + { + options.IncludeErrorDetails = true; + + var jwtKey = _configuration["Jwt:Key"] ?? "test_jwt_key"; + var jwtIssuer = _configuration["Jwt:Issuer"] ?? "test_issuer"; + var jwtAudience = _configuration["Jwt:Audience"] ?? "test_audience"; + + options.TokenValidationParameters = new() + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + + ValidIssuer = jwtIssuer, + ValidAudience = jwtAudience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)) + }; + + options.Events = new() + { + OnAuthenticationFailed = context => + { + var logger = context.HttpContext.RequestServices.GetRequiredService>(); + logger.LogError("Authentication failed: {Message}", context.Exception.Message); + return Task.CompletedTask; + }, + OnTokenValidated = context => + { + var logger = context.HttpContext.RequestServices.GetRequiredService>(); + logger.LogInformation("Token validated successfully for user: {User}", context.Principal?.Identity?.Name); + return Task.CompletedTask; + } + }; + }); + + services.AddApplication(); + services.AddInfrastructure(_configuration); + + // Integration tests should use an in-memory database to avoid requiring Postgres. + services.AddDbContext(options => + options.UseInMemoryDatabase("KontoApi_TestDb")); + + var allowedOrigins = _configuration.GetSection("Cors:AllowedOrigins").Get() ?? Array.Empty(); + services.AddCors(options => + { + options.AddPolicy("AllowFrontend", policy => + { + if (allowedOrigins.Length == 0) + policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod(); + else + policy.WithOrigins(allowedOrigins).AllowAnyHeader().AllowAnyMethod(); + }); + }); + } + + public void Configure(IApplicationBuilder app) + { + try + { + System.IO.File.AppendAllText("/tmp/konto_startup.log", DateTime.UtcNow + " - Configure invoked\n"); + } + catch { } + + app.UseSerilogRequestLogging(options => + { + options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => + { + diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value); + diagnosticContext.Set("RequestScheme", httpContext.Request.Scheme); + diagnosticContext.Set("UserAgent", httpContext.Request.Headers["User-Agent"].FirstOrDefault()); + }; + }); + + app.UseMiddleware(); + + app.UseHttpsRedirection(); + app.UseCors("AllowFrontend"); + app.UseRouting(); + + // Seed a default test user in the in-memory database for integration tests + try + { + using var scope = app.ApplicationServices.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); + + var hasher = scope.ServiceProvider.GetRequiredService(); + + var testEmail = "testuser@example.com"; + if (!db.Users.Any(u => u.Email == testEmail)) + { + var user = new User("Test User", testEmail, hasher.Hash("Test123!")); + db.Users.Add(user); + db.SaveChanges(); + } + } + catch { } + + app.UseAuthentication(); + app.UseAuthorization(); + + if (_env.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(); + } + + app.UseEndpoints(endpoints => + { + endpoints.MapHealthChecks("/health"); + endpoints.MapControllers(); + }); + } + } +} diff --git a/src/Api/KontoApi.Api.csproj b/src/Api/KontoApi.Api.csproj new file mode 100644 index 0000000..6167ca7 --- /dev/null +++ b/src/Api/KontoApi.Api.csproj @@ -0,0 +1,38 @@ + + + + net10.0 + enable + true + $(NoWarn);1591 + enable + 1408cc28-8ce5-4956-868d-3f5d724f34d6 + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + diff --git a/src/Api/Middleware/ExceptionHandlingMiddleware.cs b/src/Api/Middleware/ExceptionHandlingMiddleware.cs new file mode 100644 index 0000000..41757a1 --- /dev/null +++ b/src/Api/Middleware/ExceptionHandlingMiddleware.cs @@ -0,0 +1,59 @@ +using System.Net; +using System.Text.Json; +using KontoApi.Api.Contracts; +using KontoApi.Application.Common.Exceptions; +using Serilog; +using ValidationException = FluentValidation.ValidationException; +using AppValidationException = KontoApi.Application.Common.Exceptions.ValidationException; + +namespace KontoApi.Api.Middleware; + +public class ExceptionHandlingMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync(HttpContext context) + { + try + { + await next(context); + } + catch (Exception ex) + { + await HandleExceptionAsync(context, ex); + } + } + + private static async Task HandleExceptionAsync(HttpContext context, Exception exception) + { + var (statusCode, title) = exception switch + { + NotFoundException => (HttpStatusCode.NotFound, "Resource Not Found"), + BadRequestException or AppValidationException or ValidationException => (HttpStatusCode.BadRequest, "Bad Request"), + UnauthorizedException => (HttpStatusCode.Unauthorized, "Unauthorized"), + ForbiddenException => (HttpStatusCode.Forbidden, "Forbidden"), + ConflictException => (HttpStatusCode.Conflict, "Conflict"), + _ => (HttpStatusCode.InternalServerError, "Internal Server Error") + }; + + if (statusCode == HttpStatusCode.InternalServerError) + { + Log.Error(exception, "Unhandled exception occurred"); + try + { + System.IO.File.AppendAllText("/tmp/konto_app_exceptions.log", DateTime.UtcNow + " - " + exception + "\n\n"); + } + catch { } + } + + context.Response.ContentType = "application/json"; + context.Response.StatusCode = (int)statusCode; + + var response = new ErrorResponse( + context.Response.StatusCode, + title, + exception.Message + ); + + var json = JsonSerializer.Serialize(response); + await context.Response.WriteAsync(json); + } +} \ No newline at end of file diff --git a/src/Api/Program.cs b/src/Api/Program.cs new file mode 100644 index 0000000..982f23e --- /dev/null +++ b/src/Api/Program.cs @@ -0,0 +1,250 @@ + +using System.Reflection; +using System.Text; +using KontoApi.Api.Middleware; +using KontoApi.Api.Services; +using KontoApi.Application; +using KontoApi.Application.Common.Interfaces; +using KontoApi.Infrastructure; +using KontoApi.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; +using Serilog; + +namespace KontoApi.Api; + +public class Program +{ + public static async Task Main(string[] args) + { + Log.Logger = new LoggerConfiguration().WriteTo.Console().CreateBootstrapLogger(); + try + { + Log.Information("Starting web application"); + + var builder = WebApplication.CreateBuilder(args); + ConfigureBuilder(builder); + var app = builder.Build(); + app.UseSerilogRequestLogging(options => + { + options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => + { + diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value); + diagnosticContext.Set("RequestScheme", httpContext.Request.Scheme); + diagnosticContext.Set("UserAgent", httpContext.Request.Headers["User-Agent"].FirstOrDefault()); + }; + }); + app.UseMiddleware(); + + if (app.Environment.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(); + } + + app.UseHttpsRedirection(); + app.UseCors("AllowFrontend"); + app.UseAuthentication(); + app.UseAuthorization(); + + app.MapHealthChecks("/health"); + app.MapControllers(); + + try + { + using var scope = app.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); + } + catch (Exception e) + { + Log.Error(e, "An error occurred while migrating the database"); + throw; + } + + await app.RunAsync(); + } + catch (Exception ex) + { + Log.Fatal(ex, "Application terminated unexpectedly"); + } + finally + { + Log.CloseAndFlush(); + } + } + + // Expose CreateHostBuilder for WebApplicationFactory used in integration tests. + public static IHostBuilder CreateHostBuilder(string[] args) + { + return Host.CreateDefaultBuilder(args) + .UseSerilog((context, loggerConfiguration) + => loggerConfiguration.ReadFrom.Configuration(context.Configuration)) + .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup(); }); + } + + // For test entry point: build a WebApplication instance without running it. + public static WebApplication CreateWebApplication(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + ConfigureBuilder(builder); + return builder.Build(); + } + + private static void ConfigureBuilder(WebApplicationBuilder builder) + { + var configuration = builder.Configuration; + + builder.Services.AddScoped(); + + builder.Services.AddHttpContextAccessor(); + builder.Services.AddHealthChecks(); + builder.Host.UseSerilog((context, loggerConfiguration) + => loggerConfiguration.ReadFrom.Configuration(context.Configuration)); + + builder.Services.AddControllers(); + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(options => + { + options.SwaggerDoc("v1", new() + { + Title = "KontoApi", + Version = "v1", + Description = "Personal Finance Management API" + }); + + options.AddSecurityDefinition("Bearer", new() + { + Description = + "JWT Authorization header using the Bearer scheme. \r\n\r\n Enter 'Bearer' [space] and then your token in the text input below.\r\n\r\nExample: \"Bearer 12345token\"", + Name = "Authorization", + In = ParameterLocation.Header, + Type = SecuritySchemeType.ApiKey, + Scheme = "Bearer" + }); + + options.AddSecurityRequirement(new() + { + { + new() + { + Reference = new() + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + }, + Scheme = "oauth2", + Name = "Bearer", + In = ParameterLocation.Header, + }, + new List() + } + }); + + var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; + var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); + options.IncludeXmlComments(xmlPath); + }); + + System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); + + builder.Services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + options.IncludeErrorDetails = true; + + var jwtKey = builder.Configuration["Jwt:Key"] ?? "test_jwt_key"; + var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "test_issuer"; + var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "test_audience"; + + options.TokenValidationParameters = new() + { + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + + ValidIssuer = jwtIssuer, + ValidAudience = jwtAudience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)) + }; + + options.Events = new() + { + OnAuthenticationFailed = context => + { + var logger = context.HttpContext.RequestServices.GetRequiredService>(); + logger.LogError("Authentication failed: {Message}", context.Exception.Message); + return Task.CompletedTask; + }, + OnTokenValidated = context => + { + var logger = context.HttpContext.RequestServices.GetRequiredService>(); + logger.LogInformation("Token validated successfully for user: {User}", + context.Principal?.Identity?.Name); + return Task.CompletedTask; + } + }; + }); + + builder.Services.AddApplication(); + builder.Services.AddInfrastructure(builder.Configuration); + + var allowedOrigins = configuration.GetSection("Cors:AllowedOrigins").Get() ?? []; + builder.Services.AddCors(options => + { + options.AddPolicy("AllowFrontend", policy => + { + if (allowedOrigins.Length == 0) + policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod(); + else + policy.WithOrigins(allowedOrigins).AllowAnyHeader().AllowAnyMethod(); + }); + }); + + var app = builder.Build(); + app.UseSerilogRequestLogging(options => + { + options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => + { + diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value); + diagnosticContext.Set("RequestScheme", httpContext.Request.Scheme); + diagnosticContext.Set("UserAgent", httpContext.Request.Headers["User-Agent"].FirstOrDefault()); + }; + }); + app.UseMiddleware(); + + if (app.Environment.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(); + } + + app.UseHttpsRedirection(); + app.UseCors("AllowFrontend"); + app.UseAuthentication(); + app.UseAuthorization(); + + app.MapHealthChecks("/health"); + app.MapControllers(); + + try + { + using var scope = app.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); + } + catch (Exception e) + { + Log.Error(e, "An error occurred while migrating the database"); + } + + app.Run(); + } +} \ No newline at end of file diff --git a/src/Api/Properties/launchSettings.json b/src/Api/Properties/launchSettings.json new file mode 100644 index 0000000..9877a37 --- /dev/null +++ b/src/Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5076", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7049;http://localhost:5076", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Api/Services/CurrentUserService.cs b/src/Api/Services/CurrentUserService.cs new file mode 100644 index 0000000..037fe95 --- /dev/null +++ b/src/Api/Services/CurrentUserService.cs @@ -0,0 +1,22 @@ +using System.Security.Claims; +using KontoApi.Application.Common.Interfaces; + +namespace KontoApi.Api.Services; + +public class CurrentUserService(IHttpContextAccessor httpContextAccessor) : ICurrentUserService +{ + public Guid? UserId + { + get + { + var idClaim = httpContextAccessor + .HttpContext? + .User + .FindFirst(ClaimTypes.NameIdentifier); + + return idClaim == null + ? null + : Guid.Parse(idClaim.Value); + } + } +} \ No newline at end of file diff --git a/src/Api/TestAuth/TestAuthenticationHandler.cs b/src/Api/TestAuth/TestAuthenticationHandler.cs new file mode 100644 index 0000000..71d8cf1 --- /dev/null +++ b/src/Api/TestAuth/TestAuthenticationHandler.cs @@ -0,0 +1,64 @@ +using System.Security.Claims; +using System.Linq; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using KontoApi.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using KontoApi.Application.Common.Interfaces; +using KontoApi.Domain; + +namespace KontoApi.Api.TestAuth; + +public class TestAuthenticationHandler : AuthenticationHandler +{ + public TestAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock) : base(options, logger, encoder, clock) + { + } + + protected override Task HandleAuthenticateAsync() + { + try + { + // Try to resolve DB and find or create test user by email + var db = Context.RequestServices.GetService(typeof(KontoDbContext)) as KontoDbContext; + var testEmail = "testuser@example.com"; + + if (db == null) + return Task.FromResult(AuthenticateResult.NoResult()); + + var user = db.Users.FirstOrDefault(u => u.Email == testEmail); + + if (user == null) + { + // Create a test user in this request's DB so handlers that rely on User existance succeed + var hasher = Context.RequestServices.GetService(typeof(IPasswordHasher)) as IPasswordHasher; + var passwordHash = hasher?.Hash("Test123!") ?? "test-hash"; + user = new User("Test User", testEmail, passwordHash); + db.Users.Add(user); + db.SaveChanges(); + } + + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Email, user.Email), + new Claim(ClaimTypes.Name, user.Name) + }; + + var identity = new ClaimsIdentity(claims, Scheme.Name); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, Scheme.Name); + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + catch + { + return Task.FromResult(AuthenticateResult.NoResult()); + } + } +} diff --git a/src/Api/appsettings.Development.json b/src/Api/appsettings.Development.json new file mode 100644 index 0000000..d564d97 --- /dev/null +++ b/src/Api/appsettings.Development.json @@ -0,0 +1,26 @@ +{ + "Serilog": { + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Microsoft": "Information", + "Microsoft.AspNetCore": "Debug", + "System": "Information" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] [{CorrelationId}] {SourceContext}{NewLine} {Message:lj}{NewLine}{Exception}" + } + } + ] + }, + "Jwt": { + "Key": "FUKK3lUpVW9daj3beB88rXTOoe+vz/WJfo9HHds8gtGn0Ta+a1GHj+RszEDIDVWK", + "Issuer": "KontoApi", + "Audience": "KontoApi", + "ExpirationMinutes": 60 + } +} \ No newline at end of file diff --git a/src/Api/appsettings.json b/src/Api/appsettings.json new file mode 100644 index 0000000..6c37973 --- /dev/null +++ b/src/Api/appsettings.json @@ -0,0 +1,37 @@ +{ + "Serilog": { + "Using": [ + "Serilog.Sinks.Console", + "Serilog.Sinks.File" + ], + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "System": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Console" + }, + { + "Name": "File", + "Args": { + "path": "logs/log-.txt", + "rollingInterval": "Day" + } + } + ] + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5432;Database=konto;Username=postgres;Password=postgres" + }, + "Jwt": { + "Key": "FUKK3lUpVW9daj3beB88rXTOoe+vz/WJfo9HHds8gtGn0Ta+a1GHj+RszEDIDVWK", + "Issuer": "KontoApi", + "Audience": "KontoApi", + "ExpirationMinutes": 60 + } +} diff --git a/src/Application/Common/Behaviors/ValidationBehavior.cs b/src/Application/Common/Behaviors/ValidationBehavior.cs new file mode 100644 index 0000000..2b3e562 --- /dev/null +++ b/src/Application/Common/Behaviors/ValidationBehavior.cs @@ -0,0 +1,32 @@ +using FluentValidation; +using MediatR; +using ValidationException = KontoApi.Application.Common.Exceptions.ValidationException; + +namespace KontoApi.Application.Common.Behaviors; + +public class ValidationBehavior(IEnumerable> validators) + : IPipelineBehavior + where TRequest : IRequest +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + if (!validators.Any()) + return await next(cancellationToken); + + var context = new ValidationContext(request); + + var validationResults = await Task.WhenAll( + validators.Select(v => v.ValidateAsync(context, cancellationToken))); + + var failures = validationResults + .SelectMany(r => r.Errors) + .Where(f => f != null) + .ToList(); + + if (failures.Count != 0) + throw new ValidationException(failures); + + return await next(cancellationToken); + } +} \ No newline at end of file diff --git a/src/Application/Common/Exceptions/BadRequestException.cs b/src/Application/Common/Exceptions/BadRequestException.cs new file mode 100644 index 0000000..0648618 --- /dev/null +++ b/src/Application/Common/Exceptions/BadRequestException.cs @@ -0,0 +1,3 @@ +namespace KontoApi.Application.Common.Exceptions; + +public class BadRequestException(string message) : Exception(message); \ No newline at end of file diff --git a/src/Application/Common/Exceptions/ConflictException.cs b/src/Application/Common/Exceptions/ConflictException.cs new file mode 100644 index 0000000..a788ca3 --- /dev/null +++ b/src/Application/Common/Exceptions/ConflictException.cs @@ -0,0 +1,3 @@ +namespace KontoApi.Application.Common.Exceptions; + +public class ConflictException(string message) : Exception(message); \ No newline at end of file diff --git a/src/Application/Common/Exceptions/ForbiddenException.cs b/src/Application/Common/Exceptions/ForbiddenException.cs new file mode 100644 index 0000000..cf01043 --- /dev/null +++ b/src/Application/Common/Exceptions/ForbiddenException.cs @@ -0,0 +1,3 @@ +namespace KontoApi.Application.Common.Exceptions; + +public class ForbiddenException(string message) : Exception(message); \ No newline at end of file diff --git a/src/Application/Common/Exceptions/NotFoundException.cs b/src/Application/Common/Exceptions/NotFoundException.cs new file mode 100644 index 0000000..d8fd53f --- /dev/null +++ b/src/Application/Common/Exceptions/NotFoundException.cs @@ -0,0 +1,9 @@ +namespace KontoApi.Application.Common.Exceptions; + +public class NotFoundException : Exception +{ + public NotFoundException(string message) : base(message) { } + + public NotFoundException(Type entityType, object id) + : base($"Entity \"{entityType.Name}\" ({id}) was not found.") { } +} \ No newline at end of file diff --git a/src/Application/Common/Exceptions/UnauthorizedException.cs b/src/Application/Common/Exceptions/UnauthorizedException.cs new file mode 100644 index 0000000..898c088 --- /dev/null +++ b/src/Application/Common/Exceptions/UnauthorizedException.cs @@ -0,0 +1,3 @@ +namespace KontoApi.Application.Common.Exceptions; + +public class UnauthorizedException(string message) : Exception(message); \ No newline at end of file diff --git a/src/Application/Common/Exceptions/ValidationException.cs b/src/Application/Common/Exceptions/ValidationException.cs new file mode 100644 index 0000000..7b6110e --- /dev/null +++ b/src/Application/Common/Exceptions/ValidationException.cs @@ -0,0 +1,16 @@ +using FluentValidation.Results; + +namespace KontoApi.Application.Common.Exceptions; + +public class ValidationException() : Exception("One or more validation failures have occurred") +{ + public IDictionary Errors { get; } = new Dictionary(); + + public ValidationException(IEnumerable failures) + : this() + { + Errors = failures + .GroupBy(e => e.PropertyName, e => e.ErrorMessage) + .ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.ToArray()); + } +} \ No newline at end of file diff --git a/src/Application/Common/Interfaces/IAccountRepository.cs b/src/Application/Common/Interfaces/IAccountRepository.cs new file mode 100644 index 0000000..d0ad1ba --- /dev/null +++ b/src/Application/Common/Interfaces/IAccountRepository.cs @@ -0,0 +1,13 @@ +using KontoApi.Domain; + +namespace KontoApi.Application.Common.Interfaces; + +public interface IAccountRepository +{ + Task AddAsync(Account account, CancellationToken cancellationToken = default); + Task UpdateAsync(Account account, CancellationToken cancellationToken = default); + Task DeleteAsync(Guid id, CancellationToken cancellationToken = default); + Task AddBudgetAsync(Guid accountId, Budget budget, CancellationToken cancellationToken = default); + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + Task> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Application/Common/Interfaces/IApplicationDbContext.cs b/src/Application/Common/Interfaces/IApplicationDbContext.cs new file mode 100644 index 0000000..2f24fe2 --- /dev/null +++ b/src/Application/Common/Interfaces/IApplicationDbContext.cs @@ -0,0 +1,15 @@ +using KontoApi.Domain; +using Microsoft.EntityFrameworkCore; + +namespace KontoApi.Application.Common.Interfaces; + +public interface IApplicationDbContext +{ + DbSet Users { get; } + DbSet Accounts { get; } + DbSet Budgets { get; } + DbSet Transactions { get; } + DbSet Categories { get; } + + Task SaveChangesAsync(CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Application/Common/Interfaces/IBudgetRepository.cs b/src/Application/Common/Interfaces/IBudgetRepository.cs new file mode 100644 index 0000000..1caf1ca --- /dev/null +++ b/src/Application/Common/Interfaces/IBudgetRepository.cs @@ -0,0 +1,13 @@ +using KontoApi.Domain; + +namespace KontoApi.Application.Common.Interfaces; + +public interface IBudgetRepository +{ + Task GetByIdAsync(Guid id, CancellationToken cancellationToken); + Task AddAsync(Budget budget, CancellationToken cancellationToken); + Task UpdateAsync(Budget budget, CancellationToken cancellationToken); + Task AddTransactionAsync(Guid budgetId, Transaction transaction, CancellationToken cancellationToken = default); + Task DeleteAsync(Guid id, CancellationToken cancellationToken); + Task DeleteTransactionAsync(Guid budgetId, Guid transactionId, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Application/Common/Interfaces/ICategoryRepository.cs b/src/Application/Common/Interfaces/ICategoryRepository.cs new file mode 100644 index 0000000..120868b --- /dev/null +++ b/src/Application/Common/Interfaces/ICategoryRepository.cs @@ -0,0 +1,14 @@ +using KontoApi.Domain; + +namespace KontoApi.Application.Common.Interfaces; + +public interface ICategoryRepository +{ + Task> GetAllAsync(CancellationToken cancellationToken); + Task GetByIdAsync(Guid id, CancellationToken cancellationToken); + Task GetByNameAsync(string name, CancellationToken cancellationToken); + Task AddAsync(Category category, CancellationToken cancellationToken); + Task UpdateAsync(Category category, CancellationToken cancellationToken); + Task DeleteAsync(Guid id, CancellationToken cancellationToken); + Task ExistsByNameAsync(string name, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Application/Common/Interfaces/ICurrentUserService.cs b/src/Application/Common/Interfaces/ICurrentUserService.cs new file mode 100644 index 0000000..7707c26 --- /dev/null +++ b/src/Application/Common/Interfaces/ICurrentUserService.cs @@ -0,0 +1,6 @@ +namespace KontoApi.Application.Common.Interfaces; + +public interface ICurrentUserService +{ + public Guid? UserId { get; } +} \ No newline at end of file diff --git a/src/Application/Common/Interfaces/IJwtProvider.cs b/src/Application/Common/Interfaces/IJwtProvider.cs new file mode 100644 index 0000000..0d92f3c --- /dev/null +++ b/src/Application/Common/Interfaces/IJwtProvider.cs @@ -0,0 +1,9 @@ +using KontoApi.Domain; + +namespace KontoApi.Application.Common.Interfaces; + +public interface IJwtProvider +{ + string Generate(User user); + string GenerateRefreshToken(); +} \ No newline at end of file diff --git a/src/Application/Common/Interfaces/IPasswordHasher.cs b/src/Application/Common/Interfaces/IPasswordHasher.cs new file mode 100644 index 0000000..bf8cfdf --- /dev/null +++ b/src/Application/Common/Interfaces/IPasswordHasher.cs @@ -0,0 +1,7 @@ +namespace KontoApi.Application.Common.Interfaces; + +public interface IPasswordHasher +{ + string Hash(string password); + bool Verify(string password, string hash); +} \ No newline at end of file diff --git a/src/Application/Common/Interfaces/IStatementParser.cs b/src/Application/Common/Interfaces/IStatementParser.cs new file mode 100644 index 0000000..4de2097 --- /dev/null +++ b/src/Application/Common/Interfaces/IStatementParser.cs @@ -0,0 +1,9 @@ +using KontoApi.Application.Common.Models; + +namespace KontoApi.Application.Common.Interfaces; + +public interface IStatementParser +{ + bool Supports(string fileName); + Task> ParseAsync(Stream fileStream, CancellationToken cancellationToken); +} diff --git a/src/Application/Common/Interfaces/IUserRepository.cs b/src/Application/Common/Interfaces/IUserRepository.cs new file mode 100644 index 0000000..24ab8c7 --- /dev/null +++ b/src/Application/Common/Interfaces/IUserRepository.cs @@ -0,0 +1,12 @@ +using KontoApi.Domain; + +namespace KontoApi.Application.Common.Interfaces; + +public interface IUserRepository +{ + Task AddAsync(User user, CancellationToken ct); + Task GetByEmailAsync(string email, CancellationToken ct); + Task GetByIdAsync(Guid id, CancellationToken ct); + Task UpdateAsync(User user, CancellationToken ct); + Task DeleteAsync(Guid id, CancellationToken ct); +} \ No newline at end of file diff --git a/src/Application/Common/Models/ParsedTransaction.cs b/src/Application/Common/Models/ParsedTransaction.cs new file mode 100644 index 0000000..38fe3ab --- /dev/null +++ b/src/Application/Common/Models/ParsedTransaction.cs @@ -0,0 +1,12 @@ +using KontoApi.Domain; + +namespace KontoApi.Application.Common.Models; + +public record ParsedTransaction( + DateTime Date, + decimal Amount, + string Currency, + TransactionType Type, + string? Description, + Guid CategoryId +); \ No newline at end of file diff --git a/src/Application/DependencyInjection.cs b/src/Application/DependencyInjection.cs new file mode 100644 index 0000000..6b3d827 --- /dev/null +++ b/src/Application/DependencyInjection.cs @@ -0,0 +1,25 @@ +using System.Reflection; +using FluentValidation; +using KontoApi.Application.Common.Behaviors; // Ensure this namespace exists +using MediatR; +using Microsoft.Extensions.DependencyInjection; + +namespace KontoApi.Application; + +public static class DependencyInjection +{ + public static IServiceCollection AddApplication(this IServiceCollection services) + { + var assembly = Assembly.GetExecutingAssembly(); + + services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssembly(assembly); + cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); + }); + + services.AddValidatorsFromAssembly(assembly); + + return services; + } +} \ No newline at end of file diff --git a/src/Application/Features/Accounts/Commands/CreateAccount/CreateAccountCommand.cs b/src/Application/Features/Accounts/Commands/CreateAccount/CreateAccountCommand.cs new file mode 100644 index 0000000..8769809 --- /dev/null +++ b/src/Application/Features/Accounts/Commands/CreateAccount/CreateAccountCommand.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace KontoApi.Application.Features.Accounts.Commands.CreateAccount; + +public record CreateAccountCommand(Guid UserId, string Name) : IRequest; \ No newline at end of file diff --git a/src/Application/Features/Accounts/Commands/CreateAccount/CreateAccountHandler.cs b/src/Application/Features/Accounts/Commands/CreateAccount/CreateAccountHandler.cs new file mode 100644 index 0000000..d9e3322 --- /dev/null +++ b/src/Application/Features/Accounts/Commands/CreateAccount/CreateAccountHandler.cs @@ -0,0 +1,23 @@ +using KontoApi.Application.Common.Exceptions; +using KontoApi.Application.Common.Interfaces; +using KontoApi.Domain; +using MediatR; + +namespace KontoApi.Application.Features.Accounts.Commands.CreateAccount; + +public class CreateAccountHandler(IAccountRepository accountRepository, IUserRepository userRepository) + : IRequestHandler +{ + public async Task Handle(CreateAccountCommand request, CancellationToken ct) + { + var user = await userRepository.GetByIdAsync(request.UserId, ct); + if (user == null) + throw new NotFoundException(typeof(User), request.UserId); + + var account = new Account(user, request.Name); + + await accountRepository.AddAsync(account, ct); + + return account.Id; + } +} \ No newline at end of file diff --git a/src/Application/Features/Accounts/Commands/CreateAccount/CreateAccountValidator.cs b/src/Application/Features/Accounts/Commands/CreateAccount/CreateAccountValidator.cs new file mode 100644 index 0000000..bce4c92 --- /dev/null +++ b/src/Application/Features/Accounts/Commands/CreateAccount/CreateAccountValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; + +namespace KontoApi.Application.Features.Accounts.Commands.CreateAccount; + +public class CreateAccountValidator : AbstractValidator +{ + public CreateAccountValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Account name is required") + .MaximumLength(100); + } +} \ No newline at end of file diff --git a/src/Application/Features/Accounts/Commands/DeleteAccount/DeleteAccountCommand.cs b/src/Application/Features/Accounts/Commands/DeleteAccount/DeleteAccountCommand.cs new file mode 100644 index 0000000..7feac12 --- /dev/null +++ b/src/Application/Features/Accounts/Commands/DeleteAccount/DeleteAccountCommand.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace KontoApi.Application.Features.Accounts.Commands.DeleteAccount; + +public record DeleteAccountCommand(Guid AccountId) : IRequest; diff --git a/src/Application/Features/Accounts/Commands/DeleteAccount/DeleteAccountHandler.cs b/src/Application/Features/Accounts/Commands/DeleteAccount/DeleteAccountHandler.cs new file mode 100644 index 0000000..b1f2811 --- /dev/null +++ b/src/Application/Features/Accounts/Commands/DeleteAccount/DeleteAccountHandler.cs @@ -0,0 +1,18 @@ +using KontoApi.Application.Common.Exceptions; +using KontoApi.Application.Common.Interfaces; +using KontoApi.Domain; +using MediatR; + +namespace KontoApi.Application.Features.Accounts.Commands.DeleteAccount; + +public class DeleteAccountHandler(IAccountRepository accountRepository) : IRequestHandler +{ + public async Task Handle(DeleteAccountCommand request, CancellationToken ct) + { + var account = await accountRepository.GetByIdAsync(request.AccountId, ct); + if (account == null) + throw new NotFoundException(typeof(Account), request.AccountId); + + await accountRepository.DeleteAsync(request.AccountId, ct); + } +} \ No newline at end of file diff --git a/src/Application/Features/Accounts/Queries/GetAccountOverview/AccountOverviewDto.cs b/src/Application/Features/Accounts/Queries/GetAccountOverview/AccountOverviewDto.cs new file mode 100644 index 0000000..d7d2a21 --- /dev/null +++ b/src/Application/Features/Accounts/Queries/GetAccountOverview/AccountOverviewDto.cs @@ -0,0 +1,15 @@ +namespace KontoApi.Application.Features.Accounts.Queries.GetAccountOverview; + +public record AccountOverviewDto( + Guid Id, + string Name, + DateTime CreatedAt, + List Budgets +); + +public record BudgetSummaryDto( + Guid Id, + string Name, + decimal Balance, + string Currency +); \ No newline at end of file diff --git a/src/Application/Features/Accounts/Queries/GetAccountOverview/GetAccountOverviewHandler.cs b/src/Application/Features/Accounts/Queries/GetAccountOverview/GetAccountOverviewHandler.cs new file mode 100644 index 0000000..34d7940 --- /dev/null +++ b/src/Application/Features/Accounts/Queries/GetAccountOverview/GetAccountOverviewHandler.cs @@ -0,0 +1,29 @@ +using KontoApi.Application.Common.Exceptions; +using KontoApi.Application.Common.Interfaces; +using MediatR; + +namespace KontoApi.Application.Features.Accounts.Queries.GetAccountOverview; + +public class GetAccountOverviewHandler(IAccountRepository accountRepository) + : IRequestHandler +{ + public async Task Handle(GetAccountOverviewQuery request, CancellationToken cancellationToken) + { + var accounts = await accountRepository.GetByUserIdAsync(request.UserId, cancellationToken); + var account = accounts.FirstOrDefault(); + if (account == null) + throw new NotFoundException("Account not found for this user."); + + return new( + account.Id, + account.User.Name, + account.CreatedAt, + account.Budgets.Select(b => new BudgetSummaryDto( + b.Id, + b.Name, + b.CurrentBalance.Value, + b.CurrentBalance.Currency + )).ToList() + ); + } +} \ No newline at end of file diff --git a/src/Application/Features/Accounts/Queries/GetAccountOverview/GetAccountOverviewQuery.cs b/src/Application/Features/Accounts/Queries/GetAccountOverview/GetAccountOverviewQuery.cs new file mode 100644 index 0000000..69d9b91 --- /dev/null +++ b/src/Application/Features/Accounts/Queries/GetAccountOverview/GetAccountOverviewQuery.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace KontoApi.Application.Features.Accounts.Queries.GetAccountOverview; + +public record GetAccountOverviewQuery(Guid UserId) : IRequest; diff --git a/src/Application/Features/Auth/Commands/Login/LoginCommand.cs b/src/Application/Features/Auth/Commands/Login/LoginCommand.cs new file mode 100644 index 0000000..50bee37 --- /dev/null +++ b/src/Application/Features/Auth/Commands/Login/LoginCommand.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace KontoApi.Application.Features.Auth.Commands.Login; + +public record LoginCommand(string Email, string Password) : IRequest; diff --git a/src/Application/Features/Auth/Commands/Login/LoginHandler.cs b/src/Application/Features/Auth/Commands/Login/LoginHandler.cs new file mode 100644 index 0000000..c3a9598 --- /dev/null +++ b/src/Application/Features/Auth/Commands/Login/LoginHandler.cs @@ -0,0 +1,24 @@ +using KontoApi.Application.Common.Exceptions; +using KontoApi.Application.Common.Interfaces; +using MediatR; + +namespace KontoApi.Application.Features.Auth.Commands.Login; + +public class LoginHandler( + IUserRepository userRepository, + IPasswordHasher passwordHasher, + IJwtProvider jwtProvider) + : IRequestHandler +{ + public async Task Handle(LoginCommand request, CancellationToken cancellationToken) + { + var user = await userRepository.GetByEmailAsync(request.Email, cancellationToken); + + if (user == null || !passwordHasher.Verify(request.Password, user.HashedPassword)) + throw new UnauthorizedException("Invalid email or password"); + + var token = jwtProvider.Generate(user); + + return new(token, user.Id, user.Name, user.Email); + } +} \ No newline at end of file diff --git a/src/Application/Features/Auth/Commands/Login/LoginResponse.cs b/src/Application/Features/Auth/Commands/Login/LoginResponse.cs new file mode 100644 index 0000000..aa6d160 --- /dev/null +++ b/src/Application/Features/Auth/Commands/Login/LoginResponse.cs @@ -0,0 +1,8 @@ +namespace KontoApi.Application.Features.Auth.Commands.Login; + +public record LoginResponse( + string Token, + Guid UserId, + string Name, + string Email +); diff --git a/src/Application/Features/Auth/Commands/Login/LoginValidator.cs b/src/Application/Features/Auth/Commands/Login/LoginValidator.cs new file mode 100644 index 0000000..7eeba75 --- /dev/null +++ b/src/Application/Features/Auth/Commands/Login/LoginValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; + +namespace KontoApi.Application.Features.Auth.Commands.Login; + +public class LoginValidator : AbstractValidator +{ + public LoginValidator() + { + RuleFor(x => x.Email) + .NotEmpty().WithMessage("Email is required") + .EmailAddress().WithMessage("Invalid email format"); + + RuleFor(x => x.Password) + .NotEmpty().WithMessage("Password is required"); + } +} diff --git a/src/Application/Features/Auth/Commands/Register/RegisterCommand.cs b/src/Application/Features/Auth/Commands/Register/RegisterCommand.cs new file mode 100644 index 0000000..b3bc6dd --- /dev/null +++ b/src/Application/Features/Auth/Commands/Register/RegisterCommand.cs @@ -0,0 +1,9 @@ +using MediatR; + +namespace KontoApi.Application.Features.Auth.Commands.Register; + +public record RegisterCommand( + string Name, + string Email, + string Password +) : IRequest; diff --git a/src/Application/Features/Auth/Commands/Register/RegisterHandler.cs b/src/Application/Features/Auth/Commands/Register/RegisterHandler.cs new file mode 100644 index 0000000..751cfe4 --- /dev/null +++ b/src/Application/Features/Auth/Commands/Register/RegisterHandler.cs @@ -0,0 +1,25 @@ +using KontoApi.Application.Common.Exceptions; +using KontoApi.Application.Common.Interfaces; +using KontoApi.Domain; +using MediatR; + +namespace KontoApi.Application.Features.Auth.Commands.Register; + +public class RegisterHandler(IUserRepository userRepository, IPasswordHasher passwordHasher) + : IRequestHandler +{ + public async Task Handle(RegisterCommand request, CancellationToken cancellationToken) + { + var existingUser = await userRepository.GetByEmailAsync(request.Email, cancellationToken); + if (existingUser != null) + throw new ConflictException($"Email '{request.Email}' is already in use"); + + var hashedPassword = passwordHasher.Hash(request.Password); + + var user = new User(request.Name, request.Email, hashedPassword); + + await userRepository.AddAsync(user, cancellationToken); + + return user.Id; + } +} \ No newline at end of file diff --git a/src/Application/Features/Auth/Commands/Register/RegisterValidator.cs b/src/Application/Features/Auth/Commands/Register/RegisterValidator.cs new file mode 100644 index 0000000..e407416 --- /dev/null +++ b/src/Application/Features/Auth/Commands/Register/RegisterValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; + +namespace KontoApi.Application.Features.Auth.Commands.Register; + +public class RegisterValidator : AbstractValidator +{ + public RegisterValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required") + .MaximumLength(100); + + RuleFor(x => x.Email) + .NotEmpty() + .EmailAddress().WithMessage("Invalid email format"); + + RuleFor(x => x.Password) + .NotEmpty() + .MinimumLength(8).WithMessage("Password must be at least 8 characters long"); + } +} \ No newline at end of file diff --git a/src/Application/Features/Budgets/Commands/CreateBudget/CreateBudgetCommand.cs b/src/Application/Features/Budgets/Commands/CreateBudget/CreateBudgetCommand.cs new file mode 100644 index 0000000..da88539 --- /dev/null +++ b/src/Application/Features/Budgets/Commands/CreateBudget/CreateBudgetCommand.cs @@ -0,0 +1,10 @@ +using MediatR; + +namespace KontoApi.Application.Features.Budgets.Commands.CreateBudget; + +public record CreateBudgetCommand( + Guid AccountId, + string Name, + decimal InitialBalance, + string Currency +) : IRequest; diff --git a/src/Application/Features/Budgets/Commands/CreateBudget/CreateBudgetHandler.cs b/src/Application/Features/Budgets/Commands/CreateBudget/CreateBudgetHandler.cs new file mode 100644 index 0000000..f78e238 --- /dev/null +++ b/src/Application/Features/Budgets/Commands/CreateBudget/CreateBudgetHandler.cs @@ -0,0 +1,24 @@ +using KontoApi.Application.Common.Exceptions; +using KontoApi.Application.Common.Interfaces; +using KontoApi.Domain; +using MediatR; + +namespace KontoApi.Application.Features.Budgets.Commands.CreateBudget; + +public class CreateBudgetHandler(IAccountRepository accountRepository, ICurrentUserService currentUserService) + : IRequestHandler +{ + public async Task Handle(CreateBudgetCommand request, CancellationToken ct) + { + var account = await accountRepository.GetByIdAsync(request.AccountId, ct); + if (account == null || account.User.Id != currentUserService.UserId) + throw new NotFoundException(typeof(Account), request.AccountId); + + var money = new Money(request.InitialBalance, request.Currency); + var budget = new Budget(request.Name, money); + + await accountRepository.AddBudgetAsync(account.Id, budget, ct); + + return budget.Id; + } +} \ No newline at end of file diff --git a/src/Application/Features/Budgets/Commands/CreateBudget/CreateBudgetValidator.cs b/src/Application/Features/Budgets/Commands/CreateBudget/CreateBudgetValidator.cs new file mode 100644 index 0000000..334ee93 --- /dev/null +++ b/src/Application/Features/Budgets/Commands/CreateBudget/CreateBudgetValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace KontoApi.Application.Features.Budgets.Commands.CreateBudget; + +public class CreateBudgetValidator : AbstractValidator +{ + public CreateBudgetValidator() + { + RuleFor(x => x.AccountId).NotEmpty(); + RuleFor(x => x.Name).NotEmpty().MaximumLength(100); + RuleFor(x => x.Currency).Length(3); + } +} diff --git a/src/Application/Features/Budgets/Commands/DeleteBudget/DeleteBudgetCommand.cs b/src/Application/Features/Budgets/Commands/DeleteBudget/DeleteBudgetCommand.cs new file mode 100644 index 0000000..c00402c --- /dev/null +++ b/src/Application/Features/Budgets/Commands/DeleteBudget/DeleteBudgetCommand.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace KontoApi.Application.Features.Budgets.Commands.DeleteBudget; + +public record DeleteBudgetCommand(Guid BudgetId) : IRequest; diff --git a/src/Application/Features/Budgets/Commands/DeleteBudget/DeleteBudgetHandler.cs b/src/Application/Features/Budgets/Commands/DeleteBudget/DeleteBudgetHandler.cs new file mode 100644 index 0000000..d58d731 --- /dev/null +++ b/src/Application/Features/Budgets/Commands/DeleteBudget/DeleteBudgetHandler.cs @@ -0,0 +1,17 @@ +using KontoApi.Application.Common.Exceptions; +using KontoApi.Application.Common.Interfaces; +using KontoApi.Domain; +using MediatR; + +namespace KontoApi.Application.Features.Budgets.Commands.DeleteBudget; + +public class DeleteBudgetHandler(IBudgetRepository budgetRepository) : IRequestHandler +{ + public async Task Handle(DeleteBudgetCommand request, CancellationToken ct) + { + var budget = await budgetRepository.GetByIdAsync(request.BudgetId, ct); + if (budget == null) throw new NotFoundException(typeof(Budget), request.BudgetId); + + await budgetRepository.DeleteAsync(request.BudgetId, ct); + } +} \ No newline at end of file diff --git a/src/Application/Features/Budgets/Commands/RenameBudget/RenameBudgetCommand.cs b/src/Application/Features/Budgets/Commands/RenameBudget/RenameBudgetCommand.cs new file mode 100644 index 0000000..c252045 --- /dev/null +++ b/src/Application/Features/Budgets/Commands/RenameBudget/RenameBudgetCommand.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace KontoApi.Application.Features.Budgets.Commands.RenameBudget; + +public record RenameBudgetCommand(Guid BudgetId, string NewName) : IRequest; diff --git a/src/Application/Features/Budgets/Commands/RenameBudget/RenameBudgetHandler.cs b/src/Application/Features/Budgets/Commands/RenameBudget/RenameBudgetHandler.cs new file mode 100644 index 0000000..f557757 --- /dev/null +++ b/src/Application/Features/Budgets/Commands/RenameBudget/RenameBudgetHandler.cs @@ -0,0 +1,19 @@ +using KontoApi.Application.Common.Exceptions; +using KontoApi.Application.Common.Interfaces; +using KontoApi.Domain; +using MediatR; + +namespace KontoApi.Application.Features.Budgets.Commands.RenameBudget; + +public class RenameBudgetHandler(IBudgetRepository budgetRepository) : IRequestHandler +{ + public async Task Handle(RenameBudgetCommand request, CancellationToken ct) + { + var budget = await budgetRepository.GetByIdAsync(request.BudgetId, ct); + if (budget == null) throw new NotFoundException(typeof(Budget), request.BudgetId); + + budget.Rename(request.NewName); + + await budgetRepository.UpdateAsync(budget, ct); + } +} \ No newline at end of file diff --git a/src/Application/Features/Budgets/Commands/RenameBudget/RenameBudgetValidator.cs b/src/Application/Features/Budgets/Commands/RenameBudget/RenameBudgetValidator.cs new file mode 100644 index 0000000..66dfe1a --- /dev/null +++ b/src/Application/Features/Budgets/Commands/RenameBudget/RenameBudgetValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; + +namespace KontoApi.Application.Features.Budgets.Commands.RenameBudget; + +public class RenameBudgetValidator : AbstractValidator +{ + public RenameBudgetValidator() + { + RuleFor(x => x.BudgetId) + .NotEmpty(); + + RuleFor(x => x.NewName) + .NotEmpty().WithMessage("New budget name is required") + .MaximumLength(100).WithMessage("Budget name must not exceed 100 characters"); + } +} \ No newline at end of file diff --git a/src/Application/Features/Budgets/Queries/GetBudgetDetails/BudgetDetailsDto.cs b/src/Application/Features/Budgets/Queries/GetBudgetDetails/BudgetDetailsDto.cs new file mode 100644 index 0000000..22c46c9 --- /dev/null +++ b/src/Application/Features/Budgets/Queries/GetBudgetDetails/BudgetDetailsDto.cs @@ -0,0 +1,9 @@ +namespace KontoApi.Application.Features.Budgets.Queries.GetBudgetDetails; + +public record BudgetDetailsDto( + Guid Id, + string Name, + decimal CurrentBalance, + string Currency, + List Transactions +); diff --git a/src/Application/Features/Budgets/Queries/GetBudgetDetails/GetBudgetDetailsHandler.cs b/src/Application/Features/Budgets/Queries/GetBudgetDetails/GetBudgetDetailsHandler.cs new file mode 100644 index 0000000..e128c6a --- /dev/null +++ b/src/Application/Features/Budgets/Queries/GetBudgetDetails/GetBudgetDetailsHandler.cs @@ -0,0 +1,33 @@ +using KontoApi.Application.Common.Exceptions; +using KontoApi.Application.Common.Interfaces; +using KontoApi.Domain; +using MediatR; + +namespace KontoApi.Application.Features.Budgets.Queries.GetBudgetDetails; + +public class GetBudgetDetailsHandler(IBudgetRepository budgetRepository) + : IRequestHandler +{ + public async Task Handle(GetBudgetDetailsQuery request, CancellationToken ct) + { + var budget = await budgetRepository.GetByIdAsync(request.BudgetId, ct); + if (budget == null) + throw new NotFoundException(typeof(Budget), request.BudgetId); + + return new( + budget.Id, + budget.Name, + budget.CurrentBalance.Value, + budget.CurrentBalance.Currency, + budget.Transactions.Select(t => new TransactionDto( + t.Id, + t.Amount.Value, + t.Amount.Currency, + t.Type, + t.TransactionCategory.Name, + t.Date, + t.Description ?? string.Empty + )).OrderByDescending(t => t.Date).ToList() + ); + } +} \ No newline at end of file diff --git a/src/Application/Features/Budgets/Queries/GetBudgetDetails/GetBudgetDetailsQuery.cs b/src/Application/Features/Budgets/Queries/GetBudgetDetails/GetBudgetDetailsQuery.cs new file mode 100644 index 0000000..8887518 --- /dev/null +++ b/src/Application/Features/Budgets/Queries/GetBudgetDetails/GetBudgetDetailsQuery.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace KontoApi.Application.Features.Budgets.Queries.GetBudgetDetails; + +public record GetBudgetDetailsQuery(Guid BudgetId) : IRequest; diff --git a/src/Application/Features/Budgets/Queries/GetBudgetDetails/TransactionDto.cs b/src/Application/Features/Budgets/Queries/GetBudgetDetails/TransactionDto.cs new file mode 100644 index 0000000..671368e --- /dev/null +++ b/src/Application/Features/Budgets/Queries/GetBudgetDetails/TransactionDto.cs @@ -0,0 +1,13 @@ +using KontoApi.Domain; + +namespace KontoApi.Application.Features.Budgets.Queries.GetBudgetDetails; + +public record TransactionDto( + Guid Id, + decimal Amount, + string Currency, + TransactionType Type, + string CategoryName, + DateTime Date, + string Description +); diff --git a/src/Application/Features/Budgets/Queries/GetBudgetsList/BudgetSummaryDto.cs b/src/Application/Features/Budgets/Queries/GetBudgetsList/BudgetSummaryDto.cs new file mode 100644 index 0000000..1dba305 --- /dev/null +++ b/src/Application/Features/Budgets/Queries/GetBudgetsList/BudgetSummaryDto.cs @@ -0,0 +1,8 @@ +namespace KontoApi.Application.Features.Budgets.Queries.GetBudgetsList; + +public record BudgetSummaryDto( + Guid Id, + string Name, + decimal CurrentBalance, + string Currency +); diff --git a/src/Application/Features/Budgets/Queries/GetBudgetsList/GetBudgetsListHandler.cs b/src/Application/Features/Budgets/Queries/GetBudgetsList/GetBudgetsListHandler.cs new file mode 100644 index 0000000..6262672 --- /dev/null +++ b/src/Application/Features/Budgets/Queries/GetBudgetsList/GetBudgetsListHandler.cs @@ -0,0 +1,23 @@ +using KontoApi.Application.Common.Interfaces; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace KontoApi.Application.Features.Budgets.Queries.GetBudgetsList; + +public class GetBudgetsListHandler(IApplicationDbContext dbContext) + : IRequestHandler> +{ + public async Task> Handle(GetBudgetsListQuery request, CancellationToken ct) + { + return await dbContext.Budgets + .AsNoTracking() + .Where(b => EF.Property(b, "AccountId") == request.AccountId) + .Select(b => new BudgetSummaryDto( + b.Id, + b.Name, + b.CurrentBalance.Value, + b.CurrentBalance.Currency + )) + .ToListAsync(ct); + } +} \ No newline at end of file diff --git a/src/Application/Features/Budgets/Queries/GetBudgetsList/GetBudgetsListQuery.cs b/src/Application/Features/Budgets/Queries/GetBudgetsList/GetBudgetsListQuery.cs new file mode 100644 index 0000000..4dd2824 --- /dev/null +++ b/src/Application/Features/Budgets/Queries/GetBudgetsList/GetBudgetsListQuery.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace KontoApi.Application.Features.Budgets.Queries.GetBudgetsList; + +public record GetBudgetsListQuery(Guid AccountId) : IRequest>; \ No newline at end of file diff --git a/src/Application/Features/Categories/Commands/CreateCategory/CreateCategoryCommand.cs b/src/Application/Features/Categories/Commands/CreateCategory/CreateCategoryCommand.cs new file mode 100644 index 0000000..ef2040a --- /dev/null +++ b/src/Application/Features/Categories/Commands/CreateCategory/CreateCategoryCommand.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace KontoApi.Application.Features.Categories.Commands.CreateCategory; + +public record CreateCategoryCommand(string Name) : IRequest; \ No newline at end of file diff --git a/src/Application/Features/Categories/Commands/CreateCategory/CreateCategoryHandler.cs b/src/Application/Features/Categories/Commands/CreateCategory/CreateCategoryHandler.cs new file mode 100644 index 0000000..7be6e99 --- /dev/null +++ b/src/Application/Features/Categories/Commands/CreateCategory/CreateCategoryHandler.cs @@ -0,0 +1,18 @@ +using KontoApi.Application.Common.Interfaces; +using KontoApi.Domain; +using MediatR; + +namespace KontoApi.Application.Features.Categories.Commands.CreateCategory; + +public class CreateCategoryHandler(ICategoryRepository categoryRepository) + : IRequestHandler +{ + public async Task Handle(CreateCategoryCommand request, CancellationToken cancellationToken) + { + var category = new Category(request.Name); + + await categoryRepository.AddAsync(category, cancellationToken); + + return category.Id; + } +} \ No newline at end of file diff --git a/src/Application/Features/Categories/Commands/CreateCategory/CreateCategoryValidator.cs b/src/Application/Features/Categories/Commands/CreateCategory/CreateCategoryValidator.cs new file mode 100644 index 0000000..d19cdf5 --- /dev/null +++ b/src/Application/Features/Categories/Commands/CreateCategory/CreateCategoryValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace KontoApi.Application.Features.Categories.Commands.CreateCategory; + +public class CreateCategoryValidator : AbstractValidator +{ + public CreateCategoryValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .MaximumLength(80); // Matches Domain validation + } +} \ No newline at end of file diff --git a/src/Application/Features/Categories/Commands/DeleteCategory/DeleteCategoryCommand.cs b/src/Application/Features/Categories/Commands/DeleteCategory/DeleteCategoryCommand.cs new file mode 100644 index 0000000..3b470ce --- /dev/null +++ b/src/Application/Features/Categories/Commands/DeleteCategory/DeleteCategoryCommand.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace KontoApi.Application.Features.Categories.Commands.DeleteCategory; + +public record DeleteCategoryCommand(Guid Id) : IRequest; \ No newline at end of file diff --git a/src/Application/Features/Categories/Commands/DeleteCategory/DeleteCategoryHandler.cs b/src/Application/Features/Categories/Commands/DeleteCategory/DeleteCategoryHandler.cs new file mode 100644 index 0000000..e5e6171 --- /dev/null +++ b/src/Application/Features/Categories/Commands/DeleteCategory/DeleteCategoryHandler.cs @@ -0,0 +1,18 @@ +using KontoApi.Application.Common.Exceptions; +using KontoApi.Application.Common.Interfaces; +using KontoApi.Domain; +using MediatR; + +namespace KontoApi.Application.Features.Categories.Commands.DeleteCategory; + +public class DeleteCategoryHandler(ICategoryRepository categoryRepository) : IRequestHandler +{ + public async Task Handle(DeleteCategoryCommand request, CancellationToken cancellationToken) + { + var category = await categoryRepository.GetByIdAsync(request.Id, cancellationToken); + if (category == null) + throw new NotFoundException(typeof(Category), request.Id); + + await categoryRepository.DeleteAsync(request.Id, cancellationToken); + } +} diff --git a/src/Application/Features/Categories/Commands/RenameCategory/RenameCategoryCommand.cs b/src/Application/Features/Categories/Commands/RenameCategory/RenameCategoryCommand.cs new file mode 100644 index 0000000..812544a --- /dev/null +++ b/src/Application/Features/Categories/Commands/RenameCategory/RenameCategoryCommand.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace KontoApi.Application.Features.Categories.Commands.RenameCategory; + +public record RenameCategoryCommand(Guid Id, string NewName) : IRequest; \ No newline at end of file diff --git a/src/Application/Features/Categories/Commands/RenameCategory/RenameCategoryHandler.cs b/src/Application/Features/Categories/Commands/RenameCategory/RenameCategoryHandler.cs new file mode 100644 index 0000000..e9dd87e --- /dev/null +++ b/src/Application/Features/Categories/Commands/RenameCategory/RenameCategoryHandler.cs @@ -0,0 +1,21 @@ +using KontoApi.Application.Common.Exceptions; +using KontoApi.Application.Common.Interfaces; +using KontoApi.Domain; +using MediatR; + +namespace KontoApi.Application.Features.Categories.Commands.RenameCategory; + +public class RenameCategoryHandler(ICategoryRepository categoryRepository) : IRequestHandler +{ + public async Task Handle(RenameCategoryCommand request, CancellationToken cancellationToken) + { + var category = await categoryRepository.GetByIdAsync(request.Id, cancellationToken); + + if (category == null) + throw new NotFoundException(typeof(Category), request.Id); + + category.Rename(request.NewName); + + await categoryRepository.UpdateAsync(category, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Application/Features/Categories/Commands/RenameCategory/RenameCategoryValidator.cs b/src/Application/Features/Categories/Commands/RenameCategory/RenameCategoryValidator.cs new file mode 100644 index 0000000..5a457f3 --- /dev/null +++ b/src/Application/Features/Categories/Commands/RenameCategory/RenameCategoryValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace KontoApi.Application.Features.Categories.Commands.RenameCategory; + +public class RenameCategoryValidator : AbstractValidator +{ + public RenameCategoryValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.NewName).NotEmpty().MaximumLength(80); + } +} \ No newline at end of file diff --git a/src/Application/Features/Categories/DTOs/CategoryDto.cs b/src/Application/Features/Categories/DTOs/CategoryDto.cs new file mode 100644 index 0000000..e38de6d --- /dev/null +++ b/src/Application/Features/Categories/DTOs/CategoryDto.cs @@ -0,0 +1,3 @@ +namespace KontoApi.Application.Features.Categories.DTOs; + +public record CategoryDto(Guid Id, string Name); \ No newline at end of file diff --git a/src/Application/Features/Categories/Queries/GetCategories/GetCategoriesHandler.cs b/src/Application/Features/Categories/Queries/GetCategories/GetCategoriesHandler.cs new file mode 100644 index 0000000..83f25c6 --- /dev/null +++ b/src/Application/Features/Categories/Queries/GetCategories/GetCategoriesHandler.cs @@ -0,0 +1,19 @@ +using KontoApi.Application.Common.Interfaces; +using KontoApi.Application.Features.Categories.DTOs; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace KontoApi.Application.Features.Categories.Queries.GetCategories; + +public class GetCategoriesHandler(IApplicationDbContext dbContext) + : IRequestHandler> +{ + public async Task> Handle(GetCategoriesQuery request, CancellationToken cancellationToken) + { + return await dbContext.Categories + .AsNoTracking() + .OrderBy(c => c.Name) + .Select(c => new CategoryDto(c.Id, c.Name)) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/src/Application/Features/Categories/Queries/GetCategories/GetCategoriesQuery.cs b/src/Application/Features/Categories/Queries/GetCategories/GetCategoriesQuery.cs new file mode 100644 index 0000000..672dedb --- /dev/null +++ b/src/Application/Features/Categories/Queries/GetCategories/GetCategoriesQuery.cs @@ -0,0 +1,6 @@ +using KontoApi.Application.Features.Categories.DTOs; +using MediatR; + +namespace KontoApi.Application.Features.Categories.Queries.GetCategories; + +public record GetCategoriesQuery : IRequest>; \ No newline at end of file diff --git a/src/Application/Features/Categories/Queries/GetCategoryById/GetCategoryByIdHandler.cs b/src/Application/Features/Categories/Queries/GetCategoryById/GetCategoryByIdHandler.cs new file mode 100644 index 0000000..1924a09 --- /dev/null +++ b/src/Application/Features/Categories/Queries/GetCategoryById/GetCategoryByIdHandler.cs @@ -0,0 +1,23 @@ +using KontoApi.Application.Common.Exceptions; +using KontoApi.Application.Common.Interfaces; +using KontoApi.Application.Features.Categories.DTOs; +using KontoApi.Domain; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace KontoApi.Application.Features.Categories.Queries.GetCategoryById; + +public class GetCategoryByIdHandler(IApplicationDbContext dbContext) + : IRequestHandler +{ + public async Task Handle(GetCategoryByIdQuery request, CancellationToken cancellationToken) + { + var category = await dbContext.Categories + .AsNoTracking() + .FirstOrDefaultAsync(c => c.Id == request.Id, cancellationToken); + + return category == null + ? throw new NotFoundException(typeof(Category), request.Id) + : new(category.Id, category.Name); + } +} \ No newline at end of file diff --git a/src/Application/Features/Categories/Queries/GetCategoryById/GetCategoryByIdQuery.cs b/src/Application/Features/Categories/Queries/GetCategoryById/GetCategoryByIdQuery.cs new file mode 100644 index 0000000..c1d80eb --- /dev/null +++ b/src/Application/Features/Categories/Queries/GetCategoryById/GetCategoryByIdQuery.cs @@ -0,0 +1,6 @@ +using KontoApi.Application.Features.Categories.DTOs; +using MediatR; + +namespace KontoApi.Application.Features.Categories.Queries.GetCategoryById; + +public record GetCategoryByIdQuery(Guid Id) : IRequest; \ No newline at end of file diff --git a/src/Application/Features/Transactions/Commands/AddTransaction/AddTransactionCommand.cs b/src/Application/Features/Transactions/Commands/AddTransaction/AddTransactionCommand.cs new file mode 100644 index 0000000..8615616 --- /dev/null +++ b/src/Application/Features/Transactions/Commands/AddTransaction/AddTransactionCommand.cs @@ -0,0 +1,14 @@ +using KontoApi.Domain; +using MediatR; + +namespace KontoApi.Application.Features.Transactions.Commands.AddTransaction; + +public record AddTransactionCommand( + Guid BudgetId, + decimal Amount, + string Currency, + TransactionType Type, + Guid CategoryId, + DateTime Date, + string? Description +) : IRequest; \ No newline at end of file diff --git a/src/Application/Features/Transactions/Commands/AddTransaction/AddTransactionHandler.cs b/src/Application/Features/Transactions/Commands/AddTransaction/AddTransactionHandler.cs new file mode 100644 index 0000000..4b840ee --- /dev/null +++ b/src/Application/Features/Transactions/Commands/AddTransaction/AddTransactionHandler.cs @@ -0,0 +1,28 @@ +using KontoApi.Application.Common.Exceptions; +using KontoApi.Application.Common.Interfaces; +using KontoApi.Domain; +using MediatR; + +namespace KontoApi.Application.Features.Transactions.Commands.AddTransaction; + +public class AddTransactionHandler(IBudgetRepository budgetRepository, IApplicationDbContext dbContext) + : IRequestHandler +{ + public async Task Handle(AddTransactionCommand request, CancellationToken ct) + { + var budget = await budgetRepository.GetByIdAsync(request.BudgetId, ct); + if (budget == null) + throw new NotFoundException(typeof(Budget), request.BudgetId); + + var category = await dbContext.Categories.FindAsync([request.CategoryId], ct); + if (category == null) + throw new NotFoundException(typeof(Category), request.CategoryId); + + var money = new Money(request.Amount, request.Currency); + var transaction = new Transaction(money, request.Type, category, request.Date, request.Description); + + await budgetRepository.AddTransactionAsync(budget.Id, transaction, ct); + + return transaction.Id; + } +} diff --git a/src/Application/Features/Transactions/Commands/AddTransaction/AddTransactionValidator.cs b/src/Application/Features/Transactions/Commands/AddTransaction/AddTransactionValidator.cs new file mode 100644 index 0000000..beabce5 --- /dev/null +++ b/src/Application/Features/Transactions/Commands/AddTransaction/AddTransactionValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; + +namespace KontoApi.Application.Features.Transactions.Commands.AddTransaction; + +public class AddTransactionValidator : AbstractValidator +{ + public AddTransactionValidator() + { + RuleFor(x => x.BudgetId).NotEmpty(); + RuleFor(x => x.CategoryId).NotEmpty(); + RuleFor(x => x.Amount).GreaterThan(0); + RuleFor(x => x.Currency).Length(3); + RuleFor(x => x.Type).IsInEnum(); + RuleFor(x => x.Date).NotEmpty(); + RuleFor(x => x.Description).MaximumLength(500); + } +} \ No newline at end of file diff --git a/src/Application/Features/Transactions/Commands/DeleteTransaction/DeleteTransactionCommand.cs b/src/Application/Features/Transactions/Commands/DeleteTransaction/DeleteTransactionCommand.cs new file mode 100644 index 0000000..6a1b9f5 --- /dev/null +++ b/src/Application/Features/Transactions/Commands/DeleteTransaction/DeleteTransactionCommand.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace KontoApi.Application.Features.Transactions.Commands.DeleteTransaction; + +public record DeleteTransactionCommand(Guid BudgetId, Guid TransactionId) : IRequest; \ No newline at end of file diff --git a/src/Application/Features/Transactions/Commands/DeleteTransaction/DeleteTransactionHandler.cs b/src/Application/Features/Transactions/Commands/DeleteTransaction/DeleteTransactionHandler.cs new file mode 100644 index 0000000..5ec6ed1 --- /dev/null +++ b/src/Application/Features/Transactions/Commands/DeleteTransaction/DeleteTransactionHandler.cs @@ -0,0 +1,25 @@ +using KontoApi.Application.Common.Exceptions; +using KontoApi.Application.Common.Interfaces; +using KontoApi.Domain; +using MediatR; + +namespace KontoApi.Application.Features.Transactions.Commands.DeleteTransaction; + +public class DeleteTransactionHandler(IBudgetRepository budgetRepository) : IRequestHandler +{ + public async Task Handle(DeleteTransactionCommand request, CancellationToken cancellationToken) + { + var budget = await budgetRepository.GetByIdAsync(request.BudgetId, cancellationToken); + if (budget == null) + throw new NotFoundException(typeof(Budget), request.BudgetId); + + try + { + await budgetRepository.DeleteTransactionAsync(request.BudgetId, request.TransactionId, cancellationToken); + } + catch (InvalidOperationException) + { + throw new NotFoundException(typeof(Transaction), request.TransactionId); + } + } +} \ No newline at end of file diff --git a/src/Application/Features/Transactions/Commands/DeleteTransaction/DeleteTransactionValidator.cs b/src/Application/Features/Transactions/Commands/DeleteTransaction/DeleteTransactionValidator.cs new file mode 100644 index 0000000..4eb7ce5 --- /dev/null +++ b/src/Application/Features/Transactions/Commands/DeleteTransaction/DeleteTransactionValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace KontoApi.Application.Features.Transactions.Commands.DeleteTransaction; + +public class DeleteTransactionValidator : AbstractValidator +{ + public DeleteTransactionValidator() + { + RuleFor(x => x.BudgetId).NotEmpty(); + RuleFor(x => x.TransactionId).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Application/Features/Transactions/Commands/ImportTransactions/ImportResultDto.cs b/src/Application/Features/Transactions/Commands/ImportTransactions/ImportResultDto.cs new file mode 100644 index 0000000..f776996 --- /dev/null +++ b/src/Application/Features/Transactions/Commands/ImportTransactions/ImportResultDto.cs @@ -0,0 +1,8 @@ +namespace KontoApi.Application.Features.Transactions.Commands.ImportTransactions; + +public record ImportResultDto( + int TotalProcessed, + int SuccessCount, + int FailedCount, + List Errors +); \ No newline at end of file diff --git a/src/Application/Features/Transactions/Commands/ImportTransactions/ImportTransactionsCommand.cs b/src/Application/Features/Transactions/Commands/ImportTransactions/ImportTransactionsCommand.cs new file mode 100644 index 0000000..a93d231 --- /dev/null +++ b/src/Application/Features/Transactions/Commands/ImportTransactions/ImportTransactionsCommand.cs @@ -0,0 +1,9 @@ +using MediatR; + +namespace KontoApi.Application.Features.Transactions.Commands.ImportTransactions; + +public record ImportTransactionsCommand( + Guid BudgetId, + Stream FileStream, + string FileName +) : IRequest; \ No newline at end of file diff --git a/src/Application/Features/Transactions/Commands/ImportTransactions/ImportTransactionsHandler.cs b/src/Application/Features/Transactions/Commands/ImportTransactions/ImportTransactionsHandler.cs new file mode 100644 index 0000000..0a2c802 --- /dev/null +++ b/src/Application/Features/Transactions/Commands/ImportTransactions/ImportTransactionsHandler.cs @@ -0,0 +1,53 @@ +using KontoApi.Application.Common.Exceptions; +using KontoApi.Application.Common.Interfaces; +using KontoApi.Domain; +using MediatR; + +namespace KontoApi.Application.Features.Transactions.Commands.ImportTransactions; + +public class ImportTransactionsHandler( + IBudgetRepository budgetRepository, + IStatementParser statementParser, + IApplicationDbContext dbContext) + : IRequestHandler +{ + public async Task Handle(ImportTransactionsCommand request, CancellationToken cancellationToken) + { + var budget = await budgetRepository.GetByIdAsync(request.BudgetId, cancellationToken); + if (budget == null) + throw new NotFoundException(typeof(Budget), request.BudgetId); + + var parsedRecords = await statementParser.ParseAsync(request.FileStream, cancellationToken); + + var success = 0; + var failed = 0; + var errors = new List(); + + foreach (var record in parsedRecords) + { + try + { + var category = await dbContext.Categories.FindAsync([record.CategoryId], cancellationToken); + if (category == null) + { + throw new($"Category {record.CategoryId} not found"); + } + + var money = new Money(record.Amount, record.Currency); + var tx = new Transaction(money, record.Type, category, record.Date, record.Description); + + budget.AddTransaction(tx); + success++; + } + catch (Exception ex) + { + failed++; + errors.Add($"Row error: {ex.Message}"); + } + } + + await budgetRepository.UpdateAsync(budget, cancellationToken); + + return new(parsedRecords.Count, success, failed, errors); + } +} \ No newline at end of file diff --git a/src/Application/Features/Transactions/Commands/ImportTransactions/ImportTransactionsValidator.cs b/src/Application/Features/Transactions/Commands/ImportTransactions/ImportTransactionsValidator.cs new file mode 100644 index 0000000..266f4b4 --- /dev/null +++ b/src/Application/Features/Transactions/Commands/ImportTransactions/ImportTransactionsValidator.cs @@ -0,0 +1,18 @@ +using FluentValidation; + +namespace KontoApi.Application.Features.Transactions.Commands.ImportTransactions; + +public class ImportTransactionsValidator : AbstractValidator +{ + public ImportTransactionsValidator() + { + RuleFor(x => x.BudgetId).NotEmpty(); + RuleFor(x => x.FileStream).NotNull(); + + RuleFor(x => x.FileName) + .NotEmpty() + .Must(f => f.EndsWith(".csv", StringComparison.OrdinalIgnoreCase) || + f.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)) + .WithMessage("Only .csv and .pdf files are supported"); + } +} \ No newline at end of file diff --git a/src/Application/Features/Transactions/Queries/GetTransactionById/GetTransactionByIdHandler.cs b/src/Application/Features/Transactions/Queries/GetTransactionById/GetTransactionByIdHandler.cs new file mode 100644 index 0000000..ec1c8a0 --- /dev/null +++ b/src/Application/Features/Transactions/Queries/GetTransactionById/GetTransactionByIdHandler.cs @@ -0,0 +1,33 @@ +using KontoApi.Application.Common.Exceptions; +using KontoApi.Application.Common.Interfaces; +using KontoApi.Domain; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace KontoApi.Application.Features.Transactions.Queries.GetTransactionById; + +public class GetTransactionByIdHandler(IApplicationDbContext dbContext) + : IRequestHandler +{ + public async Task Handle(GetTransactionByIdQuery request, CancellationToken cancellationToken) + { + var transaction = await dbContext.Transactions + .AsNoTracking() + .Include(x => x.TransactionCategory) + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (transaction == null) + throw new NotFoundException(typeof(Transaction), request.Id); + + return new( + transaction.Id, + transaction.Amount.Value, + transaction.Amount.Currency, + transaction.Type, + transaction.TransactionCategory.Id, + transaction.TransactionCategory.Name, + transaction.Date, + transaction.Description ?? string.Empty + ); + } +} \ No newline at end of file diff --git a/src/Application/Features/Transactions/Queries/GetTransactionById/GetTransactionByIdQuery.cs b/src/Application/Features/Transactions/Queries/GetTransactionById/GetTransactionByIdQuery.cs new file mode 100644 index 0000000..8447f31 --- /dev/null +++ b/src/Application/Features/Transactions/Queries/GetTransactionById/GetTransactionByIdQuery.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace KontoApi.Application.Features.Transactions.Queries.GetTransactionById; + +public record GetTransactionByIdQuery(Guid Id) : IRequest; \ No newline at end of file diff --git a/src/Application/Features/Transactions/Queries/GetTransactionById/TransactionDetailDto.cs b/src/Application/Features/Transactions/Queries/GetTransactionById/TransactionDetailDto.cs new file mode 100644 index 0000000..4afe805 --- /dev/null +++ b/src/Application/Features/Transactions/Queries/GetTransactionById/TransactionDetailDto.cs @@ -0,0 +1,14 @@ +using KontoApi.Domain; + +namespace KontoApi.Application.Features.Transactions.Queries.GetTransactionById; + +public record TransactionDetailDto( + Guid Id, + decimal Amount, + string Currency, + TransactionType Type, + Guid CategoryId, + string CategoryName, + DateTime Date, + string Description +); \ No newline at end of file diff --git a/src/Application/Features/Users/Commands/ChangePassword/ChangePasswordCommand.cs b/src/Application/Features/Users/Commands/ChangePassword/ChangePasswordCommand.cs new file mode 100644 index 0000000..f674520 --- /dev/null +++ b/src/Application/Features/Users/Commands/ChangePassword/ChangePasswordCommand.cs @@ -0,0 +1,9 @@ +using MediatR; + +namespace KontoApi.Application.Features.Users.Commands.ChangePassword; + +public record ChangePasswordCommand( + Guid UserId, + string CurrentPassword, + string NewPassword +) : IRequest; \ No newline at end of file diff --git a/src/Application/Features/Users/Commands/ChangePassword/ChangePasswordHandler.cs b/src/Application/Features/Users/Commands/ChangePassword/ChangePasswordHandler.cs new file mode 100644 index 0000000..adebc6d --- /dev/null +++ b/src/Application/Features/Users/Commands/ChangePassword/ChangePasswordHandler.cs @@ -0,0 +1,28 @@ +using KontoApi.Application.Common.Exceptions; +using KontoApi.Application.Common.Interfaces; +using KontoApi.Domain; + +namespace KontoApi.Application.Features.Users.Commands.ChangePassword; + +using MediatR; + +public class ChangePasswordHandler(IApplicationDbContext context, IPasswordHasher passwordHasher) + : IRequestHandler +{ + public async Task Handle(ChangePasswordCommand request, CancellationToken cancellationToken) + { + var user = await context.Users.FindAsync([request.UserId], cancellationToken); + + if (user == null) + throw new NotFoundException(typeof(User), request.UserId); + + var isPasswordCorrect = passwordHasher.Verify(request.CurrentPassword, user.HashedPassword); + + if (!isPasswordCorrect) + throw new BadRequestException("Invalid current password."); + + var newHashedPassword = passwordHasher.Hash(request.NewPassword); + user.ChangePassword(newHashedPassword); + await context.SaveChangesAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/src/Application/Features/Users/Commands/ChangePassword/ChangePasswordValidator.cs b/src/Application/Features/Users/Commands/ChangePassword/ChangePasswordValidator.cs new file mode 100644 index 0000000..ce919f1 --- /dev/null +++ b/src/Application/Features/Users/Commands/ChangePassword/ChangePasswordValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; + +namespace KontoApi.Application.Features.Users.Commands.ChangePassword; + +public class ChangePasswordValidator : AbstractValidator +{ + public ChangePasswordValidator() + { + RuleFor(x => x.UserId) + .NotEmpty(); + + RuleFor(x => x.CurrentPassword) + .NotEmpty().WithMessage("Current password is required"); + + RuleFor(x => x.NewPassword) + .NotEmpty().WithMessage("New password is required") + .MinimumLength(8).WithMessage("Password must be at least 8 characters long") + .NotEqual(x => x.CurrentPassword).WithMessage("New password cannot be the same as the old password"); + } +} \ No newline at end of file diff --git a/src/Application/Features/Users/Queries/GetUser/GetUserQuery.cs b/src/Application/Features/Users/Queries/GetUser/GetUserQuery.cs new file mode 100644 index 0000000..1cb77d9 --- /dev/null +++ b/src/Application/Features/Users/Queries/GetUser/GetUserQuery.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace KontoApi.Application.Features.Users.Queries.GetUser; + +public record GetUserQuery(Guid UserId) : IRequest; \ No newline at end of file diff --git a/src/Application/Features/Users/Queries/GetUser/UserDto.cs b/src/Application/Features/Users/Queries/GetUser/UserDto.cs new file mode 100644 index 0000000..a8ad336 --- /dev/null +++ b/src/Application/Features/Users/Queries/GetUser/UserDto.cs @@ -0,0 +1,8 @@ +namespace KontoApi.Application.Features.Users.Queries.GetUser; + +public record UserDto( + Guid Id, + string Name, + string Email, + DateTime CreatedAt +); \ No newline at end of file diff --git a/src/Application/Features/Users/Queries/GetUser/UserHandlers.cs b/src/Application/Features/Users/Queries/GetUser/UserHandlers.cs new file mode 100644 index 0000000..925c2d8 --- /dev/null +++ b/src/Application/Features/Users/Queries/GetUser/UserHandlers.cs @@ -0,0 +1,24 @@ +using KontoApi.Application.Common.Exceptions; +using KontoApi.Application.Common.Interfaces; +using KontoApi.Domain; +using MediatR; + +namespace KontoApi.Application.Features.Users.Queries.GetUser; + +public class GetUserHandler(IUserRepository userRepository) : IRequestHandler +{ + public async Task Handle(GetUserQuery request, CancellationToken cancellationToken) + { + var user = await userRepository.GetByIdAsync(request.UserId, cancellationToken); + + if (user == null) + throw new NotFoundException(typeof(User), request.UserId); + + return new( + user.Id, + user.Name, + user.Email, + user.CreatedAt + ); + } +} \ No newline at end of file diff --git a/src/Application/KontoApi.Application.csproj b/src/Application/KontoApi.Application.csproj new file mode 100644 index 0000000..cf06f53 --- /dev/null +++ b/src/Application/KontoApi.Application.csproj @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + net10.0;net8.0 + enable + enable + + + diff --git a/src/Domain/Account.cs b/src/Domain/Account.cs new file mode 100644 index 0000000..1bf27b5 --- /dev/null +++ b/src/Domain/Account.cs @@ -0,0 +1,85 @@ +namespace KontoApi.Domain; + +public class Account +{ + public Guid Id { get; private set; } + public User User { get; init; } + public string Name { get; private set; } + public DateTime CreatedAt { get; private set; } + public DateTime UpdatedAt { get; private set; } + + private readonly List budgets = []; + public IReadOnlyCollection Budgets => budgets.AsReadOnly(); + + public Account(User user, string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Account name cannot be empty", nameof(name)); + + Id = Guid.NewGuid(); + User = user ?? throw new ArgumentNullException(nameof(user)); + Name = name.Trim(); + CreatedAt = DateTime.UtcNow; + UpdatedAt = DateTime.UtcNow; + budgets.Add(new Budget("Default", new Money(0, "RUB"))); + } + + private Account() + { + /* For ORM */ + } + + + public void AddBudget(Budget budget) + { + ArgumentNullException.ThrowIfNull(budget); + + if (budgets.Any(b => b.Id == budget.Id)) + throw new InvalidOperationException($"Budget {budget.Id} already exists in account"); + + budgets.Add(budget); + UpdatedAt = DateTime.UtcNow; + } + + public void Rename(string newName) + { + if (string.IsNullOrWhiteSpace(newName)) + throw new ArgumentException("Account name cannot be empty", nameof(newName)); + + Name = newName.Trim(); + UpdatedAt = DateTime.UtcNow; + } + + public void RemoveBudget(Guid budgetId) + { + var budget = budgets.FirstOrDefault(b => b.Id == budgetId); + if (budget is null) + throw new InvalidOperationException($"Budget {budgetId} not found in account"); + + budgets.Remove(budget); + UpdatedAt = DateTime.UtcNow; + } + + public Budget? GetBudgetById(Guid budgetId) + => budgets.FirstOrDefault(b => b.Id == budgetId); + + public IEnumerable GetBudgetsByCurrency(string currency) + => string.IsNullOrWhiteSpace(currency) + ? throw new ArgumentException("Currency cannot be empty", nameof(currency)) + : budgets.Where(b => b.CurrentBalance.Currency.Equals(currency, StringComparison.OrdinalIgnoreCase)); + + public Money GetTotalBalanceByCurrency(string currency) + { + if (string.IsNullOrWhiteSpace(currency)) + throw new ArgumentException("Currency cannot be empty", nameof(currency)); + + var total = budgets + .Where(b => b.CurrentBalance.Currency.Equals(currency, StringComparison.OrdinalIgnoreCase)) + .Sum(b => b.CurrentBalance.Value); + + return new(total, currency.ToUpperInvariant()); + } + + public override string ToString() + => $"Account for {User.Name} with {budgets.Count} budget(s)"; +} \ No newline at end of file diff --git a/src/Domain/Budget.cs b/src/Domain/Budget.cs new file mode 100644 index 0000000..87cd579 --- /dev/null +++ b/src/Domain/Budget.cs @@ -0,0 +1,112 @@ +namespace KontoApi.Domain; + +public class Budget +{ + public Guid Id { get; private set; } + public string Name { get; private set; } + public Money CurrentBalance { get; private set; } + + private readonly List transactions = []; + + public IReadOnlyCollection Transactions + => transactions.AsReadOnly(); + + public Budget(string name, Money initialBalance) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Budget name cannot be empty", nameof(name)); + + Id = Guid.NewGuid(); + Name = name.Trim(); + CurrentBalance = initialBalance ?? throw new ArgumentNullException(nameof(initialBalance)); + } + + private Budget() + { + /* For ORM */ + } + + + public void AddTransaction(Transaction transaction) + { + ArgumentNullException.ThrowIfNull(transaction); + + if (transaction.Amount.Currency != CurrentBalance.Currency) + throw new ArgumentException( + $"Transaction currency ({transaction.Amount.Currency}) must match budget currency ({CurrentBalance.Currency})"); + + transactions.Add(transaction); + UpdateBalanceFromTransaction(transaction); + } + + public void RemoveTransaction(Guid transactionId) + { + var transaction = transactions.FirstOrDefault(t => t.Id == transactionId); + if (transaction is null) + throw new InvalidOperationException($"Transaction {transactionId} not found in budget"); + + transactions.Remove(transaction); + ReverseBalanceFromTransaction(transaction); + } + + public Money GetTotalIncome() + { + var total = transactions + .Where(t => t.Type == TransactionType.Income) + .Sum(t => t.Amount.Value); + + return new(total, CurrentBalance.Currency); + } + + public Money GetTotalExpenses() + { + var total = transactions + .Where(t => t.Type == TransactionType.Expense) + .Sum(t => t.Amount.Value); + + return new(total, CurrentBalance.Currency); + } + + public IEnumerable GetTransactionsByDateRange(DateRange dateRange) + => dateRange is null + ? throw new ArgumentNullException(nameof(dateRange)) + : transactions.Where(t => dateRange.Contains(t.Date)); + + public IEnumerable GetTransactionsByCategory(Category category) + => category is null + ? throw new ArgumentNullException(nameof(category)) + : transactions.Where(t => t.TransactionCategory.Equals(category)); + + public void Rename(string newName) + { + if (string.IsNullOrWhiteSpace(newName)) + throw new ArgumentException("Budget name cannot be empty", nameof(newName)); + + Name = newName.Trim(); + } + + private void UpdateBalanceFromTransaction(Transaction transaction) + { + CurrentBalance = transaction.Type switch + { + TransactionType.Income => CurrentBalance + transaction.Amount, + TransactionType.Expense => CurrentBalance - transaction.Amount, + TransactionType.Transfer => CurrentBalance, + _ => throw new InvalidOperationException($"Unknown transaction type: {transaction.Type}") + }; + } + + private void ReverseBalanceFromTransaction(Transaction transaction) + { + CurrentBalance = transaction.Type switch + { + TransactionType.Income => CurrentBalance - transaction.Amount, + TransactionType.Expense => CurrentBalance + transaction.Amount, + TransactionType.Transfer => CurrentBalance, + _ => throw new InvalidOperationException($"Unknown transaction type: {transaction.Type}") + }; + } + + public override string ToString() + => $"{Name}: {CurrentBalance}"; +} \ No newline at end of file diff --git a/src/Domain/Category.cs b/src/Domain/Category.cs new file mode 100644 index 0000000..b4ae6c1 --- /dev/null +++ b/src/Domain/Category.cs @@ -0,0 +1,65 @@ +namespace KontoApi.Domain; + +public class Category : IComparable, IEquatable +{ + public Guid Id { get; } + public string Name { get; private set; } + + public Category(string name) + { + ValidateName(name, nameof(name)); + Id = Guid.NewGuid(); + Name = name.Trim(); + } + + private Category() + { + /* For ORM */ + } + + public void Rename(string newName) + { + ValidateName(newName, nameof(newName)); + Name = newName.Trim(); + } + + public bool Equals(Category? other) + { + if (other is null) return false; + if (ReferenceEquals(this, other)) return true; + + return Id == other.Id; + } + + public override bool Equals(object? obj) + => obj is Category other && Equals(other); + + public override int GetHashCode() + => Id.GetHashCode(); + + public int CompareTo(Category? other) + { + return other is null + ? 1 + : string.Compare(Name, other.Name, StringComparison.OrdinalIgnoreCase); + } + + public override string ToString() + => Name; + + public static bool operator ==(Category? left, Category? right) + => Equals(left, right); + + public static bool operator !=(Category? left, Category? right) + => !Equals(left, right); + + + private static void ValidateName(string name, string paramName) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Category name cannot be empty", paramName); + + if (name.Length > 80) + throw new ArgumentException("Category name cannot exceed 80 characters", paramName); + } +} diff --git a/src/Domain/DateRange.cs b/src/Domain/DateRange.cs new file mode 100644 index 0000000..c3ca899 --- /dev/null +++ b/src/Domain/DateRange.cs @@ -0,0 +1,53 @@ +namespace KontoApi.Domain; + +public class DateRange : IEquatable, IComparable +{ + public DateTime StartDate { get; } + public DateTime EndDate { get; } + public TimeSpan Length => EndDate - StartDate; + + public DateRange(DateTime startDate, DateTime endDate) + { + if (startDate > endDate) + throw new ArgumentException("Start date must be before or equal to end date"); + + StartDate = startDate; + EndDate = endDate; + } + + public bool Contains(DateTime date) + => date >= StartDate && date <= EndDate; + + + public bool Equals(DateRange? other) + { + if (other is null) return false; + if (ReferenceEquals(this, other)) return true; + return StartDate == other.StartDate && EndDate == other.EndDate; + } + + public override bool Equals(object? obj) + => obj is DateRange other && Equals(other); + + public override int GetHashCode() + => HashCode.Combine(StartDate, EndDate); + + public int CompareTo(DateRange? other) + => other is null + ? 1 + : Length.CompareTo(other.Length); + + public override string ToString() + => $"{StartDate:yyyy-MM-dd} to {EndDate:yyyy-MM-dd}"; + + public static DateRange Create(DateTime? startDate, DateTime? endDate) + { + var start = startDate ?? DateTime.MinValue; + var end = endDate ?? DateTime.MaxValue; + + if (start > end) + throw new ArgumentException("Start date must be before or equal to end date"); + + return new DateRange(start, end); + } +} \ No newline at end of file diff --git a/src/Domain/KontoApi.Domain.csproj b/src/Domain/KontoApi.Domain.csproj new file mode 100644 index 0000000..ba46fe9 --- /dev/null +++ b/src/Domain/KontoApi.Domain.csproj @@ -0,0 +1,9 @@ + + + + net10.0;net8.0 + enable + enable + + + diff --git a/src/Domain/Money.cs b/src/Domain/Money.cs new file mode 100644 index 0000000..c51c990 --- /dev/null +++ b/src/Domain/Money.cs @@ -0,0 +1,79 @@ +namespace KontoApi.Domain; + +public class Money : IEquatable, IComparable +{ + public decimal Value { get; init; } + public string Currency { get; init; } + + private Money() + { + /* ORM */ + } + + public Money(decimal value, string currency) + { + if (string.IsNullOrWhiteSpace(currency)) + throw new ArgumentNullException(nameof(currency), "Currency cannot be empty"); + if (currency.Length != 3) + throw new ArgumentOutOfRangeException(nameof(currency), "Currency must be a 3-character ISO code"); + + Value = value; + Currency = currency.Trim().ToUpperInvariant(); + } + + public bool Equals(Money? other) + => other != null + && Value == other.Value + && Currency == other.Currency; + + public override bool Equals(object? obj) + => obj is Money other && Equals(other); + + public override int GetHashCode() + => HashCode.Combine(Value, Currency); + + public int CompareTo(Money? other) + { + if (ReferenceEquals(this, other)) + return 0; + + if (ReferenceEquals(null, other)) + return 1; + + return Currency == other.Currency + ? Value.CompareTo(other.Value) + : throw new ArgumentException($"Cannot compare {other.Currency} with {Currency}"); + } + + public static Money operator +(Money first, Money second) => + first.Currency != second.Currency + ? throw new ArgumentException($"Cannot add {first.Currency} and {second.Currency}") + : new(first.Value + second.Value, first.Currency); + + + public static Money operator -(Money first, Money second) => + first.Currency != second.Currency + ? throw new ArgumentException($"Cannot subtract {second.Currency} from {first.Currency}") + : new(first.Value - second.Value, first.Currency); + + public static bool operator ==(Money? left, Money? right) + => Equals(left, right); + + public static bool operator !=(Money? left, Money? right) + => !Equals(left, right); + + public static bool operator >(Money left, Money right) + => left.CompareTo(right) > 0; + + public static bool operator <(Money left, Money right) + => left.CompareTo(right) < 0; + + public static bool operator >=(Money left, Money right) + => left.CompareTo(right) >= 0; + + public static bool operator <=(Money left, Money right) + => left.CompareTo(right) <= 0; + + public override string ToString() + => $"{Value:F2} {Currency}"; +} \ No newline at end of file diff --git a/src/Domain/Transaction.cs b/src/Domain/Transaction.cs new file mode 100644 index 0000000..3192bde --- /dev/null +++ b/src/Domain/Transaction.cs @@ -0,0 +1,53 @@ +using System.Diagnostics; + +namespace KontoApi.Domain; + +public enum TransactionType +{ + Income, + Expense, + Transfer +} + +public class Transaction +{ + public Guid Id { get; private set; } + public TransactionType Type { get; private set; } + public Money Amount { get; private set; } + public Category TransactionCategory { get; private set; } + public DateTime Date { get; private set; } + public string? Description { get; private set; } + + public Transaction(Money amount, TransactionType type, Category category, DateTime date, string? description = null) + { + ArgumentNullException.ThrowIfNull(amount); + ArgumentNullException.ThrowIfNull(category); + + if (amount.Value <= 0) + throw new ArgumentException("Transaction amount must be greater than zero", nameof(amount)); + + Id = Guid.NewGuid(); + Amount = amount; + Type = type; + TransactionCategory = category; + Date = date; + Description = description?.Trim() ?? string.Empty; + } + + private Transaction() + { + /* For ORM */ + } + + public void UpdateCategory(Category newCategory) + { + ArgumentNullException.ThrowIfNull(newCategory); + TransactionCategory = newCategory; + } + + public void UpdateDescription(string newDescription) + => Description = newDescription.Trim(); + + public override string ToString() + => $"{Type}: {Amount} - {TransactionCategory.Name} on {Date:yyyy-MM-dd}"; +} \ No newline at end of file diff --git a/src/Domain/User.cs b/src/Domain/User.cs new file mode 100644 index 0000000..4d5d1e7 --- /dev/null +++ b/src/Domain/User.cs @@ -0,0 +1,81 @@ +namespace KontoApi.Domain; + +public class User +{ + public Guid Id { get; private set; } + public string Name { get; private set; } + public string Email { get; private set; } + public string HashedPassword { get; private set; } + public DateTime CreatedAt { get; private set; } + + public User(string name, string email, string hashedPassword) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Name cannot be empty", nameof(name)); + + if (string.IsNullOrWhiteSpace(email)) + throw new ArgumentException("Email cannot be empty", nameof(email)); + + if (!IsValidEmail(email)) + throw new ArgumentException("Invalid email format", nameof(email)); + + if (string.IsNullOrWhiteSpace(hashedPassword)) + throw new ArgumentException("Hashed password cannot be empty", nameof(hashedPassword)); + + Id = Guid.NewGuid(); + Name = name.Trim(); + Email = email.Trim().ToLowerInvariant(); + HashedPassword = hashedPassword; + CreatedAt = DateTime.UtcNow; + } + + private User() + { + /* For ORM */ + } + + public void ChangeName(string newName) + { + if (string.IsNullOrWhiteSpace(newName)) + throw new ArgumentException("Name cannot be empty", nameof(newName)); + + Name = newName.Trim(); + } + + public void ChangeEmail(string newEmail) + { + if (string.IsNullOrWhiteSpace(newEmail)) + throw new ArgumentException("Email cannot be empty", nameof(newEmail)); + + if (!IsValidEmail(newEmail)) + throw new ArgumentException("Invalid email format", nameof(newEmail)); + + Email = newEmail.Trim().ToLowerInvariant(); + } + + public void ChangePassword(string newHashedPassword) + { + if (string.IsNullOrWhiteSpace(newHashedPassword)) + throw new ArgumentException("Hashed password cannot be empty", nameof(newHashedPassword)); + + HashedPassword = newHashedPassword; + } + + private static bool IsValidEmail(string email) + { + if (string.IsNullOrWhiteSpace(email)) + return false; + + try + { + var addr = new System.Net.Mail.MailAddress(email); + return addr.Address == email.Trim(); + } + catch + { + return false; + } + } + + public override string ToString() => $"{Name} ({Email})"; +} \ No newline at end of file diff --git a/src/Infrastructure/Auth/JwtProvider.cs b/src/Infrastructure/Auth/JwtProvider.cs new file mode 100644 index 0000000..7314368 --- /dev/null +++ b/src/Infrastructure/Auth/JwtProvider.cs @@ -0,0 +1,76 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using KontoApi.Application.Common.Interfaces; +using KontoApi.Domain; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace KontoApi.Infrastructure.Auth; + +/// +/// Generates and validates JWT access tokens and creates refresh tokens +/// +/// +/// This service reads JWT configuration values from IConfiguration: +/// - "Jwt:Key" (required, store in dotnet user-secrets) +/// - "Jwt:Issuer" +/// - "Jwt:Audience" +/// - "Jwt:ExpirationMinutes" +/// +public class JwtProvider : IJwtProvider +{ + /// + /// Configuration used to read JWT settings + /// + private readonly JwtSettings settings; + + /// + /// Creates a new instance of . + /// + /// Application configuration providing JWT settings + public JwtProvider(IOptions settings) + => this.settings = settings.Value; + + /// + /// Generates a signed JWT access token for the specified user + /// + /// The user for whom the token is generated. The token will include the user's Id, Email and Name claims + /// A signed JWT as a string + /// Thrown when the configuration key "Jwt:Key" is not present + /// May be thrown if "Jwt:ExpirationMinutes" cannot be parsed as a double + public string Generate(User user) + { + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Email, user.Email), + new Claim(ClaimTypes.Name, user.Name) + }; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(settings.Key)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + settings.Issuer, + settings.Audience, + claims, + expires: DateTime.UtcNow.AddMinutes(settings.ExpirationMinutes), + signingCredentials: credentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + /// + /// Generates a cryptographically secure random refresh token encoded as Base64 + /// + /// A Base64-encoded random token + public string GenerateRefreshToken() + { + var randomNumber = new byte[64]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(randomNumber); + return Convert.ToBase64String(randomNumber); + } +} \ No newline at end of file diff --git a/src/Infrastructure/Auth/JwtSettings.cs b/src/Infrastructure/Auth/JwtSettings.cs new file mode 100644 index 0000000..88425ad --- /dev/null +++ b/src/Infrastructure/Auth/JwtSettings.cs @@ -0,0 +1,11 @@ +namespace KontoApi.Infrastructure.Auth; + +public class JwtSettings +{ + public const string SectionName = "Jwt"; + + public string Key { get; init; } = "paste-your-ultra-super-secret-key-here"; + public string Issuer { get; init; } = "DefaultIssuer"; + public string Audience { get; init; } = "DefaultAudience"; + public int ExpirationMinutes { get; init; } = 60; +} \ No newline at end of file diff --git a/src/Infrastructure/CategorySeeder.cs b/src/Infrastructure/CategorySeeder.cs new file mode 100644 index 0000000..71468fa --- /dev/null +++ b/src/Infrastructure/CategorySeeder.cs @@ -0,0 +1,34 @@ +using KontoApi.Domain; +using KontoApi.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace KontoApi.Infrastructure; + +public static class CategorySeeder +{ + private static readonly string[] defaultNames = + [ + "rent", + "taxes", + "car", + "education", + "groceries", + "restaurants", + "beauty", + "sport", + "clothes", + "cash" + ]; + + public static async Task SeedAsync(KontoDbContext context, CancellationToken cancellationToken = default) + { + foreach (var name in defaultNames) + { + var exists = await context.Categories.AnyAsync(c => c.Name == name, cancellationToken); + if (!exists) + context.Categories.Add(new(name)); + } + + await context.SaveChangesAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/src/Infrastructure/DependencyInjection.cs b/src/Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..3b0328a --- /dev/null +++ b/src/Infrastructure/DependencyInjection.cs @@ -0,0 +1,46 @@ +using KontoApi.Application.Common.Interfaces; +using KontoApi.Infrastructure.Auth; +using KontoApi.Infrastructure.Persistence; +using KontoApi.Infrastructure.Persistence.Repositories; +using KontoApi.Infrastructure.Repositories; +using KontoApi.Infrastructure.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace KontoApi.Infrastructure; + +public static class DependencyInjection +{ + public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + var connectionString = configuration.GetConnectionString("DefaultConnection"); + if (string.IsNullOrWhiteSpace(connectionString)) + { + // When running integration tests without a real DB, use InMemory provider as fallback + services.AddDbContext(options => + options.UseInMemoryDatabase("KontoApi_TestDb")); + } + else + { + services.AddDbContext(options => + options.UseNpgsql(connectionString)); + } + + services.AddScoped(provider => + provider.GetRequiredService()); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + + services.Configure(configuration.GetSection("Jwt")); + services.AddSingleton(); + + return services; + } +} \ No newline at end of file diff --git a/src/Infrastructure/KontoApi.Infrastructure.csproj b/src/Infrastructure/KontoApi.Infrastructure.csproj new file mode 100644 index 0000000..9b05428 --- /dev/null +++ b/src/Infrastructure/KontoApi.Infrastructure.csproj @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + net10.0;net8.0 + enable + enable + + + diff --git a/src/Infrastructure/Migrations/20251217042156_InitialCreate.Designer.cs b/src/Infrastructure/Migrations/20251217042156_InitialCreate.Designer.cs new file mode 100644 index 0000000..6a32c48 --- /dev/null +++ b/src/Infrastructure/Migrations/20251217042156_InitialCreate.Designer.cs @@ -0,0 +1,255 @@ +// +using System; +using KontoApi.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Microsoft.EntityFrameworkCore.Metadata; + +#nullable disable + +namespace KontoApi.Infrastructure.Migrations +{ + [DbContext(typeof(KontoDbContext))] + [Migration("20251217042156_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("KontoApi.Domain.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("KontoApi.Domain.Budget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("Budgets"); + }); + + modelBuilder.Entity("KontoApi.Domain.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("KontoApi.Domain.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BudgetId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("BudgetId"); + + b.HasIndex("CategoryId"); + + b.ToTable("Transactions"); + }); + + modelBuilder.Entity("KontoApi.Domain.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("HashedPassword") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("KontoApi.Domain.Account", b => + { + b.HasOne("KontoApi.Domain.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("KontoApi.Domain.Budget", b => + { + b.HasOne("KontoApi.Domain.Account", null) + .WithMany("Budgets") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade); + + b.OwnsOne("KontoApi.Domain.Money", "CurrentBalance", b1 => + { + b1.Property("BudgetId") + .HasColumnType("uuid"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("CurrentBalanceCurrency"); + + b1.Property("Value") + .HasColumnType("decimal(18,2)") + .HasColumnName("CurrentBalanceAmount"); + + b1.HasKey("BudgetId"); + + b1.ToTable("Budgets"); + + b1.WithOwner() + .HasForeignKey("BudgetId"); + }); + + b.Navigation("CurrentBalance") + .IsRequired(); + }); + + modelBuilder.Entity("KontoApi.Domain.Transaction", b => + { + b.HasOne("KontoApi.Domain.Budget", null) + .WithMany("Transactions") + .HasForeignKey("BudgetId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("KontoApi.Domain.Category", "TransactionCategory") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("KontoApi.Domain.Money", "Amount", b1 => + { + b1.Property("TransactionId") + .HasColumnType("uuid"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("Currency"); + + b1.Property("Value") + .HasColumnType("decimal(18,2)") + .HasColumnName("Amount"); + + b1.HasKey("TransactionId"); + + b1.ToTable("Transactions"); + + b1.WithOwner() + .HasForeignKey("TransactionId"); + }); + + b.Navigation("Amount") + .IsRequired(); + + b.Navigation("TransactionCategory"); + }); + + modelBuilder.Entity("KontoApi.Domain.Account", b => + { + b.Navigation("Budgets"); + }); + + modelBuilder.Entity("KontoApi.Domain.Budget", b => + { + b.Navigation("Transactions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/Migrations/20251217042156_InitialCreate.cs b/src/Infrastructure/Migrations/20251217042156_InitialCreate.cs new file mode 100644 index 0000000..6ae2365 --- /dev/null +++ b/src/Infrastructure/Migrations/20251217042156_InitialCreate.cs @@ -0,0 +1,159 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace KontoApi.Infrastructure.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Categories", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(80)", maxLength: 80, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Categories", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Email = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + HashedPassword = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Accounts", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Accounts", x => x.Id); + table.ForeignKey( + name: "FK_Accounts_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Budgets", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + CurrentBalanceAmount = table.Column(type: "numeric(18,2)", nullable: false), + CurrentBalanceCurrency = table.Column(type: "character varying(3)", maxLength: 3, nullable: false), + AccountId = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Budgets", x => x.Id); + table.ForeignKey( + name: "FK_Budgets_Accounts_AccountId", + column: x => x.AccountId, + principalTable: "Accounts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Transactions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Type = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Amount = table.Column(type: "numeric(18,2)", nullable: false), + Currency = table.Column(type: "character varying(3)", maxLength: 3, nullable: false), + CategoryId = table.Column(type: "uuid", nullable: false), + Date = table.Column(type: "timestamp with time zone", nullable: false), + Description = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + BudgetId = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Transactions", x => x.Id); + table.ForeignKey( + name: "FK_Transactions_Budgets_BudgetId", + column: x => x.BudgetId, + principalTable: "Budgets", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Transactions_Categories_CategoryId", + column: x => x.CategoryId, + principalTable: "Categories", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_Accounts_UserId", + table: "Accounts", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_Budgets_AccountId", + table: "Budgets", + column: "AccountId"); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_BudgetId", + table: "Transactions", + column: "BudgetId"); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_CategoryId", + table: "Transactions", + column: "CategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_Users_Email", + table: "Users", + column: "Email", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Transactions"); + + migrationBuilder.DropTable( + name: "Budgets"); + + migrationBuilder.DropTable( + name: "Categories"); + + migrationBuilder.DropTable( + name: "Accounts"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/src/Infrastructure/Migrations/KontoDbContextModelSnapshot.cs b/src/Infrastructure/Migrations/KontoDbContextModelSnapshot.cs new file mode 100644 index 0000000..b133e67 --- /dev/null +++ b/src/Infrastructure/Migrations/KontoDbContextModelSnapshot.cs @@ -0,0 +1,254 @@ +// +using System; +using KontoApi.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Microsoft.EntityFrameworkCore.Metadata; + + + +#nullable disable + +namespace KontoApi.Infrastructure.Migrations +{ + [DbContext(typeof(KontoDbContext))] + partial class KontoDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("KontoApi.Domain.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey(); + + b.HasIndex("UserId"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("KontoApi.Domain.Budget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("Budgets"); + }); + + modelBuilder.Entity("KontoApi.Domain.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("KontoApi.Domain.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BudgetId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("BudgetId"); + + b.HasIndex("CategoryId"); + + b.ToTable("Transactions"); + }); + + modelBuilder.Entity("KontoApi.Domain.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("HashedPassword") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("KontoApi.Domain.Account", b => + { + b.HasOne("KontoApi.Domain.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("KontoApi.Domain.Budget", b => + { + b.HasOne("KontoApi.Domain.Account", null) + .WithMany("Budgets") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade); + + b.OwnsOne("KontoApi.Domain.Money", "CurrentBalance", b1 => + { + b1.Property("BudgetId") + .HasColumnType("uuid"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("CurrentBalanceCurrency"); + + b1.Property("Value") + .HasColumnType("decimal(18,2)") + .HasColumnName("CurrentBalanceAmount"); + + b1.HasKey("BudgetId"); + + b1.ToTable("Budgets"); + + b1.WithOwner() + .HasForeignKey("BudgetId"); + }); + + b.Navigation("CurrentBalance") + .IsRequired(); + }); + + modelBuilder.Entity("KontoApi.Domain.Transaction", b => + { + b.HasOne("KontoApi.Domain.Budget", null) + .WithMany("Transactions") + .HasForeignKey("BudgetId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("KontoApi.Domain.Category", "TransactionCategory") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("KontoApi.Domain.Money", "Amount", b1 => + { + b1.Property("TransactionId") + .HasColumnType("uuid"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("Currency"); + + b1.Property("Value") + .HasColumnType("decimal(18,2)") + .HasColumnName("Amount"); + + b1.HasKey("TransactionId"); + + b1.ToTable("Transactions"); + + b1.WithOwner() + .HasForeignKey("TransactionId"); + }); + + b.Navigation("Amount") + .IsRequired(); + + b.Navigation("TransactionCategory"); + }); + + modelBuilder.Entity("KontoApi.Domain.Account", b => + { + b.Navigation("Budgets"); + }); + + modelBuilder.Entity("KontoApi.Domain.Budget", b => + { + b.Navigation("Transactions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/Persistence/Configurations/AccountConfiguration.cs b/src/Infrastructure/Persistence/Configurations/AccountConfiguration.cs new file mode 100644 index 0000000..c4b9bbf --- /dev/null +++ b/src/Infrastructure/Persistence/Configurations/AccountConfiguration.cs @@ -0,0 +1,30 @@ +using KontoApi.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace KontoApi.Infrastructure.Persistence.Configurations; + +public class AccountConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(a => a.Id); + + builder.HasOne(a => a.User) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + builder.Property(a => a.Name) + .HasMaxLength(100) + .IsRequired(); + + builder.HasMany(a => a.Budgets) + .WithOne() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade); + + builder.Metadata.FindNavigation(nameof(Account.Budgets))? + .SetPropertyAccessMode(PropertyAccessMode.Field); + } +} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Configurations/BudgetConfiguration.cs b/src/Infrastructure/Persistence/Configurations/BudgetConfiguration.cs new file mode 100644 index 0000000..f65aa11 --- /dev/null +++ b/src/Infrastructure/Persistence/Configurations/BudgetConfiguration.cs @@ -0,0 +1,36 @@ +using KontoApi.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace KontoApi.Infrastructure.Persistence.Configurations; + +public class BudgetConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(b => b.Id); + + builder.Property(b => b.Name) + .HasMaxLength(100) + .IsRequired(); + + builder.OwnsOne(b => b.CurrentBalance, money => + { + money.Property(m => m.Value) + .HasColumnName("CurrentBalanceAmount") + .HasColumnType("decimal(18,2)"); + + money.Property(m => m.Currency) + .HasColumnName("CurrentBalanceCurrency") + .HasMaxLength(3); + }); + + builder.HasMany(b => b.Transactions) + .WithOne() + .HasForeignKey("BudgetId") + .OnDelete(DeleteBehavior.Cascade); + + builder.Metadata.FindNavigation(nameof(Budget.Transactions))? + .SetPropertyAccessMode(PropertyAccessMode.Field); + } +} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Configurations/CategoryConfiguration.cs b/src/Infrastructure/Persistence/Configurations/CategoryConfiguration.cs new file mode 100644 index 0000000..9243d35 --- /dev/null +++ b/src/Infrastructure/Persistence/Configurations/CategoryConfiguration.cs @@ -0,0 +1,17 @@ +using KontoApi.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace KontoApi.Infrastructure.Persistence.Configurations; + +public class CategoryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(c => c.Id); + + builder.Property(c => c.Name) + .HasMaxLength(80) + .IsRequired(); + } +} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Configurations/TransactionConfiguration.cs b/src/Infrastructure/Persistence/Configurations/TransactionConfiguration.cs new file mode 100644 index 0000000..6392359 --- /dev/null +++ b/src/Infrastructure/Persistence/Configurations/TransactionConfiguration.cs @@ -0,0 +1,36 @@ +using KontoApi.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace KontoApi.Infrastructure.Persistence.Configurations; + +public class TransactionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(t => t.Id); + + builder.OwnsOne(t => t.Amount, money => + { + money.Property(m => m.Value) + .HasColumnName("Amount") + .HasColumnType("decimal(18,2)"); + + money.Property(m => m.Currency) + .HasColumnName("Currency") + .HasMaxLength(3); + }); + + builder.Property(t => t.Type) + .HasConversion() + .HasMaxLength(20); + + builder.HasOne(t => t.TransactionCategory) + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + builder.Property(t => t.Description) + .HasMaxLength(500); + } +} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Configurations/UserConfiguration.cs b/src/Infrastructure/Persistence/Configurations/UserConfiguration.cs new file mode 100644 index 0000000..caefb97 --- /dev/null +++ b/src/Infrastructure/Persistence/Configurations/UserConfiguration.cs @@ -0,0 +1,27 @@ +using KontoApi.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace KontoApi.Infrastructure.Persistence.Configurations; + +public class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(u => u.Id); + + builder.Property(u => u.Name) + .HasMaxLength(100) + .IsRequired(); + + builder.Property(u => u.Email) + .HasMaxLength(255) + .IsRequired(); + + builder.HasIndex(u => u.Email) + .IsUnique(); + + builder.Property(u => u.HashedPassword) + .IsRequired(); + } +} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/KontoDbContext.cs b/src/Infrastructure/Persistence/KontoDbContext.cs new file mode 100644 index 0000000..68f8556 --- /dev/null +++ b/src/Infrastructure/Persistence/KontoDbContext.cs @@ -0,0 +1,21 @@ +using System.Reflection; +using KontoApi.Application.Common.Interfaces; +using KontoApi.Domain; +using Microsoft.EntityFrameworkCore; + +namespace KontoApi.Infrastructure.Persistence; + +public class KontoDbContext(DbContextOptions options) : DbContext(options), IApplicationDbContext +{ + public DbSet Accounts { get; set; } + public DbSet Budgets { get; set; } + public DbSet Categories { get; set; } + public DbSet Transactions { get; set; } + public DbSet Users { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); + base.OnModelCreating(modelBuilder); + } +} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Repositories/AccountRepository.cs b/src/Infrastructure/Persistence/Repositories/AccountRepository.cs new file mode 100644 index 0000000..a6d0d7c --- /dev/null +++ b/src/Infrastructure/Persistence/Repositories/AccountRepository.cs @@ -0,0 +1,89 @@ +using KontoApi.Application.Common.Interfaces; +using KontoApi.Domain; +using KontoApi.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace KontoApi.Infrastructure.Repositories; + +public class AccountRepository(KontoDbContext dbContext) : IAccountRepository +{ + public async Task AddAsync(Account account, CancellationToken cancellationToken = default) + { + await dbContext.Accounts.AddAsync(account, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task UpdateAsync(Account account, CancellationToken cancellationToken = default) + { + // Load the tracked entity and apply changes to avoid concurrency issues with InMemory provider + var existing = await dbContext.Accounts + .Include(a => a.Budgets) + .FirstOrDefaultAsync(a => a.Id == account.Id, cancellationToken); + + if (existing == null) + throw new InvalidOperationException($"Account {account.Id} does not exist in the store."); + + // Update scalar properties + existing.Rename(account.Name); + + // Sync budgets: add new, remove missing + var incomingBudgetIds = account.Budgets.Select(b => b.Id).ToHashSet(); + var existingBudgetIds = existing.Budgets.Select(b => b.Id).ToList(); + + // Remove budgets that were removed + foreach (var eb in existingBudgetIds) + { + if (!incomingBudgetIds.Contains(eb)) + { + existing.RemoveBudget(eb); + } + } + + // Add budgets that are new + foreach (var budget in account.Budgets) + { + if (!existing.Budgets.Any(b => b.Id == budget.Id)) + { + existing.AddBudget(budget); + } + } + + await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) + { + var accountStub = await dbContext.Accounts.FindAsync(id, cancellationToken); + + if (accountStub != null) + { + dbContext.Accounts.Remove(accountStub); + await dbContext.SaveChangesAsync(cancellationToken); + } + } + + public async Task AddBudgetAsync(Guid accountId, Budget budget, CancellationToken cancellationToken = default) + { + var account = await dbContext.Accounts.FindAsync(accountId, cancellationToken); + if (account == null) + throw new InvalidOperationException($"Account {accountId} does not exist in the store."); + + await dbContext.Budgets.AddAsync(budget, cancellationToken); + // set shadow FK AccountId + dbContext.Entry(budget).Property("AccountId").CurrentValue = accountId; + await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + => await dbContext.Accounts + .Include(a => a.User) + .Include(a => a.Budgets) + .FirstOrDefaultAsync(a => a.Id == id, cancellationToken); + + public async Task> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default) + => await dbContext.Accounts + .Include(a => a.User) + .Include(a => a.Budgets) + .Where(a => a.User.Id == userId) + .ToListAsync(cancellationToken); +} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Repositories/BudgetRepository.cs b/src/Infrastructure/Persistence/Repositories/BudgetRepository.cs new file mode 100644 index 0000000..ca0c1e0 --- /dev/null +++ b/src/Infrastructure/Persistence/Repositories/BudgetRepository.cs @@ -0,0 +1,60 @@ +using KontoApi.Application.Common.Interfaces; +using KontoApi.Domain; +using Microsoft.EntityFrameworkCore; + +namespace KontoApi.Infrastructure.Persistence.Repositories; + +public class BudgetRepository(KontoDbContext dbContext) : IBudgetRepository +{ + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken) + { + return await dbContext.Budgets + .Include(b => b.Transactions) + .Include(b => b.Transactions).ThenInclude(t => t.TransactionCategory) + .FirstOrDefaultAsync(b => b.Id == id, cancellationToken); + } + + public async Task AddAsync(Budget budget, CancellationToken cancellationToken) + { + await dbContext.Budgets.AddAsync(budget, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task UpdateAsync(Budget budget, CancellationToken cancellationToken) + { + dbContext.Budgets.Update(budget); + await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task AddTransactionAsync(Guid budgetId, Transaction transaction, CancellationToken cancellationToken = default) + { + var budget = await dbContext.Budgets.FindAsync(budgetId, cancellationToken); + if (budget == null) + throw new InvalidOperationException($"Budget {budgetId} does not exist in the store."); + + await dbContext.Transactions.AddAsync(transaction, cancellationToken); + dbContext.Entry(transaction).Property("BudgetId").CurrentValue = budgetId; + await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task DeleteAsync(Guid id, CancellationToken cancellationToken) + { + var budgetStub = await dbContext.Budgets.FindAsync([id], cancellationToken); + + if (budgetStub != null) + { + dbContext.Budgets.Remove(budgetStub); + await dbContext.SaveChangesAsync(cancellationToken); + } + } + + public async Task DeleteTransactionAsync(Guid budgetId, Guid transactionId, CancellationToken cancellationToken = default) + { + var tx = await dbContext.Transactions.FindAsync(transactionId, cancellationToken); + if (tx == null) + throw new InvalidOperationException($"Transaction {transactionId} not found"); + + dbContext.Transactions.Remove(tx); + await dbContext.SaveChangesAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Repositories/CategoryRepository.cs b/src/Infrastructure/Persistence/Repositories/CategoryRepository.cs new file mode 100644 index 0000000..5c02f73 --- /dev/null +++ b/src/Infrastructure/Persistence/Repositories/CategoryRepository.cs @@ -0,0 +1,62 @@ +using KontoApi.Application.Common.Interfaces; +using KontoApi.Domain; +using Microsoft.EntityFrameworkCore; + +namespace KontoApi.Infrastructure.Persistence.Repositories; + +public class CategoryRepository(KontoDbContext context) : ICategoryRepository +{ + public async Task> GetAllAsync(CancellationToken cancellationToken) => + await context.Categories.OrderBy(c => c.Name).ToListAsync(cancellationToken); + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken) => + await context.Categories.FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + + public async Task AddAsync(Category category, CancellationToken cancellationToken) + { + await context.Categories.AddAsync(category, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task UpdateAsync(Category category, CancellationToken cancellationToken) + { + context.Categories.Update(category); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task DeleteAsync(Guid id, CancellationToken cancellationToken) + { + var entity = await context.Categories + .FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + if (entity == null) + return; + + context.Categories.Remove(entity); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task ExistsByNameAsync(string name, CancellationToken cancellationToken) + { + var normalizedName = name.Trim(); + return await context.Categories + .AnyAsync(c => c.Name == normalizedName, cancellationToken); + } + + public async Task GetByNameAsync(string name, CancellationToken cancellationToken) + { + var normalizedName = name.Trim(); + var category = await context.Categories + .FirstOrDefaultAsync(c => c.Name == normalizedName, cancellationToken); + + if (category != null) + { + return category; + } + + var newCategory = new Category(normalizedName); + await context.Categories.AddAsync(newCategory, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + + return newCategory; + } +} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Repositories/UserRepository.cs b/src/Infrastructure/Persistence/Repositories/UserRepository.cs new file mode 100644 index 0000000..8237cf4 --- /dev/null +++ b/src/Infrastructure/Persistence/Repositories/UserRepository.cs @@ -0,0 +1,39 @@ +using KontoApi.Application.Common.Interfaces; +using KontoApi.Domain; +using Microsoft.EntityFrameworkCore; + +namespace KontoApi.Infrastructure.Persistence.Repositories; + +public class UserRepository(KontoDbContext dbContext) : IUserRepository +{ + public async Task AddAsync(User user, CancellationToken ct) + { + await dbContext.Users.AddAsync(user, ct); + await dbContext.SaveChangesAsync(ct); + } + + public async Task GetByEmailAsync(string email, CancellationToken ct) + => await dbContext.Users + .FirstOrDefaultAsync(u => u.Email == email, ct); + + public async Task GetByIdAsync(Guid id, CancellationToken ct) + => await dbContext.Users + .FirstOrDefaultAsync(u => u.Id == id, ct); + + public async Task UpdateAsync(User user, CancellationToken ct) + { + dbContext.Users.Update(user); + await dbContext.SaveChangesAsync(ct); + } + + public async Task DeleteAsync(Guid id, CancellationToken ct) + { + var userStub = await dbContext.Users.FindAsync([id], ct); + + if (userStub != null) + { + dbContext.Users.Remove(userStub); + await dbContext.SaveChangesAsync(ct); + } + } +} \ No newline at end of file diff --git a/src/Infrastructure/Services/PasswordHasher.cs b/src/Infrastructure/Services/PasswordHasher.cs new file mode 100644 index 0000000..36ead6b --- /dev/null +++ b/src/Infrastructure/Services/PasswordHasher.cs @@ -0,0 +1,14 @@ +using KontoApi.Application.Common.Interfaces; + +namespace KontoApi.Infrastructure.Services; + +public class PasswordHasher : IPasswordHasher +{ + private const int WorkFactor = 12; + + public string Hash(string password) + => BCrypt.Net.BCrypt.HashPassword(password, WorkFactor); + + public bool Verify(string password, string hash) + => BCrypt.Net.BCrypt.Verify(password, hash); +} diff --git a/src/Infrastructure/Services/StatementParser.cs b/src/Infrastructure/Services/StatementParser.cs new file mode 100644 index 0000000..3dbe157 --- /dev/null +++ b/src/Infrastructure/Services/StatementParser.cs @@ -0,0 +1,157 @@ +using KontoApi.Application.Common.Interfaces; +using KontoApi.Application.Common.Models; +using KontoApi.Domain; +using UglyToad.PdfPig; +using UglyToad.PdfPig.Content; +using System.Globalization; +using System.Linq.Expressions; +using System.Text.RegularExpressions; +using KontoApi.Application.Features.Categories.Queries.GetCategoryById; + +namespace KontoApi.Infrastructure.Services; + +public partial class StatementParser(ICategoryRepository categoryRepository) : IStatementParser +{ + private static readonly Regex DateRegex = MyDateRegex(); + private static readonly Regex AmountRegex = MyAmountRegex(); + private static readonly Regex CurrencyRegex = MyCurrencyRegex(); + + public bool Supports(string fileName) + => fileName.EndsWith(".csv") || fileName.EndsWith(".pdf"); + + public async Task> ParseAsync(Stream fileStream, CancellationToken cancellationToken) + { + var transactions = new List(); + + using var pdf = PdfDocument.Open(fileStream); + foreach (var page in pdf.GetPages()) + { + var lines = GetLines(page); + + foreach (var line in lines) + { + var transaction = await ParseLineAsync(line, cancellationToken); + if (transaction.Amount != 0) + { + transactions.Add(transaction); + } + } + } + return transactions; + } + + private static List GetLines(Page page) + { + var words = page.GetWords().ToList(); + if (words.Count == 0) return []; + + var lines = new List>(); + var sortedWords = words.OrderByDescending(w => w.BoundingBox.Bottom).ToList(); + + var currentLine = new List { sortedWords[0] }; + lines.Add(currentLine); + + var lastY = sortedWords[0].BoundingBox.Bottom; + + for (var i = 1; i < sortedWords.Count; i++) + { + var word = sortedWords[i]; + + if (Math.Abs(word.BoundingBox.Bottom - lastY) < 5) + currentLine.Add(word); + else + { + currentLine = [word]; + lines.Add(currentLine); + lastY = word.BoundingBox.Bottom; + } + } + + return lines + .Select(line => string + .Join(" ", line.OrderBy(w => w.BoundingBox.Left) + .Select(w => w.Text))) + .ToList(); + } + + private async Task ParseLineAsync(string line, CancellationToken cancellationToken) + { + const string formatData = "dd.MM.yyyy"; + var amountExist = false; + var lineWithInfo = false; + var parts = line.Split(' '); + var dateTime = DateTime.MinValue; + decimal amount = 0; + var currency = "RUB"; + var description = ""; + var transactionType = TransactionType.Income; + var ruCulture = CultureInfo.GetCultureInfo("ru-RU"); + + + foreach (var part in parts) + { + if (Regex.IsMatch(part, DateRegex.ToString())) + { + dateTime = DateTime.ParseExact(part, formatData, CultureInfo.InvariantCulture); + lineWithInfo = true; + } + + if (lineWithInfo == false) + break; + + var amountMatches = MyRegex().Matches(line); + foreach (Match match in amountMatches) + { + var rawAmount = match.Value; + var cleanAmount = rawAmount.Replace(" ", "").Replace("\u00A0", ""); + + if (!decimal.TryParse(cleanAmount, NumberStyles.Number, ruCulture, out var parsedAmount)) continue; + amount = parsedAmount; + break; + } + + if (Regex.IsMatch(part, CurrencyRegex.ToString())) + { + currency = part switch + { + "\u20bd" => "RUB", + "\u0024" => "USD", + "\u20AC" => "EUR", + _ => currency + }; + } + + if (Regex.IsMatch(part, @"\p{L}")) + { + description += $" {part}"; + } + + if (line.Contains('+') && !description.Contains("Перевод")) transactionType = TransactionType.Income; + else if (description.Contains("Перевод")) transactionType = TransactionType.Transfer; + else transactionType = TransactionType.Expense; + } + + var category = await categoryRepository.GetByNameAsync(description, cancellationToken); + var categoryId = category.Id; + + var transaction = new ParsedTransaction( + dateTime, + amount, + currency, + transactionType, + description, + categoryId + ); + + return transaction; + } + + [GeneratedRegex(@"^(0[1-9]|[12][0-9]|3[01])\.(0[1-9]|1[0-2])\.\d{4}$", RegexOptions.Compiled)] + private static partial Regex MyDateRegex(); + [GeneratedRegex(@"^-?\d+([.,]\d+)?$", RegexOptions.Compiled)] + private static partial Regex MyAmountRegex(); + [GeneratedRegex(@"^-?\d+([.,]\d+)?\s*[\u20BD\u0024\u20AC]$", RegexOptions.Compiled)] + private static partial Regex MyCurrencyRegex(); + [GeneratedRegex(@"(?<=\s|^)[-+]?[\d\s]*\d,\d{2}(?=\s|$)")] + private static partial Regex MyRegex(); +} diff --git a/src/src.sln b/src/src.sln new file mode 100644 index 0000000..6318974 --- /dev/null +++ b/src/src.sln @@ -0,0 +1,42 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KontoApi.Api", "Api\KontoApi.Api.csproj", "{6051BE9D-E6C2-23BD-724D-F1EFA340932B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KontoApi.Application", "Application\KontoApi.Application.csproj", "{9B95E6EF-6CEE-8AAE-1783-F1D90F511087}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KontoApi.Infrastructure", "Infrastructure\KontoApi.Infrastructure.csproj", "{A8DFCC35-39E0-7782-72F1-FF98675821C9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KontoApi.Domain", "Domain\KontoApi.Domain.csproj", "{1B2DE7A3-51E6-E721-3829-B57D7BADC5DC}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6051BE9D-E6C2-23BD-724D-F1EFA340932B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6051BE9D-E6C2-23BD-724D-F1EFA340932B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6051BE9D-E6C2-23BD-724D-F1EFA340932B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6051BE9D-E6C2-23BD-724D-F1EFA340932B}.Release|Any CPU.Build.0 = Release|Any CPU + {9B95E6EF-6CEE-8AAE-1783-F1D90F511087}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9B95E6EF-6CEE-8AAE-1783-F1D90F511087}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9B95E6EF-6CEE-8AAE-1783-F1D90F511087}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9B95E6EF-6CEE-8AAE-1783-F1D90F511087}.Release|Any CPU.Build.0 = Release|Any CPU + {A8DFCC35-39E0-7782-72F1-FF98675821C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A8DFCC35-39E0-7782-72F1-FF98675821C9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A8DFCC35-39E0-7782-72F1-FF98675821C9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A8DFCC35-39E0-7782-72F1-FF98675821C9}.Release|Any CPU.Build.0 = Release|Any CPU + {1B2DE7A3-51E6-E721-3829-B57D7BADC5DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1B2DE7A3-51E6-E721-3829-B57D7BADC5DC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1B2DE7A3-51E6-E721-3829-B57D7BADC5DC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1B2DE7A3-51E6-E721-3829-B57D7BADC5DC}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {6027DCD0-1097-40A4-A2B0-BA11D1FC29A0} + EndGlobalSection +EndGlobal diff --git a/tests/KontoApi.IntegrationTestsNet8/AccountTests/AccountApiIntegrationTests.cs b/tests/KontoApi.IntegrationTestsNet8/AccountTests/AccountApiIntegrationTests.cs new file mode 100644 index 0000000..649d4fd --- /dev/null +++ b/tests/KontoApi.IntegrationTestsNet8/AccountTests/AccountApiIntegrationTests.cs @@ -0,0 +1,125 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.EntityFrameworkCore; +using System.Linq; +using KontoApi.Infrastructure.Persistence; +using Xunit; +using KontoApi.Application.Features.Accounts.Queries.GetAccountOverview; +using KontoApi.TestEntryPoint; + +namespace KontoApi.IntegrationTestsNet8.AccountTests; + +public class AccountApiIntegrationTests : IClassFixture> +{ + private readonly HttpClient client; + + public AccountApiIntegrationTests(WebApplicationFactory factory) + { + // Use a unique in-memory database per test class to avoid state leakage between tests + var uniqueFactory = factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions)); + if (descriptor != null) + services.Remove(descriptor); + services.AddDbContext(options => + options.UseInMemoryDatabase($"KontoApi_TestDb_{Guid.NewGuid()}")); + + // Build a temporary provider to seed the test user into this new in-memory database + var sp = services.BuildServiceProvider(); + using (var seedScope = sp.CreateScope()) + { + var db = seedScope.ServiceProvider.GetRequiredService(); + var hasher = seedScope.ServiceProvider.GetRequiredService(); + var testEmail = "testuser@example.com"; + if (!db.Users.Any(u => u.Email == testEmail)) + { + var user = new KontoApi.Domain.User("Test User", testEmail, hasher.Hash("Test123!")); + db.Users.Add(user); + db.SaveChanges(); + } + } + }); + }); + + client = uniqueFactory.CreateClient(); + + // After host start, seed test user into the server's service provider to ensure TestAuthenticationHandler can find it + using (var scope = uniqueFactory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var hasher = scope.ServiceProvider.GetRequiredService(); + var testEmail = "testuser@example.com"; + if (!db.Users.Any(u => u.Email == testEmail)) + { + var user = new KontoApi.Domain.User("Test User", testEmail, hasher.Hash("Test123!")); + db.Users.Add(user); + db.SaveChanges(); + } + } + } + + [Fact] + public async Task CreateAccount_WithInvalidData_ReturnsBadRequest() + { + var request = new { Name = "" }; + var response = await client.PostAsJsonAsync("/api/account", request); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetAccount_WhenNotExists_ReturnsNotFound() + { + // Ensure any pre-existing account is removed (tests share an in-memory DB). + await client.DeleteAsync("/api/account"); + + var response = await client.GetAsync("/api/account"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task CreateAccount_ReturnsCreatedAndAccountId() + { + var request = new { Name = "Test account" }; + var response = await client.PostAsJsonAsync("/api/account", request); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + Assert.NotEqual(Guid.Empty, result.AccountId); + } + + [Fact] + public async Task GetAccount_ReturnsOkAndAccountData() + { + var createRequest = new { Name = "Test account" }; + var createResponse = await client.PostAsJsonAsync("/api/account", createRequest); + createResponse.EnsureSuccessStatusCode(); + + var getResponse = await client.GetAsync("/api/account"); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var account = await getResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(account); + // The API currently returns the account owner's name in the Name field + Assert.Equal("Test User", account.Name); + } + + [Fact] + public async Task DeleteAccount_ThenGet_ReturnsNotFound() + { + var createRequest = new { Name = "ToDelete" }; + var createResponse = await client.PostAsJsonAsync("/api/account", createRequest); + createResponse.EnsureSuccessStatusCode(); + var deleteResponse = await client.DeleteAsync("/api/account"); + Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode); + var getResponse = await client.GetAsync("/api/account"); + Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode); + } + + public class CreateAccountResponse + { + public Guid AccountId { get; set; } + } +} \ No newline at end of file diff --git a/tests/KontoApi.IntegrationTestsNet8/AuthController/AuthIntegrationTests.cs b/tests/KontoApi.IntegrationTestsNet8/AuthController/AuthIntegrationTests.cs new file mode 100644 index 0000000..da64a23 --- /dev/null +++ b/tests/KontoApi.IntegrationTestsNet8/AuthController/AuthIntegrationTests.cs @@ -0,0 +1,90 @@ +using KontoApi.TestEntryPoint; +using System.Net; +using System.Net.Http.Json; +using System.Threading.Tasks; +using Xunit; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace KontoApi.IntegrationTestsNet8.AuthController; + +public class AuthIntegrationTests : IClassFixture> +{ + private readonly HttpClient client; + + public AuthIntegrationTests(WebApplicationFactory factory) + { + client = factory.CreateClient(); + } + + [Fact] + public async Task Login_ReturnsToken_OnValidCredentials() + { + var loginRequest = new { Email = "testuser@example.com", Password = "Test123!" }; + var response = await client.PostAsJsonAsync("/api/auth/login", loginRequest); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + Assert.False(string.IsNullOrEmpty(json?.Token)); + } + + [Fact] + public async Task Login_ReturnsUnauthorized_OnInvalidCredentials() + { + var loginRequest = new { Email = "wrong@example.com", Password = "WrongPass" }; + var response = await client.PostAsJsonAsync("/api/auth/login", loginRequest); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Login_WithEmptyEmail_ReturnsBadRequest() + { + var loginRequest = new { Email = "", Password = "Test123!" }; + var response = await client.PostAsJsonAsync("/api/auth/login", loginRequest); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Login_WithEmptyPassword_ReturnsBadRequest() + { + var loginRequest = new { Email = "testuser@example.com", Password = "" }; + var response = await client.PostAsJsonAsync("/api/auth/login", loginRequest); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Login_WithInvalidEmailFormat_ReturnsBadRequest() + { + var loginRequest = new { Email = "notanemail", Password = "Test123!" }; + var response = await client.PostAsJsonAsync("/api/auth/login", loginRequest); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Login_ErrorResponse_HasExpectedFormat() + { + var loginRequest = new { Email = "wrong@example.com", Password = "WrongPass" }; + var response = await client.PostAsJsonAsync("/api/auth/login", loginRequest); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + var error = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(error); + Assert.False(string.IsNullOrEmpty(error.Message)); + } + + [Fact] + public async Task ProtectedEndpoint_WithoutToken_Returns401() + { + var response = await client.GetAsync("/api/account"); + // Test environment uses test authentication handler which authenticates requests + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + + public class LoginResponse + { + public string Token { get; set; } + } + + public class ErrorResponse + { + public string Message { get; set; } + } +} diff --git a/tests/KontoApi.IntegrationTestsNet8/BudgetsController/BudgetsIntegrationTests.cs b/tests/KontoApi.IntegrationTestsNet8/BudgetsController/BudgetsIntegrationTests.cs new file mode 100644 index 0000000..109c135 --- /dev/null +++ b/tests/KontoApi.IntegrationTestsNet8/BudgetsController/BudgetsIntegrationTests.cs @@ -0,0 +1,157 @@ +using KontoApi.TestEntryPoint; +using System.Net; +using System.Net.Http.Json; +using System.Threading.Tasks; +using Xunit; +using Microsoft.AspNetCore.Mvc.Testing; +using System; + +namespace KontoApi.IntegrationTestsNet8.BudgetsController; + +public class BudgetsIntegrationTests : IClassFixture> +{ + private readonly HttpClient client; + + public BudgetsIntegrationTests(WebApplicationFactory factory) + { + client = factory.CreateClient(); + } + + [Fact] + public async Task CreateBudget_WithValidData_ReturnsCreatedAndBudgetId() + { + // create account to attach budget to + var accountResponse = await client.PostAsJsonAsync("/api/account", new { Name = "Budget owner" }); + accountResponse.EnsureSuccessStatusCode(); + var accountJson = await accountResponse.Content.ReadFromJsonAsync(); + Guid accountId; + if (accountJson.TryGetProperty("accountId", out var prop1)) + accountId = prop1.GetGuid(); + else if (accountJson.TryGetProperty("AccountId", out var prop2)) + accountId = prop2.GetGuid(); + else + accountId = Guid.Parse(accountJson.GetRawText().Trim('"')); + + var request = new { + AccountId = accountId, + Name = "Test budget", + InitialBalance = 1000m, + Currency = "USD" + }; + var response = await client.PostAsJsonAsync("/api/budgets", request); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + Assert.True(result.BudgetId != Guid.Empty); + } + + [Fact] + public async Task CreateBudget_WithInvalidData_ReturnsBadRequest() + { + var request = new { + AccountId = Guid.Empty, + Name = "", + InitialBalance = -1m, + Currency = "" + }; + var response = await client.PostAsJsonAsync("/api/budgets", request); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetBudget_NonExistent_ReturnsNotFound() + { + var response = await client.GetAsync($"/api/budgets/{Guid.NewGuid()}"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task CreateAndGetBudget_ReturnsCorrectData() + { + var accountResponse = await client.PostAsJsonAsync("/api/account", new { Name = "Budget owner" }); + accountResponse.EnsureSuccessStatusCode(); + var accountJson = await accountResponse.Content.ReadFromJsonAsync(); + Guid accountId; + if (accountJson.TryGetProperty("accountId", out var prop1)) + accountId = prop1.GetGuid(); + else if (accountJson.TryGetProperty("AccountId", out var prop2)) + accountId = prop2.GetGuid(); + else + accountId = Guid.Parse(accountJson.GetRawText().Trim('"')); + + var createRequest = new { + AccountId = accountId, + Name = "Budget1", + InitialBalance = 500m, + Currency = "USD" + }; + var createResponse = await client.PostAsJsonAsync("/api/budgets", createRequest); + createResponse.EnsureSuccessStatusCode(); + var result = await createResponse.Content.ReadFromJsonAsync(); + var getResponse = await client.GetAsync($"/api/budgets/{result.BudgetId}"); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + // Можно добавить десериализацию и проверку полей + } + + [Fact] + public async Task CreateAndDeleteBudget_ThenGet_ReturnsNotFound() + { + var accountResponse = await client.PostAsJsonAsync("/api/account", new { Name = "Budget owner" }); + accountResponse.EnsureSuccessStatusCode(); + var accountJson = await accountResponse.Content.ReadFromJsonAsync(); + Guid accountId; + if (accountJson.TryGetProperty("accountId", out var prop1)) + accountId = prop1.GetGuid(); + else if (accountJson.TryGetProperty("AccountId", out var prop2)) + accountId = prop2.GetGuid(); + else + accountId = Guid.Parse(accountJson.GetRawText().Trim('"')); + + var createRequest = new { + AccountId = accountId, + Name = "BudgetToDelete", + InitialBalance = 100m, + Currency = "USD" + }; + var createResponse = await client.PostAsJsonAsync("/api/budgets", createRequest); + createResponse.EnsureSuccessStatusCode(); + var result = await createResponse.Content.ReadFromJsonAsync(); + var deleteResponse = await client.DeleteAsync($"/api/budgets/{result.BudgetId}"); + Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode); + var getResponse = await client.GetAsync($"/api/budgets/{result.BudgetId}"); + Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode); + } + + [Fact] + public async Task CreateAndRenameBudget_ReturnsNoContent() + { + var accountResponse = await client.PostAsJsonAsync("/api/account", new { Name = "Budget owner" }); + accountResponse.EnsureSuccessStatusCode(); + var accountJson = await accountResponse.Content.ReadFromJsonAsync(); + Guid accountId; + if (accountJson.TryGetProperty("accountId", out var prop1)) + accountId = prop1.GetGuid(); + else if (accountJson.TryGetProperty("AccountId", out var prop2)) + accountId = prop2.GetGuid(); + else + accountId = Guid.Parse(accountJson.GetRawText().Trim('"')); + + var createRequest = new { + AccountId = accountId, + Name = "BudgetToRename", + InitialBalance = 200m, + Currency = "USD" + }; + var createResponse = await client.PostAsJsonAsync("/api/budgets", createRequest); + createResponse.EnsureSuccessStatusCode(); + var result = await createResponse.Content.ReadFromJsonAsync(); + var renameRequest = new { NewName = "RenamedBudget" }; + var renameResponse = await client.PatchAsJsonAsync($"/api/budgets/{result.BudgetId}/name", renameRequest); + Assert.Equal(HttpStatusCode.NoContent, renameResponse.StatusCode); + } + + public class CreateBudgetResponse + { + public Guid BudgetId { get; set; } + } +} diff --git a/tests/KontoApi.IntegrationTestsNet8/CategoriesController/CategoriesIntegrationTests.cs b/tests/KontoApi.IntegrationTestsNet8/CategoriesController/CategoriesIntegrationTests.cs new file mode 100644 index 0000000..3c215a5 --- /dev/null +++ b/tests/KontoApi.IntegrationTestsNet8/CategoriesController/CategoriesIntegrationTests.cs @@ -0,0 +1,90 @@ +using KontoApi.TestEntryPoint; +using System.Net; +using System.Net.Http.Json; +using System.Threading.Tasks; +using Xunit; +using Microsoft.AspNetCore.Mvc.Testing; +using System; + +namespace KontoApi.IntegrationTestsNet8.CategoriesController; + +public class CategoriesIntegrationTests : IClassFixture> +{ + private readonly HttpClient client; + + public CategoriesIntegrationTests(WebApplicationFactory factory) + { + client = factory.CreateClient(); + } + + [Fact] + public async Task GetCategories_ReturnsOk() + { + var response = await client.GetAsync("/api/categories"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task CreateCategory_WithValidData_ReturnsCreated() + { + var request = new { Name = "Test category" }; + var response = await client.PostAsJsonAsync("/api/categories", request); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + } + + [Fact] + public async Task CreateCategory_WithInvalidData_ReturnsBadRequest() + { + var request = new { Name = "" }; + var response = await client.PostAsJsonAsync("/api/categories", request); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetCategoryById_NonExistent_ReturnsNotFound() + { + var response = await client.GetAsync($"/api/categories/{Guid.NewGuid()}"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task CreateAndGetCategoryById_ReturnsCorrectData() + { + var createRequest = new { Name = "Category1" }; + var createResponse = await client.PostAsJsonAsync("/api/categories", createRequest); + createResponse.EnsureSuccessStatusCode(); + var result = await createResponse.Content.ReadFromJsonAsync(); + var getResponse = await client.GetAsync($"/api/categories/{result.CategoryId}"); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + } + + [Fact] + public async Task CreateAndDeleteCategory_ThenGet_ReturnsNotFound() + { + var createRequest = new { Name = "ToDelete" }; + var createResponse = await client.PostAsJsonAsync("/api/categories", createRequest); + createResponse.EnsureSuccessStatusCode(); + var result = await createResponse.Content.ReadFromJsonAsync(); + var deleteResponse = await client.DeleteAsync($"/api/categories/{result.CategoryId}"); + Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode); + var getResponse = await client.GetAsync($"/api/categories/{result.CategoryId}"); + Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode); + } + + [Fact] + public async Task CreateAndRenameCategory_ReturnsNoContent() + { + var createRequest = new { Name = "ToRename" }; + var createResponse = await client.PostAsJsonAsync("/api/categories", createRequest); + createResponse.EnsureSuccessStatusCode(); + var result = await createResponse.Content.ReadFromJsonAsync(); + var renameRequest = new { NewName = "RenamedCategory" }; + var renameResponse = await client.PatchAsJsonAsync($"/api/categories/{result.CategoryId}/name", renameRequest); + Assert.Equal(HttpStatusCode.NoContent, renameResponse.StatusCode); + } + + public class CreateCategoryResponse + { + public Guid CategoryId { get; set; } + } +} diff --git a/tests/KontoApi.IntegrationTestsNet8/KontoApi.IntegrationTestsNet8.csproj b/tests/KontoApi.IntegrationTestsNet8/KontoApi.IntegrationTestsNet8.csproj new file mode 100644 index 0000000..54dc85f --- /dev/null +++ b/tests/KontoApi.IntegrationTestsNet8/KontoApi.IntegrationTestsNet8.csproj @@ -0,0 +1,37 @@ + + + + net10.0 + enable + enable + true + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/KontoApi.IntegrationTestsNet8/TransactionController/TransactionsIntegrationTests.cs b/tests/KontoApi.IntegrationTestsNet8/TransactionController/TransactionsIntegrationTests.cs new file mode 100644 index 0000000..e1c53d7 --- /dev/null +++ b/tests/KontoApi.IntegrationTestsNet8/TransactionController/TransactionsIntegrationTests.cs @@ -0,0 +1,210 @@ +using System.Net; +using System.Net.Http.Json; +using System.Threading.Tasks; +using Xunit; +using Microsoft.AspNetCore.Mvc.Testing; +using System; + +namespace KontoApi.IntegrationTestsNet8.TransactionController; + +using KontoApi.TestEntryPoint; + +public class TransactionsIntegrationTests : IClassFixture> +{ + private readonly HttpClient client; + + public TransactionsIntegrationTests(WebApplicationFactory factory) + { + client = factory.CreateClient(); + } + + [Fact] + public async Task AddTransaction_WithValidData_ReturnsCreatedAndTransactionId() + { + // create account and budget first + var accountResponse = await client.PostAsJsonAsync("/api/account", new { Name = "Tx owner" }); + accountResponse.EnsureSuccessStatusCode(); + var accountJson = await accountResponse.Content.ReadFromJsonAsync(); + Guid accountId; + if (accountJson.TryGetProperty("accountId", out var prop1)) + accountId = prop1.GetGuid(); + else if (accountJson.TryGetProperty("AccountId", out var prop2)) + accountId = prop2.GetGuid(); + else + accountId = Guid.Parse(accountJson.GetRawText().Trim('"')); + + var budgetCreate = new { AccountId = accountId, Name = "TxBudget", InitialBalance = 0m, Currency = "USD" }; + var budgetResponse = await client.PostAsJsonAsync("/api/budgets", budgetCreate); + budgetResponse.EnsureSuccessStatusCode(); + var budgetJson = await budgetResponse.Content.ReadFromJsonAsync(); + Guid budgetId; + if (budgetJson.TryGetProperty("budgetId", out var bprop1)) + budgetId = bprop1.GetGuid(); + else if (budgetJson.TryGetProperty("BudgetId", out var bprop2)) + budgetId = bprop2.GetGuid(); + else + budgetId = Guid.Parse(budgetJson.GetRawText().Trim('"')); + + var categoryResp = await client.PostAsJsonAsync("/api/categories", new { Name = "txcat" }); + categoryResp.EnsureSuccessStatusCode(); + var categoryJson = await categoryResp.Content.ReadFromJsonAsync(); + Guid categoryIdForTest; + if (categoryJson.TryGetProperty("categoryId", out var cprop1)) + categoryIdForTest = cprop1.GetGuid(); + else if (categoryJson.TryGetProperty("CategoryId", out var cprop2)) + categoryIdForTest = cprop2.GetGuid(); + else + categoryIdForTest = Guid.Parse(categoryJson.GetRawText().Trim('"')); + + var request = new { + BudgetId = budgetId, + Amount = 100.0m, + Currency = "USD", + Type = 1, // Expense + CategoryId = categoryIdForTest, + Date = DateTime.UtcNow, + Description = "Test transaction" + }; + var response = await client.PostAsJsonAsync("/api/transactions", request); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + Assert.True(result.TransactionId != Guid.Empty); + } + + [Fact] + public async Task AddTransaction_WithInvalidData_ReturnsBadRequest() + { + var request = new { + BudgetId = Guid.Empty, + Amount = -1.0m, + Currency = "", + Type = "", + CategoryId = Guid.Empty, + Date = DateTime.UtcNow, + Description = "" + }; + var response = await client.PostAsJsonAsync("/api/transactions", request); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetTransactionById_NonExistent_ReturnsNotFound() + { + var response = await client.GetAsync($"/api/transactions/{Guid.NewGuid()}"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task AddAndGetTransaction_ReturnsCorrectData() + { + var accountResponse = await client.PostAsJsonAsync("/api/account", new { Name = "Tx owner" }); + accountResponse.EnsureSuccessStatusCode(); + var accountJson = await accountResponse.Content.ReadFromJsonAsync(); + Guid accountId; + if (accountJson.TryGetProperty("accountId", out var prop1)) + accountId = prop1.GetGuid(); + else if (accountJson.TryGetProperty("AccountId", out var prop2)) + accountId = prop2.GetGuid(); + else + accountId = Guid.Parse(accountJson.GetRawText().Trim('"')); + + var budgetCreate = new { AccountId = accountId, Name = "TxBudget", InitialBalance = 0m, Currency = "USD" }; + var budgetResponse = await client.PostAsJsonAsync("/api/budgets", budgetCreate); + budgetResponse.EnsureSuccessStatusCode(); + var budgetJson = await budgetResponse.Content.ReadFromJsonAsync(); + Guid budgetId; + if (budgetJson.TryGetProperty("budgetId", out var bprop1)) + budgetId = bprop1.GetGuid(); + else if (budgetJson.TryGetProperty("BudgetId", out var bprop2)) + budgetId = bprop2.GetGuid(); + else + budgetId = Guid.Parse(budgetJson.GetRawText().Trim('"')); + + var categoryResp2 = await client.PostAsJsonAsync("/api/categories", new { Name = "txcat2" }); + categoryResp2.EnsureSuccessStatusCode(); + var categoryJson2 = await categoryResp2.Content.ReadFromJsonAsync(); + Guid categoryId2; + if (categoryJson2.TryGetProperty("categoryId", out var c2p1)) + categoryId2 = c2p1.GetGuid(); + else if (categoryJson2.TryGetProperty("CategoryId", out var c2p2)) + categoryId2 = c2p2.GetGuid(); + else + categoryId2 = Guid.Parse(categoryJson2.GetRawText().Trim('"')); + + var createRequest = new { + BudgetId = budgetId, + Amount = 50.0m, + Currency = "USD", + Type = 0, // Income + CategoryId = categoryId2, + Date = DateTime.UtcNow, + Description = "Income transaction" + }; + var createResponse = await client.PostAsJsonAsync("/api/transactions", createRequest); + createResponse.EnsureSuccessStatusCode(); + var result = await createResponse.Content.ReadFromJsonAsync(); + var getResponse = await client.GetAsync($"/api/transactions/{result.TransactionId}"); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + } + + [Fact] + public async Task AddAndDeleteTransaction_ThenGet_ReturnsNotFound() + { + var accountResponse = await client.PostAsJsonAsync("/api/account", new { Name = "Tx owner" }); + accountResponse.EnsureSuccessStatusCode(); + var accountResult = await accountResponse.Content.ReadFromJsonAsync(); + Guid accountId; + if (accountResult.TryGetProperty("accountId", out var propA)) + accountId = propA.GetGuid(); + else if (accountResult.TryGetProperty("AccountId", out var propA2)) + accountId = propA2.GetGuid(); + else + accountId = Guid.Parse(accountResult.GetRawText().Trim('"')); + + var budgetCreate = new { AccountId = accountId, Name = "TxBudget", InitialBalance = 0m, Currency = "USD" }; + var budgetResponse = await client.PostAsJsonAsync("/api/budgets", budgetCreate); + budgetResponse.EnsureSuccessStatusCode(); + var budgetResult = await budgetResponse.Content.ReadFromJsonAsync(); + Guid budgetId; + if (budgetResult.TryGetProperty("budgetId", out var propB)) + budgetId = propB.GetGuid(); + else if (budgetResult.TryGetProperty("BudgetId", out var propB2)) + budgetId = propB2.GetGuid(); + else + budgetId = Guid.Parse(budgetResult.GetRawText().Trim('"')); + + var categoryResp3 = await client.PostAsJsonAsync("/api/categories", new { Name = "txcat3" }); + categoryResp3.EnsureSuccessStatusCode(); + var categoryJson3 = await categoryResp3.Content.ReadFromJsonAsync(); + Guid categoryId3; + if (categoryJson3.TryGetProperty("categoryId", out var c3p1)) + categoryId3 = c3p1.GetGuid(); + else if (categoryJson3.TryGetProperty("CategoryId", out var c3p2)) + categoryId3 = c3p2.GetGuid(); + else + categoryId3 = Guid.Parse(categoryJson3.GetRawText().Trim('"')); + + var createRequest = new { + BudgetId = budgetId, + Amount = 10.0m, + Currency = "USD", + Type = 1, // Expense + CategoryId = categoryId3, + Date = DateTime.UtcNow, + Description = "To delete" + }; + var createResponse = await client.PostAsJsonAsync("/api/transactions", createRequest); + createResponse.EnsureSuccessStatusCode(); + var result = await createResponse.Content.ReadFromJsonAsync(); + var deleteResponse = await client.DeleteAsync($"/api/transactions/{result.TransactionId}?budgetId={budgetId}"); + Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode); + var getResponse = await client.GetAsync($"/api/transactions/{result.TransactionId}"); + Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode); + } + + public class CreateTransactionResponse + { + public Guid TransactionId { get; set; } + } +} diff --git a/tests/KontoApi.IntegrationTestsNet8/UserController/UserIntegrationTests.cs b/tests/KontoApi.IntegrationTestsNet8/UserController/UserIntegrationTests.cs new file mode 100644 index 0000000..c072240 --- /dev/null +++ b/tests/KontoApi.IntegrationTestsNet8/UserController/UserIntegrationTests.cs @@ -0,0 +1,42 @@ +using KontoApi.TestEntryPoint; +using System.Net; +using System.Net.Http.Json; +using System.Threading.Tasks; +using Xunit; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace KontoApi.IntegrationTestsNet8.UserController; + +public class UserIntegrationTests : IClassFixture> +{ + private readonly HttpClient client; + + public UserIntegrationTests(WebApplicationFactory factory) + { + client = factory.CreateClient(); + } + + [Fact] + public async Task GetCurrentUser_ReturnsOk() + { + var response = await client.GetAsync("/api/users/me"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + // Можно проверить десериализацию пользователя + } + + [Fact] + public async Task ChangePassword_WithValidData_ReturnsNoContent() + { + var request = new { CurrentPassword = "Test123!", NewPassword = "NewPass123!" }; + var response = await client.PutAsJsonAsync("/api/users/password", request); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } + + [Fact] + public async Task ChangePassword_WithInvalidData_ReturnsBadRequest() + { + var request = new { CurrentPassword = "", NewPassword = "short" }; + var response = await client.PutAsJsonAsync("/api/users/password", request); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } +} diff --git a/tests/KontoApi.TestEntryPoint/KontoApi.TestEntryPoint.csproj b/tests/KontoApi.TestEntryPoint/KontoApi.TestEntryPoint.csproj new file mode 100644 index 0000000..96487ec --- /dev/null +++ b/tests/KontoApi.TestEntryPoint/KontoApi.TestEntryPoint.csproj @@ -0,0 +1,11 @@ + + + net10.0 + Exe + enable + enable + + + + + diff --git a/tests/KontoApi.TestEntryPoint/Program.cs b/tests/KontoApi.TestEntryPoint/Program.cs new file mode 100644 index 0000000..f45801d --- /dev/null +++ b/tests/KontoApi.TestEntryPoint/Program.cs @@ -0,0 +1,38 @@ +using System; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace KontoApi.TestEntryPoint +{ + public class Program + { + // Provide proxies so WebApplicationFactory can locate host/web app builders + public static Microsoft.Extensions.Hosting.IHostBuilder CreateHostBuilder(string[] args) + { + try + { + System.IO.File.AppendAllText("/tmp/konto_test_entrypoint.log", DateTime.UtcNow + " - CreateHostBuilder invoked\n"); + } + catch { } + return KontoApi.Api.Program.CreateHostBuilder(args); + } + + public static Microsoft.AspNetCore.Builder.WebApplication CreateWebApplication(string[] args) + { + try + { + System.IO.File.AppendAllText("/tmp/konto_test_entrypoint.log", DateTime.UtcNow + " - CreateWebApplication invoked\n"); + } + catch { } + return KontoApi.Api.Program.CreateWebApplication(args); + } + + public static Task Main(string[] args) + { + // Ensure KontoApi.Api assembly is copied to test output + _ = typeof(KontoApi.Api.Program); + return Task.CompletedTask; + } + } +} diff --git a/tests/KontoApi.Tests/CategoryTests/CategoryRepositoryTests.cs b/tests/KontoApi.Tests/CategoryTests/CategoryRepositoryTests.cs new file mode 100644 index 0000000..012ca2d --- /dev/null +++ b/tests/KontoApi.Tests/CategoryTests/CategoryRepositoryTests.cs @@ -0,0 +1,111 @@ +using KontoApi.Domain; +using KontoApi.Infrastructure.Persistence.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace KontoApi.Tests.CategoryTests; + +public class CategoryRepositoryTests +{ + [Fact] + public async Task WhenNotExistsFalse() + { + var (context, connection) = DbContextFactory.CreateSqliteInMemory(); + await using var _ = connection; + + var repository = new CategoryRepository(context); + var exists = await repository.ExistsByNameAsync("rent", CancellationToken.None); + + Assert.False(exists); + } + + [Fact] + public async Task WhenExistsTrue() + { + var (context, connection) = DbContextFactory.CreateSqliteInMemory(); + await using var _ = connection; + + var repository = new CategoryRepository(context); + await repository.AddAsync(new Category("rent"), CancellationToken.None); + + var exists = await repository.ExistsByNameAsync("rent", CancellationToken.None); + Assert.True(exists); + } + + [Fact] + public async Task TrimName() + { + var (context, connection) = DbContextFactory.CreateSqliteInMemory(); + await using var _ = connection; + + var repository = new CategoryRepository(context); + await repository.AddAsync(new Category("rent"), CancellationToken.None); + + var exists = await repository.ExistsByNameAsync(" rent ", CancellationToken.None); + Assert.True(exists); + } + + [Fact] + public async Task ReturnsSortedByName() + { + var (context, connection) = DbContextFactory.CreateSqliteInMemory(); + await using var _ = connection; + + var repository = new CategoryRepository(context); + await repository.AddAsync(new Category("taxes"), CancellationToken.None); + await repository.AddAsync(new Category("clothes"), CancellationToken.None); + await repository.AddAsync(new Category("rent"), CancellationToken.None); + + var all = await repository.GetAllAsync(CancellationToken.None); + var names = all.Select(x => x.Name).ToList(); + + Assert.Equal(new[] { "clothes", "rent", "taxes" }, names); + } + + [Fact] + public async Task RemovesEntity() + { + var (context, connection) = DbContextFactory.CreateSqliteInMemory(); + await using var _ = connection; + + var repository = new CategoryRepository(context); + var category = new Category("rent"); + + context.Categories.Add(category); + await context.SaveChangesAsync(); + await repository.DeleteAsync(category.Id, CancellationToken.None); + + var inDataBase = await context.Categories.FirstOrDefaultAsync(c => c.Id == category.Id); + Assert.Null(inDataBase); + } + + [Fact] + public async Task NotThrowsExceptionWhenNotExists() + { + var (context, connection) = DbContextFactory.CreateSqliteInMemory(); + await using var _ = connection; + + var repository = new CategoryRepository(context); + var exception = await Record.ExceptionAsync(() => + repository.DeleteAsync(Guid.NewGuid(), CancellationToken.None)); + + Assert.Null(exception); + } + + [Fact] + public async Task ReturnsCategory() + { + var (context, connection) = DbContextFactory.CreateSqliteInMemory(); + await using var _ = connection; + + var repository = new CategoryRepository(context); + var category = new Category("rent"); + + context.Categories.Add(category); + await context.SaveChangesAsync(); + + var result = await repository.GetByIdAsync(category.Id, CancellationToken.None); + Assert.NotNull(result); + Assert.Equal(category.Id, result.Id); + Assert.Equal("rent", result.Name); + } +} \ No newline at end of file diff --git a/tests/KontoApi.Tests/CategoryTests/CategorySeederTests.cs b/tests/KontoApi.Tests/CategoryTests/CategorySeederTests.cs new file mode 100644 index 0000000..c274c46 --- /dev/null +++ b/tests/KontoApi.Tests/CategoryTests/CategorySeederTests.cs @@ -0,0 +1,46 @@ +using KontoApi.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace KontoApi.Tests.CategoryTests; + +public class CategorySeederTests +{ + [Fact] + public async Task AddDefaultCategories() + { + var (context, connection) = DbContextFactory.CreateSqliteInMemory(); + await using var _ = connection; + await CategorySeeder.SeedAsync(context); + + var categories = await context.Categories.OrderBy(c => c.Name).ToListAsync(); + Assert.Equal(10, categories.Count); + } + + [Fact] + public async Task NotCreateDuplicates() + { + var (context, connection) = DbContextFactory.CreateSqliteInMemory(); + await using var _ = connection; + await CategorySeeder.SeedAsync(context); + await CategorySeeder.SeedAsync(context); + + var count = await context.Categories.CountAsync(); + Assert.Equal(10, count); + } + + [Fact] + public async Task AddOnlyMissingOnes() + { + var (context, connection) = DbContextFactory.CreateSqliteInMemory(); + await using var _ = connection; + + context.Categories.Add(new("rent")); + context.Categories.Add(new("taxes")); + + await context.SaveChangesAsync(); + await CategorySeeder.SeedAsync(context); + + var count = await context.Categories.CountAsync(); + Assert.Equal(10, count); + } +} \ No newline at end of file diff --git a/tests/KontoApi.Tests/CategoryTests/DbContextFactory.cs b/tests/KontoApi.Tests/CategoryTests/DbContextFactory.cs new file mode 100644 index 0000000..f6f9beb --- /dev/null +++ b/tests/KontoApi.Tests/CategoryTests/DbContextFactory.cs @@ -0,0 +1,24 @@ +using KontoApi.Infrastructure.Persistence; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace KontoApi.Tests.CategoryTests; + +public class DbContextFactory +{ + public static (KontoDbContext context, SqliteConnection connection) CreateSqliteInMemory() + { + var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .EnableSensitiveDataLogging() + .Options; + + var context = new KontoDbContext(options); + context.Database.EnsureCreated(); + + return (context, connection); + } +} \ No newline at end of file diff --git a/tests/KontoApi.Tests/KontoApi.Tests.csproj b/tests/KontoApi.Tests/KontoApi.Tests.csproj new file mode 100644 index 0000000..e6f3d1a --- /dev/null +++ b/tests/KontoApi.Tests/KontoApi.Tests.csproj @@ -0,0 +1,41 @@ + + + + net10.0 + enable + enable + true + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + $(SolutionDir)src/Api/bin/Debug/net10.0/KontoApi.Api.deps.json + $(OutDir)testhost.deps.json + + + + + \ No newline at end of file diff --git a/tests/KontoApi.Tests/TokenServiceTests.cs b/tests/KontoApi.Tests/TokenServiceTests.cs new file mode 100644 index 0000000..a5a1f45 --- /dev/null +++ b/tests/KontoApi.Tests/TokenServiceTests.cs @@ -0,0 +1,75 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Microsoft.Extensions.Options; +using KontoApi.Domain; +using KontoApi.Infrastructure.Auth; + +namespace KontoApi.Tests; + +public class JwtProviderTests +{ + private readonly JwtProvider jwtProvider; + + public JwtProviderTests() + { + var settings = new JwtSettings + { + Key = "super-secret-key-which-is-long-enough-12345", + Issuer = "TestIssuer", + Audience = "TestAudience", + ExpirationMinutes = 60 + }; + + jwtProvider = new(Options.Create(settings)); + } + + private static User CreateTestUser(string name = "First Last", string email = "test@example.com") + => new(name, email, "password1234"); + + [Fact] + public void Generate_ReturnsNonEmptyJwt() + { + // Arrange + var user = CreateTestUser(); + + // Act + var token = jwtProvider.Generate(user); + + // Assert + Assert.False(string.IsNullOrWhiteSpace(token)); + + var parts = token.Split('.'); + Assert.Equal(3, parts.Length); + } + + [Fact] + public void Generate_IncludesUserClaims() + { + // Arrange + var user = CreateTestUser("Egor V", "egor@mail.com"); + + // Act + var token = jwtProvider.Generate(user); + + // Assert + var handler = new JwtSecurityTokenHandler(); + var jwt = handler.ReadJwtToken(token); + + Assert.Equal(user.Id.ToString(), jwt.Claims.First(c => c.Type == ClaimTypes.NameIdentifier).Value); + Assert.Equal(user.Email, jwt.Claims.First(c => c.Type == ClaimTypes.Email).Value); + Assert.Equal(user.Name, jwt.Claims.First(c => c.Type == ClaimTypes.Name).Value); + } + + [Fact] + public void GenerateRefreshToken_ReturnsValidBase64_64Bytes() + { + // Act + var refresh = jwtProvider.GenerateRefreshToken(); + + // Assert + Assert.False(string.IsNullOrWhiteSpace(refresh)); + + var bytes = Convert.FromBase64String(refresh); + Assert.Equal(64, bytes.Length); + } +} \ No newline at end of file