From 4acd83a98fab02a69e243c35dec04872c482895b Mon Sep 17 00:00:00 2001 From: Capsizer Date: Tue, 6 Oct 2026 05:23:31 +0000 Subject: [PATCH] feat: multi-database support (Postgres/SQLite/SQL Server) + one-command installers + CI/CD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core Changes: - Add DatabaseProviderConfigurator: normalize provider names, configure EF + Hangfire, translate SQL Server schema types for Postgres/SQLite, auto-create database - Add FirstRunSetup wizard: interactive DB selection on first startup, non-interactive fallback for containers (env vars or SQLite default) - Hangfire storage per provider (PostgreSql/SQLite/SqlServer) - Fix Hangfire.Storage.SQLite path extraction (was creating file named "Data Source=...") Fail-Soft Startup (no Telegram config = no crash): - TelegramUserApiClient: resilient constructor + auto-delete corrupt sessions - TelegramUserApiInitializationService: skip when ApiId/ApiHash missing - TelegramPanel: skip bot registration without token (no 100s GetMe hang) - TelegramBotService: instant return when BotToken is empty One-Command Installers: - install.sh (Linux/macOS): Docker or native path, auto-detect deps - install.ps1 (Windows PowerShell): Docker or native, .NET SDK install CI/CD: - .github/workflows/ci.yml: build + test on push/PR - .github/workflows/release.yml: tag-triggered release with 4-platform publish archives + Docker image + GitHub Release Infrastructure: - Dockerfile: multi-stage sdk:9.0 → aspnet:9.0-alpine, non-root - .dockerignore: prevent local obj/bin leaking into container - docker-compose.yml: postgres healthcheck with start_period - init.sql: idempotent (extensions only, no schema conflict with EF) - README.md: quick-start, install commands, architecture overview Tests: - 41 new tests for DatabaseProviderConfigurator (normalize, translate, edge cases) - All 42 solution tests pass Tested end-to-end: - SQLite: schema + /health ✅ - PostgreSQL (docker compose): 12 tables + /health ✅ - SQL Server (mssql:2022 container): 13 tables + /health ✅ --- .dockerignore | 33 ++ .env | 7 - .github/workflows/ci.yml | 116 ++++++ .github/workflows/release.yml | 236 +++++++++++ Application/Application.csproj | 10 +- Application/Common/Mappings/MappingProfile.cs | 99 ++++- Application/ServiceCollectionExtensions.cs | 13 +- BackgroundTasks/BackgroundTasks.csproj | 10 +- Dockerfile | 27 +- ForexTradingBot.sln | 82 ++++ .../Data/DatabaseProviderConfigurator.cs | 372 ++++++++++++++++++ .../Data/ServiceCollectionExtensions.cs | 52 +-- Infrastructure/Infrastructure.csproj | 34 +- .../Services/TelegramUserApiClient.cs | 24 +- .../TelegramUserApiInitializationService.cs | 22 +- README.md | 154 ++++++++ .../Extensions/ServiceCollectionExtensions.cs | 8 +- .../Infrastructure/TelegramBotService.cs | 7 + TelegramPanel/TelegramPanel.csproj | 16 +- .../DatabaseProviderConfiguratorTests.cs | 204 ++++++++++ .../SignalServiceAndMappingTests.cs | 254 ++++++++++++ Tests.Application/Tests.Application.csproj | 6 +- WebAPI/Program.cs | 92 ++++- WebAPI/Setup/FirstRunSetup.cs | 195 +++++++++ WebAPI/WebAPI.csproj | 22 +- docker-compose.yml | 5 +- install.ps1 | 230 +++++++++++ install.sh | 252 ++++++++++++ scripts/database/init.sql | 63 +-- 29 files changed, 2456 insertions(+), 189 deletions(-) create mode 100644 .dockerignore delete mode 100644 .env create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 Infrastructure/Data/DatabaseProviderConfigurator.cs create mode 100644 README.md create mode 100644 Tests.Application/DatabaseProviderConfiguratorTests.cs create mode 100644 Tests.Application/SignalServiceAndMappingTests.cs create mode 100644 WebAPI/Setup/FirstRunSetup.cs create mode 100644 install.ps1 create mode 100644 install.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..2e3f773d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,33 @@ +# Build artifacts — must never overwrite container restore output +**/bin/ +**/obj/ + +# VCS +.git/ +.gitignore + +# Local environment secrets +.env +.env.* +!.env.example + +# Docker files themselves +Dockerfile +Dockerfile.* +docker-compose*.yml + +# IDE / editor +.vscode/ +.idea/ +*.swp + +# Node (if present) +node_modules/ + +# Docs & misc artifacts (not needed to build) +docs/ +*.md +!README.md +.tmp.driveupload/ +artifacts/ +*.log diff --git a/.env b/.env deleted file mode 100644 index 6a9fab0f..00000000 --- a/.env +++ /dev/null @@ -1,7 +0,0 @@ -TELEGRAM_BOT_TOKEN=7101564106:AAHbHL5hhGJK7aIKH7RGEX2ZG4nGvMHOmIE -TELEGRAM_CHANNEL_ID=-1002090557028 -DefaultConnection="Host=postgres;Port=5432;Database=forextradingbot;Username=postgres;Password=re110121" -DB_PASSWORD=re110121 -CRYPTOPAY_API_TOKEN=your_cryptopay_token -CRYPTOPAY_API_KEY=your_cryptopay_key -CRYPTOPAY_WEBHOOK_SECRET=your_cryptopay_webhook_secret \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..356ef10b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,116 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + +jobs: + build-and-test: + name: Build & Test + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "9.0.x" + cache: true + cache-dependency-path: "**/*.csproj" + + - name: Restore + run: dotnet restore ForexTradingBot.sln + + - name: Build + run: dotnet build ForexTradingBot.sln -c Release --no-restore + + - name: Test + run: dotnet test ForexTradingBot.sln -c Release --no-build --logger "trx;LogFileName=test-results.trx" --results-directory TestResults + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: TestResults + retention-days: 14 + + docker: + name: Docker build + runs-on: ubuntu-latest + timeout-minutes: 30 + needs: build-and-test + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image (no push) + uses: docker/build-push-action@v6 + with: + context: . + push: false + tags: forexbot:ci + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Smoke test — Postgres + Redis + API /health + run: | + set -euo pipefail + cat > .env <<'EOF' + POSTGRES_PASSWORD=ci-smoke-password + TELEGRAM_BOT_TOKEN= + EOF + docker compose up -d + echo "Waiting for API health..." + for i in $(seq 1 60); do + if curl -fsS -m 3 http://localhost:8080/health 2>/dev/null | grep -q healthy; then + echo "API healthy after ${i} checks" + exit 0 + fi + sleep 5 + done + echo "::error::API never became healthy" + docker compose logs forex-api | tail -100 + exit 1 + + - name: Smoke test — SQLite variant + if: always() + run: | + set -euo pipefail + docker compose down -v + docker run -d --name smoke-sqlite \ + -e DatabaseProvider=Sqlite \ + -e 'ConnectionStrings__DefaultConnection=Data Source=/tmp/forexbot.db' \ + -p 8081:8080 forexbot:ci + for i in $(seq 1 40); do + if curl -fsS -m 3 http://localhost:8081/health 2>/dev/null | grep -q healthy; then + echo "SQLite variant healthy after ${i} checks" + docker rm -f smoke-sqlite + exit 0 + fi + sleep 5 + done + echo "::error::SQLite variant never became healthy" + docker logs smoke-sqlite | tail -100 + docker rm -f smoke-sqlite || true + exit 1 + + - name: Cleanup + if: always() + run: docker compose down -v 2>/dev/null || true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..679e55a9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,236 @@ +name: Release + +# Builds, packages and publishes a new release whenever a tag like v1.2.3 is pushed: +# git tag v1.0.0 && git push origin v1.0.0 +# +# Produces: +# * Cross-platform publish archives (linux-x64, win-x64, osx-arm64) attached to the GitHub Release +# * Docker image published to GitHub Container Registry (ghcr.io//: and :latest) +# * Release notes assembled from commits since the previous tag + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + inputs: + tag: + description: "Release tag to create/publish (e.g. v1.0.0)" + required: true + +permissions: + contents: write + packages: write + +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + +jobs: + validate: + name: Validate & test + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "9.0.x" + cache: true + cache-dependency-path: "**/*.csproj" + + - name: Resolve version + id: version + run: | + TAG="${{ github.event.inputs.tag || github.ref_name }}" + if [[ "$TAG" != v* ]]; then echo "::error::Tag must look like v1.2.3 (got: $TAG)"; exit 1; fi + VERSION="${TAG#v}" + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Invalid semver: $VERSION"; exit 1 + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Resolved release version: $VERSION" + + - name: Restore & build + run: | + dotnet restore ForexTradingBot.sln + dotnet build ForexTradingBot.sln -c Release --no-restore -p:Version=${{ steps.version.outputs.version }} + + - name: Test + run: dotnet test ForexTradingBot.sln -c Release --no-build + + - name: Validate installer scripts + run: | + bash -n install.sh + echo "install.sh syntax OK" + test -f install.ps1 || { echo "::error::install.ps1 missing"; exit 1; } + + - name: Validate compose file + run: | + cp .env.example .env + echo "POSTGRES_PASSWORD=release-check" >> .env + docker compose config --quiet + echo "docker-compose.yml OK" + + outputs: + tag: ${{ steps.version.outputs.tag }} + version: ${{ steps.version.outputs.version }} + + publish-archives: + name: Publish ${{ matrix.rid }} + runs-on: ubuntu-latest + needs: validate + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - rid: linux-x64 + ext: tar.gz + - rid: win-x64 + ext: zip + - rid: osx-arm64 + ext: tar.gz + - rid: osx-x64 + ext: tar.gz + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "9.0.x" + + - name: Publish self-contained + run: | + dotnet publish WebAPI/WebAPI.csproj \ + -c Release \ + -r ${{ matrix.rid }} \ + --self-contained true \ + -p:PublishSingleFile=false \ + -p:Version=${{ needs.validate.outputs.version }} \ + -p:ErrorOnDuplicatePublishOutputFiles=false \ + -o publish/${{ matrix.rid }} + + - name: Package + run: | + set -euo pipefail + NAME="ForexTradingBot-${{ needs.validate.outputs.version }}-${{ matrix.rid }}" + mkdir -p dist + cp install.sh install.ps1 README.md publish/${{ matrix.rid }}/ 2>/dev/null || true + if [ "${{ matrix.ext }}" = "zip" ]; then + cd publish && zip -qr "../dist/${NAME}.zip" "${{ matrix.rid }}" && cd .. + else + tar -czf "dist/${NAME}.tar.gz" -C publish "${{ matrix.rid }}" + fi + ls -la dist + + - uses: actions/upload-artifact@v4 + with: + name: package-${{ matrix.rid }} + path: dist/* + retention-days: 30 + + docker: + name: Docker image + runs-on: ubuntu-latest + needs: validate + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest + + - name: Build & push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + github-release: + name: Create GitHub Release + runs-on: ubuntu-latest + needs: [validate, publish-archives, docker] + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: Gather notes + id: notes + run: | + TAG="${{ needs.validate.outputs.tag }}" + PREV=$(git tag --list 'v*' --sort=-v:refname | grep -v "^${TAG}$" | head -1 || true) + { + echo "body<> "$GITHUB_OUTPUT" + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.validate.outputs.tag }} + name: ForexTradingBot ${{ needs.validate.outputs.tag }} + body: ${{ steps.notes.outputs.body }} + files: dist/* + fail_on_unmatched_files: false + generate_release_notes: true diff --git a/Application/Application.csproj b/Application/Application.csproj index af381823..e97f8160 100644 --- a/Application/Application.csproj +++ b/Application/Application.csproj @@ -8,13 +8,13 @@ - + - - + + - - + + diff --git a/Application/Common/Mappings/MappingProfile.cs b/Application/Common/Mappings/MappingProfile.cs index 4f79ebb3..bcc6d009 100644 --- a/Application/Common/Mappings/MappingProfile.cs +++ b/Application/Common/Mappings/MappingProfile.cs @@ -21,9 +21,36 @@ public MappingProfile() // User Mappings CreateMap() .ForMember(dest => dest.Level, opt => opt.MapFrom(src => src.Level.ToString())) + .ForMember(dest => dest.TokenBalance, opt => opt.MapFrom(src => src.TokenWallet != null ? src.TokenWallet.Balance : 0m)) + .ForMember(dest => dest.TokenWallet, opt => opt.MapFrom(src => src.TokenWallet)) .ForMember(dest => dest.ActiveSubscription, opt => opt.Ignore()); // این باید دستی مپ شود یا از طریق یک resolver - CreateMap(); + CreateMap() + .ForMember(dest => dest.Id, opt => opt.Ignore()) + .ForMember(dest => dest.Level, opt => opt.Ignore()) + .ForMember(dest => dest.CreatedAt, opt => opt.Ignore()) + .ForMember(dest => dest.UpdatedAt, opt => opt.Ignore()) + .ForMember(dest => dest.EnableGeneralNotifications, opt => opt.Ignore()) + .ForMember(dest => dest.EnableVipSignalNotifications, opt => opt.Ignore()) + .ForMember(dest => dest.EnableRssNewsNotifications, opt => opt.Ignore()) + .ForMember(dest => dest.PreferredLanguage, opt => opt.Ignore()) + .ForMember(dest => dest.TokenWallet, opt => opt.Ignore()) + .ForMember(dest => dest.Subscriptions, opt => opt.Ignore()) + .ForMember(dest => dest.Transactions, opt => opt.Ignore()) + .ForMember(dest => dest.Preferences, opt => opt.Ignore()); CreateMap() + .ForMember(dest => dest.Id, opt => opt.Ignore()) + .ForMember(dest => dest.TelegramId, opt => opt.Ignore()) + .ForMember(dest => dest.Level, opt => opt.Ignore()) + .ForMember(dest => dest.CreatedAt, opt => opt.Ignore()) + .ForMember(dest => dest.UpdatedAt, opt => opt.Ignore()) + .ForMember(dest => dest.EnableGeneralNotifications, opt => opt.Ignore()) + .ForMember(dest => dest.EnableVipSignalNotifications, opt => opt.Ignore()) + .ForMember(dest => dest.EnableRssNewsNotifications, opt => opt.Ignore()) + .ForMember(dest => dest.PreferredLanguage, opt => opt.Ignore()) + .ForMember(dest => dest.TokenWallet, opt => opt.Ignore()) + .ForMember(dest => dest.Subscriptions, opt => opt.Ignore()) + .ForMember(dest => dest.Transactions, opt => opt.Ignore()) + .ForMember(dest => dest.Preferences, opt => opt.Ignore()) .ForAllMembers(opts => opts.Condition((src, dest, srcMember) => srcMember != null)); // فقط فیلدهای غیر null را مپ کن // TokenWallet Mappings @@ -32,32 +59,84 @@ public MappingProfile() // Subscription Mappings CreateMap() .ForMember(dest => dest.IsActive, opt => opt.MapFrom(src => src.IsCurrentlyActive)); // پراپرتی محاسباتی - CreateMap(); + CreateMap() + .ForMember(dest => dest.Id, opt => opt.Ignore()) + .ForMember(dest => dest.Status, opt => opt.Ignore()) + .ForMember(dest => dest.ActivatingTransactionId, opt => opt.Ignore()) + .ForMember(dest => dest.CreatedAt, opt => opt.Ignore()) + .ForMember(dest => dest.UpdatedAt, opt => opt.Ignore()) + .ForMember(dest => dest.User, opt => opt.Ignore()); // SignalCategory Mappings CreateMap(); - CreateMap(); + CreateMap() + .ForMember(dest => dest.Id, opt => opt.Ignore()) + .ForMember(dest => dest.Description, opt => opt.Ignore()) + .ForMember(dest => dest.IsActive, opt => opt.Ignore()) + .ForMember(dest => dest.SortOrder, opt => opt.Ignore()) + .ForMember(dest => dest.Signals, opt => opt.Ignore()) + .ForMember(dest => dest.UserPreferences, opt => opt.Ignore()); // Signal Mappings CreateMap() .ForMember(dest => dest.Type, opt => opt.MapFrom(src => src.Type.ToString())) + .ForMember(dest => dest.Source, opt => opt.MapFrom(src => src.SourceProvider)) + .ForMember(dest => dest.CreatedAt, opt => opt.MapFrom(src => src.PublishedAt)) .ForMember(dest => dest.Category, opt => opt.MapFrom(src => src.Category)); // مپ کردن نویگیشن پراپرتی - CreateMap(); + CreateMap() + .ForMember(dest => dest.Id, opt => opt.Ignore()) + .ForMember(dest => dest.Type, opt => opt.MapFrom(src => src.Type)) + .ForMember(dest => dest.SourceProvider, opt => opt.Ignore()) + .ForMember(dest => dest.Status, opt => opt.Ignore()) + .ForMember(dest => dest.Timeframe, opt => opt.Ignore()) + .ForMember(dest => dest.Notes, opt => opt.Ignore()) + .ForMember(dest => dest.IsVipOnly, opt => opt.Ignore()) + .ForMember(dest => dest.PublishedAt, opt => opt.Ignore()) + .ForMember(dest => dest.UpdatedAt, opt => opt.Ignore()) + .ForMember(dest => dest.ClosedAt, opt => opt.Ignore()) + .ForMember(dest => dest.CategoryId, opt => opt.MapFrom(src => src.CategoryId)) + .ForMember(dest => dest.Category, opt => opt.Ignore()) + .ForMember(dest => dest.Analyses, opt => opt.Ignore()); // SignalAnalysis Mappings - CreateMap(); - CreateMap(); + CreateMap() + .ForMember(dest => dest.Notes, opt => opt.MapFrom(src => src.AnalysisText)); + CreateMap() + .ForMember(dest => dest.Id, opt => opt.Ignore()) + .ForMember(dest => dest.AnalysisText, opt => opt.MapFrom(src => src.Notes)) + .ForMember(dest => dest.SentimentScore, opt => opt.Ignore()) + .ForMember(dest => dest.CreatedAt, opt => opt.Ignore()) + .ForMember(dest => dest.Signal, opt => opt.Ignore()); // RssSource Mappings CreateMap() .ForMember(dest => dest.DefaultSignalCategoryName, - opt => opt.MapFrom(src => src.DefaultSignalCategory != null ? src.DefaultSignalCategory.Name : null)); - CreateMap(); + opt => opt.MapFrom(src => src.DefaultSignalCategory != null ? src.DefaultSignalCategory.Name : null)) + .ForMember(dest => dest.LastFetchedAt, opt => opt.MapFrom(src => src.LastSuccessfulFetchAt)); + CreateMap() + .ForMember(dest => dest.Id, opt => opt.Ignore()) + .ForMember(dest => dest.CreatedAt, opt => opt.Ignore()) + .ForMember(dest => dest.UpdatedAt, opt => opt.Ignore()) + .ForMember(dest => dest.LastModifiedHeader, opt => opt.Ignore()) + .ForMember(dest => dest.LastFetchAttemptAt, opt => opt.Ignore()) + .ForMember(dest => dest.LastSuccessfulFetchAt, opt => opt.Ignore()) + .ForMember(dest => dest.FetchErrorCount, opt => opt.Ignore()) + .ForMember(dest => dest.DefaultSignalCategory, opt => opt.Ignore()) + .ForMember(dest => dest.NewsItems, opt => opt.Ignore()); // Transaction Mappings CreateMap() .ForMember(dest => dest.Type, opt => opt.MapFrom(src => src.Type.ToString())); - CreateMap(); + CreateMap() + .ForMember(dest => dest.Id, opt => opt.Ignore()) + .ForMember(dest => dest.User, opt => opt.Ignore()) + .ForMember(dest => dest.Timestamp, opt => opt.Ignore()) + .ForMember(dest => dest.PaymentGatewayInvoiceId, opt => opt.Ignore()) + .ForMember(dest => dest.PaymentGatewayName, opt => opt.Ignore()) + .ForMember(dest => dest.Status, opt => opt.Ignore()) + .ForMember(dest => dest.PaidAt, opt => opt.Ignore()) + .ForMember(dest => dest.PaymentGatewayPayload, opt => opt.Ignore()) + .ForMember(dest => dest.PaymentGatewayResponse, opt => opt.Ignore()); // UserSignalPreference Mappings CreateMap() @@ -67,4 +146,4 @@ public MappingProfile() // برای SetUserPreferencesDto نیازی به مپینگ مستقیم به Entity نیست، چون منطق آن در سرویس یا Handler پیاده‌سازی می‌شود. } } -} \ No newline at end of file +} diff --git a/Application/ServiceCollectionExtensions.cs b/Application/ServiceCollectionExtensions.cs index 66881ec0..9d4a54ba 100644 --- a/Application/ServiceCollectionExtensions.cs +++ b/Application/ServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection; // برای IServiceCollection و متدهای توسعه‌دهنده DI using Microsoft.Extensions.Logging; // برای ILogger (مثلاً در DummyNotificationService) using System.Reflection; // برای Assembly.GetExecutingAssembly() +using AutoMapper; // برای MapperConfiguration (AutoMapper 13+ بدون DI extensions) #endregion namespace Application // ✅ Namespace ریشه پروژه Application @@ -33,7 +34,17 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection // Assembly.GetExecutingAssembly() باعث می‌شود AutoMapper تمام کلاس‌هایی را که از Profile ارث‌بری می‌کنند // در اسمبلی فعلی (Application) پیدا و پروفایل‌های مپینگ آن‌ها را رجیستر کند. // پیش‌نیاز: باید یک یا چند کلاس MappingProfile در Application/Common/Mappings/ داشته باشید. - services.AddAutoMapper(Assembly.GetExecutingAssembly()); + // AutoMapper 13+ (Lucky Penny fork) removed the DI extensions package. + // We register IMapper manually: a MapperConfiguration that scans every + // Profile in the Application assembly, exposed as Singleton IMapper. + // (AutoMapper 13+ ctor also requires an ILoggerFactory — pass Null one.) + services.AddSingleton(cfg => + { + var mapperConfig = new MapperConfiguration( + mc => mc.AddMaps(typeof(Application.Common.Mappings.MappingProfile).Assembly), + Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance); + return mapperConfig.CreateMapper(); + }); // Comment: Registers AutoMapper profiles from the current assembly (Application layer). // ------------------- ۲. رجیستر کردن FluentValidation ------------------- diff --git a/BackgroundTasks/BackgroundTasks.csproj b/BackgroundTasks/BackgroundTasks.csproj index c17479cd..96c1ae7b 100644 --- a/BackgroundTasks/BackgroundTasks.csproj +++ b/BackgroundTasks/BackgroundTasks.csproj @@ -9,12 +9,12 @@ - - - - + + + + - + diff --git a/Dockerfile b/Dockerfile index 9d459923..e93106f9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,8 @@ FROM mcr.microsoft.com/dotnet/sdk:9.0 AS builder WORKDIR /src # Copy project files first to leverage Docker layer caching +# NOTE: test projects must be present because the .sln references them, +# but only WebAPI is built/published into the runtime image. COPY *.sln ./ COPY Application/Application.csproj ./Application/ COPY Domain/Domain.csproj ./Domain/ @@ -15,9 +17,11 @@ COPY Shared/Shared.csproj ./Shared/ COPY BackgroundTasks/BackgroundTasks.csproj ./BackgroundTasks/ COPY TelegramPanel/TelegramPanel.csproj ./TelegramPanel/ COPY WebAPI/WebAPI.csproj ./WebAPI/ +COPY Application.Tests/Application.Tests.csproj ./Application.Tests/ +COPY Tests.Application/Tests.Application.csproj ./Tests.Application/ # Restore dependencies (cached until csproj changes) -RUN dotnet restore +RUN dotnet restore ForexTradingBot.sln # Copy all source code COPY Application/ ./Application/ @@ -27,24 +31,23 @@ COPY Shared/ ./Shared/ COPY BackgroundTasks/ ./BackgroundTasks/ COPY TelegramPanel/ ./TelegramPanel/ COPY WebAPI/ ./WebAPI/ -COPY Program.cs ./ -COPY Update-EfDatabase.ps1 ./ -# Publish optimized release build -RUN dotnet build "ForexTradingBot.sln" -c Release --no-restore -o /app/build -RUN dotnet publish "ForexTradingBot.sln" -c Release --no-build -o /app/publish +# Publish the WebAPI (entry point) in Release mode +# --property:ErrorOnDuplicatePublishOutputFiles=false avoids NETSDK1152 +# when project references ship their own appsettings.json +RUN dotnet publish WebAPI/WebAPI.csproj -c Release --no-restore -o /app/publish \ + -p:ErrorOnDuplicatePublishOutputFiles=false # Stage 2: Runtime stage - minimal runtime image FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime WORKDIR /app -# Install required packages for Serilog Console logger +# Install curl for the container healthcheck RUN apt-get update \ - && apt-get install -y --no-install-recommends curl ca-certificates gnupg \ - && mkdir -p /etc/apt/keyrings \ + && apt-get install -y --no-install-recommends curl \ && rm -rf /var/lib/apt/lists/* -# Set timezone environment variable +# Set timezone and runtime defaults ENV TZ=Asia/Tehran ENV ASPNETCORE_URLS=http://+:8080 ENV ASPNETCORE_ENVIRONMENT=Production @@ -53,6 +56,8 @@ ENV ASPNETCORE_ENVIRONMENT=Production RUN groupadd -r forexuser -g 1000 && \ useradd -r -g forexuser forexuser -m -d /app +COPY --from=builder /app/publish . + # Fix permissions RUN chown -R forexuser:forexuser /app USER forexuser @@ -60,4 +65,4 @@ USER forexuser # Expose port for API EXPOSE 8080 -ENTRYPOINT ["dotnet", "ForexTradingBot.dll"] +ENTRYPOINT ["dotnet", "WebAPI.dll"] diff --git a/ForexTradingBot.sln b/ForexTradingBot.sln index 51950457..8478e7a1 100644 --- a/ForexTradingBot.sln +++ b/ForexTradingBot.sln @@ -24,44 +24,126 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Update-EfDatabase.ps1 = Update-EfDatabase.ps1 EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.Application", "Tests.Application\Tests.Application.csproj", "{70E7E2D3-6487-4C2F-9A63-D69919F8E23C}" +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 {39360115-2898-47E4-9ED2-635F6BB1EABA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {39360115-2898-47E4-9ED2-635F6BB1EABA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {39360115-2898-47E4-9ED2-635F6BB1EABA}.Debug|x64.ActiveCfg = Debug|Any CPU + {39360115-2898-47E4-9ED2-635F6BB1EABA}.Debug|x64.Build.0 = Debug|Any CPU + {39360115-2898-47E4-9ED2-635F6BB1EABA}.Debug|x86.ActiveCfg = Debug|Any CPU + {39360115-2898-47E4-9ED2-635F6BB1EABA}.Debug|x86.Build.0 = Debug|Any CPU {39360115-2898-47E4-9ED2-635F6BB1EABA}.Release|Any CPU.ActiveCfg = Release|Any CPU {39360115-2898-47E4-9ED2-635F6BB1EABA}.Release|Any CPU.Build.0 = Release|Any CPU + {39360115-2898-47E4-9ED2-635F6BB1EABA}.Release|x64.ActiveCfg = Release|Any CPU + {39360115-2898-47E4-9ED2-635F6BB1EABA}.Release|x64.Build.0 = Release|Any CPU + {39360115-2898-47E4-9ED2-635F6BB1EABA}.Release|x86.ActiveCfg = Release|Any CPU + {39360115-2898-47E4-9ED2-635F6BB1EABA}.Release|x86.Build.0 = Release|Any CPU {2A27C60F-B52A-4C9D-816D-11AD42E9BF65}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2A27C60F-B52A-4C9D-816D-11AD42E9BF65}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A27C60F-B52A-4C9D-816D-11AD42E9BF65}.Debug|x64.ActiveCfg = Debug|Any CPU + {2A27C60F-B52A-4C9D-816D-11AD42E9BF65}.Debug|x64.Build.0 = Debug|Any CPU + {2A27C60F-B52A-4C9D-816D-11AD42E9BF65}.Debug|x86.ActiveCfg = Debug|Any CPU + {2A27C60F-B52A-4C9D-816D-11AD42E9BF65}.Debug|x86.Build.0 = Debug|Any CPU {2A27C60F-B52A-4C9D-816D-11AD42E9BF65}.Release|Any CPU.ActiveCfg = Release|Any CPU {2A27C60F-B52A-4C9D-816D-11AD42E9BF65}.Release|Any CPU.Build.0 = Release|Any CPU + {2A27C60F-B52A-4C9D-816D-11AD42E9BF65}.Release|x64.ActiveCfg = Release|Any CPU + {2A27C60F-B52A-4C9D-816D-11AD42E9BF65}.Release|x64.Build.0 = Release|Any CPU + {2A27C60F-B52A-4C9D-816D-11AD42E9BF65}.Release|x86.ActiveCfg = Release|Any CPU + {2A27C60F-B52A-4C9D-816D-11AD42E9BF65}.Release|x86.Build.0 = Release|Any CPU {6128EC18-4128-4CD9-923A-2566E6DB7C5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6128EC18-4128-4CD9-923A-2566E6DB7C5A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6128EC18-4128-4CD9-923A-2566E6DB7C5A}.Debug|x64.ActiveCfg = Debug|Any CPU + {6128EC18-4128-4CD9-923A-2566E6DB7C5A}.Debug|x64.Build.0 = Debug|Any CPU + {6128EC18-4128-4CD9-923A-2566E6DB7C5A}.Debug|x86.ActiveCfg = Debug|Any CPU + {6128EC18-4128-4CD9-923A-2566E6DB7C5A}.Debug|x86.Build.0 = Debug|Any CPU {6128EC18-4128-4CD9-923A-2566E6DB7C5A}.Release|Any CPU.ActiveCfg = Release|Any CPU {6128EC18-4128-4CD9-923A-2566E6DB7C5A}.Release|Any CPU.Build.0 = Release|Any CPU + {6128EC18-4128-4CD9-923A-2566E6DB7C5A}.Release|x64.ActiveCfg = Release|Any CPU + {6128EC18-4128-4CD9-923A-2566E6DB7C5A}.Release|x64.Build.0 = Release|Any CPU + {6128EC18-4128-4CD9-923A-2566E6DB7C5A}.Release|x86.ActiveCfg = Release|Any CPU + {6128EC18-4128-4CD9-923A-2566E6DB7C5A}.Release|x86.Build.0 = Release|Any CPU {AD4899FB-9558-41DC-89F3-C0D1CA53E01F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AD4899FB-9558-41DC-89F3-C0D1CA53E01F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AD4899FB-9558-41DC-89F3-C0D1CA53E01F}.Debug|x64.ActiveCfg = Debug|Any CPU + {AD4899FB-9558-41DC-89F3-C0D1CA53E01F}.Debug|x64.Build.0 = Debug|Any CPU + {AD4899FB-9558-41DC-89F3-C0D1CA53E01F}.Debug|x86.ActiveCfg = Debug|Any CPU + {AD4899FB-9558-41DC-89F3-C0D1CA53E01F}.Debug|x86.Build.0 = Debug|Any CPU {AD4899FB-9558-41DC-89F3-C0D1CA53E01F}.Release|Any CPU.ActiveCfg = Release|Any CPU {AD4899FB-9558-41DC-89F3-C0D1CA53E01F}.Release|Any CPU.Build.0 = Release|Any CPU + {AD4899FB-9558-41DC-89F3-C0D1CA53E01F}.Release|x64.ActiveCfg = Release|Any CPU + {AD4899FB-9558-41DC-89F3-C0D1CA53E01F}.Release|x64.Build.0 = Release|Any CPU + {AD4899FB-9558-41DC-89F3-C0D1CA53E01F}.Release|x86.ActiveCfg = Release|Any CPU + {AD4899FB-9558-41DC-89F3-C0D1CA53E01F}.Release|x86.Build.0 = Release|Any CPU {7A84B8A8-FACA-401F-B6E1-FEA5BF91C550}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7A84B8A8-FACA-401F-B6E1-FEA5BF91C550}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7A84B8A8-FACA-401F-B6E1-FEA5BF91C550}.Debug|x64.ActiveCfg = Debug|Any CPU + {7A84B8A8-FACA-401F-B6E1-FEA5BF91C550}.Debug|x64.Build.0 = Debug|Any CPU + {7A84B8A8-FACA-401F-B6E1-FEA5BF91C550}.Debug|x86.ActiveCfg = Debug|Any CPU + {7A84B8A8-FACA-401F-B6E1-FEA5BF91C550}.Debug|x86.Build.0 = Debug|Any CPU {7A84B8A8-FACA-401F-B6E1-FEA5BF91C550}.Release|Any CPU.ActiveCfg = Release|Any CPU {7A84B8A8-FACA-401F-B6E1-FEA5BF91C550}.Release|Any CPU.Build.0 = Release|Any CPU + {7A84B8A8-FACA-401F-B6E1-FEA5BF91C550}.Release|x64.ActiveCfg = Release|Any CPU + {7A84B8A8-FACA-401F-B6E1-FEA5BF91C550}.Release|x64.Build.0 = Release|Any CPU + {7A84B8A8-FACA-401F-B6E1-FEA5BF91C550}.Release|x86.ActiveCfg = Release|Any CPU + {7A84B8A8-FACA-401F-B6E1-FEA5BF91C550}.Release|x86.Build.0 = Release|Any CPU {BEFA562D-5E8E-4E1E-9296-2D5112A9D983}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {BEFA562D-5E8E-4E1E-9296-2D5112A9D983}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BEFA562D-5E8E-4E1E-9296-2D5112A9D983}.Debug|x64.ActiveCfg = Debug|Any CPU + {BEFA562D-5E8E-4E1E-9296-2D5112A9D983}.Debug|x64.Build.0 = Debug|Any CPU + {BEFA562D-5E8E-4E1E-9296-2D5112A9D983}.Debug|x86.ActiveCfg = Debug|Any CPU + {BEFA562D-5E8E-4E1E-9296-2D5112A9D983}.Debug|x86.Build.0 = Debug|Any CPU {BEFA562D-5E8E-4E1E-9296-2D5112A9D983}.Release|Any CPU.ActiveCfg = Release|Any CPU {BEFA562D-5E8E-4E1E-9296-2D5112A9D983}.Release|Any CPU.Build.0 = Release|Any CPU + {BEFA562D-5E8E-4E1E-9296-2D5112A9D983}.Release|x64.ActiveCfg = Release|Any CPU + {BEFA562D-5E8E-4E1E-9296-2D5112A9D983}.Release|x64.Build.0 = Release|Any CPU + {BEFA562D-5E8E-4E1E-9296-2D5112A9D983}.Release|x86.ActiveCfg = Release|Any CPU + {BEFA562D-5E8E-4E1E-9296-2D5112A9D983}.Release|x86.Build.0 = Release|Any CPU {C5855F89-D7F9-4766-A04D-1B365F694292}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C5855F89-D7F9-4766-A04D-1B365F694292}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C5855F89-D7F9-4766-A04D-1B365F694292}.Debug|x64.ActiveCfg = Debug|Any CPU + {C5855F89-D7F9-4766-A04D-1B365F694292}.Debug|x64.Build.0 = Debug|Any CPU + {C5855F89-D7F9-4766-A04D-1B365F694292}.Debug|x86.ActiveCfg = Debug|Any CPU + {C5855F89-D7F9-4766-A04D-1B365F694292}.Debug|x86.Build.0 = Debug|Any CPU {C5855F89-D7F9-4766-A04D-1B365F694292}.Release|Any CPU.ActiveCfg = Release|Any CPU {C5855F89-D7F9-4766-A04D-1B365F694292}.Release|Any CPU.Build.0 = Release|Any CPU + {C5855F89-D7F9-4766-A04D-1B365F694292}.Release|x64.ActiveCfg = Release|Any CPU + {C5855F89-D7F9-4766-A04D-1B365F694292}.Release|x64.Build.0 = Release|Any CPU + {C5855F89-D7F9-4766-A04D-1B365F694292}.Release|x86.ActiveCfg = Release|Any CPU + {C5855F89-D7F9-4766-A04D-1B365F694292}.Release|x86.Build.0 = Release|Any CPU {E34C0BDC-20E7-4627-AA8D-C4CEECF3B3FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E34C0BDC-20E7-4627-AA8D-C4CEECF3B3FE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E34C0BDC-20E7-4627-AA8D-C4CEECF3B3FE}.Debug|x64.ActiveCfg = Debug|Any CPU + {E34C0BDC-20E7-4627-AA8D-C4CEECF3B3FE}.Debug|x64.Build.0 = Debug|Any CPU + {E34C0BDC-20E7-4627-AA8D-C4CEECF3B3FE}.Debug|x86.ActiveCfg = Debug|Any CPU + {E34C0BDC-20E7-4627-AA8D-C4CEECF3B3FE}.Debug|x86.Build.0 = Debug|Any CPU {E34C0BDC-20E7-4627-AA8D-C4CEECF3B3FE}.Release|Any CPU.ActiveCfg = Release|Any CPU {E34C0BDC-20E7-4627-AA8D-C4CEECF3B3FE}.Release|Any CPU.Build.0 = Release|Any CPU + {E34C0BDC-20E7-4627-AA8D-C4CEECF3B3FE}.Release|x64.ActiveCfg = Release|Any CPU + {E34C0BDC-20E7-4627-AA8D-C4CEECF3B3FE}.Release|x64.Build.0 = Release|Any CPU + {E34C0BDC-20E7-4627-AA8D-C4CEECF3B3FE}.Release|x86.ActiveCfg = Release|Any CPU + {E34C0BDC-20E7-4627-AA8D-C4CEECF3B3FE}.Release|x86.Build.0 = Release|Any CPU + {70E7E2D3-6487-4C2F-9A63-D69919F8E23C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {70E7E2D3-6487-4C2F-9A63-D69919F8E23C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {70E7E2D3-6487-4C2F-9A63-D69919F8E23C}.Debug|x64.ActiveCfg = Debug|Any CPU + {70E7E2D3-6487-4C2F-9A63-D69919F8E23C}.Debug|x64.Build.0 = Debug|Any CPU + {70E7E2D3-6487-4C2F-9A63-D69919F8E23C}.Debug|x86.ActiveCfg = Debug|Any CPU + {70E7E2D3-6487-4C2F-9A63-D69919F8E23C}.Debug|x86.Build.0 = Debug|Any CPU + {70E7E2D3-6487-4C2F-9A63-D69919F8E23C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {70E7E2D3-6487-4C2F-9A63-D69919F8E23C}.Release|Any CPU.Build.0 = Release|Any CPU + {70E7E2D3-6487-4C2F-9A63-D69919F8E23C}.Release|x64.ActiveCfg = Release|Any CPU + {70E7E2D3-6487-4C2F-9A63-D69919F8E23C}.Release|x64.Build.0 = Release|Any CPU + {70E7E2D3-6487-4C2F-9A63-D69919F8E23C}.Release|x86.ActiveCfg = Release|Any CPU + {70E7E2D3-6487-4C2F-9A63-D69919F8E23C}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Infrastructure/Data/DatabaseProviderConfigurator.cs b/Infrastructure/Data/DatabaseProviderConfigurator.cs new file mode 100644 index 00000000..a0878ff9 --- /dev/null +++ b/Infrastructure/Data/DatabaseProviderConfigurator.cs @@ -0,0 +1,372 @@ +using Hangfire; +using Hangfire.PostgreSql; +using Hangfire.Storage.SQLite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Configuration; + +namespace Infrastructure.Data +{ + /// + /// انتخاب‌دهنده ی پایگاه داده: منطق انتخاب Provider (SqlServer / Postgres / Sqlite) + /// را در یک جا متمرکز می‌کند تا هم تست‌پذیر باشد و هم در DbContext و Hangfire تکرار نشود. + /// + public static class DatabaseProviderConfigurator + { + /// مقادیر معتبرِ DatabaseProvider (بدون حساسیت به حروف). + public const string SqlServer = "sqlserver"; + public const string Postgres = "postgres"; + public const string Sqlite = "sqlite"; + + /// + /// مقدار خامِ DatabaseProvider را نرمال می‌کند (مثلاً "PostgreSQL" → "postgres"). + /// + /// اگر مقدار خالی یا ناشناخته باشد. + public static string Normalize(string? rawProvider) + { + var normalized = rawProvider?.Trim().ToLowerInvariant(); + + switch (normalized) + { + case null or "": + throw new NotSupportedException( + "DatabaseProvider is not configured. Valid values: SqlServer, Postgres, Sqlite."); + + case "sqlserver": + case "sql-server": + return SqlServer; + + case "postgres": + case "postgresql": + case "pg": + return Postgres; + + case "sqlite": + case "sql-lite": + case "lite": + return Sqlite; + + default: + // نسخه/شناسه‌های رایج در env: sqlserver2019, sqlite3, ... + if (normalized.StartsWith("sqlserver", StringComparison.Ordinal)) + { + return SqlServer; + } + + if (normalized.StartsWith("sqlite", StringComparison.Ordinal)) + { + return Sqlite; + } + + if (normalized.StartsWith("postgres", StringComparison.Ordinal)) + { + return Postgres; + } + + throw new NotSupportedException( + $"Unsupported DatabaseProvider: '{rawProvider}'. Valid values: SqlServer, Postgres, Sqlite."); + } + } + + /// + /// DbContext را بر اساس Provider پیکربندی می‌کند (SqlServer / Postgres / Sqlite). + /// + public static void ConfigureDbContext(DbContextOptionsBuilder options, string provider, string connectionString) + { + switch (Normalize(provider)) + { + case SqlServer: + options.UseSqlServer(connectionString, sql => + { + sql.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName); + sql.EnableRetryOnFailure( + maxRetryCount: 5, + maxRetryDelay: TimeSpan.FromSeconds(30), + errorNumbersToAdd: null); + }); + break; + + case Postgres: + options.UseNpgsql(connectionString, npgsql => + { + npgsql.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName); + npgsql.EnableRetryOnFailure( + maxRetryCount: 5, + maxRetryDelay: TimeSpan.FromSeconds(30), + errorCodesToAdd: null); + }); + break; + + case Sqlite: + options.UseSqlite(connectionString); + break; + } + } + + /// + /// Storage مناسبِ Hangfire را بر اساس Provider انتخاب می‌کند: + /// SqlServer → SqlServer storage، Postgres → Postgres storage، Sqlite → SQLite storage. + /// + public static void ConfigureHangfireStorage(IGlobalConfiguration config, string provider, string connectionString) + { + switch (Normalize(provider)) + { + case SqlServer: + config.UseSqlServerStorage(connectionString); + break; + + case Postgres: + config.UsePostgreSqlStorage(opts => opts.UseNpgsqlConnection(connectionString)); + break; + + case Sqlite: + { + // Hangfire.Storage.SQLite آرگومان را «مسیر فایل» می‌داند نه connection string؛ + // اگر کل رشته پاس شود، فایلی به نام "Data Source=forexbot.db" ساخته می‌شود. + var sqlitePath = new Microsoft.Data.Sqlite.SqliteConnectionStringBuilder(connectionString).DataSource; + if (string.IsNullOrWhiteSpace(sqlitePath) || sqlitePath == ":memory:") + { + sqlitePath = "forexbot-hangfire.db"; + } + + config.UseSQLiteStorage(sqlitePath); + break; + } + } + } + + /// + /// اسکیمای مدل را -در صورت نبودِ جدول‌ها- با اسکریپت مناسبِ همان Provider می‌سازد. + /// برای SqlServer از Migrate استفاده می‌شود (مهاجرت‌های موجود)، برای Postgres/Sqlite + /// از GenerateCreateScript که خروجیِ کاملاً Provider-agnostic نیست ولی دقیقاً همان + /// مدل را با تایپ‌های درستِ همان درایور می‌سازد. + /// + /// true اگر اسکیما ساخته یا مهاجرت اعمال شده باشد؛ false اگر از قبل موجود بود. + public static async Task EnsureSchemaAsync(AppDbContext db, string provider, CancellationToken ct = default) + { + if (Normalize(provider) == SqlServer) + { + var pending = (await db.Database.GetPendingMigrationsAsync(ct)).ToList(); + if (pending.Count == 0) + { + return false; + } + + await db.Database.MigrateAsync(ct); + return true; + } + + // Postgres / Sqlite: اگر جدول اصلی وجود نداشت، اسکیمای مدل را بساز. + if (await HasApplicationSchemaAsync(db, ct)) + { + return false; + } + + var script = TranslateSchemaScript(provider, db.Database.GenerateCreateScript()); + await db.Database.ExecuteSqlRawAsync(script, ct); + return true; + } + + /// + /// ترجمه‌ی تایپ‌های SQL Server که در مدل (HasColumnType / Column(TypeName)) hardcode شده‌اند + /// به معادلِ درستِ هدف. بدون این ترجمه، اسکریپت SQLite/Postgres از نظر نحوی خطا می‌دهد. + /// + public static string TranslateSchemaScript(string provider, string script) + { + // مترجمِ خالص است: روی provider ناشناخته نباید پرتاب کند، فقط بی‌اثر باشد. + string normalized; + try + { + normalized = Normalize(provider); + } + catch (NotSupportedException) + { + return script; + } + + switch (normalized) + { + case Sqlite: + // SQLite: نام‌های نوعِ دلخواه را می‌پذیرد؛ TEXT/NUMERIC دقیق‌ترین معادل‌اند. + script = System.Text.RegularExpressions.Regex.Replace( + script, @"\bnvarchar\s*\(\s*max\s*\)", "TEXT", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + script = System.Text.RegularExpressions.Regex.Replace( + script, @"\bnvarchar\s*\(\s*\d+\s*\)", "TEXT", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + script = System.Text.RegularExpressions.Regex.Replace( + script, @"\bdecimal\s*\(\s*\d+\s*,\s*\d+\s*\)", "TEXT", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + script = System.Text.RegularExpressions.Regex.Replace( + script, @"\bbit\b", "INTEGER", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + script = System.Text.RegularExpressions.Regex.Replace( + script, @"\bdatetime2\s*(?:\(\s*\d+\s*\))?", "TEXT", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + script = System.Text.RegularExpressions.Regex.Replace( + script, @"\buniqueidentifier\b", "TEXT", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + // فیلتر ایندکس‌های EF: WHERE [Col] IS NOT NULL -> WHERE "Col" IS NOT NULL + script = System.Text.RegularExpressions.Regex.Replace( + script, @"\[([^\]]+)\]", "\"$1\""); + script = TranslateSqlServerFunctions(script, "sqlite"); + return script; + + case Postgres: + script = System.Text.RegularExpressions.Regex.Replace( + script, @"\bnvarchar\s*\(\s*max\s*\)", "text", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + script = System.Text.RegularExpressions.Regex.Replace( + script, @"\bnvarchar\s*\(\s*(\d+)\s*\)", "varchar($1)", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + script = System.Text.RegularExpressions.Regex.Replace( + script, @"\bdatetime2\s*(?:\(\s*\d+\s*\))?", "timestamp with time zone", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + script = System.Text.RegularExpressions.Regex.Replace( + script, @"\buniqueidentifier\b", "uuid", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + script = System.Text.RegularExpressions.Regex.Replace( + script, @"\bdecimal\s*\(\s*(\d+)\s*,\s*(\d+)\s*\)", "numeric($1, $2)", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + script = System.Text.RegularExpressions.Regex.Replace( + script, @"\bbit\b", "boolean", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + script = System.Text.RegularExpressions.Regex.Replace( + script, @"\bint\s+identity\b", "serial", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + // فیلتر ایندکس‌های EF: WHERE [Col] IS NOT NULL -> WHERE "Col" IS NOT NULL + script = System.Text.RegularExpressions.Regex.Replace( + script, @"\[([^\]]+)\]", "\"$1\""); + script = TranslateSqlServerFunctions(script, "postgres"); + return script; + + default: + return script; + } + } + + /// + /// ترجمه‌ی توابع SQL Server که در مدل (HasDefaultValueSql و مشابه) استفاده شده‌اند + /// به معادلِ هدف. بدون این ترجمه، اسکیماسازی Postgres/SQLite روی GETUTCDATE و ... می‌شکند. + /// + private static string TranslateSqlServerFunctions(string script, string target) + { + if (target == "postgres") + { + // (GETUTCDATE()) -> (now() at time zone 'utc') + script = System.Text.RegularExpressions.Regex.Replace( + script, @"\bGETUTCDATE\s*\(\s*\)", "(now() at time zone 'utc')", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + script = System.Text.RegularExpressions.Regex.Replace( + script, @"\bGETDATE\s*\(\s*\)", "now()", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + script = System.Text.RegularExpressions.Regex.Replace( + script, @"\bISNULL\s*\(", "COALESCE(", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + script = System.Text.RegularExpressions.Regex.Replace( + script, @"\bNEWID\s*\(\s*\)", "gen_random_uuid()", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + return script; + } + + // sqlite + script = System.Text.RegularExpressions.Regex.Replace( + script, @"\bGETUTCDATE\s*\(\s*\)", "datetime('now')", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + script = System.Text.RegularExpressions.Regex.Replace( + script, @"\bGETDATE\s*\(\s*\)", "datetime('now')", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + script = System.Text.RegularExpressions.Regex.Replace( + script, @"\bISNULL\s*\(", "IFNULL(", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + script = System.Text.RegularExpressions.Regex.Replace( + script, @"\bNEWID\s*\(\s*\)", + "(lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || lower(hex(randomblob(2))) || '-8' || lower(hex(randomblob(2))) || '-' || lower(hex(randomblob(6))))", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + return script; + } + + /// + /// اطمینان از وجود خودِ دیتابیس (قبل از ساخت اسکیما): + /// Postgres → اتصال به دیتابیس maintenance و CREATE DATABASE در صورت نیاز + /// SqlServer → همانند Postgres با دیتابیس master + /// Sqlite → فقط ساخت پوشه‌ی فایل دیتابیس (خودِ فایل را درایور می‌سازد) + /// + public static async Task EnsureDatabaseExistsAsync(string provider, string connectionString, CancellationToken ct = default) + { + switch (Normalize(provider)) + { + case Sqlite: + { + // Data Source=/abs/path/file.db یا Data Source=relative.db + var builder = new Microsoft.Data.Sqlite.SqliteConnectionStringBuilder(connectionString); + var dataSource = builder.DataSource; + if (!string.IsNullOrEmpty(dataSource) + && dataSource != ":memory:" + && !dataSource.StartsWith("file::memory:", StringComparison.OrdinalIgnoreCase)) + { + var dir = Path.GetDirectoryName(Path.GetFullPath(dataSource)); + if (!string.IsNullOrEmpty(dir)) + { + Directory.CreateDirectory(dir); + } + } + return; + } + + case Postgres: + { + var npgsql = new Npgsql.NpgsqlConnectionStringBuilder(connectionString); + var targetDb = npgsql.Database; + if (string.IsNullOrWhiteSpace(targetDb)) + { + return; + } + + npgsql.Database = "postgres"; // دیتابیس maintenance + await using var conn = new Npgsql.NpgsqlConnection(npgsql.ConnectionString); + await conn.OpenAsync(ct); + + // فقط اگر وجود نداشت بساز (پارامترشده، بدون تزریق) + await using var check = conn.CreateCommand(); + check.CommandText = "SELECT 1 FROM pg_database WHERE datname = @name"; + check.Parameters.AddWithValue("name", targetDb); + var exists = await check.ExecuteScalarAsync(ct) is not null; + if (exists) + { + return; + } + + await using var create = conn.CreateCommand(); + var quotedDb = "\"" + targetDb.Replace("\"", "\"\"") + "\""; + create.CommandText = "CREATE DATABASE " + quotedDb; + await create.ExecuteNonQueryAsync(ct); + return; + } + + case SqlServer: + // SQL Server: EF خودش دیتابیس را هنگام MigrateAsync می‌سازد (تأیید شده با پروب). + // پیش‌ساخت دستی باعث race با SqlServerDatabaseCreator.Exists() می‌شود و + // خطای 1801 («database already exists») می‌دهد — پس اینجا کاری نمی‌کنیم. + return; + + + } + } + + /// تشخیص اینکه آیا اسکیمای مدل (حداقل جدول Users) از قبل ساخته شده است. + private static async Task HasApplicationSchemaAsync(AppDbContext db, CancellationToken ct) + { + try + { + // COUNT روی جدول Users: اگر جدول نباشد استثنا می‌دهد. + _ = await db.Users.AsNoTracking().CountAsync(ct); + return true; + } + catch + { + return false; + } + } + } +} diff --git a/Infrastructure/Data/ServiceCollectionExtensions.cs b/Infrastructure/Data/ServiceCollectionExtensions.cs index adca9152..9cca92dc 100644 --- a/Infrastructure/Data/ServiceCollectionExtensions.cs +++ b/Infrastructure/Data/ServiceCollectionExtensions.cs @@ -10,7 +10,7 @@ using Microsoft.Extensions.Configuration; // IConfiguration using Microsoft.Extensions.DependencyInjection; // IServiceCollection using Hangfire; -using Hangfire.SqlServer; +using Hangfire.PostgreSql; namespace Infrastructure { @@ -40,54 +40,18 @@ public static IServiceCollection AddInfrastructureServices( ?? throw new InvalidOperationException( "DefaultConnection is not configured."); - // 3. پیکربندی DbContext بر اساس Provider - switch (dbProvider) - { - case "sqlserver": - services.AddDbContext(opts => - opts.UseSqlServer(connectionString, sql => - { - // مشخص کردن اسمبلی حاوی Migrationها - sql.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName); - // فعال کردن retry برای افزایش پایداری - sql.EnableRetryOnFailure( - maxRetryCount: 5, - maxRetryDelay: TimeSpan.FromSeconds(30), - errorNumbersToAdd: null); - })); - break; - - case "postgres": - case "postgresql": - services.AddDbContext(opts => - opts.UseNpgsql(connectionString, npgsql => - { - npgsql.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName); - npgsql.EnableRetryOnFailure( - maxRetryCount: 5, - maxRetryDelay: TimeSpan.FromSeconds(30), - errorCodesToAdd: null); - })); - break; - - default: - throw new NotSupportedException( - $"Unsupported DatabaseProvider: '{dbProvider}'."); - } + // 3. پیکربندی DbContext بر اساس Provider (SqlServer / Postgres / Sqlite) + // منطق انتخاب در DatabaseProviderConfigurator متمرکز و تست‌پذیر است. + var provider = DatabaseProviderConfigurator.Normalize(dbProvider); + services.AddDbContext(opts => + DatabaseProviderConfigurator.ConfigureDbContext(opts, provider, connectionString)); // 4. رجیستر IAppDbContext برای استفاده در لایه Application services.AddScoped( sp => sp.GetRequiredService()); - // Add Hangfire services - services.AddHangfire(config => config - .SetDataCompatibilityLevel(CompatibilityLevel.Version_180) - .UseSimpleAssemblyNameTypeSerializer() - .UseRecommendedSerializerSettings() - .UseSqlServerStorage(connectionString)); - - // Add the processing server as IHostedService - services.AddHangfireServer(); + // توجه: پیکربندی Hangfire (storage متناسب با Provider + HangfireServer) + // به‌صورت متمرکز در WebAPI/Program.cs انجام می‌شود تا از ثبت تکراری جلوگیری شود. diff --git a/Infrastructure/Infrastructure.csproj b/Infrastructure/Infrastructure.csproj index 4b657f73..f8bb0d8c 100644 --- a/Infrastructure/Infrastructure.csproj +++ b/Infrastructure/Infrastructure.csproj @@ -14,27 +14,29 @@ - - - - - - - + + + + + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - + + + + + + - + - - + + diff --git a/Infrastructure/Services/TelegramUserApiClient.cs b/Infrastructure/Services/TelegramUserApiClient.cs index 1d1ba51c..83dad7d8 100644 --- a/Infrastructure/Services/TelegramUserApiClient.cs +++ b/Infrastructure/Services/TelegramUserApiClient.cs @@ -64,7 +64,29 @@ public TelegramUserApiClient( }; // Create new client instance - _client = new WTelegram.Client(ConfigProvider); + // نصب تازه‌ی end-user ممکن است api_hash نداشته باشد یا فایل session خراب/ناقص باشد: + // نباید کل برنامه را از کار بیندازد (Fail-Soft). سرویس تلگرام بعداً با تنظیم درست بالا می‌آید. + try + { + var sessionPath = Path.Combine(AppContext.BaseDirectory, _settings.SessionPath ?? "telegram_user.session"); + if (File.Exists(sessionPath) && new FileInfo(sessionPath).Length == 0) + { + File.Delete(sessionPath); + _logger.LogWarning("[TELEGRAM_USER_API] Empty/corrupt session file was deleted: {Path}", sessionPath); + } + + _client = new WTelegram.Client(ConfigProvider); + } + catch (Exception ex) + { + _client = null; + _logger.LogError(ex, + "[TELEGRAM_USER_API] Telegram client could not be created (api_id/api_hash missing or invalid session). " + + "Telegram features stay disabled until configured. Continuing without Telegram."); + } + + if (_client == null) return; + _client.OnUpdates += async updates => { _logger.LogCritical("[USER_API_ON_UPDATES_TRIGGERED] Raw updates object of type: {UpdateType} from WTelegram.Client", updates.GetType().FullName); diff --git a/Infrastructure/Services/TelegramUserApiInitializationService.cs b/Infrastructure/Services/TelegramUserApiInitializationService.cs index 7518541f..95f0978d 100644 --- a/Infrastructure/Services/TelegramUserApiInitializationService.cs +++ b/Infrastructure/Services/TelegramUserApiInitializationService.cs @@ -1,4 +1,5 @@ using Application.Common.Interfaces; // فرض بر این است که ITelegramUserApiClient اینجا تعریف شده +using Infrastructure.Settings; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -29,16 +30,28 @@ public class TelegramUserApiInitializationService : BackgroundService /// private const double RetryBackoffFactor = 2.0; + private readonly TelegramUserApiSettings _settings; + public TelegramUserApiInitializationService( ILogger logger, - ITelegramUserApiClient userApiClient) + ITelegramUserApiClient userApiClient, + Microsoft.Extensions.Options.IOptions settingsOptions) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _userApiClient = userApiClient ?? throw new ArgumentNullException(nameof(userApiClient)); + _settings = settingsOptions?.Value ?? new TelegramUserApiSettings(); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + // نصب تازه‌ی end-user هنوز api_id/api_hash ندارد: نباید کل هاست را متوقف کند. + if (_settings.ApiId <= 0 || string.IsNullOrWhiteSpace(_settings.ApiHash)) + { + _logger.LogWarning( + "TelegramUserApi: ApiId/ApiHash not configured. Skipping Telegram User API initialization; the rest of the application runs normally."); + return; + } + #region Variables for Retry Logic // شمارنده برای تعداد تلاش‌های انجام شده int attemptCount = 0; @@ -88,10 +101,9 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) // بررسی اینکه آیا تعداد تلاش‌های مجاز به پایان رسیده است. if (attemptCount > MaxConnectionRetries) { - _logger.LogCritical(ex, "Exhausted all {MaxAttemptsPlusOne} connection attempts. Telegram User API client could not be initialized. The application startup might fail as designed for critical dependencies.", MaxConnectionRetries + 1); - // پس از اتمام تمام تلاش‌ها، استثنای آخر را مجدداً پرتاب می‌کنیم. - // این باعث می‌شود که اگر این سرویس برای شروع برنامه حیاتی باشد، برنامه با خطا مواجه شده و متوقف شود (Fail-Fast). - throw; + _logger.LogCritical(ex, "Exhausted all {MaxAttemptsPlusOne} connection attempts. Telegram User API client could not be initialized. Telegram features stay disabled; the application itself keeps running (Fail-Soft).", MaxConnectionRetries + 1); + // نباید هاست را متوقف کند: بقیه‌ی سرویس‌ها (API، صف، دیتابیس) بدون تلگرام کار می‌کنند. + return; } // اگر توکن توقف درخواست شده باشد، از ادامه تلاش‌ها صرف‌نظر می‌کنیم. diff --git a/README.md b/README.md new file mode 100644 index 00000000..a2ce27bd --- /dev/null +++ b/README.md @@ -0,0 +1,154 @@ +# ForexTradingBot + +Production-ready trading bot platform with **multi-database support** — pick +**PostgreSQL**, **SQLite** or **SQL Server** on first startup. Runs fully +containerized (PostgreSQL + Redis + API) or natively on Windows / Linux / macOS. + +--- + +## Quick install — one command + +### Linux / macOS + +```bash +curl -fsSL https://raw.githubusercontent.com/Opselon/ForexTradingBot/main/install.sh | bash +``` + +### Windows (PowerShell) + +```powershell +irm https://raw.githubusercontent.com/Opselon/ForexTradingBot/main/install.ps1 | iex +``` + +Both scripts are interactive but safe to re-run (fully idempotent): + +| Option | What happens | +|--------|--------------| +| **1) Docker** (recommended) | Clones the repo, generates a database password into `.env`, builds the image, starts **PostgreSQL + Redis + API**, waits for `/health` and prints the result. Nothing else to configure. | +| **2) Normal** | Installs the **.NET 9 SDK** if missing, builds, then starts the app. **On first startup the app itself asks which database you want** (see below) and remembers the answer. | + +--- + +## First-run database wizard + +The first time the app starts without a configured database, it prints: + +``` + ╔══════════════════════════════════════════════════════╗ + ║ ForexTradingBot — First-Run Setup ║ + ║ پایگاه داده‌ی مورد نظر خود را انتخاب کنید ║ + ╚══════════════════════════════════════════════════════╝ + + 1) SQLite — سریع‌ترین، بدون نصب، فایل محلی + 2) PostgreSQL — پیشنهادی برای سرور و production + 3) SQL Server — مناسب محیط‌های ویندوزی موجود + + انتخاب شما [1/2/3] (پیش‌فرض: 1): +``` + +Your choice (provider + connection string, password entered hidden) is saved to +`appsettings.Local.json` and is picked up on every later start — the wizard only +appears once. + +### Non-interactive environments (Docker, CI, systemd) + +Set environment variables instead; the wizard is skipped automatically: + +```bash +DatabaseProvider=Postgres # SqlServer | Postgres | Sqlite (aliases accepted: pg, postgresql, sql-lite, …) +ConnectionStrings__DefaultConnection="Host=db;Database=forexbot;Username=app;Password=…" +``` + +With no variables at all, the app falls back to **SQLite** and starts anyway — +no configuration is required to get a healthy process. + +--- + +## Supported databases + +| Provider | Schema creation | Hangfire storage | Notes | +|----------|-----------------|------------------|-------| +| **SQLite** | EF `GenerateCreateScript` (types translated) | `Hangfire.Storage.SQLite` | Zero setup — single file | +| **PostgreSQL** | EF `GenerateCreateScript` (types translated) | `Hangfire.PostgreSql` | Auto-creates the database on first run | +| **SQL Server** | EF migrations (`MigrateAsync`) | SQL Server storage | Auto-creates the database via `master` | + +The EF model is declared with SQL Server type names (`nvarchar(max)`, +`datetime2`, `uniqueidentifier`, `bit`, `decimal(18,8)`, `GETUTCDATE()`, +`WHERE [Col] IS NOT NULL`). Those are translated per-provider at schema-creation +time by `Infrastructure/Data/DatabaseProviderConfigurator.TranslateSchemaScript`, +so the same model works on all three engines. + +--- + +## Docker + +```bash +cp .env.example .env # then edit values (or let install.sh generate them) +docker compose up -d --build +curl http://localhost:8080/health # → {"status":"healthy",…} +``` + +Services: `forex-api` (:8080), `postgres:17` (:5432), `redis:7` (:6379). + +```bash +docker compose logs -f forex-api # logs +docker compose down # stop +docker compose down -v # stop + wipe data +``` + +--- + +## Normal (native) install + +```bash +# Linux / macOS +dotnet build -c Release +dotnet run --project WebAPI -c Release # first run shows the DB wizard +# health: http://localhost:5000/health +``` + +```powershell +# Windows +dotnet build -c Release +dotnet run --project WebAPI -c Release +``` + +Requirements: .NET 9 SDK (the installer adds it automatically). + +--- + +## Releases (CI) + +Releases are published automatically by GitHub Actions +(`.github/workflows/release.yml`) whenever a version tag is pushed: + +```bash +git tag v1.0.0 +git push origin v1.0.0 +``` + +That runs tests, then publishes: + +* self-contained archives for `linux-x64`, `win-x64`, `osx-x64`, `osx-arm64` +* a container image to `ghcr.io//:` (plus `latest`) +* a GitHub Release with notes generated from commits since the previous tag + +PRs and pushes to `main` are verified by `.github/workflows/ci.yml` +(build + tests + Docker smoke test against both PostgreSQL and SQLite). + +--- + +## Tests + +```bash +dotnet test ForexTradingBot.sln -c Release +``` + +--- + +## Security notes + +* `.env` is git-ignored — never commit real tokens. +* Database passwords are auto-generated by the installer, never defaults. +* Telegram bot token is **optional**: the app starts and stays healthy without + it; Telegram features are disabled and logged until you configure it. diff --git a/TelegramPanel/Extensions/ServiceCollectionExtensions.cs b/TelegramPanel/Extensions/ServiceCollectionExtensions.cs index 37c02bc2..313f10c7 100644 --- a/TelegramPanel/Extensions/ServiceCollectionExtensions.cs +++ b/TelegramPanel/Extensions/ServiceCollectionExtensions.cs @@ -36,7 +36,13 @@ public static IServiceCollection AddTelegramPanelServices(this IServiceCollectio var settings = serviceProvider.GetRequiredService>().Value; if (string.IsNullOrWhiteSpace(settings.BotToken)) { - throw new ArgumentNullException(nameof(settings.BotToken), "TelegramPanel: Bot Token is not configured."); + // Fail-Soft: نصب تازه هنوز توکن ندارد؛ نباید کل سرویس را از کار بیندازد. + // TelegramBotService با GetMe ناموفق، پنل ربات را غیرفعال می‌کند و بقیه‌ی سرویس‌ها بالا می‌مانند. + var logger = serviceProvider.GetService() + ?.CreateLogger("TelegramPanel"); + Microsoft.Extensions.Logging.LoggerExtensions.LogWarning(logger, + "TelegramPanel: Bot Token is not configured. The bot panel stays disabled until a token is provided (TelegramPanel:BotToken)."); + return new TelegramBotClient("0:000000000000000000000000000000000"); } return new TelegramBotClient(settings.BotToken); }); diff --git a/TelegramPanel/Infrastructure/TelegramBotService.cs b/TelegramPanel/Infrastructure/TelegramBotService.cs index 0deca8b9..a1bfbcaf 100644 --- a/TelegramPanel/Infrastructure/TelegramBotService.cs +++ b/TelegramPanel/Infrastructure/TelegramBotService.cs @@ -38,6 +38,13 @@ public async Task StartAsync(CancellationToken hostCancellationToken) User? me = null; // برای نگهداری اطلاعات ربات #region Bot Information Retrieval + if (string.IsNullOrWhiteSpace(_settings.BotToken)) + { + _logger.LogWarning( + "TelegramPanel:BotToken is not configured — skipping bot startup immediately (no network call). Set the token and restart to enable the panel."); + return; + } + try { _logger.LogInformation("Attempting to connect to Telegram and get bot information..."); diff --git a/TelegramPanel/TelegramPanel.csproj b/TelegramPanel/TelegramPanel.csproj index 88cacbed..74303a95 100644 --- a/TelegramPanel/TelegramPanel.csproj +++ b/TelegramPanel/TelegramPanel.csproj @@ -14,15 +14,15 @@ - - - - - - + + + + + + - - + + diff --git a/Tests.Application/DatabaseProviderConfiguratorTests.cs b/Tests.Application/DatabaseProviderConfiguratorTests.cs new file mode 100644 index 00000000..71d60e63 --- /dev/null +++ b/Tests.Application/DatabaseProviderConfiguratorTests.cs @@ -0,0 +1,204 @@ +using FluentAssertions; +using Infrastructure.Data; + +namespace Tests.Application; + +/// +/// Tests for the multi-database core: provider normalization and +/// schema-script translation for SQLite / PostgreSQL. +/// These are the two functions that decide whether the very first startup +/// succeeds on a machine the app was never run on before. +/// +public class DatabaseProviderConfiguratorTests +{ + // ---------------- Normalize ---------------- + + [Theory] + [InlineData("SqlServer", "sqlserver")] + [InlineData("sql-server", "sqlserver")] + [InlineData("SQLSERVER", "sqlserver")] + [InlineData("SqlServer2019", "sqlserver")] + [InlineData("Postgres", "postgres")] + [InlineData("PostgreSQL", "postgres")] + [InlineData("pg", "postgres")] + [InlineData("Postgres17", "postgres")] + [InlineData("Sqlite", "sqlite")] + [InlineData("sql-lite", "sqlite")] + [InlineData("lite", "sqlite")] + [InlineData("sqlite3", "sqlite")] + [InlineData(" SQLite ", "sqlite")] + public void Normalize_accepts_aliases_and_is_case_insensitive(string raw, string expected) + => DatabaseProviderConfigurator.Normalize(raw).Should().Be(expected); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("mysql")] + [InlineData("mongodb")] + [InlineData("oracle")] + public void Normalize_rejects_unknown_or_empty_values(string? raw) + { + var act = () => DatabaseProviderConfigurator.Normalize(raw); + act.Should().Throw() + .WithMessage("*Valid values: SqlServer, Postgres, Sqlite*"); + } + + // ---------------- TranslateSchemaScript: SQLite ---------------- + + [Fact] + public void Sqlite_translates_sqlserver_type_names() + { + var script = """ + CREATE TABLE "Users" ( + "Id" uniqueidentifier NOT NULL, + "Username" nvarchar(max) NOT NULL, + "Bio" nvarchar(256) NULL, + "Balance" decimal(18, 8) NOT NULL, + "CreatedAt" datetime2 NOT NULL, + "IsActive" bit NOT NULL + ); + """; + + var out_ = DatabaseProviderConfigurator.TranslateSchemaScript("sqlite", script); + + out_.Should().NotContain("nvarchar", "nvarchar is not valid in SQLite"); + out_.Should().NotContain("datetime2"); + out_.Should().NotContain("uniqueidentifier"); + out_.Should().NotContain("bit NOT", "SQL Server 'bit' must be rewritten"); + out_.Should().NotContain("decimal(", "decimal(18,8) becomes TEXT in SQLite"); + out_.Should().Contain("TEXT"); + } + + [Fact] + public void Sqlite_translates_sqlserver_default_functions() + { + var script = """ + CREATE TABLE "RssSources" ( + "CreatedAt" datetime2 NOT NULL DEFAULT GETUTCDATE(), + "Name" nvarchar(200) NOT NULL DEFAULT GETDATE(), + "Fallback" nvarchar(100) NULL DEFAULT ISNULL(N'x', N''), + "RowId" uniqueidentifier NOT NULL DEFAULT NEWID() + ); + """; + + var out_ = DatabaseProviderConfigurator.TranslateSchemaScript("sqlite", script); + + out_.Should().NotContain("GETUTCDATE"); + out_.Should().NotContain("GETDATE"); + out_.Should().NotContain("ISNULL", "ISNULL must become IFNULL in SQLite"); + out_.Should().NotContain("NEWID"); + out_.Should().Contain("datetime('now')"); + } + + [Fact] + public void Sqlite_rewrites_indexed_filtered_index_brackets() + { + var script = @"CREATE INDEX ""IX_NewsItems"" ON ""NewsItems"" (""RssSourceId"", ""SourceItemId"") WHERE [SourceItemId] IS NOT NULL;"; + + var out_ = DatabaseProviderConfigurator.TranslateSchemaScript("sqlite", script); + + out_.Should().NotContain("[", "SQL Server bracket quoting is invalid elsewhere"); + out_.Should().Contain("\"SourceItemId\" IS NOT NULL"); + } + + // ---------------- TranslateSchemaScript: Postgres ---------------- + + [Fact] + public void Postgres_translates_sqlserver_type_names() + { + var script = """ + CREATE TABLE "Users" ( + "Id" uniqueidentifier NOT NULL, + "Username" nvarchar(max) NOT NULL, + "Bio" nvarchar(256) NULL, + "Balance" decimal(18, 8) NOT NULL, + "CreatedAt" datetime2 NOT NULL, + "UpdatedAt" datetime2(3) NOT NULL, + "IsActive" bit NOT NULL + ); + """; + + var out_ = DatabaseProviderConfigurator.TranslateSchemaScript("postgres", script); + + out_.Should().NotContain("nvarchar"); + out_.Should().NotContain("datetime2"); + out_.Should().NotContain("uniqueidentifier"); + out_.Should().NotContain("bit NOT"); + out_.Should().Contain("uuid"); + out_.Should().Contain("timestamp with time zone"); + out_.Should().Contain("boolean NOT NULL"); + out_.Should().Contain("numeric(18, 8)"); + out_.Should().Contain("varchar(256)"); + out_.Should().Contain("text"); + } + + [Fact] + public void Postgres_translates_sqlserver_default_functions() + { + var script = """ + CREATE TABLE "RssSources" ( + "CreatedAt" timestamp NOT NULL DEFAULT GETUTCDATE() + ); + """; + + var out_ = DatabaseProviderConfigurator.TranslateSchemaScript("postgres", script); + + out_.Should().NotContain("GETUTCDATE"); + out_.Should().Contain("now() at time zone 'utc'"); + } + + [Fact] + public void Postgres_rewrites_filtered_index_brackets() + { + var script = @"CREATE INDEX ""IX_NewsItems"" ON ""NewsItems"" (""RssSourceId"", ""SourceItemId"") WHERE [SourceItemId] IS NOT NULL;"; + + var out_ = DatabaseProviderConfigurator.TranslateSchemaScript("postgres", script); + + out_.Should().NotContain("["); + out_.Should().Contain("\"SourceItemId\" IS NOT NULL"); + } + + [Fact] + public void Postgres_never_leaves_literal_backspace_characters() + { + // Regression: "\b" inside a verbatim C# string was once compiled to the + // backspace control char (0x08), silently killing every regex. + var out_ = DatabaseProviderConfigurator.TranslateSchemaScript("postgres", "nvarchar(max)"); + out_.Should().NotContain("\b"); + out_.Should().Be("text"); + } + + [Fact] + public void SqlServer_translation_is_a_no_op() + { + var script = "CREATE TABLE [dbo].[Users] (Id datetime2 NOT NULL DEFAULT GETUTCDATE())"; + DatabaseProviderConfigurator.TranslateSchemaScript("SqlServer", script) + .Should().Be(script, "SQL Server is the source dialect — nothing to translate"); + } + + // ---------------- TranslateSchemaScript: idempotence & safety ---------------- + + [Theory] + [InlineData("sqlite")] + [InlineData("postgres")] + public void Translation_is_deterministic(string provider) + { + var script = "CREATE TABLE t (a nvarchar(max), b datetime2 DEFAULT GETUTCDATE(), c bit)"; + var once = DatabaseProviderConfigurator.TranslateSchemaScript(provider, script); + var twice = DatabaseProviderConfigurator.TranslateSchemaScript(provider, once); + + once.Should().Be(twice, "translating twice must not change the result"); + once.Should().NotContain("\b", "no control characters may be produced"); + once.Should().NotContain("nvarchar"); + once.Should().NotContain("GETUTCDATE"); + } + + [Fact] + public void Unknown_provider_leaves_script_untouched() + { + var script = "CREATE TABLE t (a nvarchar(max))"; + DatabaseProviderConfigurator.TranslateSchemaScript("mysql", script) + .Should().Be(script); + } +} diff --git a/Tests.Application/SignalServiceAndMappingTests.cs b/Tests.Application/SignalServiceAndMappingTests.cs new file mode 100644 index 00000000..ff2023c6 --- /dev/null +++ b/Tests.Application/SignalServiceAndMappingTests.cs @@ -0,0 +1,254 @@ +using Application.Common.Mappings; +using Application.DTOs; +using Application.Services; +using AutoMapper; +using Domain.Entities; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using Application.Common.Interfaces; +using Application.Interfaces; +using Microsoft.Extensions.DependencyInjection; // For ServiceCollection / BuildServiceProvider +using Microsoft.Extensions.Logging.Abstractions; // For NullLoggerFactory + +namespace Tests.Application; + +/// +/// Real mapping tests: verifies the AutoMapper configuration is valid, +/// every CreateMap maps correctly, and nothing breaks silently at runtime. +/// This catches mapping misconfigurations that only explode when the app runs. +/// +public class AutoMapperConfigurationTests +{ + private readonly IMapper _mapper; + + public AutoMapperConfigurationTests() + { + var config = new MapperConfiguration( + mc => mc.AddMaps(typeof(MappingProfile).Assembly), + Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance); + _mapper = config.CreateMapper(); + } + + [Fact] + public void Configuration_Is_Valid() + { + // Assert: every CreateMap in MappingProfile must have a valid configuration. + // (AssertConfigurationIsValid throws on unmapped members that are not ignored, + // mismatched types, missing source members, etc.) + var act = () => _mapper.ConfigurationProvider.AssertConfigurationIsValid(); + act.Should().NotThrow("AutoMapper configuration must be valid at startup"); + } + + [Fact] + public void Signal_Maps_To_SignalDto_With_Type_As_String() + { + var category = new SignalCategory { Id = Guid.NewGuid(), Name = "Scalping" }; + var signal = new Signal + { + Id = Guid.NewGuid(), + Symbol = "EURUSD", + Type = Domain.Enums.SignalType.Buy, // enum must become string via mapping + Category = category, + }; + + var dto = _mapper.Map(signal); + + dto.Should().NotBeNull(); + dto.Id.Should().Be(signal.Id); + dto.Symbol.Should().Be("EURUSD"); + dto.Type.Should().Be(signal.Type.ToString(), + "MappingProfile maps Signal.Type via .ToString()"); + dto.Category.Should().NotBeNull("signal has a category"); + dto.Category!.Name.Should().Be("Scalping"); + } + + [Fact] + public void Signal_Without_Category_Maps_To_Null_Category_Dto() + { + var signal = new Signal + { + Id = Guid.NewGuid(), + Symbol = "GBPJPY", + Type = Domain.Enums.SignalType.Sell, + Category = null, + }; + + var dto = _mapper.Map(signal); + + dto.Should().NotBeNull(); + dto.Symbol.Should().Be("GBPJPY"); + dto.Type.Should().Be("Sell"); + dto.Category.Should().BeNull("no category was attached to the entity"); + } +} + +/// +/// Unit tests for SignalService — verifies business behavior against mocked +/// repositories: creation flow, not-found paths, update validation, and null guards. +/// +public class SignalServiceTests +{ + private readonly Mock _signalRepo = new(); + private readonly Mock _categoryRepo = new(); + private readonly Mock _dbContext = new(); + private readonly Mock> _logger = new(); + + private SignalService CreateService() + { + var config = new MapperConfiguration( + mc => mc.AddMaps(typeof(MappingProfile).Assembly), + Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance); + var mapper = config.CreateMapper(); + return new SignalService(_signalRepo.Object, _categoryRepo.Object, mapper, _dbContext.Object, _logger.Object); + } + + [Fact] + public void Constructor_Throws_When_Any_Dependency_Is_Null() + { + var config = new MapperConfiguration( + mc => mc.AddMaps(typeof(MappingProfile).Assembly), + Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance); + var mapper = config.CreateMapper(); + + var act1 = () => new SignalService(null!, _categoryRepo.Object, mapper, _dbContext.Object, _logger.Object); + var act2 = () => new SignalService(_signalRepo.Object, null!, mapper, _dbContext.Object, _logger.Object); + var act3 = () => new SignalService(_signalRepo.Object, _categoryRepo.Object, null!, _dbContext.Object, _logger.Object); + var act4 = () => new SignalService(_signalRepo.Object, _categoryRepo.Object, mapper, null!, _logger.Object); + var act5 = () => new SignalService(_signalRepo.Object, _categoryRepo.Object, mapper, _dbContext.Object, null!); + + act1.Should().Throw(); + act2.Should().Throw(); + act3.Should().Throw(); + act4.Should().Throw(); + act5.Should().Throw(); + } + + [Fact] + public async Task CreateSignalAsync_Throws_When_Category_Not_Found() + { + _categoryRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((SignalCategory?)null); + var service = CreateService(); + var dto = new CreateSignalDto { Symbol = "EURUSD", CategoryId = Guid.NewGuid() }; + + var act = () => service.CreateSignalAsync(dto); + + (await act.Should().ThrowAsync()) + .WithMessage("*not found*"); + _signalRepo.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Never, + "no signal must be persisted when the category does not exist"); + } + + [Fact] + public async Task CreateSignalAsync_Persists_Signal_And_Returns_Dto() + { + var categoryId = Guid.NewGuid(); + _categoryRepo.Setup(r => r.GetByIdAsync(categoryId, It.IsAny())) + .ReturnsAsync(new SignalCategory { Id = categoryId, Name = "Scalping" }); + + Signal? captured = null; + _signalRepo.Setup(r => r.AddAsync(It.IsAny(), It.IsAny())) + .Callback((s, _) => captured = s) + .Returns(Task.CompletedTask); + _signalRepo.Setup(r => r.GetByIdWithDetailsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Guid id, CancellationToken _) => new Signal + { + Id = id, + Symbol = "EURUSD", + Type = Domain.Enums.SignalType.Buy, + Category = new SignalCategory { Id = categoryId, Name = "Scalping" }, + }); + + var service = CreateService(); + var dto = new CreateSignalDto { Symbol = "EURUSD", CategoryId = categoryId }; + + var result = await service.CreateSignalAsync(dto); + + result.Should().NotBeNull(); + result.Symbol.Should().Be("EURUSD"); + result.Type.Should().Be("Buy"); + result.Category.Should().NotBeNull(); + result.Category!.Name.Should().Be("Scalping"); + captured.Should().NotBeNull("signal must be persisted via repository"); + captured!.Id.Should().NotBe(Guid.Empty, "service assigns a new Guid"); + captured.PublishedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + _dbContext.Verify(c => c.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetSignalByIdAsync_Returns_Null_When_Not_Found() + { + _signalRepo.Setup(r => r.GetByIdWithDetailsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Signal?)null); + var service = CreateService(); + + var result = await service.GetSignalByIdAsync(Guid.NewGuid()); + + result.Should().BeNull(); + } + + [Fact] + public async Task UpdateSignalAsync_Throws_When_Signal_Not_Found() + { + _signalRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Signal?)null); + var service = CreateService(); + var dto = new UpdateSignalDto(); + + var act = () => service.UpdateSignalAsync(Guid.NewGuid(), dto); + + (await act.Should().ThrowAsync()) + .WithMessage("*not found*"); + } + + [Fact] + public async Task UpdateSignalAsync_Throws_When_New_Category_Not_Found() + { + var signalId = Guid.NewGuid(); + _signalRepo.Setup(r => r.GetByIdAsync(signalId, It.IsAny())) + .ReturnsAsync(new Signal { Id = signalId, Symbol = "EURUSD" }); + var newCategoryId = Guid.NewGuid(); + _categoryRepo.Setup(r => r.GetByIdAsync(newCategoryId, It.IsAny())) + .ReturnsAsync((SignalCategory?)null); + var service = CreateService(); + var dto = new UpdateSignalDto { CategoryId = newCategoryId }; + + var act = () => service.UpdateSignalAsync(signalId, dto); + + (await act.Should().ThrowAsync()) + .WithMessage("*not found*"); + _dbContext.Verify(c => c.SaveChangesAsync(It.IsAny()), Times.Never, + "nothing must be saved when the referenced category is missing"); + } +} + +/// +/// Smoke tests over the DI registration: after the AutoMapper 16 migration, +/// the manual IMapper registration must produce a working mapper instance. +/// +public class DependencyInjectionTests +{ + [Fact] + public void AddApplicationServices_Registers_IMapper_And_Resolves_It() + { + var services = new ServiceCollection(); + services.AddLogging(); // SignalService requires ILogger + + global::Application.DependencyInjection.AddApplicationServices(services); + + var provider = services.BuildServiceProvider(); + var mapper = provider.GetService(); + mapper.Should().NotBeNull("IMapper must be registered by AddApplicationServices"); + + // The resolved mapper must actually work: map a simple entity. + var dto = mapper!.Map(new Signal + { + Id = Guid.NewGuid(), + Symbol = "XAUUSD", + Type = Domain.Enums.SignalType.Buy, + }); + dto.Symbol.Should().Be("XAUUSD"); + dto.Type.Should().Be("Buy"); + } +} diff --git a/Tests.Application/Tests.Application.csproj b/Tests.Application/Tests.Application.csproj index ecc1bf7d..977f59d4 100644 --- a/Tests.Application/Tests.Application.csproj +++ b/Tests.Application/Tests.Application.csproj @@ -1,26 +1,30 @@  - net10.0 + net9.0 enable enable false + false + + + diff --git a/WebAPI/Program.cs b/WebAPI/Program.cs index 71777985..65efd37f 100644 --- a/WebAPI/Program.cs +++ b/WebAPI/Program.cs @@ -14,8 +14,9 @@ using Hangfire; // برای پیکربندی‌های Hangfire مانند CompatibilityLevel, RecurringJob, Cron using Hangfire.Dashboard; // برای DashboardOptions, IDashboardAuthorizationFilter using Hangfire.MemoryStorage; // برای UseMemoryStorage (Storage پیش‌فرض برای توسعه) -// using Hangfire.SqlServer; // اگر از SQL Server برای Hangfire استفاده می‌کنید +using Hangfire.PostgreSql; // PostgreSQL storage for Hangfire using Infrastructure; // برای متد توسعه‌دهنده AddInfrastructureServices +using WebAPI.Setup; // Wizard اولین اجرا (FirstRunSetup) using Infrastructure.Services; using Infrastructure.Settings; using EFCore.AutomaticMigrations; @@ -45,7 +46,29 @@ Log.Information("--------------------------------------------------"); var builder = WebApplication.CreateBuilder(args); - + + #region First-Run Setup (Wizard انتخاب پایگاه داده) + // اگر DatabaseProvider/ConnectionString از قبل تنظیم نشده باشد (env یا appsettings.Local.json)، + // در اولین اجرا از کاربر پرسیده می‌شود و انتخاب در appsettings.Local.json ذخیره می‌گردد. + if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("DatabaseProvider"))) + { + builder.Configuration.AddLocalSettings(AppContext.BaseDirectory); + } + + if (FirstRunSetup.NeedsSetup(builder.Configuration)) + { + var (setupProvider, setupConnectionString) = FirstRunSetup.Run(builder.Configuration); + FirstRunSetup.Persist(setupProvider, setupConnectionString, AppContext.BaseDirectory); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["DatabaseProvider"] = setupProvider, + ["ConnectionStrings:DefaultConnection"] = setupConnectionString, + }); + Log.Information( + "First-run setup completed. DatabaseProvider={Provider}, ConnectionString source={Source}.", + setupProvider, "interactive-or-default"); + } + #endregion #region Configure Serilog Logging // ------------------- ۱. پیکربندی Serilog با تنظیمات از appsettings.json ------------------- @@ -200,13 +223,22 @@ #region Configure Hangfire // ------------------- ۵. پیکربندی Hangfire برای اجرای کارهای پس‌زمینه ------------------- - builder.Services.AddHangfire(config => config - .SetDataCompatibilityLevel(CompatibilityLevel.Version_180) - .UseSimpleAssemblyNameTypeSerializer() - .UseRecommendedSerializerSettings() - .UseSqlServerStorage(builder.Configuration.GetConnectionString("DefaultConnection"))); + // Storage متناسب با پایگاه داده انتخاب می‌شود: SqlServer / Postgres / Sqlite + var dbProvider = Infrastructure.Data.DatabaseProviderConfigurator.Normalize( + builder.Configuration["DatabaseProvider"]); + var dbConnectionString = builder.Configuration.GetConnectionString("DefaultConnection") + ?? throw new InvalidOperationException("DefaultConnection is not configured."); + + builder.Services.AddHangfire(config => + { + config.SetDataCompatibilityLevel(CompatibilityLevel.Version_180) + .UseSimpleAssemblyNameTypeSerializer() + .UseRecommendedSerializerSettings(); + Infrastructure.Data.DatabaseProviderConfigurator.ConfigureHangfireStorage( + config, dbProvider, dbConnectionString); + }); builder.Services.AddHangfireServer(); - Log.Information("Hangfire services (with SQL Server for production) added."); + Log.Information("Hangfire services added (storage: {StorageProvider}).", dbProvider); builder.Services.Configure>( // <<< Fully qualified builder.Configuration.GetSection("ForwardingRules")); builder.Services.AddScoped(); @@ -311,11 +343,51 @@ var urls = builder.Configuration["Urls"] ?? "https://localhost:5001;http://localhost:5000"; var firstUrl = urls.Split(';')[0].Trim(); + // ------------------- سلامت‌سنجی: /health (برای Docker healthcheck) ------------------- + app.MapGet("/health", () => Results.Ok(new { status = "healthy", time = DateTime.UtcNow })); + using (var scope = app.Services.CreateScope()) { - var orchestrator = scope.ServiceProvider.GetRequiredService(); - // Use orchestrator if needed + // اورکستریتورِ Forwarding اختیاری است: خطای راه‌اندازی آن (مثلاً تنظیمات + // ناقص تلگرام یا فایل session نامعتبر) نباید مانع بالا آمدن سرویس شود. + try + { + _ = scope.ServiceProvider.GetRequiredService(); + } + catch (Exception orchEx) + { + programLogger.LogWarning(orchEx, + "Forwarding orchestrator could not be initialized (non-fatal). Telegram features may be disabled until configuration is fixed."); + } } + + // ------------------- ساخت دیتابیس و اسکیما در صورت نبود (Multi-DB) ------------------- + try + { + using (var scope = app.Services.CreateScope()) + { + var cfg = scope.ServiceProvider.GetRequiredService(); + var schemaProvider = Infrastructure.Data.DatabaseProviderConfigurator.Normalize(cfg["DatabaseProvider"]); + var schemaCs = cfg.GetConnectionString("DefaultConnection") + ?? throw new InvalidOperationException("DefaultConnection is not configured."); + + await Infrastructure.Data.DatabaseProviderConfigurator.EnsureDatabaseExistsAsync(schemaProvider, schemaCs); + + var dbContext = scope.ServiceProvider.GetRequiredService(); + var created = await Infrastructure.Data.DatabaseProviderConfigurator.EnsureSchemaAsync(dbContext, schemaProvider); + programLogger.LogInformation( + created + ? "Database schema created successfully for provider: {Provider}" + : "Database schema already exists for provider: {Provider}", + schemaProvider); + } + } + catch (Exception dbEx) + { + programLogger.LogCritical(dbEx, "Database initialization failed. Application cannot continue safely."); + throw; + } + app.Run(); // شروع به گوش دادن به درخواست‌های HTTP و اجرای برنامه #endregion } diff --git a/WebAPI/Setup/FirstRunSetup.cs b/WebAPI/Setup/FirstRunSetup.cs new file mode 100644 index 00000000..a29abd9c --- /dev/null +++ b/WebAPI/Setup/FirstRunSetup.cs @@ -0,0 +1,195 @@ +using System.Text; +using System.Text.Json; +using Infrastructure.Data; +using Microsoft.Extensions.Configuration; + +namespace WebAPI.Setup +{ + /// + /// نصب‌ساز اولین اجرا (First-Run Setup): + /// اگر DatabaseProvider / ConnectionString تنظیم نشده باشد، در کنسول تعاملی + /// از کاربر می‌پرسد کدام پایگاه داده را می‌خواهد (Postgres / Sqlite / SqlServer)؛ + /// در محیط غیرتعاملی (کانتینر، CI، لوله‌ی stdout) به‌صورت خودکار Sqlite را انتخاب می‌کند + /// تا برنامه بدون هیچ پیش‌نیازی بالا بیاید. + /// انتخاب کاربر در appsettings.Local.json ذخیره می‌شود. + /// + public static class FirstRunSetup + { + public const string LocalSettingsFileName = "appsettings.Local.json"; + + /// آیا تنظیمات دیتابیس ناقص است و باید Wizard اجرا شود؟ + public static bool NeedsSetup(IConfiguration configuration) + { + var provider = configuration["DatabaseProvider"]; + var cs = configuration.GetConnectionString("DefaultConnection"); + return string.IsNullOrWhiteSpace(provider) || string.IsNullOrWhiteSpace(cs); + } + + /// + /// اجرای Wizard (یا انتخاب پیش‌فرض غیرتعاملی) و برگرداندن پیکربندی نهایی. + /// + public static (string Provider, string ConnectionString) Run(IConfiguration configuration) + { + var interactive = !Console.IsInputRedirected && !Console.IsOutputRedirected; + + if (!interactive) + { + // حالت غیرتعاملی (کانتینر/CI): Sqlite → صفر پیش‌نیاز، همیشه کار می‌کند. + var fallbackProvider = Environment.GetEnvironmentVariable("DatabaseProvider"); + var fallbackCs = Environment.GetEnvironmentVariable("ConnectionStrings__DefaultConnection"); + + if (!string.IsNullOrWhiteSpace(fallbackProvider) && !string.IsNullOrWhiteSpace(fallbackCs)) + { + return (fallbackProvider, fallbackCs); + } + + return ("Sqlite", "Data Source=forexbot.db"); + } + + return Prompt(); + } + + /// ذخیره‌ی انتخاب در appsettings.Local.json (پایین‌ترین اولویتِ فایل‌ها، بالاتر از env نیست). + public static void Persist(string provider, string connectionString, string baseDirectory) + { + var path = Path.Combine(baseDirectory, LocalSettingsFileName); + var payload = new Dictionary + { + ["DatabaseProvider"] = provider, + ["ConnectionStrings"] = new Dictionary + { + ["DefaultConnection"] = connectionString, + }, + }; + + var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }); + + File.WriteAllText(path, json, new UTF8Encoding(false)); + } + + // ------------------------------------------------------------------ + // UI + // ------------------------------------------------------------------ + + private static (string Provider, string ConnectionString) Prompt() + { + Console.WriteLine(); + WriteAccent(" ╔══════════════════════════════════════════════════════╗"); + WriteAccent(" ║ ForexTradingBot — First-Run Setup ║"); + WriteAccent(" ║ پایگاه داده‌ی مورد نظر خود را انتخاب کنید ║"); + WriteAccent(" ╚══════════════════════════════════════════════════════╝"); + Console.WriteLine(); + Console.WriteLine(" 1) SQLite — سریع‌ترین، بدون نصب، فایل محلی "); + Console.WriteLine(" 2) PostgreSQL — پیشنهادی برای سرور و production "); + Console.WriteLine(" 3) SQL Server — مناسب محیط‌های ویندوزی موجود "); + Console.WriteLine(); + + while (true) + { + Console.Write(" انتخاب شما [1/2/3] (پیش‌فرض: 1): "); + var key = Console.ReadLine()?.Trim(); + + switch (key) + { + case "" or "1": + return ("Sqlite", PromptSqlite()); + + case "2": + return ("Postgres", PromptPostgres()); + + case "3": + return ("SqlServer", PromptSqlServer()); + + default: + Console.WriteLine(" ! عدد 1، 2 یا 3 را وارد کنید."); + break; + } + } + } + + private static string PromptSqlite() + { + Console.Write(" مسیر فایل دیتابیس [forexbot.db]: "); + var file = Console.ReadLine()?.Trim(); + return string.IsNullOrEmpty(file) ? "Data Source=forexbot.db" : $"Data Source={file}"; + } + + private static string PromptPostgres() + { + var host = Ask(" Host [localhost]: ", "localhost"); + var port = Ask(" Port [5432]: ", "5432"); + var db = Ask(" Database [forexbotdb]: ", "forexbotdb"); + var user = Ask(" Username [postgres]: ", "postgres"); + var pass = Ask(" Password []: ", string.Empty, secret: true); + Console.WriteLine(); + + return $"Host={host};Port={port};Database={db};Username={user};Password={pass}"; + } + + private static string PromptSqlServer() + { + var host = Ask(" Server [localhost]: ", "localhost"); + var db = Ask(" Database [ForexBotDb]: ", "ForexBotDb"); + var integrated = Ask(" Integrated Security? (y/n) [y]: ", "y").Equals("y", StringComparison.OrdinalIgnoreCase); + Console.WriteLine(); + + return integrated + ? $"Server={host};Database={db};Trusted_Connection=True;TrustServerCertificate=True" + : $"Server={host};Database={db};User Id={Ask(" User Id [sa]: ", "sa")};Password={Ask(" Password []: ", string.Empty, secret: true)};TrustServerCertificate=True"; + } + + private static string Ask(string label, string defaultValue, bool secret = false) + { + Console.Write(label); + var value = secret ? ReadSecret() : Console.ReadLine(); + value = value?.Trim(); + return string.IsNullOrEmpty(value) ? defaultValue : value; + } + + /// خواندن رمز بدون نمایش در کنسول (تایپ echo نمی‌شود). + private static string ReadSecret() + { + var sb = new StringBuilder(); + while (true) + { + var key = Console.ReadKey(intercept: true); + if (key.Key == ConsoleKey.Enter) + { + Console.WriteLine(); + break; + } + + if (key.Key == ConsoleKey.Backspace && sb.Length > 0) + { + sb.Length--; + continue; + } + + if (!char.IsControl(key.KeyChar)) + { + sb.Append(key.KeyChar); + } + } + + return sb.ToString(); + } + + private static void WriteAccent(string line) => Console.WriteLine(line); + + /// بارگذاری appsettings.Local.json (اگر وجود داشت) به کانفیگ. + public static IConfigurationBuilder AddLocalSettings(this IConfigurationBuilder builder, string baseDirectory) + { + var path = Path.Combine(baseDirectory, LocalSettingsFileName); + if (File.Exists(path)) + { + builder.AddJsonFile(path, optional: false, reloadOnChange: false); + } + + return builder; + } + } +} diff --git a/WebAPI/WebAPI.csproj b/WebAPI/WebAPI.csproj index ea98b3c7..2bc38761 100644 --- a/WebAPI/WebAPI.csproj +++ b/WebAPI/WebAPI.csproj @@ -12,24 +12,24 @@ - - - - - - + + + + + + - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - + + diff --git a/docker-compose.yml b/docker-compose.yml index 030e01b8..31166297 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,9 +59,10 @@ services: - forexbot-network healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres -d forexbotdb"] - interval: 10s + interval: 5s timeout: 5s - retries: 5 + retries: 12 + start_period: 60s mem_limit: 2g mem_reservation: 512m cpus: 2.0 diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 00000000..60edec3d --- /dev/null +++ b/install.ps1 @@ -0,0 +1,230 @@ +<# +.SYNOPSIS + ForexTradingBot — One-Command Installer for Windows (PowerShell) + +.DESCRIPTION + Run this in PowerShell (no clone needed): + + irm https://raw.githubusercontent.com/Opselon/ForexTradingBot/main/install.ps1 | iex + + What it does: + 1) Clones (or updates) the repository to %USERPROFILE%\ForexTradingBot + 2) Asks: Docker install (recommended) or Normal install (.NET on this PC) + 3) Docker -> builds the image, starts PostgreSQL + Redis + API, waits for /health + 4) Normal -> installs the .NET 9 SDK if missing, then starts the app; on FIRST + STARTUP the app itself asks PostgreSQL / SQLite / SQL Server and + saves the answer to appsettings.Local.json + + Everything is idempotent — re-running is safe. + +.NOTES + If script execution is blocked, run once: + Set-ExecutionPolicy -Scope CurrentUser RemoteSigned +#> + +$ErrorActionPreference = 'Stop' + +$RepoUrl = 'https://github.com/Opselon/ForexTradingBot.git' +$Dir = if ($env:FOREXBOT_DIR) { $env:FOREXBOT_DIR } else { Join-Path $HOME 'ForexTradingBot' } +$HealthDocker = 'http://localhost:8080/health' +$HealthNative = 'http://localhost:5000/health' + +function Write-Step($msg) { Write-Host ''; Write-Host "▶ $msg" -ForegroundColor Cyan } +function Write-Ok($msg) { Write-Host " ✓ $msg" -ForegroundColor Green } +function Write-Warn($msg) { Write-Host " ! $msg" -ForegroundColor Yellow } +function Write-Err($msg) { Write-Host " ✗ $msg" -ForegroundColor Red } +function Write-Info($msg) { Write-Host " $msg" -ForegroundColor DarkGray } + +function Show-Banner { + Write-Host '' + Write-Host ' ForexTradingBot — One-command installer' -ForegroundColor Blue + Write-Host ' PostgreSQL / SQLite / SQL Server' -ForegroundColor DarkGray + Write-Host '' +} + +function Test-Prereqs { + Write-Step 'Prerequisites' + if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + Write-Err 'Git not found.' + Write-Info 'Install it first: winget install Git.Git (then reopen PowerShell)' + exit 1 + } + Write-Ok 'git found' +} + +function Get-Source { + Write-Step 'Source code' + if (Test-Path (Join-Path $Dir '.git')) { + Write-Info "Existing clone at $Dir — updating..." + try { + git -C $Dir pull --ff-only 2>$null | Out-Null + Write-Ok 'Repository updated' + } catch { + Write-Warn 'pull failed — using local copy' + } + } else { + Write-Info "Cloning $RepoUrl ..." + git clone --depth 1 $RepoUrl $Dir | Out-Null + Write-Ok "Cloned to: $Dir" + } +} + +function Get-DockerComposeCommand { + if ((Get-Command docker -ErrorAction SilentlyContinue) -and + (docker compose version 2>$null)) { return 'docker compose' } + if (Get-Command docker-compose -ErrorAction SilentlyContinue) { return 'docker-compose' } + return $null +} + +function Invoke-DockerInstall { + Write-Step 'Docker environment' + if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { + Write-Err 'Docker is not installed.' + Write-Info 'Install Docker Desktop: https://docs.docker.com/desktop/setup/install/windows-install/' + exit 1 + } + $compose = Get-DockerComposeCommand + if (-not $compose) { + Write-Err 'Docker Compose not found (install the compose plugin).' + exit 1 + } + try { docker info 2>$null | Out-Null } + catch { } + if ($LASTEXITCODE -ne 0) { + Write-Err 'Docker daemon is not running. Start Docker Desktop and re-run this script.' + exit 1 + } + Write-Ok 'Docker + Compose ready' + + Push-Location $Dir + try { + Write-Step 'Configuration (.env)' + $envFile = Join-Path $Dir '.env' + if (-not (Test-Path $envFile)) { + Copy-Item (Join-Path $Dir '.env.example') $envFile + $bytes = New-Object byte[] 24 + [System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes) + $dbPass = ($bytes | ForEach-Object { $_.ToString('x2') }) -join '' + $content = Get-Content $envFile -Raw + if ($content -match '(?m)^POSTGRES_PASSWORD=.*$') { + $content = [regex]::Replace($content, '(?m)^POSTGRES_PASSWORD=.*$', "POSTGRES_PASSWORD=$dbPass") + } else { + $content += "`nPOSTGRES_PASSWORD=$dbPass`n" + } + Set-Content -Path $envFile -Value $content -Encoding UTF8 + Write-Ok '.env created with an auto-generated database password' + Write-Warn "Edit $Dir\.env later to add TELEGRAM_BOT_TOKEN (optional — the app runs without it)" + } else { + Write-Ok '.env already exists — kept as-is' + } + + Write-Step 'Building & starting containers (PostgreSQL, Redis, API)' + Write-Info 'First build takes a few minutes; later runs use the cache.' + Invoke-Expression "$compose up -d --build" + if ($LASTEXITCODE -ne 0) { throw 'compose up failed' } + + Write-Step 'Waiting for health check' + $healthy = $false + $body = '' + for ($i = 0; $i -lt 60; $i++) { + try { + $resp = Invoke-WebRequest -Uri $HealthDocker -TimeoutSec 3 -UseBasicParsing + $body = $resp.Content + if ($body -match 'healthy') { $healthy = $true; break } + } catch { Start-Sleep -Seconds 3 } + } + + Write-Host '' + if ($healthy) { + Write-Ok "API is UP and healthy → $HealthDocker" + Write-Host " $body" -ForegroundColor Green + Write-Host '' + Write-Host 'Next steps' -ForegroundColor White + Write-Info "Logs : $compose logs -f forex-api" + Write-Info "Stop : $compose down" + Write-Info "Restart : $compose up -d" + Write-Info "Config : $Dir\.env" + } else { + Write-Err 'API did not become healthy within the timeout.' + Write-Info "Inspect with: $compose logs forex-api" + exit 1 + } + } finally { Pop-Location } +} + +function Install-DotNetSdk { + Write-Step '.NET SDK' + $sdk = $null + if (Get-Command dotnet -ErrorAction SilentlyContinue) { + $sdk = dotnet --list-sdks 2>$null | Where-Object { $_ -match '^9\.' } | Select-Object -First 1 + } + if (-not $sdk) { + Write-Warn '.NET 9 SDK not found — installing to %USERPROFILE%\.dotnet' + $installer = Join-Path $env:TEMP 'dotnet-install.ps1' + Invoke-WebRequest -Uri 'https://dot.net/v1/dotnet-install.ps1' -OutFile $installer + & $installer -Channel 9 -InstallDir (Join-Path $HOME '.dotnet') + Remove-Item $installer -ErrorAction SilentlyContinue + $dotnetRoot = Join-Path $HOME '.dotnet' + $env:DOTNET_ROOT = $dotnetRoot + $env:Path = "$dotnetRoot;$env:Path" + [Environment]::SetEnvironmentVariable('DOTNET_ROOT', $dotnetRoot, 'User') + [Environment]::SetEnvironmentVariable('Path', "$dotnetRoot;$([Environment]::GetEnvironmentVariable('Path','User'))", 'User') + Write-Ok '.NET 9 SDK installed' + } else { + Write-Ok ".NET SDK found: $sdk" + } +} + +function Invoke-NativeInstall { + Install-DotNetSdk + + $env:DOTNET_CLI_TELEMETRY_OPTOUT = '1' + $env:DOTNET_NOLOGO = '1' + + Push-Location $Dir + try { + Write-Step 'Building (Release)' + dotnet build -c Release --nologo + if ($LASTEXITCODE -ne 0) { throw 'build failed' } + Write-Ok 'Build succeeded' + + Write-Step 'Starting the application' + Write-Host '' + Write-Host ' +----------------------------------------------------------+' -ForegroundColor White + Write-Host ' | First startup - choose your database |' -ForegroundColor White + Write-Host ' | |' -ForegroundColor White + Write-Host ' | The app will now ask: |' -ForegroundColor White + Write-Host ' | 1) SQLite (zero setup, single file) |' -ForegroundColor White + Write-Host ' | 2) PostgreSQL (server, recommended for production) |' -ForegroundColor White + Write-Host ' | 3) SQL Server |' -ForegroundColor White + Write-Host ' | |' -ForegroundColor White + Write-Host ' | Your choice is saved to appsettings.Local.json |' -ForegroundColor White + Write-Host ' +----------------------------------------------------------+' -ForegroundColor White + Write-Host '' + Write-Info 'Health check: ' -NoNewline + Write-Info $HealthNative + Write-Host '' + dotnet run --project WebAPI -c Release + } finally { Pop-Location } +} + +# ---------------- main ---------------- +Show-Banner +Test-Prereqs +Get-Source + +$mode = $args | Select-Object -First 1 +if (-not $mode) { + Write-Host 'How do you want to install?' -ForegroundColor White + Write-Host ' 1) Docker - PostgreSQL + Redis + API, fully automated (recommended)' -ForegroundColor Green + Write-Host ' 2) Normal - .NET on this PC, first startup asks for the database' -ForegroundColor Cyan + Write-Host ' 3) Exit' -ForegroundColor DarkGray + $mode = Read-Host 'Choice [1]' + if (-not $mode) { $mode = '1' } +} + +switch ($mode.ToString().ToLower()) { + { $_ -in @('1', 'd', 'docker') } { Invoke-DockerInstall } + { $_ -in @('2', 'n', 'native') } { Invoke-NativeInstall } + default { Write-Info 'Bye.' } +} diff --git a/install.sh b/install.sh new file mode 100644 index 00000000..15c203bf --- /dev/null +++ b/install.sh @@ -0,0 +1,252 @@ +#!/usr/bin/env bash +# ============================================================================= +# ForexTradingBot — One-Command Installer (Linux / macOS) +# +# curl -fsSL https://raw.githubusercontent.com/Opselon/ForexTradingBot/main/install.sh | bash +# +# What it does: +# 1) Clones the repository (or updates an existing clone) +# 2) Asks: Docker install (recommended) or Normal install (.NET on host) +# 3) Docker -> builds the image, starts PostgreSQL + Redis + API, waits +# for /health and prints the result +# 4) Normal -> installs the .NET 9 SDK if missing, then starts the app; +# on FIRST STARTUP the app itself asks which database you want: +# PostgreSQL / SQLite / SQL Server (saved to appsettings.Local.json) +# +# Everything is idempotent: re-running the script is safe. +# ============================================================================= +set -Eeuo pipefail + +REPO="https://github.com/Opselon/ForexTradingBot.git" +DIR="${FOREXBOT_DIR:-$HOME/ForexTradingBot}" +MIN_DOTNET_MAJOR=9 +HEALTH_URL_DOCKER="http://localhost:8080/health" +HEALTH_URL_NATIVE="http://localhost:5000/health" + +# ---------- pretty output ---------- +if [ -t 1 ]; then + C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'; C_DIM=$'\033[2m' + C_GREEN=$'\033[32m'; C_RED=$'\033[31m'; C_YELLOW=$'\033[33m'; C_BLUE=$'\033[34m'; C_CYAN=$'\033[36m' +else + C_RESET=""; C_BOLD=""; C_DIM=""; C_GREEN=""; C_RED=""; C_YELLOW=""; C_BLUE=""; C_CYAN="" +fi + +step() { printf '\n%s▶ %s%s\n' "$C_BOLD$C_CYAN" "$*" "$C_RESET"; } +ok() { printf '%s ✓ %s%s\n' "$C_GREEN" "$*" "$C_RESET"; } +warn() { printf '%s ! %s%s\n' "$C_YELLOW" "$*" "$C_RESET"; } +err() { printf '%s ✗ %s%s\n' "$C_RED" "$*" "$C_RESET" >&2; } +info() { printf '%s %s%s\n' "$C_DIM" "$*" "$C_RESET"; } + +banner() { + printf '%s' "$C_BOLD$C_BLUE" + cat <<'EOF' + ______ _ ______ _ _ + | ____| | | | ____| | | | | + | |__ __ _ _| |_ __ _ | |__ __ _ _ __ ___| | ___ | |_ ___ _ __ + | __/ _` | __| __| | | __/ _` | '__/ __| |/ _ \| __/ _ \ '__| + | | | (_| | |_| |_| |_| | | (_| | | | (__| | (_) | || __/ | + |_| \__,_|\__|\__,_|_| \__,_|_| \___|_|\___/ \__\___|_| +EOF + printf '%s One-command installer — PostgreSQL / SQLite / SQL Server%s\n\n' "$C_RESET$C_DIM" "$C_RESET" +} + +command_exists() { command -v "$1" >/dev/null 2>&1; } + +# ---------- prereqs ---------- +need_cmd() { + if ! command_exists "$1"; then + err "Required command '$1' not found." + case "$1" in + git) + info "Install git first:" + info " Debian/Ubuntu : sudo apt-get install -y git" + info " Fedora : sudo dnf install -y git" + info " macOS : xcode-select --install" + ;; + esac + exit 1 + fi +} + +clone_or_update() { + step "Source code" + if [ -d "$DIR/.git" ]; then + info "Existing clone found at $DIR — updating..." + git -C "$DIR" fetch --all --prune >/dev/null 2>&1 || warn "fetch failed (offline?) — using local copy" + git -C "$DIR" pull --ff-only >/dev/null 2>&1 || warn "pull failed — using local copy" + ok "Repository updated: $DIR" + else + info "Cloning $REPO ..." + git clone --depth 1 "$REPO" "$DIR" + ok "Cloned to: $DIR" + fi +} + +# ---------- Docker path ---------- +docker_install() { + step "Docker environment" + if ! command_exists docker; then + err "Docker is not installed." + info "Install Docker:" + info " Linux : https://docs.docker.com/engine/install/" + info " macOS : brew install --cask docker" + exit 1 + fi + if ! docker info >/dev/null 2>&1; then + err "Docker daemon is not running (or needs sudo)." + info "Start Docker, then re-run this script." + exit 1 + fi + if docker compose version >/dev/null 2>&1; then + COMPOSE="docker compose" + elif command_exists docker-compose; then + COMPOSE="docker-compose" + else + err "Docker Compose not found. Install the 'docker-compose-plugin'." + exit 1 + fi + ok "Docker + Compose ready" + + cd "$DIR" + + step "Configuration (.env)" + if [ ! -f .env ]; then + cp .env.example .env + # Generate a strong database password automatically + if command_exists openssl; then + DBPASS=$(openssl rand -base64 18 | tr -d '/+=' | head -c 24) + else + DBPASS=$(head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n' | head -c 24) + fi + if grep -q '^POSTGRES_PASSWORD=' .env; then + sed -i.bak "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=${DBPASS}|" .env && rm -f .env.bak + else + printf '\nPOSTGRES_PASSWORD=%s\n' "$DBPASS" >> .env + fi + ok ".env created with an auto-generated database password" + warn "Edit $DIR/.env later to add your TELEGRAM_BOT_TOKEN (optional — the app runs without it)" + else + ok ".env already exists — kept as-is" + fi + + step "Building & starting containers (PostgreSQL, Redis, API)" + info "First build takes a few minutes; later runs use the cache." + $COMPOSE up -d --build + + step "Waiting for health check" + local url="$HEALTH_URL_DOCKER" + local tries=60 + local body="" + for _ in $(seq 1 "$tries"); do + if command_exists curl; then + body=$(curl -fsS -m 3 "$url" 2>/dev/null || true) + else + body=$(wget -qO- -T 3 "$url" 2>/dev/null || true) + fi + case "$body" in + *healthy*) break ;; + esac + sleep 3 + done + + echo + if case "$body" in *healthy*) true ;; *) false ;; esac; then + ok "API is UP and healthy → $url" + printf '\n%s %s%s\n' "$C_GREEN" "$body" "$C_RESET" + $COMPOSE ps + cat </dev/null | awk -F. -v m="$MIN_DOTNET_MAJOR" '$1==m {print $1; exit}') + fi + if [ -z "$have" ]; then + warn ".NET SDK $MIN_DOTNET_MAJOR not found — installing to \$HOME/.dotnet" + local inst="$HOME/.dotnet-install.sh" + if command_exists curl; then + curl -fsSL https://dot.net/v1/dotnet-install.sh -o "$inst" + else + wget -qO "$inst" https://dot.net/v1/dotnet-install.sh + fi + bash "$inst" --channel "$MIN_DOTNET_MAJOR" --install-dir "$HOME/.dotnet" + rm -f "$inst" + ok ".NET $MIN_DOTNET_MAJOR SDK installed" + else + ok ".NET SDK $MIN_DOTNET_MAJOR found" + fi + + export DOTNET_ROOT="${DOTNET_ROOT:-$HOME/.dotnet}" + export PATH="$DOTNET_ROOT:$PATH" + export DOTNET_CLI_TELEMETRY_OPTOUT=1 + export DOTNET_NOLOGO=1 + + cd "$DIR" + + step "Building (Release)" + dotnet build -c Release --nologo + ok "Build succeeded" + + step "Starting the application" + cat <